Таймаут после tool call — это не ошибка: как не выполнить действие агента дважды

Вчера под статьёй о валидации ответов LLM мне справедливо напомнили о состоянии, которое часто теряется между success и error: результат неизвестен.

Агент вызвал внешний инструмент, через десять секунд получил таймаут и не увидел ответа. Но таймаут не доказывает, что действие не произошло. Платёж мог пройти, письмо — уйти, а задача в CRM — создаться. Если просто повторить вызов, мы рискуем выполнить корректную операцию второй раз.

Поэтому для tool-calling агента недостаточно проверять JSON и добавлять retry. Нужен отдельный сценарий reconciliation — выяснение фактического состояния операции до любого повтора.

Три результата вместо двух

В обычном коде удобно мыслить двумя состояниями:

— success — получили успешный ответ; — failure — получили ошибку.

Для внешних действий этого мало. Нужно третье состояние:

— unknown — запрос отправлен, но окончательный результат подтвердить не удалось.

Unknown возникает не только при таймауте. Например:

— соединение оборвалось после отправки тела запроса; — провайдер вернул 502 от своего gateway, хотя внутренний сервис уже принял операцию; — клиент получил ответ, но не успел сохранить его из-за падения процесса; — webhook задержался или пришёл раньше, чем создалась локальная запись; — очередь доставки подтвердила приём, но статус downstream-системы неизвестен.

Главная ошибка — автоматически превращать unknown в failure. Failure можно безопасно повторить только тогда, когда мы точно знаем, что действие не началось. При unknown такого знания нет.

Как появляется дублированная операция

Представим агента, который оформляет возврат денег.

1. Модель сформировала корректный вызов create_refund. 2. Наш сервис отправил запрос платёжному провайдеру. 3. Провайдер создал возврат, но ответ потерялся по дороге. 4. Клиент дождался таймаута и пометил попытку как failed. 5. Retry создал новый idempotency key и отправил запрос снова. 6. Провайдер увидел новую операцию и выполнил второй возврат.

В этом инциденте JSON был валидным, права проверены, сумма допустима, а провайдер работал. Ошибка возникла в трактовке неопределённого результата.

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

Business operation ID должен появляться до вызова LLM-инструмента

У операции нужен стабильный идентификатор, который создаётся нашей системой один раз и живёт дольше отдельной HTTP-попытки.

Например:

operation_id: refund_74219_01 status: prepared payload_hash: ... idempotency_key: ... provider_operation_id: null

Затем операция проходит состояния:

prepared → executing → succeeded prepared → executing → failed prepared → executing → unknown → reconciling → succeeded/failed/manual_review

Важно, что retry не создаёт новую бизнес-операцию. Он продолжает существующую и использует тот же idempotency key и тот же payload.

Correlation ID можно менять для каждой сетевой попытки — он нужен для трассировки. Но operation ID и idempotency key должны оставаться прежними.

Idempotency key — не магия

Сам по себе ключ не гарантирует безопасность. Нужен контракт обеих сторон.

На клиенте:

— ключ создаётся один раз на бизнес-операцию; — сохраняется до первого внешнего вызова; — повтор отправляется с тем же ключом; — payload не меняется между попытками; — результат привязывается к сохранённой операции атомарно.

На сервере провайдера:

— ключ сохраняется вместе с состоянием и результатом; — одинаковый ключ и payload возвращают результат первой операции; — одинаковый ключ с другим payload отклоняется; — запись хранится достаточно долго для реального окна повторов.

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

Сначала проверить статус, потом повторять

При unknown безопасный алгоритм выглядит так:

1. Запретить автоматический переход в обычный retry. 2. Проверить, появился ли provider operation ID в локальном логе, webhook или очереди событий. 3. Запросить статус у провайдера по business operation ID, idempotency key или внешнему идентификатору. 4. Сверить фактическое состояние бизнес-объекта: платёж, заказ, письмо, запись CRM. 5. Только если провайдер подтверждает, что операция не создавалась, разрешить повтор с тем же ключом. 6. Если однозначного ответа нет — оставить unknown и отправить на reconciliation или ручную проверку.

Проверка должна смотреть не только на транспортный статус. Если API отвечает «не найдено», это ещё не всегда доказывает отсутствие операции: возможно, реплика отстаёт, поиск eventual consistent или данные доступны по другому идентификатору.

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

Reconciliation — это не обработка исключения, а отдельный процесс

Reconciliation сравнивает то, что мы ожидали выполнить, с тем, что реально произошло во внешней системе.

Для небольшого проекта достаточно фонового воркера, который регулярно выбирает операции в состояниях unknown и reconciling.

Он должен:

— получать актуальный статус у провайдера; — сопоставлять webhook и исходящий запрос; — проверять локальный бизнес-объект; — фиксировать доказательство результата; — завершать операцию или эскалировать человеку; — не выполнять исходное действие самостоятельно без явного разрешения политики.

