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

Возобновление агента после согласования без повторного платежа

Возобновление агента после согласования без дублей: журнал операции, идемпотентность, сверка платежей и безопасные записи в CRM.

Возобновление агента после согласования без повторного платежа

Агент не должен повторять действие только потому, что процесс снова запустился. После согласования опасность обычно создаёт не модель, а разрыв между тремя событиями: запрос уже ушёл во внешний сервис, ответ не сохранился, а оркестратор после resume видит незавершённый шаг и вызывает инструмент ещё раз.

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

Пауза не отменяет неопределённость внешнего вызова

Согласование делит процесс на две части, но не делает вторую часть атомарной. Человек утвердил платёж. Агент отправил запрос в банк. Банк принял запрос и создал перевод. В этот момент сеть оборвалась или воркер завершился до записи ответа. При следующем запуске у вас нет права ни объявить операцию провалившейся, ни отправить её повторно.

Это состояние надо называть прямо: unknown. Оно означает: система не знает, произошло ли внешнее изменение. Многие реализации сглаживают его до failed, потому что так проще рисовать интерфейс и строить ретраи. Потом бухгалтерия ищет второй перевод, а команда выясняет, какой из двух воркеров «на самом деле» был виноват.

У действия с побочным эффектом есть четыре разных момента:

  1. Агент сформировал намерение.
  2. Человек утвердил именно это намерение.
  3. Внешний сервис принял или отклонил вызов.
  4. Ваше приложение надёжно записало результат.

Между третьим и четвёртым моментом всегда возможен сбой. Поэтому журнал операции должен существовать отдельно от истории диалога, трассировки LLM и очереди задач. Диалог объясняет, почему агент предложил действие. Журнал отвечает на другой вопрос: какое изменение система уже пыталась сделать и чем это закончилось.

OpenAI в практическом руководстве по агентам относит платежи, крупные возвраты и другие необратимые действия к случаям, где нужен надзор человека. Это разумная граница, но само согласование не устраняет дублирование при повторном запуске.

Согласовывать надо снимок намерения, а не текст ответа модели

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

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

{
  "operation_id": "op_01JQ8R7D9M4K",
  "action": "create_payment",
  "tenant_id": "org_482",
  "payload": {
    "invoice_id": "inv_9182",
    "beneficiary_id": "vendor_77",
    "amount_minor": 1250000,
    "currency": "KZT"
  },
  "policy_version": "payments-v3",
  "payload_hash": "sha256:7c4f...",
  "approval": {
    "status": "pending",
    "expires_at": "2026-07-23T16:00:00Z"
  },
  "tool": {
    "name": "payments.create",
    "idempotency_key": "op_01JQ8R7D9M4K"
  },
  "execution_status": "not_started"
}

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

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

Идемпотентность инструмента и идемпотентность процесса не одно и то же

Идемпотентный API умеет узнать повторный запрос и не создать второй объект. Это полезно, но это только свойство одного внешнего вызова. Идемпотентный процесс должен пережить повторную доставку сообщения, падение воркера, два конкурентных resume, задержанный webhook и ручной перезапуск задачи.

Документация Stripe хорошо показывает границу. Для POST-запросов сервис принимает Idempotency-Key, сохраняет результат первого выполнения и при повторе с тем же ключом возвращает сохранённый результат. Но это не помогает, если приложение сгенерировало новый ключ при каждом запуске, изменило параметры под старым ключом или вообще не знает, какой вызов успел начаться. Stripe также предупреждает, что результаты сохраняются только после начала исполнения запроса, а ошибки валидации и конфликт конкурентного выполнения могут требовать отдельной обработки.

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

Плохой вариант выглядит так:

# Каждый retry создаёт новую операцию. Так делать нельзя.
key = uuid4()
payments.create(invoice_id=invoice_id, amount=amount, idempotency_key=str(key))

Рабочий вариант хранит идентификатор до вызова:

operation = db.get(operation_id)
key = operation.tool_idempotency_key
result = payments.create(
    invoice_id=operation.payload["invoice_id"],
    amount=operation.payload["amount_minor"],
    idempotency_key=key,
)

Даже такой код не должен вызываться первым при resume. Сначала он обязан посмотреть сохранённый статус и выполнить сверку с провайдером, если предыдущая попытка могла уйти наружу.

Журнал операции должен хранить попытку, результат и доказательство

Одной строки approved = true недостаточно. Нужна запись, которая описывает жизненный цикл конкретного побочного действия и не меняет исходный снимок намерения задним числом.

Минимальная модель обычно включает следующие поля:

ПолеЗачем оно нужно
operation_idСвязывает согласование, попытки, логи и внешний объект.
payload_hashДоказывает, что исполнение соответствует утверждённому намерению.
execution_statusРазличает not_started, in_progress, unknown, succeeded, failed, cancelled.
attempt_noПомогает разобрать ретраи и конкуренцию.
idempotency_keyДаёт внешнему API возможность узнать повтор.
external_idПозволяет читать состояние у провайдера без поиска по сумме.
request_fingerprintФиксирует метод, маршрут и хеш тела без секретов.
evidenceХранит код ответа, request ID провайдера, время, ссылку на webhook или снимок ответа.

