Перейти к содержимому
5 мин чтения

Почему replay и resume AI-агента нельзя путать?

Replay и resume AI-агента решают разные задачи: разбор истории и безопасное продолжение процесса без повторных побочных эффектов.

Почему replay и resume AI-агента нельзя путать?

У replay и resume похожая механика: оба берут сохранённую историю и запускают код после некоторой точки. Но цель у них разная, а цена ошибки тоже. Replay нужен, когда команда хочет заново пройти историю и понять, почему агент принял решение. Resume нужен, когда конкретная рабочая операция остановилась и должна дойти до конца без повторной отправки уже выполненных команд.

Смешать их в одной кнопке «Запустить снова» легко. Потом агент дважды создаёт обращение в CRM, повторно бронирует товар или отправляет клиенту два письма с разными формулировками. Обычно это не ошибка модели. Это ошибка контракта между оркестратором, хранилищем состояния и внешними инструментами.

Replay проверяет прошлую историю, а resume продолжает обязательство

Replay создаёт повторное исполнение после контрольной точки. Его используют для отладки, проверки новой ветки, анализа решения модели или воспроизведения дефекта на сохранённых входах. Такой запуск должен быть отделён от исходного процесса: у него свой идентификатор, причина запуска и режим работы с инструментами.

Resume продолжает тот же логический процесс. У него уже есть обязательства перед пользователем и внешними системами. Если агент остановился на согласовании перевода, resume должен дождаться решения и выполнить дальнейшие узлы. Он не должен заново классифицировать запрос, создавать новую заявку или повторно резервировать остатки, если эти действия уже подтверждены.

Полезно закрепить это в модели данных. Один разговор пользователя не равен одному запуску, один запуск не равен одной командe, а команда не равна внешнему эффекту.

conversation_id = "conv_8841"
run_id          = "run_01J..."
command_id      = "cmd_send_offer_03"
effect_id       = "eff_run_01J_cmd_send_offer_03"

conversation_id связывает сообщения. run_id описывает отдельное исполнение графа. command_id определяет намерение агента внутри исполнения. effect_id связывает попытки создать один внешний результат. Если инженер использует один thread_id для всех четырёх ролей, он лишает систему возможности ответить на простой вопрос: «Это продолжение прежней операции или новая попытка сделать то же самое?»

Документация LangGraph формулирует границу довольно прямо: replay запускает узлы после выбранного checkpoint, а узлы до него не исполняет, потому что их результаты уже сохранены. При этом последующие вызовы LLM, API и interrupts могут сработать снова. Это не «просмотр лога». Это рабочее исполнение кода.

Снимок состояния не доказывает, что внешний эффект не случился

Checkpoint фиксирует состояние оркестратора. Он не превращает сеть в транзакцию и не знает автоматически, принял ли сторонний сервис запрос в момент падения процесса.

Представьте узел send_invoice. Агент отправил HTTP-запрос в биллинговую систему. Биллинг создал счёт и вернул 201 Created, но процесс упал до записи ответа в checkpoint. После восстановления оркестратор видит старое состояние: поле invoice_id пустое. Внешняя система видит созданный счёт. Если resume просто повторит запрос, клиент получит два счёта.

Это окно неопределённости существует даже в очень аккуратной архитектуре:

  1. агент записал намерение выполнить действие;
  2. агент вызвал внешний сервис;
  3. внешний сервис выполнил действие;
  4. ответ потерялся, таймаутился или процесс завершился до фиксации результата;
  5. оркестратор пытается восстановиться.

Логи помогают расследовать случай, но сами по себе не решают его. В логах может быть исходящий запрос, но не факт принятия. В журнале внешнего сервиса может быть эффект, но оркестратор не сумеет сопоставить его с попыткой без общего идентификатора. Нужен протокол, который переживает разрыв между «отправили» и «сохранили ответ».

Для критичных действий храните отдельную запись намерения эффекта до сетевого вызова. После вызова обновляйте ту же запись результатом. При следующей попытке код сначала читает её статус, а не действует по принципу «раз в checkpoint пусто, значит ничего не было».

{
  "effect_id": "eff_run_01J_cmd_send_offer_03",
  "type": "crm.create_offer",
  "status": "pending",
  "request_hash": "sha256:...",
  "provider_reference": null,
  "created_at": "2026-07-23T09:14:06Z"
}

