Суть
Раз узлы могут вызываться повторно (retry, восстановление из чекпоинта, возобновление после HITL), исполнение должно быть устойчиво к повторам и сбоям. Три опоры: идемпотентность, ограничение циклов, политика повторов.
Как работает
Идемпотентность. Повторный вызов узла с теми же входными данными даёт тот же результат. Обязательна, если узел может вызваться снова. Приёмы: детерминировать LLM (temperature=0, seed), проверять operation_id перед необратимой операцией, передавать idempotency_key в API, кэшировать результат в state. Особый случай: interrupt() возобновляет узел с начала, поэтому необратимый код (списание) нельзя ставить до прерывания — выносим его за узел с interrupt() (см. LangGraph HITL).
Циклы и лимиты. Цикличный граф (узел, возвращающий управление себе) рискует зациклиться, если маршрутизатор не сработает (например, агент бесконечно дописывает сообщения). Решение — жёсткий лимит итераций + условное ребро на выход/fallback при превышении порога.
RetryPolicy. Повтор узла при временных ошибках (таймаут API, невалидный JSON) с экспоненциальной задержкой:
from langgraph.types import RetryPolicy
retry = RetryPolicy(max_attempts=3, initial_interval=1.0,
backoff_factor=2.0, max_interval=10.0, jitter=True) # 1с→2с→4с, плюс 0–1 с сверху
workflow.add_node("api_call", api_call_node, retry=retry)
Значения по умолчанию, если политику не настраивать: max_attempts=3, initial_interval=0.5, backoff_factor=2.0, max_interval=128.0, jitter=True.
Runbook типовых отказов:
| Отказ | Решение |
|---|---|
| Потеря состояния | PostgreSQL checkpointer (LangGraph Checkpointers) |
| Бесконечные циклы | Лимит итераций + fallback |
Неидемпотентный код до interrupt() |
Вынести код за узел с interrupt() |
| Опасные действия агента | interrupt на каждый опасный tool |
| Дорогие LLM при replay | Кэшировать результаты в state |
Контракт узла: валидация на границе
Узел, который молча вернул неполный результат, ломает не себя, а того, кто читает состояние следом. Дальше по цепочке это выглядит как KeyError в третьем узле от места аварии или, хуже, как тихо испорченный результат — и отладка начинается не там, где сломалось.
Лечится это тем, что каждый узел явно объявляет, чего ждёт на входе и что обязан вернуть:
def validate_node(required_in: list[str], required_out: list[str]):
def deco(fn):
@wraps(fn)
def wrapper(state) -> dict:
for k in required_in: # вход: чего не хватает — видно сразу
if k not in state:
log_error_span(fn.__name__, f"missing input: {k}")
return {"errors": [*state.get("errors", []), {"node": fn.__name__, "missing": k}]}
out = fn(state)
for k in required_out: # выход: узел перестал что-то отдавать — тоже ловим
if k not in out:
log_error_span(fn.__name__, f"missing output: {k}")
return {"errors": [*state.get("errors", []), {"node": fn.__name__, "missing_out": k}]}
return out
return wrapper
return deco
@validate_node(["deduplicated"], ["scraped"])
def n_scrape_safe(state): return n_scrape(state)
Две детали делают этот декоратор рабочим, а не декоративным.
Нарушение не бросает исключение, а копится в состоянии. Узел возвращает запись в state["errors"] и отдаёт управление дальше. Граф не падает целиком из-за одной строки, а доходит до конца с явным списком того, что не получилось, — и этот список потом читают алерты и метрики. Падать имеет смысл там, где продолжать бессмысленно, и это отдельное решение, а не побочный эффект отсутствующего ключа.
Ошибка пишется в трассировку как span уровня ERROR. В интерфейсе наблюдаемости видно не «что-то сломалось», а конкретно какой узел и какого ключа ему не хватило (см. Agent Observability).
Побочная польза важнее заявленной: узлы перестают быть набором функций, читающих общий словарь наугад, и становятся компонентами с объявленным интерфейсом. Внутренности узла после этого можно переписывать, не перечитывая весь граф — контракт зафиксирован в одной строке над функцией.
Лимит супершагов: где его задают и как задают мимо
Потолок числа супершагов в LangGraph — recursion_limit. По умолчанию 1000 (на версии 1.0.6); при превышении поднимается GraphRecursionError.
Ключ верхнего уровня, а не внутри configurable. Это единственное место, где здесь легко ошибиться, и ошибка молчаливая:
graph.invoke(inputs, {"recursion_limit": 10}) # читается
graph.invoke(inputs, {"configurable": {"recursion_limit": 10}}) # не читается
Вложенный вариант не вызывает ни ошибки, ни предупреждения — просто применяется значение по умолчанию. То есть автор считает, что поставил лимит в десять шагов, а граф спокойно крутится до тысячи. Ровно тот же класс, что manager_llm при Process.sequential у CrewAI (CrewAI): параметр принят, прочитан не будет, и узнать об этом неоткуда.
Обрыв исключением — не единственный вариант, и не лучший. Управляемое состояние RemainingSteps даёт узлу текущий остаток шагов, и решение принимается внутри графа, до срабатывания потолка:
from langgraph.managed import RemainingSteps
class State(TypedDict):
messages: Annotated[list, operator.add]
remaining_steps: RemainingSteps
def agent(state: State) -> dict:
if state["remaining_steps"] <= 2:
return {"messages": ["дошёл досюда, дальше бюджета не хватает"]}
...
Это техническая реализация правила из ReAct: на исчерпании лимита пользователю полезнее частичный результат со статусом, чем исключение. Перехват GraphRecursionError снаружи оставляет тот же сценарий, но уже без частичного результата — граф упал, накопленное состояние осталось только в чекпоинте.
Circuit Breaker и джиттер на сетевом слое
Ретраи без разброса создают собственную проблему — thundering herd: все клиенты, получившие 429, возвращаются одновременно и добивают сервис. Экспоненциальная выдержка с джиттером размазывает пик по времени.
@retry(
wait=wait_random_exponential(min=1, max=10),
stop=stop_after_attempt(3),
retry=retry_if_exception_type(openai.RateLimitError),
reraise=True,
)
Разброс бывает разной силы, и разница между вариантами не косметическая. Здесь важно не ошибиться в том, что именно делает jitter=True в RetryPolicy: это не отклонение в обе стороны и не доля от паузы, а прибавка сверху. Реализация добавляет к расчётному интервалу случайную величину от 0 до 1 секунды — interval + random.uniform(0, 1).
Отсюда два следствия, которые меняют применимость. Прибавка односторонняя: пауза только удлиняется, раньше расчётного момента не вернётся никто. И прибавка абсолютная, поэтому её вес падает с ростом интервала: на первой попытке при initial_interval=0.5 секунда сверху — это до +200%, а у потолка max_interval=128 та же секунда даёт меньше процента. То есть ровно там, где клиенты уже долго ждут и вот-вот вернутся все разом, штатный джиттер практически перестаёт их разводить.
Full Jitter отказывается от центра совсем: пауза берётся равномерно от нуля до расчётного потолка.
sleep = random.uniform(0, min(max_backoff, base * factor ** attempt))
Расчётное значение здесь работает не как цель, а как верхняя граница. Часть клиентов вернётся почти сразу, часть — на всём интервале, и совпадений между ними практически не остаётся. Стоит это того, что отдельный клиент иногда ждёт дольше необходимого; выигрывает система в целом. Показание к применению — общий ресурс, который повторами добивают: та же база чекпоинтов, куда после сетевого сбоя разом ломятся все воркеры.
Ретраи спасают от кратковременного всплеска, но не от долгой недоступности провайдера — там нужен circuit breaker. Состояния классические: Closed (норма) → Open (авария, запросы к основному провайдеру не идут вообще) → Half-Open (пробный запрос) → обратно. Ориентир срабатывания из продакшен-факультатива: более 15% ошибок 5xx за 30 секунд. Порог здесь на порядок выше, чем у алерта (2% за 2 минуты) и у гейта канареечной выкатки (1% за 3 минуты), и это не разнобой источников: размыкание цепи выключает провайдера целиком, поэтому срабатывать оно должно только на настоящей аварии. Сравнение трёх порогов — в AI Observability Stack.
import pybreaker
db_breaker = pybreaker.CircuitBreaker(fail_max=5, reset_timeout=60)
def robust_inference_gateway(prompt):
try:
return call_primary_llm_api(prompt)
except (pybreaker.CircuitBreakerError, Exception):
# аварийный переход на локальный инференс
fallback = OpenAI(base_url='http://vllm-local:8000/v1')
return fallback.chat.completions.create(model='llama-3-local', messages=[...])
Смысл конструкции не в самом переключателе, а в наличии запасного пути: локальная модель на vLLM держит сервис живым, пока облачный провайдер недоступен. Без fallback размыкание цепи просто превращает ошибки провайдера в ошибки вашего сервиса.
Четыре таймаута и бюджет ретраев
Один таймаут на запрос — это недоделанная настройка. LLM-вызов растянут во времени неравномерно, и разные его фазы ломаются по-разному, поэтому границ нужно четыре:
- Connect — установка соединения; срабатывает быстро и означает сетевую проблему, а не медленную модель.
- TTFT — ожидание первого токена; здесь ловится перегруженный провайдер.
- Idle — пауза между токенами в стриме; ловит зависший поток, который формально не закрыт.
- Deadline — общий потолок на весь вызов; страховка от всего, что не поймали предыдущие три.
Без разделения приходится ставить один большой таймаут по худшему сценарию — и тогда зависший стрим держит слот минутами, потому что до общего потолка ещё далеко.
Ретрай — это политика из четырёх решений, а не try/except: что повторяем (только транзиентное — сеть, 5xx, 429), сколько раз (жёсткий потолок), с какой выдержкой (экспонента с джиттером — см. выше про thundering herd) и в пределах какого бюджета. Бюджет ретраев ограничивает их долю от общего числа запросов: без него при массовом сбое провайдера система сама себя добивает, утраивая нагрузку в худший момент.
Жёсткие границы агентного цикла. Агентный цикл — это while True с привязанной кредиткой, и ограничивать его нужно по четырём независимым осям сразу: число шагов, деньги, время и детекция зацикливания. Первые три очевидны, четвёртая — нет: счётчик шагов честно досчитает до лимита, пока агент вызывает один и тот же инструмент с одним и тем же аргументом. Дешёвый признак — хэш значимого подмножества состояния: повторился трижды подряд, значит прогресса нет, и цикл пора рвать принудительно, не дожидаясь исчерпания бюджета.
Детектор стагнации в коде
Механизм из предыдущего раздела сводится к нескольким строкам: значимые параметры шага сворачиваются в хэш, и совпадение хэша подряд означает, что прогресса нет.
class StagnationDetector:
@staticmethod
def compute_hash(query: str, amount: float) -> str:
# в хэш идут только параметры, определяющие смысл шага,
# а не весь стейт: иначе меняющийся таймстемп даст новый хэш всегда
payload = f"{query}_{amount}".encode("utf-8")
return hashlib.sha256(payload).hexdigest()
Существенно, что именно попадает в хэш. Брать состояние целиком бессмысленно: любое служебное поле со временем или счётчиком сделает каждый шаг «новым». Хэшируют критическое подмножество — текст запроса, аргументы вызова, — то есть ровно то, повторение чего и означает зацикливание.
Таймаут ставят по замеру, а не по интуиции
Распределение латентности у LLM-вызова с тяжёлым правым хвостом: p99 отличается от медианы в разы. Таймаут, выставленный «по среднему плюс запас», отсекает не сбои, а легитимные медленные запросы — и выглядит это как деградация качества, хотя провайдер работал штатно.
Правильный порядок обратный: сначала померить распределение на своих реальных вызовах, потом поставить границу по p95 или p99. Значение, взятое из головы, гарантированно неверно — в одну сторону или в другую.
Retry budget: сервис, который сам себя тормозит
Экспоненциальная выдержка с джиттером размазывает всплеск, но не ограничивает его суммарный объём: при массовом сбое провайдера все инстансы всё равно будут повторять, и нагрузка вырастет кратно.
Бюджет ретраев ограничивает саму долю повторов. Механика — «ведро с токенами»: каждый успешный вызов подкидывает в ведро немного, каждый повтор снимает токен. Пока сбои единичны, ведро полно и ретраи бесплатны. Когда падает всё, ведро быстро пустеет — и сервис перестаёт повторять, то есть сам себя throttle'ит вместо того, чтобы добивать провайдера.
Порог актуальности простой: бюджет нужен, как только под нагрузкой работает больше одного инстанса.
Те же четыре границы вне LangGraph — и слой, о котором забывают
Раздел живёт в заметке про LangGraph, но ни таймауты, ни повторы этому каркасу не принадлежат: их приходится настраивать даже там, где никакого каркаса нет, потому что первым их настраивает клиент провайдера.
Официальный питоновский клиент Claude тому пример. По умолчанию запрос отваливается через десять минут, и это значение настраивается либо одним числом, либо раздельно — connect, read, write отдельными полями таймаута транспорта. Повторы там тоже уже есть: два по умолчанию с короткой экспоненциальной выдержкой, и перечень повторяемого совпадает с политикой из предыдущего раздела почти дословно — обрывы соединения, 408, 409, 429 и всё от 500. Настраивается это max_retries на клиенте или на отдельном запросе. Отдельно документирован случай длинного запроса без стриминга: клиент откажется его отправлять, если ожидаемая длительность превышает те же примерно десять минут, и предложит стриминг — ровно потому, что сети рвут простаивающие соединения.
Второй каркас показывает то же самое своими именами: в Mastra повторы задаются retryConfig с полями attempts и delay на уровне рабочего процесса и переопределяются полем retries на отдельном шаге. Три независимых места сходятся на одном наборе ручек — это и есть признак, что настройка принадлежит задаче, а не инструменту.
Практическое следствие, которое видно только при взгляде на все слои сразу: таймаут и повторы перемножаются, а слои повторов складываются. Клиент, у которого истёк таймаут, повторяет запрос — то есть худшее время ожидания равно таймауту, умноженному на число попыток. А если поверх клиента стоит ещё и политика повторов узла графа, попытки перемножаются: три попытки узла поверх трёх попыток клиента дают девять запросов и девять таймаутов подряд, чего в конфигурации ни одного из двух слоёв не написано.
Отсюда правило: повторы держат на одном слое, а остальные явно выключают. Какой слой выбрать, решается по тому, где виден бюджет: клиент знает про запрос, узел графа — про шаг, и только внешний контур знает про весь агентный цикл.
Связано с
- Agent CostControl —
RetryPolicy, лимиты итераций, дорогой replay = контроль стоимости - LangGraph HITL — идемпотентность вокруг
interrupt() - LangGraph Checkpointers — персистентность как ответ на потерю состояния
- LangGraph — «метрики качества» контролируемого workflow
- Agent Observability — куда уходит ERROR-span при нарушении контракта узла
- LangGraph State — общее состояние, доступ к которому контракт узла и упорядочивает