Статус in_progress нужен до сетевого вызова. Он говорит следующему воркеру: «кто-то уже получил право исполнять эту операцию». Если процесс завершается после отправки запроса, запись может остаться в in_progress или перейти в unknown по тайм-ауту аренды. Это не повод автоматически повторять вызов. Это повод начать сверку.

Поле external_id нельзя оставлять пустым, если провайдер вернул его до полного ответа. Сохраняйте его в отдельной короткой транзакции сразу после получения. Для многих API полезны и идентификатор запроса, и ключ идемпотентности. Stripe, например, публикует request ID в заголовке ответа и использует его для поиска конкретного вызова в журналах.

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

Resume сначала сверяет состояние, потом решает, можно ли писать

Ограничьте доступ ключом
Используйте ключевые rate-limits, чтобы ограничивать модельный доступ до вызовов платёжных и CRM-инструментов.

Логика возобновления не должна зависеть от того, насколько убедительно модель объяснила свой предыдущий шаг. Это обычный детерминированный обработчик, который получает operation_id и выбирает один из ограниченных переходов.

Ниже порядок, который стоит реализовать для платежа, записи в CRM или изменения статуса заказа.

  1. Заблокируйте запись операции через compare-and-set или аренду на короткий срок. Два воркера не должны одновременно выполнять одну задачу.
  2. Прочитайте согласование, срок действия, payload_hash и версию политики. При несовпадении переведите операцию в cancelled или создайте новую заявку на согласование.
  3. Если статус succeeded, верните сохранённый результат. Инструмент не вызывайте.
  4. Если статус unknown или истекла аренда in_progress, вызовите lookup у провайдера по external_id, ключу идемпотентности или уникальному внешнему полю.
  5. Если сверка доказала отсутствие операции, верните статус в not_started и только тогда выполните вызов с прежним ключом. Если сверка не дала однозначного ответа, оставьте unknown и передайте случай оператору.

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

Для критичных действий разделяйте команды на prepare, execute и reconcile. prepare проверяет данные и формирует намерение. execute делает один внешний вызов. reconcile ничего не создаёт, а только читает состояние и приводит ваш журнал к факту. Когда эти три роли слиты в одну функцию pay_invoice(), повторный запуск почти неизбежно начинает создавать побочные эффекты во время диагностики.

Платёж после согласования надо строить вокруг сверки, а не retry

Представим счёт inv_9182 на 12 500 KZT. Агент проверил правила, подготовил намерение op_01JQ8R7D9M4K и получил согласование. Он сменил статус на in_progress, отправил POST с ключом op_01JQ8R7D9M4K, но соединение оборвалось до ответа.

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

Правильная реализация делает так:

operation_id: op_01JQ8R7D9M4K
status: unknown
provider_lookup:
  reference: op_01JQ8R7D9M4K
  result: payment_78431, status=accepted
local_update:
  status: succeeded
  external_id: payment_78431
  evidence: provider lookup at 2026-07-23T14:18:09Z

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

Не путайте обработку технической ошибки с отменой бизнес-решения. Ошибка 400 из-за неверного ИИН или закрытого счёта означает, что одобренное намерение нельзя исполнить. Нельзя просто поправить реквизит и повторить вызов под старым согласованием. Исправленные реквизиты меняют объект действия.

CRM тоже создаёт необратимые последствия

Не передавайте PII модели
Применяйте маскирование PII к LLM-запросам, не добавляя персональные данные в журнал исполнения.

Команды часто считают CRM безопасной зоной: «ну продублируется контакт, не деньги же». На практике двойной лид запускает две цепочки писем, меняет отчётность, закрепляет клиента за разными менеджерами и создаёт ручную работу для продаж. А повторное изменение стадии сделки может отправить webhook в биллинг, поддержку или систему доступа.

Для создания сущности используйте уникальный внешний идентификатор, который CRM сохраняет вместе с объектом. Например, agent_operation_id = op_01JQ8R7D9M4K. При resume сначала ищите запись по этому полю. Нашли одну запись, фиксируете external_id и завершаете операцию. Нашли несколько, не выбирайте «самую свежую»: это инцидент данных, который требует отдельного правила разрешения.

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

{
  "operation_id": "op_01JQ8R7D9M4K",
  "action": "crm.update_deal",
  "target": "deal_442",
  "expected_version": 19,
  "patch": {
    "stage": "contract_sent"
  }
}

Если CRM отвечает конфликтом версии, агент не должен повторять PATCH с новыми данными из карточки. Он должен показать, что изменилось, и запросить новое решение. Одобрение «перевести сделку в контракт» может не означать согласия перевести её после того, как сумма и ответственный менеджер поменялись.

Популярная рекомендация «сделайте все tool calls идемпотентными» здесь недостаточна. Создание записи можно сделать идемпотентным через внешний идентификатор. Изменение записи требует ещё и контроля версии. Удаление, отправка письма и смена владельца имеют свои условия. Один общий флаг idempotent: true скрывает эти различия и рождает ложное чувство безопасности.

Webhook подтверждает внешний факт, но не заменяет журнал

Отделите модель от исполнения
Направьте модельные запросы через единый OpenAI-совместимый эндпоинт, а журнал операций оставьте в приложении.

Провайдер может принять платёж асинхронно. CRM может вернуть 202 Accepted, а фактически создать объект позже. В этих случаях webhook помогает завершить сверку, но сам webhook тоже приходит повторно, поздно или в другом порядке.

Обработчик события должен сохранять идентификатор события в таблице дедупликации и связывать его с operation_id либо external_id. Затем он обновляет запись операции допустимым переходом. Событие «платёж обработан» не должно воскресить операцию, которую оператор уже пометил как спорную, без отдельного правила расследования.

Хорошая схема не ждёт webhook бесконечно. У операции есть время, после которого воркер запускает reconcile: читает состояние у провайдера, учитывает полученные события и оставляет понятный итог. Если API предоставляет итоговый статус только через выписку или ручной кабинет, это ограничение надо честно отразить в дизайне. Для таких интеграций автоматическое resume после неясной отправки недопустимо.

Проверка должна ломать процесс в самых неприятных местах

Тест «агент одобряет платёж и получает 200» ничего не доказывает. Проверяйте моменты, где состояние расходится между вашей базой и внешним сервисом.

Минимальный набор сценариев для автоматических тестов:

  • процесс завершился после отправки HTTP-запроса, но до сохранения ответа;
  • два воркера одновременно получили один сигнал resume;
  • одобрение пришло после истечения срока;
  • человек изменил сумму или получателя в новой версии заявки;
  • webhook доставили дважды и после ручной сверки.

В каждом тесте проверяйте не только финальный статус. Проверяйте число вызовов инструмента, неизменность payload_hash, единственность внешнего объекта, а также то, что запись unknown не превращается в повторный вызов без lookup.

В production добавьте метрики для операций в unknown, времени до сверки, конфликтов версии CRM, повторных согласований и количества ручных разборов. Резкий рост unknown обычно говорит о проблеме сети, тайм-аутах или изменении API провайдера. Рост повторных согласований часто означает, что агент слишком рано формирует действие и слишком долго ждёт человека.

Если модельный слой у вас проходит через AI Router, это не меняет описанную схему: ваш сервис должен сохранять журнал операции и выполнять детерминированный resume независимо от того, какая модель подготовила предложение.

Безопасный агент после согласования не «продолжает с того же места». Он продолжает только после того, как сверил утверждённое намерение, локальный журнал и внешний факт. Для действий с деньгами и данными клиентов это единственный порядок, который выдерживает сбой между запросом и ответом.

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

Нужно ли сохранять состояние агента до согласования?

Да, если пауза находится до вызова инструмента и вы сохраняете неизменяемый снимок намерения. После согласования агент должен выполнять именно этот снимок, а не заново строить запрос из текущего диалога или свежих данных CRM.

Достаточно ли idempotency key для защиты от двойного платежа?

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

Когда считать вызов платёжного API успешным?

Отмечайте задачу как succeeded только после того, как получили идентификатор объекта у провайдера или подтвердили его отдельным запросом. Ответ с тайм-аутом, сетевой ошибкой или обрывом соединения должен переводить задачу в unknown, а не в failed.

Как не создать дубликат лида в CRM после resume?

Да. Если CRM поддерживает внешний идентификатор, записывайте туда operation_id и создавайте запись через upsert по этому значению. Если такой возможности нет, перед созданием ищите объект по сохранённому идентификатору, а не по имени клиента или тексту заметки.

Нужно ли повторное согласование, если изменились параметры?

Согласование должно хранить хеш канонического намерения, срок действия и версию политики. Если сумма, получатель, поля CRM или допустимый инструмент изменились, старое согласование недействительно, и агент обязан запросить новое.

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

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

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

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

Может ли агент продолжить работу после истечения согласования?

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

Нужны ли webhook, если агент уже получил ответ API?

Webhook подтверждает изменение у провайдера, но не заменяет запись в журнале операции. Обработчик webhook должен сопоставить событие с operation_id, сохранить внешний статус и безопасно переживать повторную доставку одного события.

Зависит ли безопасное resume от LLM-провайдера?

Не обязательно менять архитектуру агента. AI Router можно использовать как OpenAI-совместимый модельный шлюз, а журнал операций, согласования и адаптеры инструментов оставить в вашем приложении. Безопасность resume определяется границами побочных действий, а не выбором модели.