Статусы pending, accepted, completed, failed полезнее одного булева поля done. pending означает, что процесс должен проверить судьбу вызова перед новой отправкой. accepted означает, что получатель принял команду, но окончательный результат ещё неизвестен. Эта разница особенно важна для платежей, доставки сообщений и асинхронных задач.

Idempotency key должен описывать действие, а не попытку

Идемпотентность означает, что повторная доставка одной и той же команды не создаёт второй эффект. Она не означает, что можно бездумно повторять любую функцию.

Самая частая ошибка: генерировать UUID внутри функции инструмента. При первой попытке агент отправляет один ключ, при resume генерирует другой, и внешний сервис честно считает запрос новой операцией. Такой «идемпотентный» код защищает только от случайной повторной отправки в рамках одного HTTP-клиента, но не от восстановления процесса.

Ключ должен появиться раньше первой попытки и жить в состоянии процесса.

from hashlib import sha256

def effect_key(run_id: str, command_id: str) -> str:
    raw = f"{run_id}:crm.create_offer:{command_id}".encode()
    return sha256(raw).hexdigest()

async def create_offer(state, crm):
    key = state["effect_id"]
    existing = await crm.find_effect(key)
    if existing and existing["status"] == "completed":
        return {"offer_id": existing["offer_id"], "effect_status": "completed"}

    response = await crm.create_offer(
        customer_id=state["customer_id"],
        amount=state["amount"],
        idempotency_key=key,
    )
    return {"offer_id": response["id"], "effect_status": "completed"}

У этого фрагмента есть два слоя защиты. Внутренняя сверка позволяет агенту увидеть уже зафиксированный эффект. Внешний idempotency_key просит получателя склеить повторные доставки в одну операцию. В идеале получатель хранит ключ вместе с хешем полезной нагрузки и отклоняет повтор с тем же ключом, но другими параметрами. Иначе ошибка маршрутизации может превратить ключ «создать предложение» в разрешение изменить сумму предложения.

Не все инструменты предоставляют idempotency key. Тогда вы строите дедупликацию сами: создаёте таблицу эффектов с уникальным индексом на (effect_type, effect_id), записываете намерение транзакционно и перед вызовом проверяете завершённую или ожидающую запись. Это не избавляет от необходимости сверяться с внешним получателем, если он мог выполнить запрос до вашего сбоя. Но это даёт системе место, где живёт правда о намерении.

Идемпотентность надо отличать от компенсации. Отмена брони не делает первоначальное бронирование идемпотентным. Это отдельная операция с новым риском, новыми условиями и собственным журналом. Компенсация нужна, когда бизнес допускает откат. Защита от повторной доставки нужна всегда, когда один логический запрос может прийти дважды.

Пауза на согласовании может перезапустить код узла

Human-in-the-loop часто выглядит безопасно: агент подготовил действие, запросил одобрение, получил ответ, продолжил работу. Но многие рантаймы возобновляют не строку кода, а весь узел или функцию от сохранённой границы.

В документации LangGraph для interrupt() сказано, что при resume узел стартует с начала. Поэтому код до interrupt() тоже выполняется снова. Авторы документации советуют ставить побочные эффекты после interrupt, делать предшествующие действия идемпотентными или выносить их в отдельные узлы.

Плохой узел выглядит безобидно:

def approve_and_send(state):
    audit.create({"event": "offer_prepared", "run_id": state["run_id"]})
    decision = interrupt({"offer": state["offer"]})
    if decision["approved"]:
        mail.send(state["recipient"], state["offer"])

После resume audit.create() исполнится ещё раз. Если аудит строится на append-only событиях без уникального ключа, вы получите два события подготовки. Это ещё терпимо. Хуже, когда до паузы стоит crm.create_lead() или payment.authorize().

Безопаснее разделить фазы:

def request_approval(state):
    return interrupt({"effect_id": state["effect_id"], "offer": state["offer"]})

def send_approved_offer(state):
    if not state["approved"]:
        return {"status": "rejected"}
    return send_with_idempotency_key(state)

