Когда узел падает: повторы, таймауты и отладка графа | Курс LangGraph урок 8
Цель урока: настроить граф так, чтобы сбой или зависание узла не заканчивали запуск, и находить место сбоя без внешнего сервиса.
Необходимые знания:
1) урок 7 курса по LangChain: поток, режимы потока, форма chunk при version="v2"
2) урок 2 этого курса: параметр runtime: Runtime в функции узла
3) урок 3 этого курса: Command с параметрами update и goto, копии узла из Send
4) урок 4 этого курса: узел выполняется с первой строки, побочные действия должны выдерживать повторное выполнение
5) урок 5 этого курса: чекпойнтер, thread_id, задача как одно выполнение узла, продолжение invoke(None, config) после сбоя, правки задач упавшего шага, get_state_history, update_state с as_node
6) урок 6 этого курса: выполнение файла узлом run_file, пауза interrupt(), параметр interrupt_after
7) урок 7 этого курса: подграф узлом и внутри функции узла
8) Python на уровне джуниора: subprocess.run с параметрами check и timeout, async def и await, asyncio.run, модуль logging
Чем закончился урок 7
В уроке 7 слои агента, который разбирает чужой репозиторий, стали подграфами. В слое выполнения узел run_file выполняет чужие файлы: тесты, скрипты, точку входа сервиса. Чужой файл может упасть с ошибкой или зависнуть. Сейчас ошибка в run_file заканчивает весь запуск графа. Зависший файл держит запуск без конца.
В уроке 5 запуск после сбоя продолжал человек: устранял причину и вызывал invoke(None, config). В этом уроке сбой узла обрабатывает сам граф. Настройки узла для этого срабатывают по порядку:
1) попытка, одно выполнение функции узла, падает с исключением. Таймаут узла тоже заканчивает попытку исключением, NodeTimeoutError
2) политика повторных попыток решает, выполнить ли функцию узла ещё раз
3) когда политика больше попыток не даёт, срабатывает обработчик ошибки: функция, которая правит состояние и может выбрать следующий узел. Без обработчика исключение выходит из вызова invoke
Пример 5 работает на стенде из урока 1: положите рядом с примерами папку mini_repo с файлами app.py, config.py, db.py и utils.py. Их листинги есть в уроке 1. Примерам 2, 3, 4 и 6 нужен второй стенд: положите рядом с примерами папку bad_repo с четырьмя файлами ниже. Пример 1 стенда не требует. Модель и ключ API в этом уроке не нужны.
Файл ok.py
"""Файл, который выполняется без ошибок."""
print("ok")
Файл broken.py
"""Файл с ошибкой в коде: падает при каждом выполнении."""
orders = {"total": 3}
print(orders["count"])
Файл hang.py
"""Файл, который зависает: ждёт ответа, который не придёт."""
import time
time.sleep(30)
print("answer")
Файл chatty.py
"""Долгий файл, который печатает строку о ходе работы."""
import time
for step in range(1, 5):
time.sleep(0.25)
print(f"step {step}", flush=True)
Повторная попытка: RetryPolicy
Задача, это одно выполнение узла, так она определена в уроке 5. С политикой повторных попыток у задачи бывает несколько попыток: после сбоя функция узла выполняется заново внутри той же задачи. Документация называет это retry, дальше пишу "повторная попытка". Повтор из урока 5 запускает граф от прошлого checkpoint новым вызовом. Повторная попытка идёт внутри одного вызова. Checkpoint между попытками не пишется.
Политику задаёт объект RetryPolicy из langgraph.types. Его передают в add_node параметром retry_policy.
Пример 01_retry.py
"""Пример 1 урока 8: повторные попытки узла и политика RetryPolicy.
Узел check_db проверяет, что база отвечает. Базы в примере нет: первые
попытки падают по счётчику failures. Так пример воспроизводит базу, которая
поднимается не сразу. Номер попытки узел берёт из
runtime.execution_info.node_attempt и пишет в ATTEMPTS. Сообщения LangGraph
о повторных попытках печатает обработчик журнала PrintRetries.
"""
import logging
from langgraph.graph import END, START, StateGraph
from langgraph.runtime import Runtime
from langgraph.types import RetryPolicy
from typing_extensions import TypedDict
ATTEMPTS: list[int] = []
class PrintRetries(logging.Handler):
"""Печатает сообщение LangGraph о повторной попытке без трассировки."""
def emit(self, record: logging.LogRecord) -> None:
print(" журнал:", record.getMessage())
logging.getLogger("langgraph").addHandler(PrintRetries())
logging.getLogger("langgraph").setLevel(logging.INFO)
class DbState(TypedDict):
status: str
def make_check_db(error: Exception, failures: int):
"""Узел check_db: первые failures попыток падают с ошибкой error."""
def check_db(state: DbState, runtime: Runtime) -> dict:
nonlocal failures
ATTEMPTS.append(runtime.execution_info.node_attempt)
if failures > 0:
failures -= 1
raise error
return {"status": "база отвечает"}
return check_db
def build(error: Exception, failures: int, retry_policy: RetryPolicy | None):
"""Граф из одного узла check_db с заданной политикой."""
builder = StateGraph(DbState)
builder.add_node("check_db", make_check_db(error, failures), retry_policy=retry_policy)
builder.add_edge(START, "check_db")
builder.add_edge("check_db", END)
return builder.compile()
POLICY = RetryPolicy(initial_interval=0.1, jitter=False)
DOWN = ConnectionError("база не отвечает")
BUILDS = [
("без retry_policy, база отвечает с третьей попытки", DOWN, 2, None),
("RetryPolicy, база отвечает с третьей попытки", DOWN, 2, POLICY),
("RetryPolicy, база не отвечает три раза", DOWN, 3, POLICY),
("RetryPolicy, ValueError в первой попытке", ValueError("неверный адрес базы"), 1, POLICY),
]
for number, (title, error, failures, policy) in enumerate(BUILDS, start=1):
ATTEMPTS.clear()
print(f"{number}. {title}")
try:
result = build(error, failures, policy).invoke({"status": ""})
print(" попытки:", ATTEMPTS, "|", result["status"])
except Exception as exc:
print(" попытки:", ATTEMPTS, "| запуск упал:", type(exc).__name__)
# Вывод:
# 1. без retry_policy, база отвечает с третьей попытки
# попытки: [1] | запуск упал: ConnectionError
# 2. RetryPolicy, база отвечает с третьей попытки
# журнал: Retrying task check_db after 0.10 seconds (attempt 1) after ConnectionError база не отвечает
# журнал: Retrying task check_db after 0.20 seconds (attempt 2) after ConnectionError база не отвечает
# попытки: [1, 2, 3] | база отвечает
# 3. RetryPolicy, база не отвечает три раза
# журнал: Retrying task check_db after 0.10 seconds (attempt 1) after ConnectionError база не отвечает
# журнал: Retrying task check_db after 0.20 seconds (attempt 2) after ConnectionError база не отвечает
# попытки: [1, 2, 3] | запуск упал: ConnectionError
# 4. RetryPolicy, ValueError в первой попытке
# попытки: [1] | запуск упал: ValueError
Пункт 1: без retry_policy у узла одна попытка, и первая же ошибка выходит из invoke.
Пункт 2: с политикой узел check_db выполнился трижды, третья попытка прошла. Номер попытки узел берёт из runtime.execution_info.node_attempt. У первой попытки номер равен 1. Перед каждой повторной попыткой LangGraph пишет строку в журнал langgraph модуля logging на уровне INFO.
Пункт 3: max_attempts считает и первую попытку. Три сбоя подряд израсходовали три попытки, и из вызова вышло исключение последней.
Пункт 4: политика с retry_on по умолчанию не повторяет ValueError. Причина в следующем разделе.
Параметры RetryPolicy и их значения по умолчанию:
1) max_attempts=3, число попыток вместе с первой
2) initial_interval=0.5, пауза в секундах перед второй попыткой
3) backoff_factor=2.0, множитель паузы перед каждой следующей попыткой
4) max_interval=128.0, верхняя граница паузы в секундах
5) jitter=True, случайная добавка к паузе. С ней пауза может превысить max_interval
6) retry_on, какие исключения повторять
В пункте 2 первая пауза равна initial_interval, 0,1 секунды. Вторая умножена на backoff_factor и равна 0,2 секунды. Так пауза растёт до max_interval. При jitter=True к паузе добавляется случайное число от 0 до 1 секунды. Поэтому в примере стоит jitter=False, и строки журнала одинаковы при каждом выполнении примера.
Правки упавшей попытки LangGraph отбрасывает. Следующая попытка выполняет функцию узла с первой строки, как в уроке 4 при возврате в узел. Побочные действия упавшей попытки остаются: запись в файл, запрос к API, выполнение чужого файла.
Какие ошибки повторять: retry_on
Повторная попытка помогает, когда сбой может пройти сам: сеть, перегруженный сервис, медленный ответ. Ошибку в коде или в данных она не исправит: тот же вход снова даёт ту же ошибку.
По умолчанию retry_on равен функции default_retry_on из langgraph.types. Она повторяет любое исключение, кроме этих классов и их наследников: ValueError, TypeError, ArithmeticError, ImportError, LookupError, NameError, SyntaxError, RuntimeError, ReferenceError, StopIteration, StopAsyncIteration и OSError. Это ошибки кода и данных: неверное значение или тип, нет ключа, нет модуля, нет файла. Поэтому в пункте 4 примера 1 попытка одна. Встроенный ConnectionError функция проверяет раньше списка и повторяет: в примере 1 у базы было три попытки. Ошибку статуса httpx.HTTPStatusError она повторяет только с кодом 5xx. requests.HTTPError в langgraph 1.2.16 повторяется при любом коде ошибки, 4xx тоже. Код статуса функция проверяет, только если ответ сервера в поле response ошибки истинен. У объекта Response из requests логическое значение равно response.ok, а при кодах от 400 до 599 это False. Сетевые ошибки requests, например requests.ConnectionError, наследуют OSError и по умолчанию не повторяются. Встроенный ConnectionError они не наследуют: их предок RequestException, наследник OSError.
Пример 02_retry_on.py
"""Пример 2 урока 8: какие ошибки выполнения файла стоит повторять.
Пункт 1 печатает ответ default_retry_on для ошибок разных типов. Дальше
узел run_file выполняет файл из папки bad_repo отдельным процессом. Параметр
check=True превращает ненулевой код возврата в CalledProcessError, timeout
останавливает процесс и бросает TimeoutExpired. broken.py падает при каждом
выполнении, hang.py зависает. RUNS считает выполнения файла.
"""
import subprocess
import sys
from pathlib import Path
import requests
from langgraph.errors import NodeTimeoutError
from langgraph.graph import END, START, StateGraph
from langgraph.types import RetryPolicy, default_retry_on
from typing_extensions import TypedDict
REPO = Path(__file__).parent / "bad_repo"
RUNS: list[str] = []
NOT_FOUND = requests.Response()
NOT_FOUND.status_code = 404
class RunState(TypedDict):
root: str
current: str
output: str
def run_file(state: RunState) -> dict:
"""Выполняет файл стенда, ошибка выполнения выходит из узла исключением."""
RUNS.append(state["current"])
subprocess.run(
[sys.executable, state["current"]],
cwd=state["root"],
capture_output=True,
check=True,
timeout=1.5,
)
return {"output": "код 0"}
def is_transient(error: Exception) -> bool:
"""Повторять только зависание: ошибка в коде файла повторится и в следующий раз."""
return isinstance(error, subprocess.TimeoutExpired)
def build(retry_policy: RetryPolicy):
"""Граф из одного узла run_file."""
builder = StateGraph(RunState)
builder.add_node("run_file", run_file, retry_policy=retry_policy)
builder.add_edge(START, "run_file")
builder.add_edge("run_file", END)
return builder.compile()
# 1. Решение default_retry_on по типу ошибки.
print("1. default_retry_on:")
for error in (
ValueError(),
KeyError(),
OSError(),
TimeoutError(),
ConnectionError(),
requests.ConnectionError(),
requests.HTTPError(response=NOT_FOUND),
subprocess.CalledProcessError(1, "broken.py"),
subprocess.TimeoutExpired("hang.py", 1.5),
NodeTimeoutError("run_file", 1.5, kind="run", run_timeout=1.5),
):
name = f"{type(error).__module__.split('.')[0]}.{type(error).__name__}"
if getattr(error, "response", None) is not None:
name += f" {error.response.status_code}"
print(f" {name.removeprefix('builtins.'):<30} {default_retry_on(error)}")
BUILDS = [
("retry_on по умолчанию", "broken.py", RetryPolicy(initial_interval=0.1, jitter=False)),
(
"retry_on=is_transient",
"broken.py",
RetryPolicy(initial_interval=0.1, jitter=False, retry_on=is_transient),
),
(
"retry_on=is_transient, max_attempts=2",
"hang.py",
RetryPolicy(max_attempts=2, initial_interval=0.1, jitter=False, retry_on=is_transient),
),
]
for number, (title, name, policy) in enumerate(BUILDS, start=2):
RUNS.clear()
try:
build(policy).invoke({"root": str(REPO), "current": name, "output": ""})
except Exception as exc:
print(f"{number}. {title}, {name}: выполнений {len(RUNS)}, ошибка {type(exc).__name__}")
# Вывод:
# 1. default_retry_on:
# ValueError False
# KeyError False
# OSError False
# TimeoutError False
# ConnectionError True
# requests.ConnectionError False
# requests.HTTPError 404 True
# subprocess.CalledProcessError True
# subprocess.TimeoutExpired True
# langgraph.NodeTimeoutError True
# 2. retry_on по умолчанию, broken.py: выполнений 3, ошибка CalledProcessError
# 3. retry_on=is_transient, broken.py: выполнений 1, ошибка CalledProcessError
# 4. retry_on=is_transient, max_attempts=2, hang.py: выполнений 2, ошибка TimeoutExpired
Пункт 1: ответ default_retry_on по классу ошибки. Страница документации про отказоустойчивость пишет, что наследники OSError не повторяются, а ошибки HTTP повторяются только с кодом 5xx. Строки ConnectionError и requests.HTTPError 404 показывают, что в 1.2.16 это не так. Встроенный TimeoutError наследует OSError и не повторяется. NodeTimeoutError от таймаута узла наследует Exception и повторяется.
У subprocess.run с check=True ненулевой код возврата даёт CalledProcessError, а сработавший timeout даёт TimeoutExpired. Оба класса наследуют SubprocessError, которого в списке нет. Поэтому retry_on по умолчанию повторяет и падение файла, и его зависание.
Пункт 2: broken.py выполнился три раза. Каждая повторная попытка заново выполнила чужой файл, и ошибка в его коде повторилась. Для run_file значение retry_on по умолчанию не годится.
Пункт 3: retry_on принимает класс исключения, список классов или функцию. Функция получает исключение и возвращает True для тех, которые надо повторить. is_transient возвращает True только для TimeoutExpired, и broken.py выполнился один раз.
Пункт 4: hang.py выполнился дважды, и оба раза его остановил таймаут процесса в 1,5 секунды. Зависание бывает разовым: занятый порт, медленный ответ сервиса. Одна повторная попытка для него оправдана.
Параметр retry_policy принимает и список политик. Тогда срабатывает первая политика, у которой retry_on вернул True, и пауза берётся из неё. Счётчик попыток у задачи общий: с max_attempts сработавшей политики сравнивается число всех попыток.
Когда политика больше попыток не даёт и обработчика нет, исключение выходит из invoke, как в уроке 5. Если граф собран с чекпойнтером, ошибка упавшей задачи сохраняется в нём. Правки законченных задач того же шага тоже сохранены. Продолжение invoke(None, config) выполняет упавшую задачу заново, и политика снова даёт ей все попытки.
Таймаут узла: timeout и NodeTimeoutError
В примере 2 зависший файл остановил таймаут самого процесса, параметр timeout у subprocess.run. У вызова модели или запроса к API такого параметра может не быть. Тогда предел задают параметром timeout у add_node. Он ограничивает время одной попытки.
Число секунд или timedelta задают предел всей попытки. Объект TimeoutPolicy из langgraph.types задаёт пределы по отдельности:
1) run_timeout, таймаут попытки: время одной попытки от начала до конца
2) idle_timeout, таймаут простоя: время, за которое узел не подал ни одного признака работы. Каждый признак обнуляет отсчёт
Признаки работы:
1) события LangChain от узла и вложенных вызовов: токены модели, вызовы инструментов
2) данные, которые узел пишет в поток
3) запись правок в состояние и запуск дочерних задач
4) вызов runtime.heartbeat()
Если заданы оба предела, попытку заканчивает тот, что сработал первым.
Таймаут работает только у асинхронного узла, функции async def. LangGraph отменяет попытку средствами asyncio. Синхронную функцию внутри процесса так отменить нельзя.
Отмена срабатывает только там, где узел ждёт на await. Пока асинхронный узел стоит в блокирующем вызове, например time.sleep или subprocess.run, таймаут не срабатывает. Если после такого вызова узел закончил работу без await, попытка проходит, хотя предел превышен. Поэтому в примере 3 процесс файла запускает asyncio.create_subprocess_exec.
Пример 03_timeout.py
"""Пример 3 урока 8: таймаут попытки, таймаут простоя и процесс файла.
Узел run_file асинхронный: файл из bad_repo выполняется процессом из
asyncio.create_subprocess_exec. После каждой строки вывода узел вызывает
runtime.heartbeat(). hang.py зависает, chatty.py работает около секунды и
печатает строку каждые 0,25 секунды. Фабрика make_run_file собирает узел в
двух вариантах: без остановки процесса файла и с остановкой в блоке finally.
PROCESSES хранит процессы, чтобы после паузы в 0,5 секунды проверить,
работают ли они.
"""
import asyncio
import sys
from pathlib import Path
from langgraph.errors import NodeTimeoutError
from langgraph.graph import END, START, StateGraph
from langgraph.runtime import Runtime
from langgraph.types import RetryPolicy, TimeoutPolicy
from typing_extensions import TypedDict
REPO = Path(__file__).parent / "bad_repo"
PROCESSES: list[asyncio.subprocess.Process] = []
class RunState(TypedDict):
current: str
lines: int
def make_run_file(stop_process: bool):
"""Узел run_file. При stop_process=True процесс файла останавливается при отмене попытки."""
async def run_file(state: RunState, runtime: Runtime) -> dict:
process = await asyncio.create_subprocess_exec(
sys.executable,
state["current"],
cwd=REPO,
stdout=asyncio.subprocess.PIPE,
)
PROCESSES.append(process)
try:
lines = 0
async for _ in process.stdout:
lines += 1
runtime.heartbeat()
await process.wait()
return {"lines": lines}
finally:
if stop_process and process.returncode is None:
process.kill()
await process.wait()
return run_file
def build(node, timeout, retry_policy=None):
"""Граф из одного узла run_file с таймаутом."""
builder = StateGraph(RunState)
builder.add_node("run_file", node, timeout=timeout, retry_policy=retry_policy)
builder.add_edge(START, "run_file")
builder.add_edge("run_file", END)
return builder.compile()
def run_file_sync(state: RunState) -> dict:
"""Синхронный узел: таймаут для него не поддерживается."""
return {"lines": 0}
# 1. Таймаут у синхронного узла и вызов invoke у асинхронного.
try:
build(run_file_sync, timeout=0.5)
except ValueError as error:
print("1. компиляция синхронного узла:", error)
try:
build(make_run_file(True), timeout=0.5).invoke({"current": "ok.py", "lines": 0})
except ValueError as error:
print(" invoke асинхронного узла:", type(error).__name__)
async def main() -> None:
retry = RetryPolicy(max_attempts=2, initial_interval=0.1, jitter=False)
# 2 и 3. Зависший файл, таймаут 0,5 секунды, две попытки.
for number, stop_process in ((2, False), (3, True)):
PROCESSES.clear()
graph = build(make_run_file(stop_process), timeout=0.5, retry_policy=retry)
try:
await graph.ainvoke({"current": "hang.py", "lines": 0})
except NodeTimeoutError as error:
await asyncio.sleep(0.5)
alive = sum(process.returncode is None for process in PROCESSES)
title = "с остановкой процесса" if stop_process else "без остановки процесса"
print(f"{number}. hang.py {title}: {type(error).__name__}, kind={error.kind}")
print(f" попыток {len(PROCESSES)}, процессов файла работает {alive}")
# Уборка: процессы, пережившие таймаут, останавливаются здесь.
for process in PROCESSES:
if process.returncode is None:
process.kill()
await process.wait()
# 4 и 5. Файл работает дольше 0,6 секунды и печатает строку каждые 0,25 секунды.
node = make_run_file(True)
for number, title, policy in (
(4, "run_timeout=0.6", TimeoutPolicy(run_timeout=0.6)),
(5, "idle_timeout=0.6", TimeoutPolicy(idle_timeout=0.6)),
):
try:
result = await build(node, timeout=policy).ainvoke({"current": "chatty.py", "lines": 0})
print(f"{number}. chatty.py, {title}: строк {result['lines']}")
except NodeTimeoutError as error:
print(f"{number}. chatty.py, {title}: {type(error).__name__}, kind={error.kind}")
asyncio.run(main())
# Вывод:
# 1. компиляция синхронного узла: Node timeouts are only supported for async nodes because sync Python execution cannot be safely cancelled in-process. Node 'run_file' is sync.
# invoke асинхронного узла: ValueError
# 2. hang.py без остановки процесса: NodeTimeoutError, kind=run
# попыток 2, процессов файла работает 2
# 3. hang.py с остановкой процесса: NodeTimeoutError, kind=run
# попыток 2, процессов файла работает 0
# 4. chatty.py, run_timeout=0.6: NodeTimeoutError, kind=run
# 5. chatty.py, idle_timeout=0.6: строк 4
Пункт 1: синхронный узел с timeout не проходит компиляцию. Граф с асинхронным узлом вызывают методом ainvoke или astream. Вызов invoke у такого графа падает с тем же ValueError и тем же сообщением про синхронный узел.
Пункт 2: по таймауту LangGraph бросает NodeTimeoutError из langgraph.errors. Поле kind называет сработавший предел, run или idle. Поля node и elapsed называют узел и прошедшие секунды. retry_on по умолчанию повторяет NodeTimeoutError, поэтому попыток две. Отсчёт времени у каждой попытки свой.
Таймаут отменяет корутину узла. Процесс файла при этом продолжает работать. После двух попыток работают два процесса hang.py, третья попытка добавила бы третий.
Пункт 3: блок finally в run_file останавливает процесс методом kill, когда попытку отменили. LangGraph не ждёт конца этого блока: NodeTimeoutError выходит из ainvoke раньше. Поэтому пример проверяет процессы после паузы в 0,5 секунды.
Пункты 4 и 5: chatty.py работает около секунды. Таймаут попытки в 0,6 секунды его останавливает. Таймаут простоя той же длины даёт ему доработать: после каждой строки вывода узел вызывает runtime.heartbeat(), и отсчёт начинается заново. Чтение вывода процесса признаком работы для LangGraph не считается. Без heartbeat() таймаут простоя на этом файле срабатывает с kind=idle.
Синхронному узлу run_file из примера 2 хватает таймаута процесса: subprocess.run останавливает процесс сам. Таймаут узла нужен асинхронному узлу с вызовом модели или API. Таймаут простоя подходит долгому узлу, который регулярно подаёт признаки работы: выполнению тестов со строкой вывода на каждый тест или ответу модели по токенам.
Копия узла из Send получает свой таймаут параметром timeout у Send. Без параметра timeout у Send действует таймаут узла.
Обработчик ошибки: error_handler и NodeError
Обработчик ошибки срабатывает, когда узел упал и политика больше попыток не даёт: попытки исчерпаны, retry_on эту ошибку не повторяет или политики у узла нет. Обработчик передают в add_node параметром error_handler. Функция обработчика получает состояние и сведения о сбое. Сведения приходят в параметр с аннотацией NodeError из langgraph.errors. У NodeError два поля: node, имя узла, и error, исключение последней попытки.
Обработчик возвращает правку, как узел, или Command с update и goto из урока 3. Метод set_node_defaults у StateGraph задаёт обработчик по умолчанию, один на все узлы графа. Тот же метод задаёт значения по умолчанию для retry_policy, timeout и cache_policy. Значение из add_node заменяет значение по умолчанию.
Пример 04_error_handler.py
"""Пример 4 урока 8: обработчик ошибки узла и set_node_defaults.
Обход из уроков 4 и 5, в котором scan_file заменён узлом run_file из
урока 6, выполняет файлы папки bad_repo: collect_files, pick_file, run_file
и функция маршрутизации route_next. Обработчик skip_file стоит у
run_file: записывает сбой в находки и возвращает Command с переходом в
pick_file. Обработчик mark_failed задан всем узлам методом set_node_defaults:
записывает сбой в status и перехода не задаёт. RUNS считает выполнения
файлов, CALLS отмечает выполнение pick_file.
"""
import operator
import subprocess
import sys
from pathlib import Path
from typing import Annotated, Literal
from langgraph.errors import NodeError
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, RetryPolicy
from typing_extensions import TypedDict
REPO = Path(__file__).parent / "bad_repo"
RUNS: list[str] = []
CALLS: list[str] = []
class WalkState(TypedDict):
root: str
queue: list[str]
current: str
findings: Annotated[list[str], operator.add]
status: str
def collect_files(state: WalkState) -> dict:
"""Складывает имена файлов в очередь. Нет папки, значит FileNotFoundError."""
names = sorted(path.name for path in Path(state["root"]).iterdir() if path.suffix == ".py")
return {"queue": names, "current": ""}
def pick_file(state: WalkState) -> dict:
"""Берёт следующее имя из очереди и убирает его оттуда."""
CALLS.append("pick_file")
queue = list(state["queue"])
if not queue:
return {"current": ""}
return {"current": queue.pop(0), "queue": queue}
def route_next(state: WalkState) -> Literal["run_file", "__end__"]:
"""Пустое имя означает, что очередь закончилась."""
if not state["current"]:
return END
return "run_file"
def run_file(state: WalkState) -> dict:
"""Выполняет файл отдельным процессом, ошибка выходит из узла исключением."""
RUNS.append(state["current"])
subprocess.run(
[sys.executable, state["current"]],
cwd=state["root"],
capture_output=True,
check=True,
timeout=1.5,
)
return {"findings": [f"{state['current']}: код 0"]}
def skip_file(state: WalkState, error: NodeError) -> Command:
"""Обработчик ошибки run_file: запись о сбое и переход к следующему файлу."""
return Command(
update={"findings": [f"{state['current']}: {type(error.error).__name__}"]},
goto="pick_file",
)
def mark_failed(state: WalkState, error: NodeError) -> dict:
"""Обработчик по умолчанию: запись о сбое в status, без перехода."""
return {"status": f"сбой в {error.node}: {type(error.error).__name__}"}
RETRY = RetryPolicy(
max_attempts=2,
initial_interval=0.1,
jitter=False,
retry_on=subprocess.TimeoutExpired,
)
def build(with_handlers: bool):
"""Обход с обработчиками или без них. Политика повторных попыток одна."""
builder = StateGraph(WalkState)
if with_handlers:
builder.set_node_defaults(error_handler=mark_failed)
builder.add_node("collect_files", collect_files)
builder.add_node("pick_file", pick_file)
builder.add_node(
"run_file",
run_file,
retry_policy=RETRY,
error_handler=skip_file if with_handlers else None,
)
builder.add_edge(START, "collect_files")
builder.add_edge("collect_files", "pick_file")
builder.add_conditional_edges("pick_file", route_next)
builder.add_edge("run_file", "pick_file")
return builder.compile()
def start_state(root: Path) -> dict:
return {"root": str(root), "queue": [], "current": "", "findings": [], "status": ""}
# 1. Без обработчиков: первая ошибка заканчивает запуск.
RUNS.clear()
try:
build(with_handlers=False).invoke(start_state(REPO))
except Exception as exc:
print("1. без обработчиков: запуск упал,", type(exc).__name__, "| выполнения:", RUNS)
# 2. С обработчиками: сбой файла становится находкой, обход идёт дальше.
RUNS.clear()
result = build(with_handlers=True).invoke(start_state(REPO))
print("2. с обработчиками, выполнения:", RUNS)
for line in result["findings"]:
print(" ", line)
print(" status:", repr(result["status"]))
# 3. Папки нет: collect_files падает, срабатывает обработчик по умолчанию.
CALLS.clear()
result = build(with_handlers=True).invoke(start_state(REPO / "missing"))
print("3. папки нет, status:", result["status"])
print(" pick_file выполнялся:", bool(CALLS), "| находок:", len(result["findings"]))
# Вывод:
# 1. без обработчиков: запуск упал, CalledProcessError | выполнения: ['broken.py']
# 2. с обработчиками, выполнения: ['broken.py', 'chatty.py', 'hang.py', 'hang.py', 'ok.py']
# broken.py: CalledProcessError
# chatty.py: код 0
# hang.py: TimeoutExpired
# ok.py: код 0
# status: ''
# 3. папки нет, status: сбой в collect_files: FileNotFoundError
# pick_file выполнялся: False | находок: 0
Пункт 1: без обработчиков запуск закончился на первом файле очереди, broken.py.
Пункт 2: обработчик skip_file записал сбой в находки и перевёл обход в pick_file. Обход дошёл до ok.py. Политика повторяет только TimeoutExpired, поэтому broken.py попал в обработчик после первой попытки, а hang.py выполнился дважды.
Пункт 3: папки нет, и collect_files упал с FileNotFoundError. Своего обработчика у этого узла нет, поэтому сработал mark_failed из set_node_defaults. Он вернул правку без goto, и запуск закончился: pick_file не выполнялся. У обработчика нет своих рёбер. Рёбра упавшего узла после сбоя тоже не срабатывают. Обработчик без goto подходит, когда дальше упавшего узла идти не надо.
Из значений set_node_defaults на сами обработчики действуют retry_policy и timeout. Обработчик по умолчанию и cache_policy на них не действуют. Исключение в самом обработчике выходит из invoke, как ошибка узла без обработчика.
GraphInterrupt от паузы interrupt() из урока 6 не вызывает ни повторных попыток, ни обработчика. Граф встаёт на паузу как обычно.
Ошибка узла внутри подграфа из урока 7 выходит из узла родителя, в котором стоит подграф. Срабатывает обработчик этого узла родителя, и в error.error лежит исключение из подграфа. Значения set_node_defaults родителя на узлы подграфа не переходят: у каждого графа свои.
Допустим, после сбоя узла упал и сам обработчик. Тогда продолжение invoke(None, config) выполняет только обработчик. Упавший узел заново не выполняется. В error.error после продолжения лежит строка вида "CalledProcessError(1, ...)": чекпойнтер сохраняет исключение строкой. Страница документации про отказоустойчивость пишет, что обработчик получит тот же NodeError. Проверка isinstance(error.error, ...) после продолжения вернёт False. Обработчик skip_file из примера 4 запишет в находку имя str вместо класса ошибки.
Остановка по запросу: RunControl и GraphDrained
Бывает, что сервер с агентом перезапускают или планировщик снимает задачу. Процесс агента должен закончиться, а запуск графа потом надо продолжить с того же места. Для этого запуск останавливают по запросу снаружи, на границе шага. С чекпойнтером состояние на этой границе сохранено в checkpoint, и запуск потом продолжают. Документация называет такую остановку graceful shutdown, а сам запрос drain.
Запрос делают объектом RunControl из langgraph.runtime. Объект передают в invoke или stream параметром control. Остановку запрашивают методом request_drain(reason) из обработчика сигнала или из другого потока выполнения threading. Вызов графа заканчивается исключением GraphDrained из langgraph.errors. Причина остановки лежит в поле reason этого исключения.
Пример 05_drain.py
"""Пример 5 урока 8: остановка запуска по запросу и продолжение после неё.
Обход стенда mini_repo с чекпойнтером InMemorySaver. Узел run_file выполняет
файл отдельным процессом. Перед выполнением config.py узел сам вызывает
control.request_drain("sigterm"), поэтому остановка каждый раз приходится на
один и тот же файл. В рабочем приложении request_drain вызывает обработчик сигнала.
RUNS отмечает выполнения файлов.
"""
import operator
import subprocess
import sys
from pathlib import Path
from typing import Annotated, Literal
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.errors import GraphDrained
from langgraph.graph import END, START, StateGraph
from langgraph.runtime import RunControl
from typing_extensions import TypedDict
REPO = Path(__file__).parent / "mini_repo"
CONFIG = {"configurable": {"thread_id": "walk-1"}}
RUNS: list[str] = []
control = RunControl()
class WalkState(TypedDict):
root: str
queue: list[str]
current: str
findings: Annotated[list[str], operator.add]
def collect_files(state: WalkState) -> dict:
"""Складывает имена файлов стенда в очередь обхода."""
names = sorted(path.name for path in Path(state["root"]).glob("*.py"))
return {"queue": names, "current": ""}
def pick_file(state: WalkState) -> dict:
"""Берёт следующее имя из очереди и убирает его оттуда."""
queue = list(state["queue"])
if not queue:
return {"current": ""}
return {"current": queue.pop(0), "queue": queue}
def route_next(state: WalkState) -> Literal["run_file", "__end__"]:
"""Пустое имя означает, что очередь закончилась."""
if not state["current"]:
return END
return "run_file"
def run_file(state: WalkState) -> dict:
"""Выполняет файл стенда. Перед выполнением config.py запрашивает остановку."""
RUNS.append(state["current"])
if state["current"] == "config.py":
control.request_drain("sigterm")
done = subprocess.run(
[sys.executable, state["current"]],
cwd=state["root"],
capture_output=True,
timeout=1.5,
)
return {"findings": [f"{state['current']}: код {done.returncode}"]}
builder = StateGraph(WalkState)
builder.add_node("collect_files", collect_files)
builder.add_node("pick_file", pick_file)
builder.add_node("run_file", run_file)
builder.add_edge(START, "collect_files")
builder.add_edge("collect_files", "pick_file")
builder.add_conditional_edges("pick_file", route_next)
builder.add_edge("run_file", "pick_file")
graph = builder.compile(checkpointer=InMemorySaver())
START_STATE = {"root": str(REPO), "queue": [], "current": "", "findings": []}
# 1. Запуск с RunControl: остановка на границе шага после config.py.
try:
graph.invoke(START_STATE, CONFIG, control=control)
except GraphDrained as error:
print("1. GraphDrained, reason:", error.reason, "| выполнения:", RUNS)
# 2. Что сохранил чекпойнтер.
snapshot = graph.get_state(CONFIG)
print("2. next:", snapshot.next, "| находки:", snapshot.values["findings"])
# 3. Отработавший RunControl остаётся в состоянии остановки.
print("3. control.drain_requested:", control.drain_requested)
# 4. Продолжение тем же thread_id, без control.
RUNS.clear()
result = graph.invoke(None, CONFIG)
print("4. продолжение, выполнения:", RUNS)
print(" находки:", result["findings"])
# Вывод:
# 1. GraphDrained, reason: sigterm | выполнения: ['app.py', 'config.py']
# 2. next: ('pick_file',) | находки: ['app.py: код 0', 'config.py: код 0']
# 3. control.drain_requested: True
# 4. продолжение, выполнения: ['db.py', 'utils.py']
# находки: ['app.py: код 0', 'config.py: код 0', 'db.py: код 0', 'utils.py: код 0']
Пункт 1: запрос пришёл в узле run_file перед выполнением config.py. Узел доработал, и запуск остановился на границе шага, перед pick_file. Выполняющийся узел запрос не прерывает, поэтому зависший файл без таймаута задержит и остановку. Повторные попытки, которые уже идут, тоже доходят до конца: до успеха или до последней попытки.
Пункт 2: checkpoint сохранил находки обоих файлов, в next стоит pick_file.
Пункт 3: RunControl рассчитан на один вызов. После запроса он остаётся в состоянии остановки, и следующий вызов с ним остановится до первого узла. Для каждого вызова создают новый объект.
Пункт 4: продолжение такое же, как после сбоя в уроке 5: invoke(None, config) с тем же thread_id. Без чекпойнтера GraphDrained тоже приходит. Продолжать запуск тогда не от чего.
Если запрос пришёл на последнем шаге, вызов возвращает обычный результат. Такой запуск отличают по control.drain_requested. Узел проверяет запрос по полям runtime.drain_requested и runtime.drain_reason. Если остановка уже запрошена, узел может пропустить долгую работу.
request_drain вызывает обработчик сигнала остановки процесса: signal.signal(signal.SIGTERM, lambda *_: control.request_drain("sigterm")). На Windows signal.signal принимает только SIGABRT, SIGFPE, SIGILL, SIGINT, SIGSEGV, SIGTERM и SIGBREAK, на другие бросает ValueError. Придёт ли процессу SIGTERM на Windows, страница модуля signal не говорит. Ctrl+C в консоли даёт SIGINT, Ctrl+Break даёт SIGBREAK.
Отладка без внешнего сервиса
Место сбоя находят средствами самого LangGraph: режимами потока, историей checkpoints и вызовом узла в тесте. Режимы для отладки:
1) tasks: начало и конец каждой задачи. В событии конца лежат правка задачи и ошибка
2) checkpoints: каждый checkpoint в той же форме, что у get_state
3) debug: события tasks и checkpoints вместе. Каждое завёрнуто в словарь с полями type, step, timestamp и payload
Пример 06_debug.py
"""Пример 6 урока 8: отладка графа без внешнего сервиса.
Обход папки bad_repo без узла collect_files: очередь из broken.py и ok.py
приходит во входе. У run_file RetryPolicy с max_attempts=2 и retry_on по
умолчанию, и обработчик skip_file из примера 4. Пункты: поток tasks,
события потоков debug и checkpoints, поиск упавшей задачи в истории,
вызов узла из graph.nodes и частичный запуск. RUNS считает выполнения
файлов, HANDLED считает вызовы обработчика.
"""
import operator
import subprocess
import sys
from collections import Counter
from pathlib import Path
from typing import Annotated, Literal
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.errors import NodeError
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, RetryPolicy
from typing_extensions import TypedDict
REPO = Path(__file__).parent / "bad_repo"
RUNS: list[str] = []
HANDLED: list[str] = []
class WalkState(TypedDict):
root: str
queue: list[str]
current: str
findings: Annotated[list[str], operator.add]
def pick_file(state: WalkState) -> dict:
"""Берёт следующее имя из очереди и убирает его оттуда."""
queue = list(state["queue"])
if not queue:
return {"current": ""}
return {"current": queue.pop(0), "queue": queue}
def route_next(state: WalkState) -> Literal["run_file", "__end__"]:
"""Пустое имя означает, что очередь закончилась."""
if not state["current"]:
return END
return "run_file"
def run_file(state: WalkState) -> dict:
"""Выполняет файл отдельным процессом, ошибка выходит из узла исключением."""
RUNS.append(state["current"])
subprocess.run(
[sys.executable, state["current"]],
cwd=state["root"],
capture_output=True,
check=True,
timeout=1.5,
)
return {"findings": [f"{state['current']}: код 0"]}
def skip_file(state: WalkState, error: NodeError) -> Command:
"""Обработчик ошибки run_file: запись о сбое и переход к следующему файлу."""
HANDLED.append(state["current"])
return Command(
update={"findings": [f"{state['current']}: {type(error.error).__name__}"]},
goto="pick_file",
)
def build(checkpointer):
builder = StateGraph(WalkState)
builder.add_node("pick_file", pick_file)
builder.add_node(
"run_file",
run_file,
retry_policy=RetryPolicy(max_attempts=2, initial_interval=0.1, jitter=False),
error_handler=skip_file,
)
builder.add_edge(START, "pick_file")
builder.add_conditional_edges("pick_file", route_next)
builder.add_edge("run_file", "pick_file")
return builder.compile(checkpointer=checkpointer)
def start_state(queue: list[str]) -> dict:
return {"root": str(REPO), "queue": queue, "current": "", "findings": []}
graph = build(InMemorySaver())
QUEUE = ["broken.py", "ok.py"]
# 1. Поток tasks: начало и конец каждой задачи.
print("1. поток tasks:")
for chunk in graph.stream(
start_state(QUEUE),
{"configurable": {"thread_id": "walk-1"}},
stream_mode="tasks",
version="v2",
):
task = chunk["data"]
if "result" not in task:
print(f" начало {task['name']}")
else:
error = type(task["error"]).__name__ if task["error"] else "-"
print(f" конец {task['name']:<26} ошибка {error:<18} правка {task['result']}")
print(" выполнения файлов:", RUNS)
# 2. Сколько событий каждого типа отдают потоки debug и checkpoints.
print("2. события потоков:")
for title, checkpointer in (("с чекпойнтером", InMemorySaver()), ("без чекпойнтера", None)):
for mode in ("debug", "checkpoints"):
events = Counter(
chunk["data"]["type"] if mode == "debug" else chunk["type"]
for chunk in build(checkpointer).stream(
start_state(QUEUE),
{"configurable": {"thread_id": "walk-2"}},
stream_mode=mode,
version="v2",
)
)
print(f" {mode:<11} {title}: {dict(events)}")
# 3. Шаг с упавшей задачей в истории thread walk-1.
print("3. история walk-1, задачи с ошибкой:")
for snapshot in graph.get_state_history({"configurable": {"thread_id": "walk-1"}}):
for task in snapshot.tasks:
if task.error:
print(f" шаг {snapshot.metadata['step']}, {task.name}, current={snapshot.values['current']}")
print(f" ошибка: {task.error.split('(')[0]}")
# 4. Узел напрямую: без повторных попыток и без обработчика.
RUNS.clear()
HANDLED.clear()
try:
graph.nodes["run_file"].invoke({**start_state([]), "current": "broken.py"})
except subprocess.CalledProcessError as error:
print(
"4. graph.nodes['run_file']:", type(error).__name__,
"| выполнений:", len(RUNS), "| вызовов обработчика:", len(HANDLED),
)
# 5. Частичный запуск: состояние как после pick_file, остановка после run_file.
RUNS.clear()
config = {"configurable": {"thread_id": "test-1"}}
graph.update_state(config, start_state(["broken.py"]) | {"current": "ok.py"}, as_node="pick_file")
result = graph.invoke(None, config, interrupt_after="run_file")
print("5. частичный запуск, выполнения:", RUNS, "| находки:", result["findings"])
print(" next:", graph.get_state(config).next)
# Вывод:
# 1. поток tasks:
# начало pick_file
# конец pick_file ошибка - правка {'current': 'broken.py', 'queue': ['ok.py']}
# начало run_file
# конец run_file ошибка CalledProcessError правка {}
# конец __error_handler__run_file ошибка - правка {'findings': ['broken.py: CalledProcessError']}
# начало pick_file
# конец pick_file ошибка - правка {'current': 'ok.py', 'queue': []}
# начало run_file
# конец run_file ошибка - правка {'findings': ['ok.py: код 0']}
# начало pick_file
# конец pick_file ошибка - правка {'current': ''}
# выполнения файлов: ['broken.py', 'broken.py', 'ok.py']
# 2. события потоков:
# debug с чекпойнтером: {'checkpoint': 7, 'task': 5, 'task_result': 6}
# checkpoints с чекпойнтером: {'checkpoints': 7}
# debug без чекпойнтера: {'task': 5, 'task_result': 6}
# checkpoints без чекпойнтера: {}
# 3. история walk-1, задачи с ошибкой:
# шаг 1, run_file, current=broken.py
# ошибка: CalledProcessError
# 4. graph.nodes['run_file']: CalledProcessError | выполнений: 1 | вызовов обработчика: 0
# 5. частичный запуск, выполнения: ['ok.py'] | находки: ['ok.py: код 0']
# next: ('pick_file',)
Пункт 1: у задачи run_file для broken.py одно начало и один конец, хотя файл выполнился дважды. Повторные попытки идут внутри задачи, и поток их не показывает. В событии конца упавшей задачи лежат исключение и пустая правка. Событие конца обработчика пришло под именем узла __error_handler__run_file, события начала у него нет. Под тем же именем правка обработчика приходит в режиме updates. Обработчик из set_node_defaults называется __default_error_handler__.
Пункт 2: без чекпойнтера режим checkpoints пуст, а debug отдаёт только события задач. Страница документации про стриминг пишет, что режиму tasks нужен чекпойнтер. В 1.2.16 события задач приходят и без него.
Пункт 3: в истории checkpoint шага 1 хранит задачу run_file с ошибкой, хотя после сбоя сработал обработчик. Значения этого checkpoint показывают состояние перед сбоем: current=broken.py. Ошибка лежит строкой, как в уроке 5.
Пункт 4: graph.nodes отдаёт узлы скомпилированного графа по имени. Метод invoke у узла выполняет его функцию без чекпойнтера, без повторных попыток и без обработчика. broken.py выполнился один раз, обработчик не вызван. Так тестом проверяют одну функцию узла. В поле retry_policy узла из graph.nodes лежат его политики, в том числе заданные set_node_defaults.
Пункт 5: частичный запуск проверяет участок графа. update_state с as_node="pick_file" записывает состояние так, будто pick_file только что выполнился. Вызов invoke(None, config, interrupt_after="run_file") выполняет run_file и останавливается после него. В уроке 6 interrupt_after назван параметром компиляции, здесь он передан в вызов.
Слой из урока 7 проверяют отдельно: скомпилированный подграф вызывают методом invoke со входом слоя, без родителя.
Слой выполнения в агенте
Слой выполнения из урока 7, это подграф из узлов plan_run, review_run и run_file со схемой RunState. Настройки из этого урока стоят в нём так:
1) run_file остаётся синхронным и выполняет файл вызовом subprocess.run с check=True и timeout. Зависший файл останавливает таймаут процесса
2) у run_file политика повторных попыток с retry_on, который возвращает True только для TimeoutExpired. Ошибка в коде чужого файла возникает при каждом выполнении, поэтому её не повторяют
3) обработчик у run_file пишет сбой в output, например "сбой: TimeoutExpired", и перехода не задаёт. На этом слой заканчивается, а родительский граф идёт дальше по своим рёбрам
4) обработчик по умолчанию в set_node_defaults графа слоя срабатывает на plan_run и review_run
5) пауза interrupt() в review_run остаётся: обработчик ошибки её не перехватывает
set_node_defaults родителя на узлы слоя не переходит. Если у узла слоя обработчика нет, ошибка выходит из слоя в узел родителя run_step. Её ловит обработчик run_step, а без него она выходит из invoke. Когда обработчики есть у всех узлов слоя, ошибку узла ловит обработчик внутри слоя.
Родительскому графу нужен чекпойнтер SqliteSaver из урока 5. Каждый вызов, продолжение тоже, получает новый RunControl. Обработчик сигнала вызывает request_drain у объекта текущего вызова. Запуск потом продолжается вызовом invoke(None, config).
Для отладки родителя вызывают методом stream в режиме tasks с subgraphs=True. Сбои run_file ловит обработчик внутри слоя, и у задач родителя поле error пустое. События задач слоя с непустым полем error скрипт отладки пишет в журнал. Тесты вызывают graph.nodes["run_file"] у скомпилированного слоя на файлах вроде broken.py.
Практическое задание
Соберите обход папки bad_repo, который доходит до последнего файла при любом сбое и останавливается по запросу.
Требования:
1) граф обхода из примера 4: collect_files, pick_file, функция маршрутизации route_next и run_file с subprocess.run(..., check=True, timeout=1.5)
2) политика повторных попыток задана всем узлам методом set_node_defaults: max_attempts=2, initial_interval=0.1, jitter=False, retry_on=is_transient из примера 2
3) у run_file обработчик skip_file из примера 4
4) чекпойнтер InMemorySaver, первый вызов получает RunControl параметром control
5) узел run_file сам вызывает request_drain перед выполнением chatty.py, как в примере 5
6) после GraphDrained скрипт продолжает запуск вызовом stream(None, config, stream_mode="tasks", version="v2") и собирает события
Как проверить результат:
1) первый вызов закончился GraphDrained, next в get_state равен ("pick_file",)
2) после продолжения в findings четыре строки по порядку: "broken.py: CalledProcessError", "chatty.py: код 0", "hang.py: TimeoutExpired", "ok.py: код 0"
3) broken.py выполнился один раз, hang.py два раза
4) среди событий продолжения одно событие конца задачи __error_handler__run_file, его правка относится к hang.py
5) у collect_files и pick_file в graph.nodes[имя].retry_policy лежит та же политика, что у run_file: значение из set_node_defaults дошло до узлов без своей политики
6) если убрать retry_on из политики, проверка 3 перестаёт сходиться: broken.py выполняется дважды
Подсказка: событие обработчика узнают по полю name. Выполнения файлов собирайте списком RUNS, как в примере 4.
Итоги урока
Попытка, это одно выполнение функции узла. Без retry_policy у узла одна попытка. RetryPolicy выполняет функцию заново внутри той же задачи, по умолчанию до трёх попыток вместе с первой. Побочные действия упавшей попытки остаются.
default_retry_on повторяет любое исключение, кроме ошибок кода и данных из своего списка: среди них ValueError, LookupError и OSError. Встроенный ConnectionError, CalledProcessError и TimeoutExpired повторяются. Узлу, который выполняет чужой файл, retry_on задают явно.
Параметр timeout у add_node работает только у асинхронного узла и срабатывает, только пока узел ждёт на await. run_timeout ограничивает всю попытку, idle_timeout время без признаков работы. Вызов runtime.heartbeat() обнуляет отсчёт простоя. retry_on по умолчанию повторяет NodeTimeoutError. Таймаут отменяет корутину узла, но процесс файла не останавливает: это делает сам узел в блоке finally. LangGraph не ждёт конца этого блока, и NodeTimeoutError выходит из вызова раньше, чем процесс остановлен.
Обработчик из error_handler срабатывает, когда политика больше попыток не даёт, и получает NodeError с полями node и error. С Command и goto он переводит запуск в другой узел. Без goto рёбра упавшего узла не срабатывают, и в примере 4 запуск закончился. set_node_defaults задаёт всем узлам графа обработчик, политику, таймаут и кеш по умолчанию. Значение из add_node их заменяет. Если упал сам обработчик, продолжение выполняет только его, и в error.error тогда лежит строка.
Остановку по запросу делает request_drain() у RunControl: запуск останавливается на границе шага, вызов заканчивается исключением GraphDrained. Выполняющийся узел дорабатывает. С чекпойнтером запуск продолжается вызовом invoke(None, config). RunControl для каждого вызова создают новый.
Режим потока tasks показывает начало и конец задачи. В событии конца лежат правка и ошибка. У обработчика ошибки есть только событие конца. Режим checkpoints с чекпойнтером отдаёт каждый checkpoint, debug оба вида событий вместе. Упавшая задача хранится в истории с ошибкой строкой. graph.nodes[имя].invoke проверяет функцию узла, а update_state с as_node и interrupt_after в вызове проверяют участок графа.
Этим уроком курс по LangGraph закончен. Агент, который разбирает чужой репозиторий, теперь собирается из графа со своим состоянием, ветвлений, циклов с остановкой по признаку, сохранения и продолжения запуска. К ним добавились пауза перед действием с ответом человека, слои подграфами, повторные попытки и таймауты узлов, остановка по запросу.
Код урока
Примеры этого урока лежат в репозитории курса, папка lesson_08. Закреплённые версии, на которых получен вывод в тексте, лежат в requirements.txt в корне репозитория.
Предыдущий урок: Подграфы: слой как отдельный граф
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться разрабатывать AI агентов, пишите, обсудим условия
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru