Почему один тайм-аут способен создать две реальные операции
ИИ-агент может корректно подготовить письмо, счёт или карточку сделки, отправить команду во внешнюю систему и не получить ответ вовремя. Для оркестратора это выглядит как ошибка. Для CRM, почтового сервера или бухгалтерской системы первая команда при этом могла уже завершиться. Если агент просто повторит вызов, бизнес получит не «повышенную надёжность», а два письма, две задачи или две попытки списания.
Это обычная проблема распределённых систем, а не особый дефект языковых моделей. Но агентные сценарии делают её заметнее: модель выбирает инструменты динамически, цепочка длиннее обычного API-вызова, а оператор часто видит только итоговый ответ. Чем больше шагов — поиск клиента, создание документа, согласование, отправка, запись результата, — тем больше точек, где ответ может потеряться после фактического исполнения.
AWS описывает безопасный повтор через идемпотентный API: клиент передаёт уникальный идентификатор намерения, а сервис распознаёт повтор и не создаёт новый побочный эффект. Microsoft отдельно предупреждает, что брокер может доставить сообщение повторно, поэтому одной дедупликации на стороне очереди недостаточно — обработчик тоже должен быть идемпотентным.
Для малого бизнеса практический вывод прост: команда «повтори при ошибке» безопасна только тогда, когда повтор связан с той же бизнес-операцией и система умеет доказать, что она уже выполнена или ещё не выполнялась.
Что именно считать одной операцией
Идемпотентный ключ нельзя создавать заново при каждом сетевом запросе. Он должен обозначать бизнес-намерение, а не попытку доставки. Например:
- `создать-счёт / заказ-1842 / версия-1`;
- `отправить-подтверждение / бронирование-731 / русский`;
- `обновить-статус-сделки / сделка-558 / квалифицирована`;
- `сформировать-возврат / платёж-902 / 1500-рублей`.
Первая и вторая попытки одной команды используют одинаковый ключ и одинаковые параметры. Если сотрудник изменил сумму, получателя или тип действия, это уже новое намерение и новый ключ. AWS обращает внимание именно на различие между повтором того же запроса и новым намерением с похожими параметрами.
Хеш от всего JSON без бизнес-контекста — слабая замена. Незначимое изменение порядка полей породит новый ключ, а два осознанных одинаковых действия могут ошибочно слиться. Надёжнее хранить явный `operation_id`, созданный до обращения к модели или внешнему API, и связывать с ним версию данных, инициатора и целевой объект.
Минимальная архитектура: намерение, журнал, исполнитель
Рабочую схему можно собрать без тяжёлой платформы. Достаточны прикладная база данных, фоновый обработчик и адаптеры к внешним системам.
1. Пользователь или процесс создаёт намерение: например, «отправить подтверждение бронирования».
2. Приложение сохраняет бизнес-изменение и запись в таблице исходящих операций в одной транзакции.
3. Фоновый исполнитель забирает готовую запись, проверяет политику и вызывает внешний API.
4. Один и тот же `operation_id` передаётся как идемпотентный ключ, если целевая система это поддерживает.
5. Результат, внешний идентификатор и время исполнения записываются в журнал.
6. При тайм-ауте операция не создаётся заново: она остаётся в состоянии `неизвестно` и проходит повтор или сверку с внешней системой.
Таблица может содержать поля `operation_id`, `action_type`, `target_id`, `payload_hash`, `status`, `attempt_count`, `next_attempt_at`, `external_id`, `last_error_class`, `created_at` и `completed_at`. Полный prompt хранить здесь обычно не нужно: для исполнения важны утверждённые параметры действия и ссылка на версию исходных данных.
Такой журнал отделяет рассуждение модели от необратимого действия. LLM может предложить команду, но детерминированный слой проверяет схему, права, лимиты и уникальность операции. Это особенно важно для отправки сообщений, изменения цен, резервирования товара, создания платёжных документов и записи в учётную систему.
Зачем нужен transactional outbox
Без outbox возникает неприятное окно между двумя действиями. Приложение может сохранить заказ в базе, а затем упасть до отправки события обработчику. Или наоборот: отправить событие, но не успеть зафиксировать новый статус. В обоих случаях бизнес-данные и очередь расходятся.
Transactional outbox закрывает это окно: изменение бизнес-записи и новая строка исходящей операции фиксируются одной транзакцией в одной базе. Отдельный воркер позже публикует или исполняет накопленные строки. Microsoft рекомендует этот паттерн для согласованности между бизнес-данными и исходящими сообщениями.
В небольшом контуре роль очереди может выполнять таблица PostgreSQL. Несколько воркеров выбирают порцию готовых строк с блокировкой. Документация PostgreSQL прямо указывает, что `SKIP LOCKED` не подходит для общего согласованного чтения, но полезен нескольким потребителям очередеподобной таблицы: занятые строки пропускаются, и воркеры не ждут друг друга.
Это не означает, что PostgreSQL всегда лучше брокера. Для десятков операций в минуту таблица часто проще в эксплуатации. При больших потоках, нескольких независимых потребителях, длительном хранении событий или сложной маршрутизации удобнее специализированный брокер. Но идемпотентный потребитель всё равно нужен: режим доставки «как минимум один раз» допускает повтор.
Четыре состояния вместо бинарного «успех/ошибка»
Самая опасная ошибка реализации — считать тайм-аут доказанным неуспехом. Нужны как минимум четыре состояния:
- `pending` — команда ещё не отправлялась;
- `processing` — попытка начата, есть аренда или блокировка;
- `succeeded` — получено подтверждение и сохранён внешний идентификатор;
- `unknown` — ответ потерян, результат во внешней системе неизвестен;
- `failed_permanent` — параметры неверны или действие запрещено, автоматический повтор бессмыслен.
Для `unknown` сначала выполняют сверку: поиск по идемпотентному ключу, внешнему идентификатору, номеру заказа или другому устойчивому признаку. Только если целевая система подтверждает отсутствие результата, допустим повтор. Stripe на примере платёжного API рекомендует при сетевой неопределённости повторять тот же запрос с тем же ключом и теми же параметрами; смена параметров требует нового намерения.
Постоянные ошибки — неверный адрес, закрытая сделка, отсутствие права, нарушение бизнес-лимита — следует отправлять в очередь исключений, а не гонять бесконечно. Временные ошибки — тайм-аут, ограничение частоты, краткая недоступность — можно повторять с экспоненциальной задержкой и случайным разбросом. Jitter нужен, чтобы множество операций не проснулось одновременно и не устроило новую перегрузку.
Где остаётся человек
Идемпотентность защищает от технического дубля, но не доказывает правильность самого намерения. Если модель дважды независимо решила создать два возврата, два разных ключа честно пропустят оба. Поэтому до фиксации `operation_id` нужны бизнес-ограничения:
- один активный возврат на строку заказа;
- один финальный счёт на версию отгрузки;
- не более одного исходящего письма данного типа за заданный интервал;
- сумма выше порога — только после подтверждения сотрудника;
- изменение реквизитов и получателя — повторное согласование;
- массовая операция — предварительный просмотр и лимит размера партии.
Для рискованных действий сотруднику показывают не длинную историю рассуждений модели, а компактную карточку: что изменится, в какой системе, для какого объекта, на какую сумму, по чьему запросу и чем операция отличается от уже выполненных. Решение человека тоже записывают рядом с `operation_id`.
Наблюдаемость без хранения лишнего
Руководителю нужен не поток технических логов, а несколько операционных показателей:
- доля операций, завершённых с первой попытки;
- число безопасно погашенных дублей;
- количество состояний `unknown` и среднее время сверки;
- размер и возраст очереди исключений;
- повторные действия, остановленные бизнес-ограничениями;
- доля операций, потребовавших человека, и причины.
Отдельно полезно считать «стоимость неопределённости»: время сотрудника на проверку, задержку процесса и потенциальную цену дубля. Тогда улучшение надёжности можно сравнить с затратами на разработку. Для малой компании зачастую выгоднее сначала защитить три дорогих действия — оплату, юридически значимую отправку и изменение учётных данных, — а не строить универсальную шину для всех процессов.
Модельный пилот на пять рабочих дней
Начать можно с одного процесса, где уже случались повторы или ручные сверки.
- День 1: выписать все необратимые действия и точки сетевой неопределённости.
- День 2: определить бизнес-идентификатор операции и правила нового намерения.
- День 3: добавить журнал операций, уникальное ограничение и статусы `unknown`/`failed_permanent`.
- День 4: включить ограниченные повторы с backoff и ручную очередь исключений.
- День 5: проверить сценарии «ответ потерян после успеха», «одна команда доставлена дважды», «параметры изменились», «воркер упал после внешнего вызова».
Критерий готовности — не отсутствие ошибок в демонстрации. Система должна повторно проиграть каждую аварийную точку и показать, что внешний эффект либо один, либо явно остановлен для сверки.
Что взять руководителю
Перед подключением агента к CRM, почте, складу или бухгалтерии попросите команду показать путь одной команды от бизнес-намерения до внешнего результата. У неё должен быть устойчивый идентификатор, атомарная запись в журнале, понятное состояние при тайм-ауте, ограниченная политика повторов и процедура сверки.
Если на вопрос «что произойдёт, когда ответ потеряется после успешной отправки?» система отвечает «агент попробует ещё раз», автоматизация пока не готова к реальному контуру. Правильный ответ начинается со слов: «он повторит ту же операцию с тем же ключом или остановится для проверки».