Первая функция показывает человеку конкретное намерение. Вторая вызывает внешний сервис только после того, как процесс зафиксировал решение. Если ваш рантайм умеет сохранять результат задач, вынесите сетевой вызов в такую задачу и всё равно оставьте идемпотентность на стороне получателя. Кэш результата сокращает повторы в штатном сценарии. Он не заменяет защиту, если задача стартовала и упала до отметки о завершении.

Не оборачивайте механизм паузы широким try/except, который проглатывает внутренний сигнал остановки. В LangGraph interrupt реализован через специальное исключение и должен дойти до рантайма. Пойманная пауза часто выглядит как «агент сам отказался от действия», а затем ломает состояние следующего resume.

Replay LLM-вызова не равен воспроизведению ответа

Отделите модели от эффектов
Один OpenAI-совместимый endpoint маршрутизирует LLM-вызовы, а журнал эффектов остаётся в вашем приложении.

Команды часто называют replay «детерминированным отладочным запуском». Для агента это верно только при жёстком определении того, что именно вы воспроизводите.

Если вы сохраняете запрос и возвращаете записанный ответ модели, вы воспроизводите историю принятия решений. Это полезно, когда нужно понять, почему агент вызвал инструмент или выбрал маршрут. Если же replay снова отправляет тот же prompt в модель, вы повторяете эксперимент. Ответ может отличаться, а вслед за ним изменятся tool calls, порядок действий и итог.

Даже фиксированные параметры не снимают все различия. Внешний поиск меняет выдачу. Векторный поиск меняется после переиндексации. Инструмент «получить баланс» возвращает актуальные данные. Модель у провайдера может получить новую версию. Время, случайное число и сетевой ответ внутри управляющего кода меняют ветвление.

Для отладки отмечайте каждый вход как один из трёх типов:

  • записанный факт, который replay обязан вернуть без нового вызова;
  • повторяемое вычисление, которое можно исполнить заново на тех же данных;
  • живой запрос, который сознательно обращается к текущему внешнему миру.

У replay должен быть выбранный режим. В режиме evidence агент берёт сохранённые ответы LLM, tool outputs и документы. В режиме simulation разрешены тестовые инструменты и изолированные копии данных. В режиме live разрешены реальные вызовы, но он обязан создавать новый run и запрещать необратимые команды без явного допуска.

Слово «replay» без такого режима опасно. Инженер думает, что открывает запись матча, а на деле снова выпускает игрока на поле.

Ветвление истории требует нового run_id и ясной родословной

Когда команда меняет prompt, исправляет значение в состоянии или выбирает старый checkpoint, она создаёт альтернативную историю. Не продолжает исходную.

Храните у ветки минимум четыре поля:

{
  "run_id": "run_01K_new",
  "parent_run_id": "run_01J_original",
  "parent_checkpoint_id": "cp_0042",
  "launch_reason": "debug_after_tool_timeout"
}

Эта родословная нужна не ради красивого графа в интерфейсе. Она предотвращает опасную подмену. Если оператор отредактировал сумму, убрал документ из контекста или заменил ответ инструмента, итог больше нельзя считать результатом первоначального запроса. Новая ветка может быть отличным способом проверить исправление. Она не должна отправить своё действие в очередь, которую ждёт исходный рабочий процесс.

В LangGraph обновление состояния создаёт новый checkpoint, а не меняет старый. Это правильная модель: прошлое остаётся доступным для расследования, а новый путь получает собственную точку отсчёта.

Для операторского интерфейса разделите команды по смыслу:

  • «Продолжить» доступна только паузе, сбою или ожидающему действию исходного run;
  • «Повторить с checkpoint» всегда создаёт новый run;
  • «Создать ветку с изменением» показывает различия состояния и требует причины;
  • «Повторить внешний эффект» не прячьте внутри replay, это отдельная привилегированная команда.

Последняя команда должна отображать получателя, полезную нагрузку, существующий effect_id, статус последней попытки и способ дедупликации. Кнопка «Запустить снова» не передаёт этой информации. Значит, её нельзя использовать для операции, которая меняет внешний мир.

Журнал эффектов важнее красивой трассировки tool calls

Выбирайте модель под задачу
Подключите OpenAI, Anthropic, Google, DeepSeek и другие модели через совместимый API-шлюз.

Трассировка показывает, что модель попросила вызвать инструмент. Журнал эффектов показывает, что система сделала во внешнем мире. Для продакшена второй документ обычно важнее.

