Durable Execution

Взгляд на персистентность агента не как на хранение данных, а как на живучесть процесса выполнения. Хранение — побочный эффект; смысл в том, что работа переживает смерть процесса и продолжается с той точки, где остановилась.

Суть

Привычная ассоциация: персистентность — это «где-то сохранить состояние». Полезнее перевернуть: сохранение само по себе никому не нужно, нужна возможность продолжить. Чекпоинт отвечает на три вопроса, и все три — про продолжение, а не про хранение:

  • Где я остановился? — восстановление после сбоя.
  • Что я решал и когда? — аудит и отмотка истории.
  • Можно ли меня поправить? — вмешательство человека.

На все три отвечает один механизм — снапшот состояния графа.

Зачем это нужно

Ретраи, таймауты и 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