НАДЁЖНОСТЬ AI-АГЕНТОВ
Повторные попытки AI-агента без дублей и побочных эффектов
AI-агент отправил запрос на создание задачи, но не получил ответ из-за обрыва сети. Можно ли повторить запрос? Без дополнительного протокола агент не знает, завершилось ли действие. Решение начинается с идемпотентности: все попытки одной логической операции получают один ключ и приводят максимум к одному побочному эффекту.
Откуда появляется дубль
Сетевой клиент видит только локальный результат обмена. Тайм-аут не означает, что сервер отменил работу. Запрос мог пройти, сервер мог сохранить данные, а ответ — потеряться на обратном пути.
агент сервис
│ POST /tasks │
├──────────────────►│
│ │ задача создана
│ ответ потерян │
│◄─────── × ────────┤
│ timeout │
│ POST /tasks снова │
├──────────────────►│
│ │ вторая задача создана
Повторять только «до отправки» недостаточно: клиент обычно не может достоверно определить эту границу. Нужна серверная запись, связывающая все попытки с одной логической операцией.
Модель безопасной операции
Разделите три идентификатора:
run_idобозначает запуск агента;operation_idобозначает намерение, например «создать задачу по обращению 481»;attemptобозначает номер транспортной попытки.
При retry меняется только attempt. Если заново генерировать operation_id, сервер не сможет отличить повтор от нового действия.
{
"run_id": "run-example-42",
"operation_id": "ticket-481:create-task:v1",
"attempt": 2,
"action": "create_task",
"arguments": {
"source_ticket": "481",
"title": "Проверить обращение"
}
}
Это условный пример, а не формат конкретного API. В реальной системе идентификатор должен строиться из стабильного бизнес-контекста и версии операции, а не из текста, заново сгенерированного моделью.
Шаг 1. Зафиксируйте контракт идемпотентности
Для изменяющих состояние методов примите заголовок Idempotency-Key. Один ключ разрешено использовать повторно только с тем же действием и теми же значимыми аргументами.
POST /agent-actions HTTP/1.1
Content-Type: application/json
Idempotency-Key: ticket-481:create-task:v1
{
"action": "create_task",
"arguments": {
"source_ticket": "481",
"title": "Проверить обращение"
}
}
Сервер должен сохранить не только ключ, но и отпечаток запроса. Тогда случайное повторное использование ключа с другим содержимым завершится конфликтом, а не возвратом чужого результата.
request_hash = SHA-256(
canonical_json({
"action": action,
"arguments": arguments
})
)
Канонизация должна иметь одно определение для всех обработчиков: фиксированный порядок ключей, кодировка UTF-8 и отсутствие незначащих пробелов. Не включайте в хеш номер попытки, текущее время или случайный идентификатор транспорта.
Шаг 2. Создайте журнал операций
Ниже — пример схемы для PostgreSQL. Команды создают отдельную таблицу и не удаляют существующие данные.
CREATE TABLE agent_operations (
idempotency_key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status TEXT NOT NULL CHECK (
status IN ('processing', 'succeeded', 'failed')
),
response_code INTEGER,
response_body JSONB,
error_code TEXT,
lease_until TIMESTAMPTZ,
attempts INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX agent_operations_lease_idx
ON agent_operations (lease_until)
WHERE status = 'processing';
Запись должна появиться до побочного эффекта. Уникальный первичный ключ превращает параллельные попытки в одну операцию даже тогда, когда два worker начинают обработку одновременно.
Шаг 3. Атомарно получите право на выполнение
Первая попытка регистрирует операцию и получает lease — ограниченное право на обработку:
INSERT INTO agent_operations (
idempotency_key,
request_hash,
status,
lease_until
)
VALUES (
:idempotency_key,
:request_hash,
'processing',
now() + interval '90 seconds'
)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key;
Если запрос вернул строку, обработчик может продолжать. Если не вернул, нужно прочитать существующую запись и сравнить request_hash.
succeededи тот же хеш — вернуть сохранённый ответ без повторного действия;processingс действующим lease — сообщить, что операция ещё выполняется, или повторить чтение позже;- тот же ключ и другой хеш — вернуть конфликт, например HTTP 409;
failed— применить заранее определённую политику повторов.
Не используйте последовательность SELECT, затем INSERT без уникального ограничения: между командами другой worker может начать ту же операцию.
Шаг 4. Закройте разрыв перед внешним сервисом
Локальный журнал не гарантирует единственность действия в стороннем API. Процесс может вызвать внешний сервис и завершиться до сохранения результата. Следующая попытка увидит незавершённую запись.
Лучший вариант — передать тот же ключ идемпотентности внешнему сервису:
POST /tasks HTTP/1.1
Idempotency-Key: ticket-481:create-task:v1
Content-Type: application/json
{
"source_ticket": "481",
"title": "Проверить обращение"
}
Если внешний сервис не поддерживает такой ключ, безопасные варианты ограничены:
- использовать естественный уникальный ключ, например ограничение
UNIQUE(source_ticket); - сначала записать команду в транзакционный outbox, а доставку поручить отдельному worker;
- после неопределённого ответа искать результат по стабильному внешнему идентификатору;
- остановить автоматический retry и отправить операцию на ручную сверку, если действие необратимо.
Retry безопасен не потому, что он редкий, а потому, что получатель умеет распознать уже выполненную операцию.
Шаг 5. Разделите временные и окончательные ошибки
Повторять следует только ошибки, которые могут исчезнуть без изменения запроса. Ниже приведён пример конфигурации; конкретные интервалы нужно подобрать по задержкам и лимитам вашей системы.
retry:
max_attempts: 5
timeout_seconds: 20
backoff: exponential
initial_delay_ms: 500
max_delay_ms: 15000
jitter: full
retry_on:
- connection_timeout
- connection_reset
- http_408
- http_429
- http_502
- http_503
- http_504
do_not_retry:
- invalid_arguments
- authentication_failed
- permission_denied
- idempotency_conflict
- policy_rejected
Экспоненциальная задержка с jitter уменьшает синхронные повторы множества агентов. Заголовок Retry-After, если он получен и корректно разобран, должен иметь приоритет над локальной задержкой.
Модель не должна самостоятельно решать, что считать временной ошибкой. Классификацию выполняет детерминированный слой инструментов по коду ответа, типу исключения и политике операции.
Шаг 6. Сохраните результат до ответа агенту
После завершения действия сохраните его результат, а затем отвечайте клиенту:
UPDATE agent_operations
SET status = 'succeeded',
response_code = 200,
response_body = :response_body,
lease_until = NULL,
updated_at = now()
WHERE idempotency_key = :idempotency_key
AND request_hash = :request_hash
AND status = 'processing';
Повторная попытка получает сохранённый семантический результат. Необязательно воспроизводить транспортные заголовки побайтно, но идентификатор созданного объекта и итог операции должны оставаться прежними.
Ошибку валидации также полезно сохранять как окончательный результат. Временную ошибку можно записать отдельно, увеличив attempts, но нельзя превращать её в новое намерение с новым ключом.
Воспроизводимая проверка
Проверку можно выполнить на тестовом обработчике с безопасным побочным эффектом — добавлением строки в отдельную таблицу. Это пример локального сценария, а не утверждение о тестах конкретного продукта.
CREATE TABLE example_tasks (
operation_key TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO example_tasks (operation_key, title)
VALUES (
'ticket-481:create-task:v1',
'Проверить обращение'
)
ON CONFLICT (operation_key) DO NOTHING;
Выполните последний INSERT дважды, затем проверьте количество строк:
SELECT operation_key, count(*)
FROM example_tasks
WHERE operation_key = 'ticket-481:create-task:v1'
GROUP BY operation_key;
Ожидаемый результат — одна строка с count = 1. Затем проверьте следующие случаи на тестовом контуре:
- Отправьте две параллельные попытки с одним ключом и одинаковым телом. Побочный эффект должен возникнуть один раз.
- Повторите завершённый запрос. Ответ должен ссылаться на прежний объект.
- Передайте прежний ключ с изменённым телом. Сервер должен вернуть конфликт.
- Оборвите клиентское ожидание после начала обработки и повторите запрос с тем же ключом.
- Остановите worker до побочного эффекта. После истечения lease другой worker должен получить право продолжить.
- Остановите worker после внешнего вызова. Система должна сверить результат по ключу, а не безусловно вызывать сервис снова.
Проверяйте журнал операций и таблицу результата, а не только ответ агента. Критерий успеха: несколько попыток видны в наблюдаемости, но одному idempotency_key соответствует не более одного логического побочного эффекта.
Типовые ошибки
Новый ключ при каждом retry. Такой ключ описывает попытку, а не операцию, поэтому не защищает от дублей.
Ключ генерирует языковая модель. Модель может изменить регистр, формулировку или состав полей. Ключ должен вычислять детерминированный оркестратор.
Кэш вместо устойчивого журнала. При вытеснении записи или перезапуске процесса завершённое действие снова выглядит новым.
Слишком раннее освобождение lease. Два worker могут одновременно решить, что операция зависла. Продлевайте lease во время долгой обработки и проверяйте владельца блокировки.
Retry всех исключений. Повтор запроса с неверными аргументами только расходует ресурсы; повтор отказа в доступе может дополнительно активировать защитные лимиты.
Сохранение только статуса. Без request_hash нельзя обнаружить, что тот же ключ случайно использован для другого действия.
Удаление ключа слишком рано. Поздняя доставка после очистки журнала создаст дубль. Срок хранения должен покрывать максимальное окно повторов и задержанных сообщений.
Ограничения
Идемпотентность не обеспечивает атомарность сразу в нескольких независимых системах. Если получатель не поддерживает ключ, уникальный бизнес-идентификатор или поиск результата, после потерянного ответа невозможно всегда доказать, был ли выполнен необратимый эффект.
Длительное хранение ответов требует политики очистки и защиты чувствительных данных. Для больших ответов можно сохранять стабильный идентификатор результата вместо полного тела. Ключи не должны содержать секреты или персональные данные в открытом виде.
Наконец, защита от дублей не исправляет ошибочное решение агента. Она гарантирует однократное выполнение конкретного намерения, но проверку разрешений, аргументов и допустимости действия нужно проводить отдельно до регистрации операции.
Итоговая схема
стабильное намерение
→ idempotency_key + request_hash
→ атомарная регистрация операции
→ проверка существующего результата
→ один защищённый побочный эффект
→ сохранение результата
→ retry с тем же ключом и backoff
Ключевое правило простое: агент может повторять доставку запроса, но не должен повторно формировать намерение. Уникальное ограничение, журнал состояния и поддержка идемпотентности на последней границе превращают неопределённый сетевой сбой из источника дублей в обычный управляемый retry.