Содержание
- Смерть процесса — отказ другого класса
- Что лежит в снимке и как его продолжают
- Узел выполнится заново: чей это ключ
- Отмотка, ветка и пауза: одна операция над одной историей
- Прогон, который спит в базе
- Одно соединение под замком
- Изоляция сессий: чужие данные в своём треде
- Во что обходится накопленная история
- Предполётный чеклист, пересобранный по исходнику
- Итог
- FAQ
- Источники
Ночью пачка из двадцати заявок на блокировку карт уходит агенту на разбор. На седьмой ядро убивает воркер по нехватке памяти, планировщик поднимает новый под, и агент стартует с пустой головой. Тринадцать неразобранных заявок умерли вместе с процессом, шесть разобранных пришлось ставить в очередь заново. Карту по седьмой заявке агент блокирует второй раз.
Повторы, таймауты и предохранители на такой отказ не рассчитаны. Все они живут в переменных того же процесса и исчезают с ним. Чтобы новый процесс подхватил работу, прогресс должен лежать снаружи, в базе, к которой он придёт после старта.
Отсюда и берётся персистентность состояния, хотя к хранению как таковому она имеет мало отношения. Речь дальше идёт о прогоне — так называют одно исполнение графа от запроса до ответа, со всеми шагами и паузами внутри. Снимок в базе нужен, чтобы после сбоя продолжить с последнего шага, чтобы по истории разобрать, что агент решил и когда, и чтобы оператор успел поправить решение до того, как оно ушло в банк. Дальше по порядку: что попадает в снимок, чем платят за внешний вызов после возобновления, как остановить прогон перед решением человека и во что обходятся соединения к базе, конкурентные прогоны и накопленная история.
Дневник курса, урок 18. Здесь понадобится разобранное раньше: что переживает сбой узла внутри живого прогона (урок 13) — граница, на которой пишется снимок; повторы, предохранители и лестница деградации (урок 16) — то, что работает, пока процесс жив. Пост читается отдельно: все термины вводятся заново.
Сквозной пример прежний — чат-бот банка на 8000 обращений в сутки. Ночная пачка из вступления — тот же бот в фоновом режиме, он разбирает заявки, которые днём поставила в очередь первая линия.
1. Смерть процесса — отказ другого класса
К моменту выхода в прод вокруг агента обычно уже стоит приличная обвязка. В посте про надёжность агента (урок 16) разобраны классификация ошибок и повтор, таймауты, предохранитель (circuit breaker), валидация ответа с отправкой на исправление, и всё это работает ровно до тех пор, пока жив процесс. Обвязка целиком живёт в его памяти. Убийца процессов при нехватке памяти (OOM killer), перезапуск пода при выкатке, переезд контейнера на другую ноду, задача длиннее жизни процесса — сюда обвязка не дотягивается.
Разницу между двумя классами отказа лучше проговорить прямо. Урок 16 разбирал агента, который жив и ошибается. Там рвётся цепочка вызовов, галлюцинирует модель, отдаёт мусор инструмент. Здесь агент умер и воскрес, и вопрос ровно один — помнит ли он что-нибудь.
Куда тогда класть память между рестартами? У бэкендера есть три привычных ответа, и все три попадаются в прототипах.
Первым в голову приходит глобальный словарь в процессе, session_store = {} где-нибудь на уровне модуля. Работает он идеально до первого рестарта и только на одной реплике.
Второй ответ прячет ту же проблему этажом ниже. Балансировщик привязывает сессию к конкретному поду (sticky sessions по ip_hash или cookie), но рестарт этого пода теряет всех, кто к нему прилип, а масштабирование и выкатка начинают ломать диалоги. Третий звучит как «перезапустим пайплайн целиком», run_batch(retry=True). Он честно доводит пачку до конца и по дороге повторяет уже выполненные необратимые шаги. Двойная блокировка карты из вступления — как раз он.
Само слово «память» скорее мешает, чем помогает. Спорят о ней обычно потому, что говорят о разных слоях.
| Слой | Что в нём лежит | Сколько живёт | Аналог из бэкенда |
|---|---|---|---|
| Диалог | сообщения текущего разговора | пока жив процесс | локальные переменные |
| Состояние исполнения | где остановились, что решили на каждом шаге | пока не вычистим базу | транзакция и журнал упреждающей записи |
| Долговременная память | факты о клиенте между неделями | собственный жизненный цикл | отдельная база |
Средний слой и есть предмет разговора. Верхний живёт внутри него, нижний строится отдельно, на векторном поиске или на специализированном хранилище фактов. Состояние исполнения — обычные данные, а данным место в базе. В LangGraph такой компонент называется чекпоинтером, а его запись зовут снимком состояния, или чекпоинтом.
2. Что лежит в снимке и как его продолжают
Подключается персистентность одной строкой при компиляции графа, builder.compile(checkpointer=saver), и после этого каждая граница шага оставляет в базе запись. Что именно в ней записано, видно из объекта, который отдаёт get_state.
snapshot = graph.get_state(config)
# StateSnapshot(
# values = {"plan_text": "заблокировать карту",
# "proposed_action": {"type": "block_card", "card": "••••7712"}},
# next = ("execute",), # где остановились
# config = {"configurable": {"thread_id": "client-4412",
# "checkpoint_id": "1f0a…"}},
# metadata = {"step": 4, "writes": {...}, "source": "loop"},
# )
Три поля отвечают на три разных вопроса, и вопросы эти будут возвращаться до конца поста. По next видно, с какого узла продолжать, и это про восстановление после сбоя. checkpoint_id — адрес точки в истории, по нему можно вернуться назад. values вместе с metadata показывают, что агент решил и на каком шаге, то есть дают опору для разбора.
Продолжают прогон одной строкой, и выглядит она непривычно.
graph.invoke(None, config) # «доиграй незавершённый прогон»
Пустой вход вместо полезной нагрузки означает, что новых данных нет и нужно поднять сохранённое состояние по thread_id из конфига. Тред здесь означает цепочку снимков под общим идентификатором, обычно один диалог одного клиента.
Формулировка «снимок после каждого узла» тут немного опережает действительность. Записывается он на границе шага графа, и по умолчанию физическая запись уходит в фон параллельно следующему шагу: режим сохранности (durability) в LangGraph 1.2 равен "async". Между «шаг закрылся» и «строка в Postgres» остаётся окно, внутри которого падение процесса съедает несохранённый снимок. Раскладка трёх режимов записи и их цена разобраны в посте про гарантии графа (урок 13), там же объяснено, почему по одному ходу диалога в базу уходит больше одной строки.
3. Узел выполнится заново: чей это ключ
Восстановление вернуло прогресс, и на этом хорошие новости про внешний мир заканчиваются. Чекпоинтер обещает, что узел отработает хотя бы один раз (at-least-once), и ничего не обещает о единственности внешнего вызова внутри узла. Формулировка стоит дословно в докстроке interrupt(), где сказано, что граф возобновляется с начала узла, переисполняя всю его логику. Семантика знакомая, примерно то же даёт консьюмер очереди сообщений. Обходят её ключом идемпотентности — одним и тем же значением при повторе, по которому приёмная сторона узнаёт уже выполненный запрос.
Механика видна на нашем узле execute. Он вызывает API банка, вызов уходит, карта блокируется — и в этот момент процесс умирает, не успев записать снимок. Для базы шага не было. При возобновлении узел стартует с первой строки, и block_card уходит второй раз.
Против этого есть ходовой рецепт.
def execute(state: SupportState) -> dict:
bank_api.block_card(state["card_id"],
idempotency_key=state.checkpoint_id)
Идея верная, ключ выбран неудачно. Что не так с checkpoint_id? Сверка по исходнику langgraph 1.2.11 даёт два довода против него, и второй серьёзнее первого.
Первый довод короткий. Внутри узла checkpoint_id попросту нет. Конфиг задачи собирается в _algo.py явной строкой CONFIG_KEY_CHECKPOINT_ID: None, так что config["configurable"]["checkpoint_id"] там равен None, а в состоянии графа поля checkpoint_id нет вовсе, если его не положили туда руками.
Второй довод остаётся в силе, даже если идентификатор всё-таки достать снаружи и протащить в состояние. Один шаг графа может активировать несколько узлов сразу, и все они делят один checkpoint_id. Веер из трёх узлов, каждый со своим внешним вызовом, отправил бы в банк три разных запроса с одним и тем же ключом идемпотентности — а банк на то и держит идемпотентность, чтобы схлопнуть их в один. Ошибка выйдет тихая и дорогая. Пропущенный платёж выглядит не как сбой, а как выполненная операция.
Идентификатор, который годится, у рантайма есть, и он детерминированный. Под ключом __pregel_task_id в конфиге задачи лежит xxh3_128 от идентификатора чекпоинта, пространства имён, номера шага, имени узла и списка триггеров. Из состава хэша видны оба нужных свойства. Имя узла разводит параллельные задачи одного шага, а всё остальное не меняется между проходами, поэтому при возобновлении с того же чекпоинта тот же узел получает тот же идентификатор. Рантайм и сам на это опирается. Рядом с вычислением стоит сверка полученного идентификатора с контрольным.
def execute(state: SupportState, config: RunnableConfig) -> dict:
# рантайм кладёт сюда детерминированный идентификатор задачи
task_id = config["configurable"]["__pregel_task_id"]
bank_api.block_card(state["card_id"], idempotency_key=task_id)
return {"blocked": True}
Имя ключа приватное, с двумя подчёркиваниями в начале, поэтому доставать его лучше одной своей функцией, а её закрыть тестом. Тогда переезд на другую версию библиотеки упрётся в красный тест, а не в счёт от банка. В разборе корпоративных интеграций (урок 17) ключ идемпотентности обсуждался со стороны инструмента, где повторять безопасно только тот вызов, который к повтору готов. Здесь виден второй конец того же контракта: откуда оркестратор берёт значение, которое переживёт переигровку.
Посмотрим, что стало со сценой из вступления, когда на месте и чекпоинтер, и ключ.
без чекпоинтера
10:41:58 triage заявка 7/20
10:41:59 plan → block_card(••••7712)
10:42:08 воркер убит по памяти
10:42:31 рестарт, состояние = {}
10:42:55 block_card(••••7712) ← второй раз
───────────────────────────────────────────
13 потеряно · 6 переделано заново · карта заблокирована дважды
с чекпоинтером
10:41:58 triage заявка 7/20
10:41:59 ✓ снимок: план и действие в базе
10:42:08 воркер убит по памяти
10:42:31 рестарт → invoke(None, t=client-4412)
10:42:33 block_card(••••7712) ← тот же task_id
───────────────────────────────────────────
0 заявок потеряно · карта заблокирована один разblock_card пришёл в банк второй раз. Со снимком в базе остаток пачки пережил рестарт, а повторный вызов пришёл с тем же ключом идемпотентности и схлопнулся в одну блокировку.Заслуги здесь у двух механизмов, и путать их не стоит. Тринадцать неразобранных заявок спас чекпоинтер. От второй блокировки карту уберёг ключ. Сам по себе чекпоинтер про внешний мир ничего не знает и повторного вызова не отменяет.
4. Отмотка, ветка и пауза: одна операция над одной историей
Три сценария, которые обычно разбирают порознь, в графе делаются одним и тем же действием. Продолжаем тред с выбранного чекпоинта, а меняется только то, кто выбирает точку и что подставляет на входе.
Первый случай — процесс жив, а ошибся агент. Клиент потерял карту, агент разобрал заявку и заблокировал не ту, ••••1111 вместо ••••7712. Сбоя не было, повторы не помогут, чинить надо решение.
# находим точку до ошибочного действия
cp2 = next(s for s in graph.get_state_history(config) if s.next == ("execute",))
fork_config = graph.update_state(cp2.config, {"card_id": "••••7712"})
graph.invoke(None, fork_config) # доигрываем ветку с новым значением
update_state не переписывает выбранный снимок, а создаёт от него потомка — новую ветку. Прошлое неизменяемо, изменяемо только будущее, и ошибочная ветка целиком доступна для разбора инцидента. По ней видно, что агент решил, на каком шаге и с какими данными.
Отмотка (time travel) в посте про каркас графа (урок 6) появлялась как инструмент отладки, здесь она же работает штатной процедурой исправления. Платить за неё придётся хранением, потому что отмотать можно ровно по тем снимкам, которые ещё не вычистили из базы.
история треда client-4412
cp1 план ──▶ cp2 действие: ••••1111 ──▶ cp3 выполнено ✗ не та карта
│
└─ update_state(cp2, {"card_id": "••••7712"})
│
▼
cp2' действие: ••••7712 ──▶ cp3' выполнено ✓
cp3 из истории не исчезает — по нему разбирают инцидентupdate_state создаёт от снимка cp2 потомка cp2' с исправленным номером карты, продолжение идёт по новой ветке, а ошибочная ветка cp3 остаётся в истории целиком.Отмотка не умеет одного. Блокировка карты ••••1111 уже случилась, состояние графа вернулось, а запись в ядре банка осталась. Граница здесь та же, что в третьем разделе, и снимать её придётся компенсирующей операцией, история чекпоинтов не поможет.
Второй сценарий не даёт ошибке случиться. Перед необратимым действием граф останавливается сам и ждёт решения оператора. Называется этот приём человек в контуре (HITL). Точка останова бывает статической, объявленной на сборке, и динамической, вызванной изнутри узла.
graph = builder.compile(checkpointer=saver, interrupt_before=["execute"])
def propose_action(state: SupportState) -> dict:
decision = interrupt({"card": state["card_id"],
"amount": state["amount"]})
return {"approved": decision["approved"]}
# оператор посмотрел карточку и нажал «подтвердить»
graph.invoke(Command(resume={"approved": True}), config)
Динамическая форма удобнее тем, что в неё передают полезную нагрузку — карточку, которую увидит оператор в своей админке. Пауза HITL при этом остаётся обычным чекпоинтом, и отсюда следует то, о чём предупреждал третий раздел. После Command(resume=...) узел стартует с первой строки. Если перед interrupt() стояла отправка уведомления оператору, он получит его дважды, один раз при постановке на паузу и второй при возобновлении.
Одну шероховатость в этом месте недавно убрали. Когда на шаге сработало несколько прерываний, а в resume подан не словарь ответов по их идентификаторам, рантайм больше не пытается угадать, к какому из них относится ответ. В _loop.py поднимается RuntimeError с требованием указать идентификатор прерывания. Громкий отказ здесь честнее тихого угадывания, ведь подтверждение, приложенное не к тому действию, стоит дороже упавшего прогона.
Три случая сходятся в одну картину, и унести из темы стоит именно её.
тред client-4412: cp1 ──▶ cp2 ──▶ cp3 ──▶ ⏸ пауза
после сбоя → resume invoke(None, config)
не та карта → ветвление update_state(cp, ...) + invoke
необратимое → человек invoke(Command(resume={...}))Графу всё равно, кто продолжил прогон: рестарт, оператор или вы из прошлого. Отсюда практическое следствие, которое экономит проектирование. Отдельный «режим восстановления», отдельная «админка правки состояния» и отдельный «контур подтверждений» складываются в одну подсистему с тремя входами, и строить её надо один раз.
5. Прогон, который спит в базе
Оператор отвечает не через секунду. Ночная пачка встала на паузе в 10:42, оператор увидел карточку в 9:00 следующего дня, и всё это время под с агентом ничего не делал. Точнее — его вообще не было.
Между чекпоинтом и продолжением нет живого процесса: ни потока, ни корутины, ни объекта в памяти. Состояние целиком лежит в базе, а от прогона остаётся строка в таблице. Такой спящий прогон (dormant run) занимает ноль процессорного времени и ноль памяти, и именно поэтому пауза длиной в сутки не стоит ничего, а сервис между делом можно перевыкатить.
Продолжение приходит снаружи, обычным HTTP-хендлером.
@app.post("/threads/{thread_id}/approve")
async def approve(thread_id: str, decision: Decision):
config = {"configurable": {"thread_id": thread_id}}
return await graph.ainvoke(Command(resume=decision.model_dump()), config)
Очередь задач в этой схеме тоже находит место, но роль у неё не та, которую ей обычно приписывают. Celery, APScheduler или Inngest не заменяют чекпоинтер, а дополняют его. Задача в очереди несёт всего одну инструкцию: «сделай invoke(None, thread=X)». Доставка живёт отдельно, состояние отдельно, и путать эти два хранилища опасно. Очередь отвечает за то, что за работу кто-то возьмётся, а база хранит, где именно остановились.
Границу известного лучше обозначить сразу. Всё сказанное в этом разделе — архитектурное рассуждение, замерами оно не подкреплено. Публичных чисел по спящим прогонам нет ни у вендора, ни в препринтах. Никто не мерил, сколько тредов в среднем висит на паузе, какая доля просыпается вообще и во что обходится пробуждение против удержания живого воркера. Довод в пользу спящего прогона держится на арифметике, которую каждый считает у себя. Сутки простаивающего пода против строки в таблице.
6. Одно соединение под замком
Ходовой совет про чекпоинтер звучит так. Дефолтный пул из десяти соединений мал — поднимите его под пиковую конкуренцию и держите сумму ниже потолка Postgres. Формула сайзинга здесь правильная и полезная.
total = реплики × max_size пула + накладные соединения
предел = max_connections в Postgres (по умолчанию 100)
условие: total < пределmax_connections в Postgres, который по умолчанию равен 100.А вот дефолта, от которого предлагается отталкиваться, не существует. AsyncPostgresSaver.from_conn_string в aio.py вызывает AsyncConnection.connect(...) с autocommit=True, prepare_threshold=0 и row_factory=dict_row — это одно соединение; никакого ConnectionPool там нет. Пул подключают снаружи, передавая его в конструктор сейвера.
Второе наблюдение из того же файла интереснее и меняет ожидания от пула. Курсор открывается под self.lock, и замок держится всё время, пока курсор жив. Один экземпляр сейвера сериализует свои операции целиком, и увеличение max_size само по себе параллелизма ему не даёт. Пул нужен для другого — чтобы соединения переиспользовались и обновлялись, а настоящий параллелизм записи берётся из числа реплик сервиса.
Отсутствие ручек болезненнее, чем кажется. Запрос на pool_config висит в трекере LangGraph с 26 марта 2026 года и на сентябрь не закрыт, так что max_idle, max_lifetime, min_size, max_size и reconnect_timeout через from_conn_string задать нельзя.
Описанный там механизм отказа ждёт всякого, у кого на инстансе стоит таймаут простоя. У автора issue он равен 300 секундам, соединение с той стороны закрывают, сейвер об этом не знает и не переоткрывает — и все операции чекпоинтера начинают падать с psycopg.OperationalError и жалобой на закрытое соединение. Соседний запрос про prepare_threshold, заведённый сопровождающим ещё в январе, — про ту же жёсткость. Нулевой порог захардкожен, расширенный протокол запросов при этом используется, и в типичной связке с пулером всплывает prepared statement "_pg3_3" does not exist.
Здесь придётся поправить и собственную вики. В нашей заметке про чекпоинтеры лежит рецепт «устойчивого» сейвера, который не работает.
return AsyncPostgresSaver(pool, pipeline=True) # падает дважды
Параметр конструктора называется pipe, а не pipeline, так что первым прилетает TypeError. С правильным именем прилетает второе исключение. Сочетание пула с конвейером запрещено явной проверкой, ValueError: Pipeline should be used only with a single AsyncConnection, not AsyncConnectionPool. Конвейерный режим и пул складывать нельзя, это выбор между двумя способами экономить обращения к базе.
Там же в заметке асинхронному сейверу приписан threading.Lock(). В aio.py замок asyncio.Lock(), а threading.Lock() стоит в синхронной реализации. Заметка писалась по расшифровке сессии и до исходника не доходила — та самая циркуляция пересказов, из-за которой мы завели правило сверять утверждение с первоисточником, а не с чужим конспектом.
Последнее про соединения — предостережение из чужого постмортема, и оно про диагностику. Команда Decagon разбирала зависавшие голосовые запросы, которые отваливались ровно через 300 секунд. Первым сигналом была ошибка пула SQLAlchemy, QueuePool limit of size 2 overflow 20 reached. Выглядело как нехватка соединений, но соседние показатели под эту версию не подходили.
Настоящая причина лежала четырьмя слоями ниже. Postgres вернул TLS-запись в 4118 байт, PgBouncer читал своим обычным буфером в 4096 байт, служебный пакет ParameterStatus в 86 байт лёг на границу буфера, и пулер прочитал из него только первые 70 байт и ушёл ждать событий. Соединение заклинило, а исчерпание пула оказалось следствием. Стек тут чужой, SQLAlchemy и PgBouncer в голосовом агенте вместо трафика чекпоинтера LangGraph, и переносить оттуда числа нельзя. Переносится вывод. «Кончились соединения» бывает симптомом, и лечение симптома увеличением пула сдвигает срок следующего инцидента, а не отменяет его.
7. Изоляция сессий: чужие данные в своём треде
Изоляция диалогов в графе достаётся почти даром. Один клиент — один thread_id, истории не пересекаются, и при чтении одного треда состояние другого не видно. Правило простое, и обратная сторона у него одна. Разделять thread_id между пользователями нельзя, это прямая утечка чужого контекста.
Даром достаётся не всё. Двух конкурентных писателей в один тред никто не разнимает, и ведут они себя по-разному в зависимости от того, в какой канал пишут. Канал — именованная ячейка состояния, куда узел кладёт свой вывод. Часть каналов служебные, их заводит сам рантайм.
В base.py под запись выводов узла заготовлены две SQL-команды, и выбор между ними делается в put_writes. Пачка, где все каналы служебные (ERROR, SCHEDULED, INTERRUPT, RESUME), пишется с ON CONFLICT ... DO UPDATE. Смотрит put_writes на всю пачку разом, поэтому пачка с хотя бы одной обычной записью целиком уходит с ON CONFLICT ... DO NOTHING.
два прогона пишут в один thread_id
пачка с обычным выводом узла ── ON CONFLICT DO NOTHING
прогон A ──▶ строка легла
прогон B ──▶ строка отброшена, статус «успех»
пачка целиком из служебных каналов ── ON CONFLICT DO UPDATE
ERROR · SCHEDULED · INTERRUPT · RESUME
прогон A ──▶ строка перезаписана
прогон B ──▶ побеждает последний писательМолчаливость и есть проблема. Ни исключения, ни предупреждения в логе. Запись просто не легла, вызов вернулся успешно, а расхождение всплывёт позже и в другом месте. Гонку на одном ключе внутри графа отлавливает движок, про неё был отдельный разговор (урок 13). Два независимых прогона на одном треде — уровень выше, и разводить их приходится самим.
Как выглядит отказ изоляции в проде, показывает единственный найденный публичный разбор такого инцидента, issue в JS-версии LangGraph, заведённый в марте 2026 года и закрытый в июне. Команда гоняла воркер BullMQ с параллельностью 2 на одном общем экземпляре агента. В треде 751 модель вызвала инструмент, куда попали имя, адрес, товары и координаты клиента A. В истории треда 751 этих данных не было, авторы проверили весь список сообщений чекпоинта. Дальше сработала бизнес-логика. Клиент B прислал корректный платёж на 16 000 долларов, агент сравнил его с суммой заказа клиента A в 24 980 долларов и эскалировал платёж на человека.
Границы переноса здесь жёсткие. Это @langchain/langgraph под Node.js, а корневой причиной автор называет утечку контекста через initializeAsyncLocalStorageSingleton() — конструкцию рантайма JavaScript, которой в Python нет. Переносится не механизм, а класс отказа. Общий экземпляр агента обслуживает конкурентные вызовы, и узнают такой отказ по чужим данным в чекпоинте при формально правильном thread_id. Однако сам по себе правильный thread_id в логах и в трассировке ничего не доказывает. В инциденте он тоже был правильным.
8. Во что обходится накопленная история
Персистентность стоит места, и порядок величины угадать сложно, потому что объём растёт быстрее, чем длина диалога. Единственный найденный публичный замер этого роста — бенчмарк AgentFootprint, прогнавший восемь агентных фреймворков под одинаковыми моделями, инструментами и задачами.
Главное число там разностное. Прокрутив одну и ту же зафиксированную траекторию через семь хранящих фреймворков, авторы получили разброс удержанного объёма в 6,7 раза — при том, что работа агента была буквально одна и та же, а различался только слой персистентности. На полных прогонах разброс шире. Конфигурации, показавшие одинаковую точность 100%, различались по удержанным байтам в 15,7 раза.
Читать это «в пятнадцать раз хуже» нельзя, и авторы оговаривают почему. Разные дефолты дают разные возможности восстановления и разбора. Крайний случай в их же таблице — SmolAgents с нулём сохранённых байт и прочерком в графе «продолжить в новом процессе». Он не хранит ничего и поэтому не восстанавливается вовсе. У LangGraph в той же таблице 5,20 МБ на одной модели и 2,49 МБ на другой, а у OpenAI Agents SDK всего 0,31 и 0,21 МБ.
| Что измеряли | Число | Условие |
|---|---|---|
| Разброс на одной зафиксированной траектории | 6,7× | семь хранящих фреймворков, одна и та же работа агента |
| Разброс при одинаковой точности 100% | 15,7× | одни модели, инструменты и задачи, дефолтные конфигурации |
| Показатель роста α у фреймворков с полной историей на каждом шаге | до 1,95 | против почти линейного роста у фреймворков с окном контекста |
| Хранилище с адресацией по содержимому | 4,8×–32,7× экономии | одинаковые куски состояния пишутся один раз и адресуются хэшем содержимого, без потери оценок восстановимости |
Что такое показатель α из третьей строки? Он говорит, насколько объём обгоняет линейный рост. При полной истории на каждом шаге каждый следующий снимок переписывает всё предыдущее, поэтому сумма записанного идёт почти как квадрат числа шагов. Предельный случай авторы приводят сами. Цикл наблюдения на 200 раундов над одним файлом статуса размером 2 КБ оставил после себя 323 МБ, при том что полезных данных в задаче два килобайта.
Ту же арифметику вендор подтверждает своими числами. В посте про дельта-каналы LangChain пишет, что у агента с длинной историей сообщений хранилище чекпоинтов растёт как O(N²), и кодовый агент на 200 ходов оставляет 5,3 ГБ, а дельта-каналы приводят это к 129 МБ.
Соблазн сказать «дельты делают рост линейным» авторы снимают сами, и оговорку мы сохраним. Рост остаётся квадратичным, потому что полный снимок всё равно пишется раз в K шагов, и меняется только коэффициент, примерно в 1/K раз. Заявленное сокращение в 41 раз в том же посте отнесено и к 200 ходам, и к 500, так что привязывать множитель к конкретной длине сессии не выйдет. Надёжно только направление: выигрыш растёт с длиной сессии и упирается в потолок порядка K.
Отсюда и берётся пункт «настроить чистку», который стоит в любом чеклисте. И тут выясняется, что вызывать нечего. Базовый класс BaseCheckpointSaver объявляет prune(thread_ids, strategy="keep_latest") и асинхронную пару к нему — обе поднимают NotImplementedError. Ни langgraph-checkpoint-postgres 3.1.2, ни langgraph-checkpoint-sqlite 3.1.1 их не реализуют, слова prune в этих пакетах нет вовсе. Выбор самого бэкенда мы обсуждали в посте про каркас графа (урок 6), где в прод пошёл Postgres, а SQLite остался отладочным из-за единственного писателя. По ретенции бэкенды расходятся сильнее, чем по API. Что доступно на самом деле:
delete_thread(thread_id)— снести тред целиком со всей историей;- «мелкие» сейверы (
ShallowPostgresSaverи его асинхронная пара), которые по докстроке хранят только последний чекпоинт и не держат истории, — ретенция покупается отказом от отмотки из четвёртого раздела; - настоящий TTL, который есть ровно в одном бэкенде:
langgraph-checkpoint-redisпринимаетttl={"default_ttl": <минуты>}, где-1означает «не истекать».
Самодельная чистка при этом опаснее, чем выглядит, и предупреждение об этом стоит прямо в докстроке нереализованного prune. Наивный вариант «оставить последний», выбрасывающий промежуточные чекпоинты вместе с их записями, рвёт цепочку дельт: выживший «последний» снимок редко оказывается полным, и дельта-каналы после такой чистки восстановятся пустыми. Ошибки при этом не будет, история просто вернёт пустой результат.
Пункт «предупреждать, когда снимок перевалил за мегабайт» проверяется тем же способом и заканчивается иначе. Поиск по langgraph и langgraph-checkpoint не даёт ни константы размера снимка, ни предупреждения по объёму. Порога в коде нет вовсе. Сам мегабайт пришёл из консалтингового блога, замера за ним не стоит. Как стартовая отсечка он не хуже любого другого круглого числа, а дальше отсечку двигают по своим замерам.
Есть и то, что делает историю дороже строчки в счёте за диск. Из чекпоинта грузятся объекты, а значит, база состояния попадает в тот же класс, что и база с деньгами. Адвайзори GHSA-g48c-2wqr-h844 описывает небезопасную десериализацию msgpack при загрузке чекпоинта: severity medium, CVSS 6,8, затронуты версии langgraph до 1.0.9 включительно, исправлено в 1.0.10.
Формулировки авторы подобрали осторожные, и передать их лучше без усиления. Они относят это к слоям защиты за периметром и к тому, что делают уже после взлома; чтобы воспользоваться уязвимостью, нужна возможность писать подконтрольные байты чекпоинта в хранилище; свидетельств использования в реальных атаках нет, и практического пути эксплуатации в существующих развёртываниях авторы не знают.
Случай при этом не единичный. У langgraph-checkpoint-sqlite набралось четыре записи, включая SQL-инъекцию через ключ метафильтра, у langgraph-checkpoint — три, включая исполнение кода в JSON-режиме сериализатора. Одну деталь видно только в исходнике. Митигация существует, но по умолчанию выключена: STRICT_MSGPACK_ENABLED читается из LANGGRAPH_STRICT_MSGPACK, а там "false".
9. Предполётный чеклист, пересобранный по исходнику
Перед выкаткой чекпоинтера в прод обычно проходят по пяти пунктам, и пункты эти правильные — сверка меняет не их состав, а содержимое четырёх из пяти.
| Пункт | Как его обычно формулируют | Что показала сверка с кодом |
|---|---|---|
| Пул посчитан явно | дефолтный max_size = 10 мал под пиковую конкуренцию |
дефолта нет: from_conn_string открывает одно соединение. Пул подключают снаружи, и одному сейверу он параллелизма не добавит — операции сериализует замок вокруг курсора |
| Узлы с побочными эффектами идемпотентны | ключ идемпотентности берут из checkpoint_id |
внутри узла он None, а на параллельных узлах одного шага общий. Годится __pregel_task_id: детерминированный и разный у соседей по шагу |
| Чистка чекпоинтов настроена | вызвать prune по расписанию |
в Postgres и SQLite не реализован. Остаются delete_thread, мелкие сейверы ценой отмотки и TTL в Redis; самодельная чистка рвёт дельта-цепочку молча |
| Мониторинг | p95 записи чекпоинта, предупреждение на снимке больше мегабайта, тест восстановления в CI | порога в коде нет; за мегабайтом замера не стоит. Остальное подтверждается: сломанный resume до прода ловит именно тест восстановления |
| Необратимое ждёт человека | список необратимых действий покрыт паузами | подтверждается, с уточнением: узел при возобновлении стартует с первой строки, поэтому всё, что стояло до паузы, отработает дважды |
Тест восстановления из четвёртой строки заслуживает отдельного слова. Он проверяет тему целиком и делается одним действием: убить процесс посреди многошаговой задачи и запустить заново. Признаков работающей персистентности три, и ломаются они по отдельности. Агент прочитал снимок, иначе разъезжается thread_id. Назвал последний завершённый шаг, иначе в состоянии лежит промежуточный мусор без постановки задачи. Продолжил со следующего, иначе он повторяет сделанное, и мы возвращаемся к двойной блокировке карты.
Итог
Персистентность — не про хранение, а про восстановление после сбоев и вмешательство человека. Обе способности живут на одном механизме, а снимки в базе остаются его побочным эффектом.
- Сбой внутри процесса и смерть процесса лечатся по-разному. Повторы, таймауты и предохранители живут в памяти прогона; состояние исполнения выносят в базу, и после этого прогон продолжает любой новый процесс.
- Продолжить тред может рестарт, оператор или вы из прошлого — это одна операция. Восстановление, ветвление от старого снимка и возобновление после решения человека отличаются точкой в истории и тем, что подано на вход.
- Гарантия чекпоинтера кончается на границе узла. Узел выполняется хотя бы один раз, внешний вызов внутри — тоже, и разводит повторы только ключ идемпотентности, детерминированный и разный у соседей по шагу.
- История стоит денег и растёт быстрее диалога. Замеренный разброс между фреймворками при одинаковой точности достигает 15,7 раза, дельта-каналы сбивают коэффициент, но не степень, а штатной чистки в Postgres-бэкенде нет вовсе.
- Изоляция диалогов бесплатна, изоляция прогонов — нет. Один клиент, один тред; два конкурентных писателя в один тред теряют обычную запись молча и с успешным статусом.
Наш агент поддержки после этого урока переживает убитый воркер, доигрывает пачку с седьмой заявки и блокирует карту один раз, а необратимое действие ждёт оператора хоть сутки, ничего не потребляя. Чего он всё ещё не умеет, так это показать, что именно происходило внутри прогона, когда всё пошло не так. Снимок хранит результат шага, а путь к результату восстанавливают уже по трассировке.
FAQ
Что происходит с состоянием агента при перезапуске процесса?
Без чекпоинтера — ничего не происходит, потому что состояния уже нет. История и прогресс лежали в памяти процесса и умерли вместе с ним. С подключённым чекпоинтером состояние записано в базу на границе каждого шага графа, и новый процесс поднимает его по thread_id. Продолжение выглядит как вызов графа с пустым входом, где graph.invoke(None, config) означает «доиграй незавершённый прогон», а не «начни новый».
Почему checkpoint_id не годится ключом идемпотентности?
По двум причинам. Внутри узла его нет, потому что конфиг задачи собирается со значением None, а в состоянии графа такого поля не существует, если не положить его туда самому. И даже добытый снаружи, он общий для всех узлов одного шага, поэтому несколько параллельных внешних вызовов ушли бы с одинаковым ключом и схлопнулись бы на приёмной стороне в один. Детерминированный идентификатор задачи __pregel_task_id свободен от обеих проблем: он не меняется между проходами и различает соседей по шагу.
Чем отмотка отличается от отката?
Откат подразумевает, что прошлое изменилось. Отмотка в LangGraph создаёт от выбранного снимка потомка с исправленными значениями и продолжает новую ветку, а исходная ветка остаётся в истории целиком. Это удобно для разбора инцидента, потому что видно и ошибочное решение агента, и исправление. Внешний мир при этом не отматывается — если ошибочное действие уже ушло во внешний сервис, его отменяют отдельно, обычными компенсирующими операциями.
Как удалять старые чекпоинты в LangGraph?
Метод prune объявлен в базовом классе, но в Postgres- и SQLite-бэкендах не реализован и поднимает NotImplementedError. Доступны три пути: delete_thread(thread_id) для полного удаления треда, «мелкие» сейверы, которые хранят только последний чекпоинт и потому лишают возможности отматывать историю, и TTL в Redis-бэкенде через ttl={"default_ttl": <минуты>}. Самодельная чистка «оставить последний» опасна тем, что рвёт цепочку дельта-каналов, и те после неё восстанавливаются пустыми без всякой ошибки.
Увеличит ли больший пул соединений пропускную способность чекпоинтера?
Одному экземпляру сейвера — нет. В асинхронной реализации курсор открывается под замком, который держится весь срок его жизни, поэтому операции одного сейвера идут по очереди независимо от размера пула. Пул решает другую задачу: переиспользование и своевременное обновление соединений, без которого удалённый Postgres закрывает простаивающее соединение по своему таймауту и все операции чекпоинтера начинают падать. Реальный параллелизм записи даёт число реплик сервиса.
Сколько места занимает персистентность агента?
Зависит от фреймворка сильнее, чем от самой работы агента. В бенчмарке AgentFootprint одна и та же зафиксированная траектория, прокрученная через семь хранящих фреймворков, дала разброс удержанного объёма в 6,7 раза, а на полных прогонах конфигурации с одинаковой точностью 100% разошлись в 15,7 раза. Рост при этом почти квадратичный там, где полная история пишется на каждом шаге. Показатель роста доходит до 1,95, а цикл на 200 раундов над файлом в 2 КБ оставил в замере 323 МБ.
Источники
- Checkpointers — Docs by LangChain — канонические формулировки по режимам записи, границе возобновления,
interrupt()иCommand(resume=...). - The Hidden Footprint: Making Storage a First-Class Metric for LLM Agent Evaluation — arXiv:2607.11149 — единственный найденный публичный замер стоимости персистентности: разброс 6,7× и 15,7×, показатель роста α, случай с 323 МБ.
- Delta Channels: How We're Evolving our Runtime for Long-Running Agents — LangChain — вендорские числа 5,3 ГБ против 129 МБ и собственная оговорка авторов, что рост остаётся квадратичным.
- Add pool_config support to AsyncPostgresSaver.from_conn_string() — issue #7304 — отсутствие ручек пула и отказ всех операций после таймаута простоя удалённого Postgres.
- Expose prepare_threshold — issue #6705 — захардкоженный нулевой порог подготовленных выражений и ошибка
prepared statement does not exist. - Cross-thread checkpoint data contamination — langgraphjs issue #2040 — разбор реального инцидента с чужими данными в треде, с корневой причиной в рантайме Node.js.
- How we debugged a latent PgBouncer bug across four layers of the stack — Decagon — исчерпание пула как вторичный симптом и разбор до границы TLS-записи.
- GHSA-g48c-2wqr-h844 — GitHub Advisory Database — небезопасная десериализация при загрузке чекпоинта, severity и границы применимости в формулировках самих авторов.
Утверждения про устройство чекпоинтера сверены по распакованным пакетам langgraph 1.2.11, langgraph-checkpoint 4.2.0, langgraph-checkpoint-postgres 3.1.2, langgraph-checkpoint-sqlite 3.1.1 и langgraph-checkpoint-redis 0.5.2; в других версиях детали могут отличаться. Числовые ориентиры по объёму хранения сняты на конкретных наборах задач и моделей, поэтому на свой профиль их переносят только как порядок величины: объём зависит от длины сессии, размера состояния и того, что именно в это состояние положено.