LangGraph Checkpointers

Checkpointer — компонент персистентности: сохраняет снимки состояния графа в хранилище и позволяет восстановить сессию по thread_id после перезапуска. Это фундамент, на котором стоят human-in-the-loop и time-travel.

Суть

Проблема: при остановке/падении 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 — на какой ступени чинить найденный тест-восстановлением провал