У каждой записи должны быть идентификатор эффекта, тип команды, нормализованный хеш запроса, время попыток, идентификатор получателя, итоговый статус и ссылка на run/checkpoint. Полную полезную нагрузку храните только там, где это допускают ваши правила доступа и хранения данных. Для персональных данных часто достаточно зашифрованного защищённого журнала и маскированного представления для оператора.

Не подменяйте «инструмент вернул 200» статусом бизнес-эффекта. API мог принять задачу, но не завершить её. Например, отправка документа может вернуться с accepted, а письмо уйдёт позже или будет отклонено политикой получателя. Агенту нужен понятный контракт каждого инструмента: синхронный ли эффект, как узнать окончательный статус, можно ли запросить его по effect_id, как долго сервис хранит ключ дедупликации.

Проверять это надо аварийными тестами, а не чтением кода. Возьмите инструмент, который создаёт реальный тестовый объект. Искусственно остановите процесс после отправки запроса, до checkpoint. Затем resume должен получить уже созданный объект по effect_id или повторить вызов с тем же ключом, сохранив один объект. После этого запустите replay из checkpoint в тестовом окружении и убедитесь, что он создал новый run, а не продолжил исходный.

Если этот тест невозможно поставить, ваша команда ещё не описала семантику восстановления. Наличие оркестратора этого не исправит.

Версии кода могут испортить resume незаметнее, чем сбой сети

Старый checkpoint содержит не только данные. Он предполагает порядок узлов, смысл полей состояния и порядок запросов на согласование. Новый код может нарушить это предположение.

Особенно опасно вставить новый interrupt до существующего или поменять местами вызовы, которые рантайм сопоставляет с сохранёнными результатами. Документация LangGraph отдельно предупреждает, что изменение порядка task и interrupt до точки resume может связать сохранённое значение не с тем вызовом. Там же советуют дать незавершённым процессам закончиться, вынести новую логику в новую task или запустить новую версию entrypoint.

Практическое правило простое: у каждого workflow есть workflow_version, сохранённая в run. Рантайм проверяет совместимость до resume. Если новая версия не умеет читать старое состояние, она не пытается «угадать». Она предлагает миграцию состояния, выполнение старой версии или ручной разбор.

{
  "run_id": "run_01J...",
  "workflow_name": "sales_offer_agent",
  "workflow_version": 7,
  "state_schema_version": 4,
  "status": "waiting_approval"
}

Не обновляйте значение поля так, чтобы старый смысл молча стал новым. Поле approved не должно внезапно означать «одобрено руководителем и пройдена проверка лимита». Добавьте новое поле и миграцию. Состояние долгоживущего агента похоже на публичный контракт, даже если его читает только ваш код.

Политика повторов должна зависеть от класса инструмента

Единая точка для LLM
AI Router направляет запросы к 500+ моделям через единый API для контролируемых LLM-вызовов.

Одна глобальная настройка retry для всех tool calls почти всегда ошибочна. Чтение, запись и необратимое действие имеют разный риск.

Чтение каталога или поиск документа можно повторить, но результат может измениться. В журнале нужно сохранить время и версию источника, если ответ влияет на решение. Запись с естественным ключом можно повторить при корректной дедупликации. Необратимое действие, например публикация документа, внешнее уведомление или движение денег, требует отдельной политики: проверить существующий эффект, возможно запросить подтверждение и только затем доставить команду.

Удобная классификация выглядит так:

КлассПримерResumeReplay
Чистое вычислениеразбор JSON, ранжированиеможно повторитьможно повторить
Чтение внешних данныхпоиск заказаможно повторить с отметкой свежестилучше вернуть запись или использовать sandbox
Идемпотентная записьupsert профиляповторить с тем же ключомтолько в новой ветке
Необратимое действиеотправка, платёж, публикациясначала сверка эффектатолько при явном разрешении

LLM-вызов не относится автоматически к чистому вычислению. Он не меняет вашу базу напрямую, но может изменить последующее решение. Если ответ используется только для черновика, повтор обычно приемлем. Если он определяет команду во внешний сервис, храните исходный ответ, аргументы tool call и выбранный маршрут как доказательство того, почему произошёл эффект.

