Суть
Проблема: при остановке/падении workflow всё состояние (история, прогресс) теряется, потому что по умолчанию оно живёт в оперативной памяти. Решение: подключить checkpointer при компиляции графа — он пишет состояние в БД на каждом шаге, и любой запуск с тем же thread_id подхватывает сохранённую историю.
Зачем это нужно
- Отказоустойчивость: сервер перезапустился — агент продолжает с того же места, а не с нуля.
- Многопользовательские сессии:
thread_id= идентификатор диалога/сессии; каждый пользователь восстанавливается независимо. - Включает HITL и Time-Travel: пауза на аппрув (LangGraph HITL) и откат к прошлому чекпоинту (LangGraph Time Travel) возможны только потому, что состояние персистентно.
Как работает
- Реализации saver'ов. В основных пакетах:
InMemorySaver(в памяти — теряется при перезапуске, только для отладки и тестов),SqliteSaver/AsyncSqliteSaver,PostgresSaver/AsyncPostgresSaver. Отдельными пакетами:RedisSaver(langgraph-checkpoint-redis),MongoDBSaver(langgraph-checkpoint-mongodb), а также Cosmos DB черезlangchain-azure-cosmosdb. - Имя
MemorySaverустарело. В коде оно живёт как псевдоним обратной совместимости (MemorySaver = InMemorySaver), поэтому старые примеры продолжают работать; в новом коде пишутInMemorySaver. - Подключение:
graph = builder.compile(checkpointer=checkpointer). Запуск сconfig={"configurable": {"thread_id": "chat_123"}}. - Восстановление: пересобрать граф с тем же checkpointer →
aget_state(thread); история —aget_state_history(thread, limit=...). - SQLite vs Postgres: SQLite — «детская» БД для разработки (нет конкурентного доступа, блокирует; при многопоточном обращении нужен особый параметр коннектора). В проде меняют connection string на Postgres/Redis — код графа не меняется.
Пример
checkpointer = PostgresSaver.from_conn_string(conn_string)
checkpointer.setup() # инициализация таблиц
graph = builder.compile(checkpointer=checkpointer)
thread = {"configurable": {"thread_id": "chat_123"}}
await graph.ainvoke({"counter": 0}, thread)
# после рестарта сервера состояние есть в БД:
state = await graph.aget_state(thread) # продолжаем тот же thread_id
Дефолтный чекпоинтер не готов к нагрузке
Отдельное предупреждение для highload: AsyncPostgresSaver из коробки на сотнях запросов в секунду ведёт себя плохо по трём причинам.
- Нет автопереподключения. Сбой в облачном Postgres роняет все активные сессии с
psycopg.OperationalError— восстанавливаться приходится вручную. - Внутренняя блокировка. Стандартная реализация использует
threading.Lock(), который под нагрузкой сводит на нет всю асинхронность: запросы выстраиваются в очередь на замке. - Утечка мёртвых соединений. При перезапусках подов остаются висеть stale-реплики, дальше —
PoolClosed.
Лечится собственным пулом соединений с явными лимитами жизни:
from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
pool = AsyncConnectionPool(
conninfo=db_url,
min_size=5, max_size=50,
max_idle=300, # убивает простаивающие коннекты (лечит idle timeout)
max_lifetime=1800, # принудительное обновление раз в 30 минут
open=True,
)
async def init_resilient_checkpointer():
return AsyncPostgresSaver(pool, pipeline=True) # батчинг SQL снижает latency
pipeline=True включает батчинг запросов: вместо отдельного round-trip на каждую запись состояния уходит пачка.
В снимок попадает ровно то, что лежит в состоянии
Вопрос «что именно сохраняется» кажется техническим, а на деле это архитектурное решение, принимаемое задолго до чекпоинтера: всё, что живёт в полях состояния, переживает перезапуск и откат; всё остальное — нет.
Практическое следствие видно на рабочей области агента. Агент, выгружающий промежуточные результаты в файлы, может держать их двумя способами:
- на диске или в песочнице — тогда снимок содержит сообщения и переменные, но не файлы, и после отката состояние графа и содержимое каталога расходятся: граф считает, что шага не было, а файл от этого шага лежит на месте;
- полем состояния — словарь «имя файла → содержимое» с редьюсером слияния. Тогда рабочая область попадает в снимок вместе со всем остальным, откат возвращает и её, а перемещение во времени (LangGraph Time Travel) становится честным: возврат к шагу N даёт ровно те файлы, что были на шаге N.
Цена второго варианта прямая: файлы едут в хранилище чекпоинтов при каждом шаге, поэтому размер снимка растёт вместе с объёмом выгруженного, и упирается это в те же ограничения, что описаны выше про дефолтный чекпоинтер. Плюс такая рабочая область эфемерна: она живёт ровно столько, сколько живёт поток исполнения, и не годится на роль долговременной памяти (Agent Memory).
Отсюда рабочее правило: в состояние кладут то, к чему относится откат. Черновики, промежуточные выжимки и план — да; артефакты, которые должны пережить задачу, — нет, им место во внешнем хранилище с собственным жизненным циклом.
Требование не принадлежит фреймворку: два других способа его выполнить
Заметка живёт в разделе LangGraph, и из этого легко сделать неверный вывод, будто «состояние переживает перезапуск» — свойство чекпоинтера. Требование общее, а способов его выполнить как минимум три, и они не эквивалентны.
| Способ | Кто так делает | Что хранится |
|---|---|---|
| снимок состояния после шага | LangGraph, Mastra | сериализованное состояние целиком |
| журнал событий с воспроизведением | Temporal | последовательность решений; состояние восстанавливается повтором кода |
Снимок — то, что описано выше. В Mastra та же идея под другими именами: suspend() внутри шага сохраняет снимок в настроенное хранилище, resume() продолжает с того же места, и документация прямо обещает, что снимки переживают перезапуск приложения и развёртывание. Совпадение с LangGraph здесь не поверхностное — совпадает и то, что без настроенного хранилища механизм тихо вырождается: снимок некуда положить.
Журнал событий решает ту же задачу с другого конца. Воркер упал — новый воркер проигрывает код заново по журналу, восстанавливает состояние на момент до падения и продолжает, как если бы падения не было; сам журнал лежит в базе сервиса и переживает падение сервиса тоже.
Разница не в удобстве, а в том, чем за живучесть платят:
- за снимок платят объёмом: всё, что должно пережить перезапуск, лежит в состоянии и едет в хранилище на каждом шаге;
- за воспроизведение платят детерминизмом кода: раз шаги проигрываются заново, в них нельзя брать случайное число, читать системное время или ходить в сеть напрямую — иначе повтор разойдётся с журналом. Всё недетерминированное выносится в отдельные вызовы, результат которых записан в журнал, а расхождение при повторе — это отдельный класс ошибок.
Практический вывод для проектирования: выбор между двумя способами делается по тому, что дешевле обеспечить — ограничение на размер состояния или ограничение на код шага. И проверка «переживает ли перезапуск» одна и та же для обоих (Durable Execution — тест восстановления ниже).
Тест восстановления: как убедиться, что персистентность настоящая
Пункт «переживает ли состояние перезапуск» проверяется не чтением конфигурации, а одним действием: закрыть сессию посреди многошаговой задачи и открыть заново. Работающая персистентность даёт три наблюдаемых признака сразу — агент прочитал снимок, назвал последний завершённый шаг и продолжил со следующего. Не выполнен любой из трёх — персистентности нет, как бы ни был настроен чекпоинтер.
Отличать надо именно эти три, потому что ломаются они по отдельности:
- не прочитал — снимок пишется, но при старте не подхватывается: чаще всего разъехался
thread_id; - прочитал, но переспрашивает — в снимке нет постановки задачи, только промежуточное состояние;
- прочитал и начал заново — в снимке нет отметки о завершённых шагах, и агент повторяет сделанное, иногда с побочными эффектами.
Третий случай дороже остальных: повторно выполненный шаг с записью наружу — это уже не потеря прогресса (Idempotency For Agents).
Чекпоинт протухает так же, как заметка
Отдельный отказ, который прунинг не закрывает: снимок жив, доступен и описывает мир, которого больше нет. Трёхнедельный чекпоинт ссылается на файлы, которых уже нет, на решения, которые отменили, и на артефакты, которые заменили. Агент читает его как текущее состояние и планирует от несуществующего.
Молчаливость здесь та же, что у устаревшего документа: снимок не сообщает о своём возрасте, а по форме он неотличим от свежего. Отсюда три меры, и первые две дешёвые:
- срок годности у снимка — не только ретенция ради места, но и отказ восстанавливаться из слишком старого;
- один активный снимок на задачу, а не история, из которой можно случайно взять не тот;
- проверка ссылок при восстановлении — пути из снимка существуют; не существуют, значит снимок описывает другой мир (Stale Write Guard).
Ретенция: прунинг чекпоинтов
Каждый шаг графа порождает новую запись. На долгоживущей сессии база растёт экспоненциально, размер одной сессии легко переваливает за сотни мегабайт, и первым деградирует не диск, а чтение состояния: SQL-запросы на выборку последнего снапшота замедляются, и это бьёт по каждому вызову агента.
Рабочая схема — два уровня хранения:
Hot Storage (PostgreSQL) последние ~5 чекпоинтов + точки HITL
│
│ Async Pruning Job (export → delete)
▼
Cold Storage (S3 / Data Lake) архив исторических сообщений
Глубину задают числом чекпоинтов, а не временем: время не связано с объёмом — сессия на сотню шагов за час занимает больше, чем десяток шагов за неделю, и ретенция по часам даёт непредсказуемый размер горячего хранилища. Порядок величины — последние ~5 снапшотов, а всё, что должно переживать прунинг независимо от глубины (точки HITL и границы возможного replay), сохраняется по признаку, а не по позиции в очереди.
Промежуточные состояния между свежими чекпоинтами удаляются безопасно — они нужны только для отмотки, а не для продолжения.
Грабля, из-за которой прунинг легко сделать разрушительным. Функция LangGraph Time Travel работает именно по тем чекпоинтам, которые прунинг удаляет. Вырезав историю ради размера базы, вы вместе с ней вырезаете возможность разобрать инцидент и отмотать ветку. Поэтому точки human-in-the-loop и границы, по которым реально может понадобиться replay, из горячего хранилища не удаляют.
Что мониторить помимо размера базы: p95 времени записи чекпоинта и размер отдельного снапшота — предупреждение при превышении 1 МБ. И отдельно — тест восстановления в CI: без него о сломанном recovery узнаёшь в проде.
Изоляция как побочная выгода. Один пользователь — один thread_id, истории не пересекаются, get_state одной сессии никогда не увидит другую. Обратная сторона правила: шарить thread_id между пользователями нельзя, это прямая утечка чужого контекста.
Связано с
- Agent Memory — checkpointer = механизм краткосрочной/сессионной памяти агента
- Durable Execution — зачем всё это нужно: живучесть процесса, а не хранение как таковое
- Vector Store Persistence — родственная идея: сохранение/перенос состояния между запусками
- LangGraph HITL — прерывания работают только при наличии checkpointer
- LangGraph Time Travel — replay/fork по истории чекпоинтов
- Stale Write Guard — тот же класс отказа на файлах: прочитанное перестало соответствовать миру
- Harness Optimization — на какой ступени чинить найденный тест-восстановлением провал