Для платежей можно сверять сумму, валюту, заказ и внешний transaction ID. Для CRM — искать запись по внешнему ключу. Для отправки письма — использовать message ID провайдера и журнал доставки. Для публикации — проверять идентификатор и фактическое содержимое созданного объекта.

Если внешний API не предоставляет status endpoint, webhook или поиск по клиентскому идентификатору, его нельзя считать безопасным для полностью автоматических необратимых действий.

Outbox защищает момент до отправки

Есть ещё одно окно ошибки: локальная транзакция завершилась, но процесс упал до внешнего вызова — или внешний вызов прошёл, а локальный статус не сохранился.

Transactional outbox уменьшает этот разрыв.

В одной транзакции мы сохраняем:

— изменение бизнес-состояния; — запись операции; — команду для отправки во внешнюю систему.

Отдельный воркер читает outbox и выполняет команду. После результата он атомарно фиксирует статус и событие.

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

Inbox нужен для webhook и событий

Входящие webhook тоже могут повторяться, приходить не по порядку или задерживаться.

Поэтому каждое внешнее событие полезно сначала сохранять в inbox по уникальному event ID, а уже затем обрабатывать. Повторный webhook не должен второй раз менять бизнес-состояние.

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

Вместе outbox, inbox и operation ledger дают воспроизводимую историю: что мы планировали, что отправили, что сообщил провайдер и какое состояние приняли итоговым.

Какие действия можно повторять автоматически

Политика зависит от класса операции.

Безопаснее повторять:

— чтение данных; — вычисления без внешнего эффекта; — создание черновика с уникальным бизнес-ключом; — обновление ресурса через условную запись с version или ETag; — запросы, где провайдер документированно гарантирует идемпотентность.

Нельзя автоматически повторять при unknown:

— списание или возврат денег; — отправку сообщения внешнему получателю; — выдачу доступа; — удаление или публикацию; — создание заказа без стабильного клиентского ключа; — физическое действие или бронирование ограниченного ресурса.

Для таких операций лучше дождаться подтверждения, выполнить reconciliation или запросить решение человека.

LLM не должен решать, повторять ли действие

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

Решение принимает детерминированный orchestration-слой, который знает:

— класс риска инструмента; — operation ID; — был ли запрос фактически отправлен; — поддерживает ли API идемпотентность; — TTL ключа; — доступность status endpoint; — оставшийся временной бюджет; — допустима ли ручная проверка.

Инструмент должен возвращать агенту не только success или error, а структурированный статус:

succeeded failed_safe_to_retry unknown_reconciliation_required rejected_by_policy

Тогда модель не будет маскировать неопределённость красивой фразой «операция не выполнена».

Стартовая схема для небольшого проекта

Я бы начал с таких правил:

1. Каждое внешнее изменение получает operation ID до вызова инструмента. 2. Payload и его hash сохраняются до отправки. 3. Idempotency key привязан к операции, а не к HTTP-попытке. 4. Таймаут после отправки переводит операцию в unknown. 5. Unknown никогда не попадает в общий retry автоматически. 6. Сначала выполняется status check и сверка webhook. 7. Retry разрешён только с тем же ключом и payload. 8. Для необратимых действий без надёжной идемпотентности — manual review. 9. Операции с истёкшим TTL ключа не повторяются автоматически. 10. Все переходы состояния и доказательства результата сохраняются в журнале.

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

Что измерять

На дашборде полезно видеть:

— долю операций, перешедших в unknown; — причины неопределённости: timeout, connection reset, crash, потерянный webhook; — среднее время reconciliation; — число операций, подтверждённых после первоначального таймаута; — количество предотвращённых повторов; — операции, ушедшие на manual review; — возраст самого старого unknown; — ошибки сопоставления webhook; — повторы после истечения idempotency TTL; — расхождения локального и внешнего состояния.

Отдельный алерт нужен на старые unknown-операции. Они опасны тем, что не выглядят как массовая авария, но постепенно превращаются в финансовые и операционные расхождения.

Итог

Таймаут означает только одно: мы не получили ответ вовремя. Он ничего не говорит о том, выполнилось ли действие.

Поэтому внешний tool call должен завершаться не двумя, а тремя состояниями: success, failure и unknown. Для unknown нужен сохранённый operation ID, стабильный idempotency key, проверка фактического состояния и отдельный reconciliation-процесс.

Правильный вопрос после таймаута — не «сколько раз повторить запрос», а «какие доказательства у нас есть, что первая операция не произошла».

А как вы обрабатываете unknown outcome: проверяете статус у провайдера, сверяете webhook или сразу отправляете операцию

человеку?

Таймаут после tool call — это не ошибка: как не выполнить действие агента дважды