AI Router может быть единым OpenAI-совместимым шлюзом для вызовов моделей, но checkpoint, журнал эффектов и идемпотентность всё равно остаются обязанностью приложения. Прокси маршрутизирует запрос к модели, а не отвечает за судьбу вашего письма, платежа или записи в CRM.

Сначала уберите опасную кнопку, потом усложняйте граф

Если в вашем интерфейсе есть одна команда «Retry», замените её до следующего инцидента. Для незавершённой операции назовите действие «Продолжить». Для расследования назовите его «Создать replay». Для повторной доставки внешней команды создайте отдельную операцию с видимым effect_id и проверкой статуса у получателя.

Затем выберите один инструмент с реальным внешним эффектом и проведите описанный аварийный тест. Он быстро покажет, где у вас живёт идентификатор действия, что сохраняется до вызова, что происходит при таймауте и способен ли оператор отличить новую ветку от продолжения обязательства.

Агент может рассуждать непредсказуемо. Исполнение его команд не должно быть таким же.

Часто задаваемые вопросы

Чем replay отличается от resume у AI-агента?

Replay заново исполняет часть истории после выбранной точки, чтобы проверить поведение, восстановить ход вычислений или пройти другую ветку. Resume продолжает конкретный незавершённый экземпляр процесса из сохранённого состояния. Если вы называете обе операции «повтором», интерфейс рано или поздно даст человеку возможность повторно отправить письмо, списать деньги или создать дубликат заявки.

Можно ли безопасно повторять любой запуск агента?

Нет, если агент делает только чистые вычисления над зафиксированными входами. Но почти любой production-агент вызывает поиск, CRM, платёжный API, почту, базы данных или внутренние сервисы. Для таких действий replay безопасен только при явной политике побочных эффектов.

Что делать, если агент упал после отправки запроса во внешний API?

После сбоя сначала выясните, получил ли внешний сервис запрос и сохранил ли результат. Если это неизвестно, не запускайте узел вслепую: выполните сверку по идентификатору операции или запросите статус у получателя. Resume должен подхватить подтверждённый результат либо безопасно повторить вызов с тем же idempotency key.

Нужен ли idempotency key для инструментов агента?

Идемпотентный ключ связывает несколько попыток одного логического действия с одним эффектом у внешнего сервиса. Его нельзя генерировать заново на каждой попытке. Хороший ключ строят из постоянного идентификатора запуска и идентификатора команды, например run_id и effect_id.

Достаточно ли логов вместо checkpoint для resume?

Нет. Лог анализа и снимок состояния отвечают на разные вопросы. Лог говорит, что происходило во времени, а checkpoint даёт материализованное состояние, из которого рантайм может продолжить работу; для расследования обычно нужны оба.

Можно ли редактировать состояние перед повторным запуском?

Часто да, но только как новая ветка с новым идентификатором запуска и записанной причиной. Нельзя молча менять состояние завершённой истории и выдавать результат за исходный. Особенно опасно менять данные после того, как агент уже вызвал внешние сервисы.

Будет ли replay LLM-вызова воспроизводимым?

Повтор LLM-вызова не даёт гарантию того же текста, набора tool calls или маршрута. Даже при одинаковом промпте ответ может измениться из-за параметров модели, доступности инструмента, контекста и версии модели. Replay для отладки должен фиксировать входы и помечать, какие результаты воспроизведены, а какие получены заново.

Как должны выглядеть кнопки replay и resume в интерфейсе?

Кнопка resume должна появляться только у запуска со статусом ожидания, сбоя или контролируемой паузы. Кнопка replay должна требовать выбора checkpoint, режима побочных эффектов и нового run_id. Одной кнопки «Запустить снова» для этих сценариев недостаточно.

Как проверить идемпотентность инструмента агента?

Проверяйте idempotency key, у которого есть стабильный scope, понятный срок жизни и запись в журнале эффекта. Затем искусственно оборвите процесс после отправки запроса и до сохранения ответа. Повторная попытка не должна создавать второй эффект, а должна вернуть исходный результат или статус уже принятой операции.

Какие идентификаторы нужно хранить для agent workflow?

Разделите идентификаторы разговора, процесса, команды и внешнего эффекта. Не используйте один thread_id как универсальный ключ для всего: пользователь может продолжать один диалог месяцами, а платёж или письмо должны иметь собственную неизменную идентичность. Так проще расследовать инциденты и безопасно повторять отдельные действия.