Содержание
- Что переживает падение узла
- Отказ узла: таймаут, повтор, обработчик
- Когда снимок попадает в базу
- Что записывается и почему таблицы растут быстрее диалога
- Каналы: поле состояния как объект с правилом
- Кто решает, какие узлы активны: Command, Send и гонка на одном ключе
- Повтор с первой строки: идемпотентность и кэш внутри узла
- Пауза, которая останавливает не всех
- Когда граф не собирается заканчивать
- Релиз под живыми тредами
- Срок хранения: чистка базы против разбора инцидентов
- База чекпоинтов как эксплуатационный объект
- Итог
- FAQ
- Источники
На одном ходу диалога агент поддержки опрашивает три системы сразу: лимиты по карте, историю обращений клиента и антифрод. Запросы уходят параллельно, потому что по очереди это лишние секунды в ответе. Антифрод отвечает медленно и на четвёртой секунде отваливается по таймауту. Два других ответа к этой секунде уже получены.
Что стало с этими двумя ответами? Агент здесь собран как граф: узлы делают работу, рёбра решают, кто пойдёт следующим. Вычислительную модель, на которой стоит LangGraph, обычно описывают как транзакционную. Шаг либо применяется целиком, либо откатывается. Из такого описания следует, что при возобновлении оба успевших узла отработают заново и оба вызова придётся оплатить второй раз. Документация описывает другое поведение. Выводы успевших узлов записаны отдельно ещё до того, как шаг закрылся, и при возобновлении не переигрываются.
Разница не в терминологии. Атомарен коммит состояния, а не работа шага и тем более не то, что узел успел сделать во внешнем мире. Эта граница определяет всё дальнейшее: во что обходится снимок состояния и почему таблицы растут быстрее диалога, отчего узел выполняется с первой строки после каждой паузы, что делает соседняя ветвь, пока одна ждёт решения оператора, и как выкатывать новый граф, когда в базе висят чужие приостановленные диалоги.
Дневник курса, урок 13. Здесь понадобится разобранное раньше: агент как граф: состояние, чекпоинты и паузы (урок 6) — на этот каркас достраивается всё дальнейшее; бюджеты и наблюдаемость агента (урок 7) — откуда берутся лимиты шагов и денег. Пост читается отдельно: все термины вводятся заново.
Сквозным примером остаётся тот же чат-бот банка с 8000 обращений в сутки и диалогом в пятнадцать ходов. Каждый диалог живёт в графе отдельным тредом, то есть цепочкой сохранённых состояний под общим идентификатором.
1. Что переживает падение узла
Три параллельных запроса из вступления составляют один шаг графа. Шаг здесь называют супершагом (superstep), и разложен он на три фазы; из этой раскладки видно, к чему относится обещание «всё или ничего».
- Планирование. Движок выбирает активные узлы. На первом шаге это те, что получили вход, дальше — подписанные на каналы, обновлённые предыдущим шагом. Канал в LangGraph — поле состояния вместе с правилом записи в него.
- Исполнение. Выбранные узлы работают параллельно, пока все не завершатся, либо один не упадёт, либо не выйдет таймаут. Записи в каналы всё это время невидимы для узлов: результат соседа появится только на следующем шаге.
- Обновление. Движок применяет к каналам значения, записанные на этом шаге.
Изоляция внутри фазы исполнения и есть та часть детерминизма, которую граф даёт бесплатно. Два узла не могут повлиять друг на друга в пределах шага просто потому, что не видят чужих записей, и порядок ответов от сети на результат не влияет.
Атомарность живёт этажом ниже. Пока узлы работают, каждый закончивший кладёт свой вывод в checkpoint_writes отдельной записью, привязанной к ещё не закрытому чекпоинту — снимку состояния графа, который пишется на границе шага. Полный снимок коммитится один раз, когда шаг закрылся целиком. Механика называется pending writes и в документации сформулирована прямо: если другой узел того же супершага упал, выводы успевших уже сохранены и при возобновлении не переигрываются.
ФАЗА 1. Планирование ФАЗА 2. Исполнение ФАЗА 3. Обновление
активны узлы, подписанные узлы идут параллельно и не каналы принимают
на обновлённые каналы видят записей друг друга записанные значения
│ │ │
▼ ▼ ▼
┌────────────┐ ┌───────────────────────────────┐ ┌──────────────┐
│ лимиты │───────▶│ готово ──▶ checkpoint_writes │ │ │
│ история │───────▶│ готово ──▶ checkpoint_writes │────▶│ снимок │
│ антифрод │───────▶│ таймаут ✗ │ │ НЕ коммичен │
└────────────┘ └───────────────────────────────┘ └──────────────┘
возобновление переигрывает
только антифродcheckpoint_writes, полный снимок на этом шаге не коммитится, и при возобновлении заново идёт только упавший антифрод.Отсюда ответ на вопрос из вступления. Лимиты и история сохранены, возобновление поднимет их из таблицы записей и перезапустит один антифрод. Формулировка «упал один узел, откатилась вся итерация», которая часто сопровождает описание этой модели, верна, если говорить о состоянии. Наполовину применённый шаг в базу не попадёт. Про работу узлов она неверна, и разница считается в деньгах, потому что иначе каждый сбой в ветви веера оплачивался бы повторным вызовом всех соседних API.
У границы есть и обратная сторона. Продолжить можно только с границы супершага, из середины узла — нет. И откат состояния ничего не делает с внешним миром: если бы четвёртым узлом на шаге стояла отправка письма клиенту, письмо осталось бы отправленным при любом исходе шага.
2. Отказ узла: таймаут, повтор, обработчик
Антифрод отвалился по таймауту, и первым делом полезно понять, чей это был таймаут. Границы HTTP-клиента (соединение, первый токен, простой в потоке, общий дедлайн) живут снаружи графа и о шагах ничего не знают. Начиная с версии 1.2 у LangGraph есть два своих порога на уровне узла: общий потолок на попытку и потолок простоя, когда попытка формально не закрыта, но признаков жизни не подаёт. Долгий узел может подтверждать активность вручную, вызовом heartbeat, который вне режима с контролем простоя просто ничего не делает.
Дальше отказ идёт по фиксированному порядку. Любое исключение, включая таймаут узла, сначала попадает в политику повторов, и только когда попытки исчерпаны, вызывается обработчик ошибки узла.
from langgraph.types import RetryPolicy, TimeoutPolicy
builder.add_node(
"antifraud", antifraud_node,
timeout=TimeoutPolicy(run_timeout=8.0, idle_timeout=3.0, refresh_on="heartbeat"),
retry_policy=RetryPolicy(max_attempts=3, initial_interval=0.5),
error_handler=degrade_to_manual_review, # вызовется после третьей неудачи
)
Дефолты придётся запомнить, потому что переопределяют их редко. Описание отказоустойчивости называет три попытки, считая первую, и начальный интервал в полсекунды. По умолчанию повторяется любое исключение, кроме программистских ошибок вроде ValueError, TypeError и LookupError, а для requests и httpx повтор случается только на пятисотых кодах. Таймаут узла повторяется тоже, так что восьмисекундный потолок при трёх попытках даёт почти полминуты худшего случая; закладывать это в бюджет ответа приходится самому.
Из общего порядка выпадает пауза. Вызов interrupt() уходит наверх особым механизмом всплытия и не попадает ни в политику повторов, ни в обработчик ошибок. Остановка ради человека отказом не считается. Полезная деталь для разбора инцидентов: контекст упавшего узла тоже чекпоинтится, и, если процесс умер после падения, но до того, как обработчик доработал, при возобновлении обработчик увидит ту же ошибку. У субграфов необработанное исключение всплывает в родительский узел, и обработчик родителя получает его целиком.
3. Когда снимок попадает в базу
Коммит на границе шага и физическая запись в Postgres — не одно и то же событие, и разводит их отдельная настройка вызова, durability. Режимов три, и различает их ровно одно: где по отношению к шагу стоит поход в базу.
| Режим | Когда пишет | Что стоит | Чем рискуем |
|---|---|---|---|
sync |
синхронно, до начала следующего шага | полный round-trip до Postgres на каждой границе шага | ничем: на диске всегда последний закрытый шаг |
async (по умолчанию) |
в фоне, параллельно следующему шагу | почти ничего в критическом пути | окном, внутри которого падение съедает несохранённый чекпоинт |
exit |
один раз, на выходе из графа | меньше всего обращений к базе | промежуточным состоянием: из середины прогона не восстановиться |
Выходом третий режим считает всё подряд: успех, ошибку и паузу на человеке. Документация чекпоинтеров прямо называет exit лучшим по производительности для долгих графов — с оговоркой, что промежуточное состояние при нём не сохраняется вовсе.
# режим задаётся на вызове графа, а не на отдельном узле
async for chunk in graph.astream(payload, config, durability="sync"):
...
Асинхронное окно выглядит так: чекпоинт уже отправлен на запись, шаг уже идёт дальше, и, если процесс в этот момент умрёт, агент после рестарта переиграет шаг, который на самом деле отработал.
Прогон, в котором возможно необратимое действие, гоняют синхронно; справочный диалог обходится дефолтом. Наш агент возвращает деньги за неудачную операцию, и повторное списание из-за потерянного чекпоинта обойдётся дороже, чем задержка записи на каждом ходу. Режим при этом выбирается для прогона целиком, так что делить приходится по сценариям.
4. Что записывается и почему таблицы растут быстрее диалога
Каждый ход диалога оставляет в базе как минимум две группы записей, и вторая обычно оказывается неожиданностью. Контракт хранения обещает две абстрактные таблицы: checkpoints принимает строку на супершаг, checkpoint_writes — строку на каждый вывод узла. Реализация для Postgres раскладывает это ещё и по отдельным блобам, но опираться в коде стоит на обещанный контракт, а не на её частности.
checkpoints (строка на супершаг) checkpoint_writes (строка на вывод узла)
┌──────────────────────────────────┐ ┌────────────────────────────────┐
│ thread_id, checkpoint_id │◀─────│ checkpoint_id │
│ parent_checkpoint_id │ │ task_id │
│ channel_values ← всё состояние │ │ channel, value │
│ channel_versions, versions_seen │ └────────────────────────────────┘
└──────────────────────────────────┘
снимок 15-го хода содержит все 15 ходов целикомcheckpoint_writes и привязаны к ещё не закрытому чекпоинту через checkpoint_id.Сериализует всё это JsonPlusSerializer поверх ormsgpack и JSON, а pickle подключается только явным флагом. Расхожий совет «перейти на protobuf со сжатием zstd» здесь не сработает, потому что такой ручки в персистентности LangGraph нет вовсе. О размере чекпоинта говорят в других понятиях — в том, что именно кладут в состояние и как часто это переписывают.
Механизм роста виден на нашем диалоге. История сообщений живёт в состоянии, а состояние сериализуется в снимок целиком на каждом шаге. Пятый ход пишет пять ходов, пятнадцатый пишет пятнадцать. Диалог растёт линейно, а суммарный записанный объём растёт как квадрат числа ходов. Умножим на поток: 8000 диалогов по пятнадцать ходов дают 120 тысяч ходов в сутки, и, если ход укладывается в два супершага, за сутки набегает порядка четверти миллиона снимков плюс по строке на каждый вывод узла.
Первым от этого деградирует не диск, а чтение. Выборка последнего состояния стоит на пути каждого вызова агента, из-за чего распухшая таблица бьёт по задержке ответа сильнее, чем по счёту за хранение. Отсюда два числа, которые имеет смысл держать на дашборде с самого начала: p95 времени записи чекпоинта и размер отдельного снимка. Порог предупреждения по размеру подбирают под свой профиль состояния; мегабайт — разумная стартовая отсечка, дальше её двигают по факту. Оба замера снимаются там же, где считается задержка хода: шаг узла ложится в отдельный спан трассировки, а запись чекпоинта — в дочерний.
5. Каналы: поле состояния как объект с правилом
Урезать историю мы не можем, а платить за её переписывание на каждом шаге не хотим — и разрешается это противоречие на уровне канала. В уроке 6 канал появлялся как метафора поля с редьюсером, то есть с функцией слияния обновлений. На самом деле за каждым полем стоит объект конкретного класса, и классов несколько.
| Канал | Поведение | Где уместен |
|---|---|---|
LastValue |
не больше одного значения за шаг, дефолт для обычного поля | статус, идентификатор, флаг |
BinaryOperatorAggregate |
то, что стоит за Annotated[list, add] |
накопление списков и счётчиков |
EphemeralValue |
хранит значение, полученное предыдущим шагом, затем очищается | сырой ответ инструмента |
Topic |
настраиваемая тема в духе pub/sub | сигналы между ветвями |
DeltaChannel |
в чекпоинт пишется только приращение (beta, с 1.2) | длинные накопительные поля |
EphemeralValue полезнее, чем кажется. Сырой ответ инструмента нужен ровно одному следующему узлу, а в снимки попадает навсегда и тянет за собой весь дальнейший рост. Эфемерный канал отдаёт значение следующему шагу и очищается, так что в истории остаётся уже осмысленная выжимка.
DeltaChannel решает ту же задачу для того, что накапливать всё-таки надо. Без него полный список пересериализуется в каждый чекпоинт, с ним пишутся только новые сообщения. Бесплатным это не бывает, и здесь мы платим чтением. Без снимков прочитать значение канала означает проиграть всю историю записей, то есть работу, линейную по числу шагов треда. Ограничивает глубину периодический полный снимок, и параметр snapshot_frequency задаёт, раз во сколько обновлений его писать. По умолчанию это 1000 обновлений, плюс системный потолок в 5000 супершагов, после которого снимок пишется принудительно даже для канала, в который давно не писали. Значение None отключает снимки совсем и подходит коротким тредам либо полям, которые почти никогда не читают.
ход диалога 1 2 3 4 5 … 15
полный снимок ▪ ▪▪ ▪▪▪ ▪▪▪▪ ▪▪▪▪▪ … ▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪ сумма ≈ n²/2
дельта ▪ ▪ ▪ ▪ ▪ … ▪ сумма ≈ n
▲
полный снимок раз в snapshot_frequency обновлений
(по умолчанию 1000, системный потолок 5000 шагов)Наш пятнадцатиходовый диалог укладывается в три десятка супершагов, так что снимок по умолчанию не понадобится ни разу, а проигрывание тридцати записей стоит незаметно. Для треда-долгожителя, вроде сессии внутреннего агента, живущей неделями, расклад обратный, и snapshot_frequency там подбирают по тому, как часто состояние читают.
Дельты требуют аккуратности в двух местах. Редьюсер такого канала обязан быть ассоциативным, иначе накопленные пачками приращения дадут не тот результат, что применённые по одному.
# контракт редьюсера дельта-канала
reducer(reducer(state, xs), ys) == reducer(state, xs + ys)
Второе место дороже. Начиная с 1.2 дельта-чекпоинты пишутся в новом формате, который прежние версии прочитать не умеют, и откат библиотеки на версию без поддержки дельт документация прямо называет неподдерживаемым. Определения каналов вообще положено держать неизменными всю жизнь треда. К релизам мы вернёмся в десятом разделе, а пока запомним: включение дельт — решение в одну сторону.
6. Кто решает, какие узлы активны: Command, Send и гонка на одном ключе
Активные узлы выбираются по обновлённым каналам, но выбор этот всё-таки задаём мы, и в свежем API у узла появился прямой способ распорядиться маршрутом. Объект Command возвращается из узла вместо словаря и несёт сразу обновление состояния и имя следующего узла. Маршрут перестаёт быть отдельной функцией снаружи, что удобно, когда решение зависит от того, что узел только что вычислил.
from langgraph.types import Command, Send
def triage(state: SupportState) -> Command:
if state["amount"] > AUTO_LIMIT:
return Command(update={"status": "manual_review"}, goto="operator_review")
return Command(update={"status": "auto"}, goto="refund")
def fan_out(state: SupportState) -> list[Send]:
return [Send("check_system", {"system": s, "client": state["client_id"]})
for s in ("limits", "history", "antifraud")]
Ловушка тут ровно одна, и документация выделяет её отдельно: Command добавляет динамическое ребро, но не отменяет статические. Если из triage вдобавок объявлено add_edge("triage", "audit_log"), то отработают оба перехода, и audit_log, и тот узел, что назван в goto. Правило в документации сформулировано жёстко. Из конкретного узла маршрутизируют либо командой, либо статическими рёбрами. Параметров у команды при этом четыре. Кроме обновления и перехода есть graph для навигации в родительский граф и resume для возобновления после паузы. Последний отличается ещё и тем, что это единственная форма команды, которую штатно подают на вход invoke().
Send из того же примера решает соседнюю задачу. Он принимает имя узла и состояние для конкретной задачи. Условное ребро возвращает список таких объектов, и параллельных задач порождается столько, сколько в этом списке элементов. Наш веер из трёх систем — как раз он.
Веер приводит к вопросу, который в линейном графе не возникает. Что будет, если две ветви вернут значение для одного ключа? Ничего случайного: движок поднимет InvalidUpdateError с кодом INVALID_CONCURRENT_GRAPH_UPDATE. Это не гонка в привычном смысле, где побеждает тот, кто записал последним. Движку просто неоткуда взять правило слияния, и он отказывается угадывать. Лечится это редьюсером через Annotated, то есть тем же механизмом, что накапливает историю сообщений.
Заодно снимем завышенные ожидания от compile(). В документации это «довольно простой шаг» с несколькими базовыми проверками структуры вроде отсутствия осиротевших узлов; здесь же подключают чекпоинтер и точки останова. Верификации маршрутов там нет, да и проверка на ацикличность была бы не к месту, раз циклы в агентном графе законны.
Граница ответственности движка на этом и проходит. Конкурентную запись в один канал он замечает сам и останавливает прогон с внятной ошибкой. А то, что узел после паузы или отказа запустится с самой первой своей строки и повторит уже сделанное, не поймает ни компиляция, ни рантайм. Это остаётся на нас, и цена ошибки здесь доходит до второго списания с карты клиента.
7. Повтор с первой строки: идемпотентность и кэш внутри узла
Возобновление перезапускает упавший узел, и делает оно это с первой строки функции, а не с места падения. Причина всё та же — граница атомарности. Чекпоинты пишутся на границах супершага, середина вызова точкой сохранения не бывает, и документация формулирует следствие прямым текстом. Код и побочные эффекты, стоявшие до паузы, выполняются повторно, а значит, каждый внешний вызов обязан это переживать: ключ идемпотентности, проверка перед записью или upsert — запись, которая при повторе обновляет существующую строку вместо вставки новой.
Наш узел возврата денег наступает на это дважды за жизнь. Первый раз, когда падает соседний узел веера и шаг переигрывается. Второй раз, когда узел сам стоит на подтверждении оператора и продолжает работу после Command(resume=...). Если перед паузой узел шлёт оператору уведомление, оператор получит его дважды: один раз при постановке на паузу, второй при возобновлении. Базовая версия этой ловушки, где до паузы стоит само списание, разобрана в посте про каркас графа (урок 6). Здесь интереснее штатный обход.
from langgraph.func import task
from langgraph.types import interrupt
@task
def fetch_statement(client_id: str) -> dict:
return core_banking.statement(client_id) # результат чекпоинтится
def refund_node(state: SupportState) -> dict:
statement = fetch_statement(state["client_id"]).result()
decision = interrupt({"amount": state["amount"], "statement": statement})
return {"approved": decision["approved"], "statement": statement}
Обёрнутая в @task работа чекпоинтится отдельно, и при возобновлении завершённая задача не выполняется заново, хотя сам узел стартует с первой строки. Выписка тянется из ядра банка один раз, даже если оператор думал сутки. Оговорка у приёма есть. Кэшированные значения сопоставляются по порядку, и, если между прогонами поменять порядок задач или прерываний до точки возобновления, соответствие сломается. Правило отсюда простое — код до точки возобновления в живом графе не переставляют.
Ещё одна ручка того же семейства, кэш узла целиком, задаётся через CachePolicy со сроком жизни и функцией ключа. По умолчанию ключом служит хэш входа, посчитанный через pickle, и про это придётся помнить, если в состояние приезжают объекты, чьё бинарное представление гуляет от прогона к прогону.
Ключ идемпотентности внешнего вызова берут из того, что переживает переигровку. Годится идентификатор задачи, чекпоинта или бизнес-операции. Ключ, собранный из текущего времени, на повторе окажется новым, и второе списание пройдёт.
8. Пауза, которая останавливает не всех
Одна ветвь веера встала на подтверждение оператора, и естественно ждать, что остальные ветви тоже встанут. Название примитива это и обещает. Гейт подтверждения (проверка, перед которой поток встаёт и ждёт человека), отмена прогона и таймаут описаны словами, которые подразумевают барьер. Пока прогон стоит на паузе, отменён или просрочен, ни одно действие за гейтом не должно случиться.
Препринт «Stop Means Stop», вышедший в июле 2026 года, проверил этот контракт на шести распространённых открытых фреймворках. Не выполняется он ни в одном. Самый частый режим отказа авторы называют sibling leak: гейт останавливает свою ветвь, а соседняя ветвь того же шага в это время доводит своё действие до конца, и отказ оператора приходит, когда отменять уже нечего. Утечка воспроизведена в пяти фреймворках из шести, то есть во всех, где вообще есть гейт перед исполнением, на четырёх разных моделях исполнения и двух языковых рантаймах.
один супершаг, время идёт вниз
t1 ветвь A гейт подтверждения ──▶ ПАУЗА, ждём оператора
t2 ветвь B вызов инструмента ──▶ ушёл наружу
t3 ветвь B эффект выполнен ✔ пауза соседа его не удержала
t4 ветвь A оператор ответил «отклонить» ✗
│
└──▶ отменять нечего: эффект случился на t3Три остальных отказа из той же таксономии знакомы по предыдущим разделам, только теперь у них есть имена. Повторное выполнение при переигровке мы уже разбирали, это наш узел, стартующий с первой строки. «Сироты» после отмены — вызовы, ушедшие наружу до отмены и вернувшиеся после неё. «Зомби» после таймаута ведут себя так же, только отсчёт идёт от дедлайна.
Масштаб в живых прогонах авторы измерили: 215 утечек на 1200 запусков. Механизм при этом резко асимметричный. Если модель выдала «протекающую» форму плана, где действие и запрос подтверждения оказываются на одном шаге, утечка случается всегда, условная вероятность здесь равна единице. У передовых моделей такая форма плана встречается не чаще чем в одном случае из семи. На спокойном трафике разрыв оттого почти не виден, модели обычно выстраивают записи в очередь сами. Однако инъекция во входных данных вызывает его гарантированно, и это уже не редкий случай, а сценарий атаки.
Чинится это четырьмя инвариантами, и они читаются как чеклист для собственного кода:
- hold-until-decided — действие за гейтом не выполняется, пока решение не принято, независимо от того, в какой ветви оно оказалось;
- reject-cancels — отказ действительно отменяет работу, а не просто закрывает свою ветвь;
- dedup-on-replay — переигровка не выполняет уже сделанный эффект второй раз;
- fence-on-cancel — после отмены отставший вызов не проходит, потому что на пути стоит проверка версии.
Починка недорога: в замерах авторов порядка миллисекунды на запись при пропускной способности около 12 тысяч проверок допуска в секунду и без единого лишнего отказа на тех эпизодах tau-bench, где стоит гейт. В этом наборе сценариев агент обслуживает клиента через инструменты и обязан держаться правил компании, так что «лишний отказ» здесь означает сорванное обслуживание. Практический вывод для графа такой. Необратимое действие не ставят рядом с гейтом на одном шаге, его выносят за гейт, в узел, до которого поток дойдёт только после решения. А несколько прерываний, сработавших на одном шаге, возобновляются адресно, через словарь ответов по идентификаторам прерываний.
9. Когда граф не собирается заканчивать
Гейт подтверждения и лимит шагов отвечают на один и тот же вопрос: где граф останавливает себя сам. Гейт мы только что разобрали, и оказалось, что удерживает он лишь ту ветвь, в которой стоит. Со счётчиком шагов история зеркальная. Останавливает он прогон целиком, но замечает далеко не всякое зацикливание.
Цикл в графе законен, и предохранитель от бесконечного цикла оттого встроен и работает по числу супершагов за один прогон. С версии 1.0.6 лимит по умолчанию равен 1000 шагам, при превышении поднимается GraphRecursionError, а задают его отдельным ключом верхнего уровня в конфиге вызова, на одном уровне с configurable:
result = graph.invoke(payload, {"configurable": {"thread_id": tid}, "recursion_limit": 25})
Дефолт великоват для диалога. Ход нашего агента укладывается примерно в два супершага, значит, тысяча шагов — это сотни ходов подряд, и до срабатывания предохранителя агент успеет сжечь заметную часть дневного бюджета. Падения на лимите можно и избежать: для этого есть управляемое значение RemainingSteps. Узел видит, сколько шагов осталось, и заранее сворачивается к короткому ответу или передаче человеку. Номер текущего шага доступен там же, в метаданных вызова, под ключом langgraph_step.
Счётчик шагов ловит не всё. Агент, который раз за разом задаёт одному и тому же инструменту один и тот же вопрос, честно досчитает до лимита, потратив на это реальные деньги. Отсутствие прогресса ловят дешевле, отпечатком значимой части шага.
import hashlib
def step_fingerprint(query: str, amount: float) -> str:
# только то, что задаёт смысл шага: таймстемп сделал бы каждый шаг новым
return hashlib.sha256(f"{query}_{amount}".encode()).hexdigest()
Совпал трижды подряд — прогресса нет, и маршрут уводят в аварийный узел. Важно, что это приём поверх фреймворка, а не его функция. В API есть лимит шагов и RemainingSteps, детектор стагнации пишут руками. Остальные бюджеты агентного цикла, денежный и временной, живут ещё выше и разобраны в посте про эксплуатацию агента (урок 7).
10. Релиз под живыми тредами
Под работающим графом рано или поздно выкатывается новая ревизия, и вопрос распадается надвое: что делать с прогоном, который сейчас в середине, и по какому графу продолжатся треды, приостановленные вчера. Оба ответа есть в документации, и второй заметно неочевиднее первого.
Оркестратор посылает поду SIGTERM, и с версии 1.2 у графа есть кооперативная остановка. Объект управления прогоном получает запрос на остановку, прогон доходит до конца текущего супершага, сохраняет чекпоинт и завершается исключением GraphDrained с указанием причины. Продолжается он потом обычным вызовом с пустым входом по тому же треду.
import signal
from langgraph.runtime import RunControl
from langgraph.errors import GraphDrained
control = RunControl()
signal.signal(signal.SIGTERM, lambda *_: control.request_drain("sigterm"))
try:
result = graph.invoke(payload, config, control=control)
except GraphDrained as drained:
print("остановлены на границе шага, причина:", drained.reason)
# после рестарта тот же тред продолжится: graph.invoke(None, config)
Семантика остановки описана подробно, и детали тут не академические, потому что из них складывается срок, который поду нужно дать на завершение. Узел в середине исполнения дорабатывает до конца, остановка применяется на следующей границе шага. Узел в цикле повторов докручивает повторы до успеха или исчерпания. Остановка, запрошенная внутри субграфа, всплывает к родителю и останавливает его на его собственной границе. Внутри узла флаг запроса виден, так что дорогую работу можно пропустить и вернуть минимальный результат. В худшем случае поду придётся ждать, пока узел докрутит все свои повторы, а следом ещё и записи финального чекпоинта на ближайшей границе шага.
Вторая половина вопроса интереснее. По какому графу продолжится диалог, приостановленный до релиза, по тому, с которым он начинался, или по свежему? LangGraph применяет последний задеплоенный граф ко всем тредам, и новым, и возобновляемым из чекпоинта. Движки процессов обычно поступают наоборот и прибивают прогон к той версии кода, с которой он стартовал. Документация формулирует это жёстче. Каждая выкатка — это, по сути, обратно совместимая правка API по отношению к уже сохранённым чекпоинтам. Исправление ошибки долетает до идущих диалогов без церемоний, и ровно поэтому за совместимостью приходится следить руками.
Ломается совместимость по трём линиям.
- Техническая. Новый код обязан исполняться поверх состояния, записанного старым.
- Бизнесовая. Начатые прогоны по-хорошему должны доигрываться по прежним правилам, даже когда правила уже поменялись.
- Недетерминизм при переигровке. Касается только Functional API, императивной альтернативы графу, где шаги описываются обычными функциями с декораторами, а порядок вызовов при возобновлении восстанавливается по записи. Устройство этого API разобрано в посте про каркас графа (урок 6).
Типовая техническая поломка описана в документации почти как наш случай. Узел переименовали или удалили, а треды стоят на паузе в нём или сохранённое условное ребро на него ссылается. При возобновлении движок не находит узел по сохранённому имени, и прогон падает. У нас это диалоги, ждущие оператора со вчерашнего вечера, и падают они пачкой сразу после релиза.
t0 SIGTERM поду
t1 запрос остановки текущий узел дорабатывает
t2 граница супершага чекпоинт сохранён, GraphDrained("sigterm")
t3 ревизия N+1 поднялась к тредам применяется уже новый граф
t4 invoke(None, config) прогон продолжается с чекпоинта
▲
здесь падает, если узел из чекпоинта переименован
или его определение канала изменилосьПри этом менять топологию можно, и популярную формулировку «динамически менять граф запрещено» здесь придётся поправить. Внутри одного прогона она верна: скомпилированный граф неизменяем. Между деплоями узлы и рёбра добавляют и удаляют свободно, потому что возобновлённый прогон берёт сохранённое состояние и исполняет тот граф, который скомпилирован сейчас. Опасны не структурные правки, а имена и схемы, на которые ссылается уже записанное состояние.
Новые поля состояния добавляют с дефолтами и читают старые снимки мягко, поля не переименовывают, а узлы убирают в два приёма. Сначала перестаём в них маршрутизировать, ждём, пока приостановленные треды дойдут до конца, и только потом удаляем из графа. Обычный способ пережить всё это своими силами — хранить номер версии схемы состояния прямо в метаданных чекпоинта и разбирать старые снимки по нему. Путь не единственный, и у документации на этот счёт есть отдельные разделы с рекомендованными шаблонами и со способом найти незавершённые треды перед выкаткой. И помним про дельта-каналы из пятого раздела: после их включения откат библиотеки не поддерживается, а значит, привычный план отката на предыдущий образ здесь придётся переписать на движение только вперёд.
11. Срок хранения: чистка базы против разбора инцидентов
Диалоги кончаются, строки остаются, и без отдельного решения таблица чекпоинтов растёт вечно. Штатный ответ у платформенной версии есть, и это TTL в конфигурации чекпоинтера с двумя стратегиями. Стратегия delete сносит тред целиком со всеми прогонами и чекпоинтами. Стратегия keep_latest оставляет тред и последний чекпоинт, а старые снимки удаляет.
{
"checkpointer": {
"ttl": {
"strategy": "keep_latest",
"sweep_interval_minutes": 5,
"default_ttl": 43200
}
}
}
Дефолты полезно знать до первого инцидента. Стратегия по умолчанию delete, подметание идёт раз в пять минут, за одну итерацию обрабатывается до десяти тысяч тредов (с версии сервера 0.12; раньше их была тысяча), а default_ttl не задан вовсе — без него треды не истекают никогда. В примере документации стоит 43 200 минут, то есть тридцать суток. И одна тонкость, которую легко пропустить: окно delete отсчитывается от момента применения TTL и активностью не продлевается, а окно keep_latest обновляется после завершения прогона или обновления состояния.
Чистка конфликтует ровно с одной функцией, зато с важной. Отмотка истории живёт на тех самых чекпоинтах, которые удаляет чистка. Вырезав историю ради размера базы, мы вместе с ней вырезаем возможность разобрать инцидент. Оттого точки, где граф стоял на решении оператора, и границы, с которых может понадобиться переигровка, из подметания исключают по смыслу самого чекпоинта: пометкой на треде или отдельным правилом, которое смотрит, что за шаг там сохранён. Порядковый номер в очереди на удаление для этого не годится.
12. База чекпоинтов как эксплуатационный объект
TTL мы настраивали в конфигурации сервера, а не в коде графа, и граница проходит именно здесь. Часть вопросов про базу чекпоинтов документация относит к серверу развёртывания, а в самой библиотеке их нет вовсе. На своём хостинге голого графа эти вопросы закрывают руками, и сводятся они к трём. Кто пишет в базу одновременно, кто её видит и выдержит ли она поток соединений.
Первый вопрос возникает, когда клиент пишет второе сообщение, пока агент думает над первым, и на одном треде оказываются два прогона сразу. Возьмём худший вариант для нашего агента: возврат денег уже запущен, и в этот момент приходит «отмените, я передумал». У платформы для таких случаев четыре стратегии, и диалог заканчивается по-разному в каждой. Одна оговорка про имена. Стратегия interrupt в таблице ниже — про остановку прогона сервером, а не про паузу interrupt() из седьмого раздела, хотя пишутся они одинаково.
| Стратегия | Что делает с текущим прогоном | Чем это кончится для нашего возврата |
|---|---|---|
enqueue (по умолчанию) |
доигрывает до конца, новый ввод берёт следом | деньги уйдут, и агент будет отвечать на отмену того, что уже закончилось |
reject |
не пускает новый прогон, пока идёт текущий | возврат доиграется ровно в том виде, в каком его начинали, а клиент получит отказ и, скорее всего, напишет третье сообщение |
interrupt |
останавливает прогон, сохраняя достигнутый прогресс, и продолжает с новым вводом | отмена дойдёт вовремя, но вызов инструмента мог начаться и не завершиться: списание ушло, состояние о нём не знает, хвост подчищаем сами |
rollback |
останавливает прогон и откатывает прогресс вместе с исходным вводом | диалог начнётся с чистого листа, из состояния пропадёт и первое обращение клиента, а ушедший в ядро банка перевод откат не вернёт |
Общее у всех четырёх видно по правой колонке: ни одна не отменяет того, что уже случилось во внешнем мире. Граница ровно та же, что в первом разделе, — движок распоряжается состоянием, а не деньгами в ядре банка. Выбор сводится к тому, какой хвост придётся подчищать руками.
Распределённая блокировка в Redis и оптимистическая версия состояния, которые часто предлагают для той же задачи, в API графа отсутствуют и остаются прикладными паттернами, которые придётся писать и сопровождать самим.
Второй вопрос всплывает, когда тот же агент продан как сервис другим банкам и диалоги одного банка не должны быть видны другому. Питоновский AsyncPostgresSaver не принимает ни имя схемы, ни контекст арендатора, а запросы на это висят в трекере открытыми. Изоляцию поэтому строят слоем выше. Отдельная база или отдельный пул соединений на арендатора, идентификатор арендатора в составе thread_id, права на уровне подключения. Рецепты, где идентификатор арендатора подставляется в строку SQL-запроса форматированием, вдобавок ко всему открывают инъекцию. Дешёвой изоляция не бывает ни на одном из этих уровней, а во что она обходится на архитектуре команды агентов, разбирает пост про мультиагентные системы в продакшене (урок 11).
Всерьёз относиться к изоляции заставляет предупреждение в документации сериализатора. Если атакующий может писать напрямую в базу чекпоинтов, он может добиться исполнения кода в момент десериализации. Ход мыслей тот же, что в разборе модели угроз агента (урок 9), где недоверенным входом считается всё, куда дотягивается чужой текст. Хранилище состояния исключением не становится. Ограничить набор восстанавливаемых типов можно переменной окружения LANGGRAPH_STRICT_MSGPACK, а шифрование данных на диске включается ключом в LANGGRAPH_AES_KEY. База чекпоинтов после этого перестаёт быть просто хранилищем истории и попадает в тот же класс, что база с деньгами.
Третьими в том же списке идут соединения. Дефолтный асинхронный сейвер под сотнями запросов в секунду ведёт себя плохо, и механика тут простая: свободные соединения кончаются раньше, чем чекпоинтер успевает вернуть занятые, и запись чекпоинта встаёт в очередь на самом горячем месте — границе шага. Пул под него собирают снаружи, через AsyncConnectionPool из psycopg_pool, с явными ограничениями времени простоя и жизни соединения. Штатных ручек вроде настройки пула, схемы или порога подготовленных выражений в самом сейвере нет, что бы ни писали обзоры; всё это задаётся на уровне соединения.
Итог
Вернёмся к упавшему антифроду, с которого всё началось. Теперь про этот шаг известно точно: выводы лимитов и истории лежат в checkpoint_writes и переигрываться не будут, снимок шага не закоммичен, возобновление стартует с границы шага и запустит заново один узел с первой его строки. А если бы четвёртым узлом на том же шаге стояла отправка письма клиенту, письмо ушло бы навсегда, и ни одна из перечисленных гарантий его не вернула бы.
- Атомарен коммит состояния, а не работа шага. Успешные соседи по супершагу сохранены отдельно, поэтому один сбой в веере стоит одного повторного вызова, а работа остальных ветвей оплачена единожды.
- Персистентность — подсистема со своей стоимостью. Режим записи выбирается на прогон, история переписывается в каждый снимок целиком, дельта-каналы меняют размер на глубину чтения, а TTL решает, что из этого доживёт до разбора инцидента.
- Любая пауза означает повторное выполнение узла. Идемпотентные ключи и
@taskвнутри узла превращают повтор из аварии в штатный ход. - Барьер прерывания недостроен во всех проверенных фреймворках. Соседняя ветвь того же шага доводит своё действие до конца, поэтому необратимое выносят за гейт, а четыре инварианта из препринта работают чеклистом.
- Последний граф применяется ко всем тредам. Переименованный узел роняет диалоги, ждущие оператора со вчера, зато топологию между релизами менять можно.
Общий знаменатель у всех пяти пунктов один. Граф даёт транзакцию на собственное состояние и не даёт её ни на что за своими границами: ни на внешние вызовы, ни на деньги, ни на время, ни на версию кода, под которой тред продолжится завтра. Всё перечисленное выше достраивает гарантии там, где транзакция графа закончилась.
Наш агент поддержки после этого разбора переживает падение узла без повторной оплаты соседей, мягко останавливается по SIGTERM и продолжает диалоги после релиза. Чего он всё ещё не умеет — доказать, что новая ревизия его графа отвечает клиентам лучше прежней. Для этого нужна отдельная процедура: канареечная доля трафика, пороги приёмки по метрикам качества и автоматический стоп при их пробое. Гарантиями графа она не покрывается.
FAQ
Что происходит с результатами других узлов, если один узел супершага упал?
Они сохраняются. По ходу шага каждый закончивший узел пишет свой вывод в checkpoint_writes отдельной записью, привязанной к ещё не закрытому чекпоинту. При возобновлении эти записи подхватываются, и успешные узлы не выполняются повторно. Атомарен именно коммит полного снимка состояния, а работа отдельных узлов внутри шага под откат не попадает.
Чем режимы записи sync, async и exit отличаются на практике?
sync пишет чекпоинт синхронно до начала следующего шага и добавляет обращение к базе в критический путь каждого хода. async отправляет запись в фон параллельно следующему шагу и оставляет окно, в котором падение процесса съедает несохранённое состояние. exit сохраняет состояние только при выходе из графа, включая выход по ошибке или паузе, поэтому восстановления из середины прогона с ним нет. Режим задаётся на вызове графа, поэтому выбирается для прогона целиком.
Почему база чекпоинтов растёт быстрее, чем сам диалог?
Потому что состояние сериализуется в снимок целиком на каждой границе супершага, а история сообщений лежит внутри состояния. Пятнадцатый ход диалога записывает все пятнадцать ходов заново, поэтому суммарный объём записи растёт примерно как квадрат числа ходов. Штатное лечение — дельта-каналы, которые пишут только приращение, и периодический полный снимок с настраиваемой частотой, ограничивающий глубину чтения.
Можно ли выкатывать новый граф, пока в базе висят приостановленные треды?
Можно, и по-другому не получится: LangGraph применяет последний задеплоенный граф ко всем тредам, включая возобновляемые из чекпоинта. Добавлять и удалять узлы и рёбра между релизами допустимо. Опасны переименование или удаление узла, на который ссылается сохранённое состояние приостановленного треда, и изменение определений каналов посреди жизни треда: в обоих случаях возобновление падает.
Почему узел выполняется заново при возобновлении и как не сделать работу дважды?
Чекпоинты пишутся на границах супершага, а не внутри функции узла, поэтому после паузы или повтора узел стартует с первой строки, и весь код до точки паузы исполняется повторно. Помогают ключи идемпотентности во внешних вызовах, вынос необратимого действия в отдельный узел за гейтом подтверждения и обёртка @task внутри узла, результаты которой чекпоинтятся и при возобновлении не пересчитываются.
Что такое sibling leak и почему он важен для агента с подтверждениями?
Так называют режим отказа, при котором пауза на подтверждение удерживает только свою ветвь исполнения, а соседняя ветвь того же шага успевает выполнить своё действие, пока человек принимает решение. Отказ приходит слишком поздно, отменять уже нечего. В препринте «Stop Means Stop» этот отказ воспроизведён в пяти открытых фреймворках из шести. Защита строится на стороне приложения четырьмя правилами: удерживать действие до решения, отменять по отказу, дедуплицировать при переигровке и ставить проверку версии после отмены.
Источники
- LangGraph runtime (Pregel) — Docs by LangChain — три фазы супершага, набор каналов, дельта-хранение и обмен «размер чекпоинта против глубины чтения».
- Checkpointers — Docs by LangChain — pending writes, контракт двух таблиц, режимы записи, сериализатор по умолчанию.
langgraph.checkpoint.serde.jsonplus— LangChain Reference — предупреждение о десериализации недоверенных объектов и строгий режим msgpack.- Graph API overview — Docs by LangChain —
CommandиSend, повторное выполнение узла,@task, лимит рекурсии. - INVALID_CONCURRENT_GRAPH_UPDATE — Docs by LangChain — конкурентная запись в один канал и лечение редьюсером.
- Fault tolerance — Docs by LangChain — таймауты узла, порядок «повторы, затем обработчик», кооперативная остановка.
- Backward compatibility — Docs by LangChain — последний граф ко всем тредам, три категории поломок при выкатке.
- Stop Means Stop: Measuring and Repairing the Enforcement Gap in Agent-Framework Control Primitives — arXiv:2607.14166 — измеренный разрыв контракта барьера, sibling leak и четыре инварианта.
- Add TTLs to your application — Docs by LangChain — стратегии чистки и их дефолты.
- Double-texting — Docs by LangChain — четыре стратегии конкурентных прогонов и граница между открытым фреймворком и сервером.
Числовые ориентиры из текста (дефолты повторов и таймаутов, частота снимков дельта-канала, лимит супершагов, параметры TTL) — это значения по умолчанию из документации на версию 1.2. Арифметика по объёму записи считается от профиля нашего примера и меняется вместе с длиной диалога, размером состояния и числом узлов на шаг.