ИИ-агент не получил ответа от API. Он записал или нет?

Мы делаем Сам Решу — ИИ-агента, который работает в кабинетах маркетплейсов, складских программах, банках и CRM. За последний год мы поняли: самые дорогие ошибки агента случаются не в диалоге и не в «галлюцинациях». Они случаются в момент, когда агент что-то записывает во внешнюю систему, а ответ не приходит.

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

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

Когда агент читает — остатки, заказы, выписку, — сбой безвреден. Не получилось, прочитал ещё раз.

Когда агент пишет, у запроса три исхода:

  • прошёл — сервис ответил и вернул результат;
  • отклонён — сервис ответил отказом: нет прав, неверные данные;
  • неизвестно — запрос ушёл, ответ не пришёл: таймаут чтения, обрыв соединения, 5xx на стороне сервиса.

Большинство автоматизаций знают только первые два. Для них «нет ответа» — это ошибка, и дальше сценарий либо падает, либо повторяет шаг. Но запрос без ответа мог дойти до сервиса и выполниться. Сервис создал заказ, а ответ потерялся по дороге обратно.

В RFC 9110 это сформулировано прямо: неидемпотентный запрос нельзя повторять автоматически, если нет способа узнать, что первый не применился.

Что делает агент, у которого нет этого различения

Возьмём заказ поставщику на 40 позиций. Запрос ушёл, 30 секунд тишины. Дальше три варианта, и все плохие:

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

У LLM-агента есть и своя специфика: модель склонна закрывать неопределённость уверенным ответом. Если не дать ей явного состояния «исход неизвестен», она выберет одно из двух привычных.

Одно правило на все интеграции

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

  1. Чтение повторять можно. Оно ничего не меняет.
  2. Идемпотентную запись повторять можно. PUT и DELETE приводят систему в то же состояние: поставить цену 990 ₽ дважды — всё равно 990 ₽.
  3. Создание можно повторять, только если сервис сам обещает дедупликацию. Это обещание записано в описании интеграции явно, для конкретных эндпоинтов. Заголовок, который кто-то придумал сам, слой не принимает за обещание: поддержку ключа подтверждает сервис, а не вызывающий код.
  4. Всё остальное слой не повторяет. Агент получает явное состояние: «исход неизвестен, проверь, прежде чем повторять запись».

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

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

Кто ставит ключ: программа, а не модель

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

Поэтому ключ у нас всегда ставит код: один ключ на одну логическую операцию, и его несут все попытки этой операции. В ЮKassa это заголовок Idempotence-Key: сеть оборвалась, запрос ушёл снова, ЮKassa видит знакомый ключ и возвращает тот же платёж, а не проводит второй. У Stripe механизм устроен так же и рекомендация та же: повторять с тем же ключом и теми же параметрами.

МойСклад: поиск по syncId вместо повтора вслепую

У МойСклад есть поле syncId. Если прислать повторный POST с уже занятым syncId, сервис вернёт ранее созданную сущность, а не создаст вторую. У документов по этому полю ещё и можно фильтровать.

Когда агент создаёт заказ, приёмку или списание, происходит следующее:

  1. Хелпер ставит syncId в сам payload. Повтор того же вызова несёт тот же ключ, а новый черновик — новый.
  2. Если ответ потерян, агент не сообщает об ошибке, а ищет документ по syncId.
  3. Нашёл — запись состоялась, агент продолжает с найденным документом.
  4. Не нашёл — исход всё ещё неизвестен: сервис мог не принять запись или не закончить её. Агент не говорит ни «создано», ни «не создано» и называет единственный безопасный ход: тот же вызов с тем же payload.

С массовыми правками механика другая. Обновления перечитываются по id и досылаются меньшими пачками. Созданные записи сначала ищутся фильтром, и только потом решается, что отправлять заново.

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

Ozon: «успешно» — ещё не итог

Проверять надо не только потерянный ответ, но и полученный.

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

Отказ по правам: читать узко

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

В МойСклад права «создавать» и «проводить» документ разделены. Если у ключа есть первое и нет второго, на создание проведённой приёмки сервис отвечает 403 с кодом 1092. Смысл узкий: провести нельзя, создать можно.

До недавнего времени наш агент читал этот отказ широко: «нет доступа к разделу» — и бросал задачу. Причина была банальной: код 1092 отсутствовал в нашей таблице восстановлений и проваливался в общий ролевой вердикт, а тот ещё и запрещал соседние пути. Инструкция «повтори с applicable=false» у нас была, но агент до неё не доходил. Официальная таблица ошибок МойСклад тут не помогает: у 1092 описание скопировано от соседнего кода.

Теперь на 1092 агент повторяет запрос с applicable=false, получает непроведённый черновик и говорит прямо: документ создан, провести его нужно вам.

И отдельно: там, где право выдать нельзя, агент не отправляет человека его выдавать. У приложения из маркетплейса решений МойСклад права на проведение нет в принципе — набор прав задан дескриптором приложения, и ни администратор, ни пользователь его не расширят. Совет «попросите администратора» в таком случае стоит клиенту второго обращения в поддержку. Честный итог — черновик и одна кнопка «Провести».

Почему это важнее, чем кажется

В подкасте Latent Space основатель TypeSafe AI Диогу Алмейда сформулировал парадокс: модели берутся за задачи уровня «премии тысячелетия», но до сих пор не автоматизируют базовую работу. В разборе модели Jev от Browserbase есть точная формулировка той же мысли: ответ может убедить человека и всё равно не годиться для автоматизации без присмотра.

Разрыв между демо и продакшеном во многом состоит из таких мест: потерянных ответов, частично применённых пачек, отказов, которые значат не то, что написано, и «успешно», за которым стоит другая цена. В свежем опросе разработчиков Zapier пять навыков, которые они считают главными на будущее, сводятся к одному — решить, безопасно ли это, правильно ли и можно ли это выпускать. Для агентов, которые пишут в чужие системы, это и есть основная работа.

Что спросить у поставщика ИИ-агента

  • Что агент делает, если на запись не пришёл ответ? Хороший ответ: считает исход неизвестным и проверяет. Плохой: повторяет или сообщает об ошибке.
  • Кто ставит ключ идемпотентности — код или модель? Правильно — код, один на операцию.
  • Правило повтора общее или своё в каждом сценарии? Общее, с доп. проверками там, где у сервиса свои механизмы.
  • Проверяет ли агент результат после успешной записи? «Сервис ответил 200» — не проверка.
  • Что будет при нехватке прав? Хорошо: сделает, что можно, например черновик, и скажет, что осталось вам.
  • Что агент скажет, если не знает, чем закончилась запись? Честное «исход неизвестен, вот как проверить», а не «готово».

Самая быстрая проверка — попросить агента создать тестовый документ и спросить, что он сделает, если ответ не придёт. Если слышите «попробую ещё раз», главное вы узнали.

Источники: RFC 9110, §9.2.2 Idempotent Methods; документация ЮKassa об идемпотентности; Stripe API, Idempotent requests; МойСклад JSON API 1.2, назначение поля syncId; Latent Space, «Jev: System One models for Prod, not God»; Browserbase, «What is Jev?»; Zapier, опрос разработчиков, сентябрь 2026.