Суть
Привычная ассоциация: персистентность — это «где-то сохранить состояние». Полезнее перевернуть: сохранение само по себе никому не нужно, нужна возможность продолжить. Чекпоинт отвечает на три вопроса, и все три — про продолжение, а не про хранение:
- Где я остановился? — восстановление после сбоя.
- Что я решал и когда? — аудит и отмотка истории.
- Можно ли меня поправить? — вмешательство человека.
На все три отвечает один механизм — снапшот состояния графа.
Зачем это нужно
Ретраи, таймауты и circuit breaker живут в памяти процесса. Пока процесс жив, обвязка работает. Умер процесс — умерла вместе с ним и вся обвязка, и это не гипотетический сценарий: OOM-kill воркера или перезапуск пода в Kubernetes случаются штатно.
Показательный отказ: агент разбирает очередь заявок, на седьмой из двадцати воркер убит по памяти. После рестарта состояние пустое. Агент не помнит, что уже заблокировал карту клиента, — и блокирует её второй раз. Потеряны и остальные заявки, и, что хуже, выполнено необратимое действие дважды.
Отсюда требование: состояние должно жить вне процесса.
Как работает
Аналогия с очередью сообщений. Ближайшая понятная модель — консьюмер Kafka или RabbitMQ: он не хранит прогресс в себе, а возобновляет чтение со смещения. Агент с чекпоинтером ведёт себя так же, и checkpoint_id естественно работает ключом идемпотентности для узлов с побочными эффектами.
Dormant run. Между чекпоинтом и продолжением живого Python-процесса может не быть вообще. Агент остановился на подтверждении человека в среду, оператор нажал «одобрить» в четверг утром — всё это время не расходовалось ни памяти, ни CPU, состояние целиком лежало в базе. Это то, что делает возможными сценарии длиной в дни и недели: напоминание, ожидание документа, согласование.
Очередь задач не заменяет чекпоинтер, а дополняет. Джоба в очереди — это «вызови граф по такому-то треду»; доставка живёт отдельно от состояния.
Чего чекпоинтер не делает. Сохранение состояния ещё не есть durable execution — у механизма есть дыры, и каждую надо закрывать отдельно. Первая и главная: чекпоинтер не детектирует падение. Он честно хранит последний снапшот, но никто не знает, что процесс умер и пора возобновлять, — нужен внешний супервизор или очередь.
И не управляет prompt. Checkpointer отвечает, переживёт ли состояние смерть процесса, но не решает, какую его часть увидит модель. Граф с messages reducer может надёжно сохранять всю append-only историю и на каждом узле снова отправлять её модели: durable execution работает, а стоимость шага продолжает расти. Ортогональный вопрос — является ли ограниченный state достаточным входом следующего шага (State Centric Execution).
Пример
Изоляция сессий даётся тем же механизмом бесплатно: один пользователь — один thread_id, истории не пересекаются.
# dev: файл на диске, один writer
app = graph.compile(checkpointer=SqliteSaver(conn))
# продолжение конкретной сессии — состояние подтягивается из базы
app.invoke(None, config={"configurable": {"thread_id": "user-4412"}})
Шарить thread_id между пользователями нельзя — это прямая утечка чужого контекста.
Эксплуатация
- Бэкенды. InMemory — юнит-тесты, пропадает при рестарте. SQLite — dev, файл на диске, один писатель. Postgres — продакшен по умолчанию: ACID, аудит, пул соединений. Redis — высокая нагрузка и низкая латентность, TTL. Код графа при смене бэкенда не меняется, меняется одна строка.
- Что мониторить. p95 времени записи чекпоинта; размер снапшота с предупреждением при превышении 1 МБ; тест восстановления в CI — иначе о том, что восстановление сломалось, узнаёшь в проде.
- Автокоммит. Если писать в режиме с отложенным коммитом и упасть до него, база откатится, а проснувшийся агент начнёт делать заново то, что уже успешно выполнил.
Идемпотентность узла в коде
Раздел выше говорит, что checkpoint_id естественно работает ключом идемпотентности. Минимальная реализация шлюза, который нельзя вызвать дважды:
def execute_external_payment(transaction_id: str, amount: float,
idempotency_key: str) -> dict[str, Any]:
if idempotency_key in IDEMPOTENCY_REGISTRY: # ключ уже отработан
return {"status": "blocked", "cached": True} # повтор гасится молча и штатно
IDEMPOTENCY_REGISTRY.add(idempotency_key)
return {"status": "success", "cached": False}
Две детали, которые легко упустить.
Повтор возвращает валидный ответ, а не исключение: агент после восстановления должен продолжить работу, а не упасть на защите. Флаг cached при этом отличает настоящее выполнение от подавленного дубля — без него в логах не разобрать, списали деньги или нет.
И реестр ключей обязан жить вне процесса — в Redis или той же базе, где чекпоинты. Множество в памяти, как в учебном примере, обнуляется тем же рестартом, от последствий которого и защищает.
Когда именно писать чекпоинт
Момент записи — настраиваемая ручка «скорость против гарантий», и дефолт подходит не всем узлам.
| Режим | Когда пишется | Чем платим |
|---|---|---|
sync |
синхронно, до следующего шага | максимальная гарантия, латентность на каждом узле |
async (по умолчанию) |
в фоне, параллельно следующему шагу | баланс, но есть окно, в котором падение съедает несохранённый чекпоинт |
exit |
только на выходе из графа | быстро и дёшево, но восстановления в середине выполнения нет вовсе |
Правило простое: узлы с необратимыми побочными эффектами пишутся синхронно. Для них потерянный чекпоинт означает не «переделаем шаг», а повторное списание или повторную блокировку — то есть ровно тот отказ, ради защиты от которого персистентность и вводилась.
Остальным узлам дефолтного асинхронного режима обычно достаточно.
Чем детектировать падение и когда брать Temporal
Детекция падения. Все три механизма рабочие, и выбор определяется тем, кто владеет запуском. Если работа приходит из очереди — visibility timeout закрывает вопрос сам: сообщение, не подтверждённое вовремя, возвращается другому потребителю, и отдельная детекция не нужна. Если процесс запущен по HTTP и живёт дольше запроса — нужен heartbeat в общее хранилище, а истёкшая запись становится признаком падения. Внешний супервизор оправдан только когда процессов много и они разнородны: он дороже, потому что сам становится компонентом, который может упасть. Практический порядок — сначала пробовать положить работу в очередь, и только если это невозможно, строить heartbeat.
Граница с Temporal. Чекпоинтер графа восстанавливает состояние одного исполнения и этого достаточно, пока процесс живёт в пределах одного сервиса и минут-часов. Полноценный движок долгоживущих процессов нужен, когда появляется хотя бы одно из трёх: длительность в дни и недели с ожиданием внешних событий; компенсирующие действия при откате (не просто «вернуться в состояние», а «отменить уже сделанное»); исполнение, распределённое между сервисами, где общего состояния нет по построению. Ни один из трёх признаков не про сложность графа — все три про время и границы владения, и это точный критерий, по которому не стоит тащить внешний движок в задачу, где хватает чекпоинта.
Со стороны среды, а не графа
Здесь возобновляемость решается со стороны исполнения: контрольные точки графа позволяют продолжить ход работы после перезапуска процесса. У арендованной среды исполнения та же задача стоит иначе — снимок сохраняет файлы, но не процессы и не память, поэтому после восстановления сборка, шедшая в момент снимка, начинается заново (Sandbox Lifecycle).
Общий у двух подходов один признак, и он переносимый: пауза длиннее жизни процесса обязана жить вне процесса. Обычный таймер в бессерверной среде не переживает завершения функции, и логика, на него опирающаяся, не выполняется никогда — молча.
Связано с
- Sandbox Lifecycle — та же задача со стороны арендованной среды: снимок сохраняет файлы, но не процессы
- LangGraph Checkpointers — механизм снапшотов, на котором всё это стоит
- LangGraph HITL — пауза перед необратимым действием как один из трёх вопросов чекпоинта
- LangGraph Time Travel — отмотка истории, второй из трёх вопросов
- Agent Memory — слои памяти агента: диалог, состояние выполнения, долговременные факты
- Pregel Model — почему граница супершага годится как точка сохранения
- State Centric Execution — сохранность state не равна использованию state вместо полной истории в prompt