Надёжность агентных систем
Идемпотентность действий агента: защита от повторных платежей, писем и заявок
Тайм-аут не означает, что внешний вызов завершился ошибкой. Платёж мог пройти, письмо — уйти, а заявка — появиться уже после разрыва соединения. Если агент просто повторит действие, пользователь получит два реальных результата.
Почему обычного retry недостаточно
Идемпотентность — свойство операции, при котором повтор одного и того же логического запроса не создаёт дополнительного эффекта. Для чтения баланса это почти естественно. Для списания денег, отправки письма или создания заявки его нужно проектировать отдельно.
Опасный сценарий выглядит так:
- Агент вызывает инструмент
create_payment. - Провайдер создаёт платёж, но ответ теряется или приходит позже тайм-аута.
- Агент видит неопределённость и повторяет вызов.
- Провайдер создаёт второй платёж.
Ключевой вывод: после тайм-аута состояние операции не равно failed. Оно равно unknown. Повтор допустим только с тем же ключом идемпотентности либо после проверки фактического результата.
Архитектура безопасного действия
Надёжный контур состоит из трёх частей:
- стабильный ключ, идентифицирующий логическое намерение пользователя;
- журнал операций, фиксирующий состояние до внешнего вызова;
- сверка, определяющая реальный результат после тайм-аута или сбоя процесса.
намерение пользователя
│
▼
вычислить operation_key
│
▼
атомарно зарегистрировать pending
│
├── запись уже succeeded ──► вернуть сохранённый результат
│
├── параметры отличаются ──► отклонить конфликт
│
└── новая операция
│
▼
вызвать внешний сервис
с тем же operation_key
│
┌─────────┴─────────┐
▼ ▼
точный ответ тайм-аут
│ │
▼ ▼
сохранить итог состояние unknown
│
▼
проверить по ключу
или внешнему ID
Шаг 1. Определите границу логической операции
Ключ должен обозначать не сетевую попытку, а бизнес-намерение. «Оплатить заказ 817 на 4900 рублей» — одна операция, даже если агент сделал три HTTP-вызова.
Практическое правило:
- повтор того же намерения использует прежний ключ;
- новое осознанное действие пользователя получает новый ключ;
- внутренний retry никогда не генерирует новый ключ.
Пример формата ключа:
payment:order-817:intent-01
email:case-442:confirmation-v1
application:user-93:program-2026
Это примеры, а не обязательный стандарт. В production ключ удобно хранить как случайный непрозрачный идентификатор, а бизнес-связи — в отдельных полях. Не включайте в него адреса, токены, содержимое писем и другие чувствительные данные.
Шаг 2. Свяжите ключ с неизменяемыми параметрами
Одинаковый ключ с разными параметрами опаснее явного дубля. Поэтому вместе с ключом сохраняют отпечаток нормализованного запроса.
canonical_payload = canonical_json({
"action": "create_payment",
"order_id": "817",
"amount_minor": 490000,
"currency": "RUB"
})
request_hash = sha256(canonical_payload)
Нормализация должна быть детерминированной: фиксированный порядок полей, единое представление денежных сумм, валюты, дат и пустых значений. Для денег используйте целое число в минимальных единицах, а не число с плавающей точкой.
При повторе:
- ключ и хеш совпали — это повтор той же операции;
- ключ совпал, хеш отличается — верните конфликт и не вызывайте внешний сервис;
- ключ новый — зарегистрируйте новую операцию.
Шаг 3. Создайте журнал операций
Минимальная схема должна поддерживать атомарную вставку и уникальность ключа. Ниже — пример SQL, который следует адаптировать к используемой СУБД:
CREATE TABLE agent_operations (
operation_key VARCHAR(200) PRIMARY KEY,
action_type VARCHAR(80) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
external_id VARCHAR(200),
result_json TEXT,
error_code VARCHAR(100),
lease_until TIMESTAMP,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
CHECK (status IN (
'pending',
'in_progress',
'unknown',
'succeeded',
'failed_final'
))
);
Поле external_id хранит идентификатор платежа, сообщения или заявки во внешней системе. result_json — очищенный результат, который можно безопасно вернуть при повторе. Секреты, полные платёжные реквизиты и лишние персональные данные в журнал помещать не следует.
Рекомендуемые значения состояний:
pending- Операция зарегистрирована, внешний вызов ещё не начат.
in_progress- Исполнитель получил временную аренду и выполняет вызов.
unknown- Ответ не получен; внешний эффект мог произойти.
succeeded- Фактический результат подтверждён и сохранён.
failed_final- Подтверждено, что операция окончательно не выполнена и автоматический повтор запрещён.
Шаг 4. Регистрируйте намерение до вызова
Сначала журнал, затем сеть. Атомарная вставка с уникальным ограничением не даст двум воркерам одновременно считать одну операцию новой.
BEGIN;
INSERT INTO agent_operations (
operation_key,
action_type,
request_hash,
status,
created_at,
updated_at
)
VALUES (
:operation_key,
:action_type,
:request_hash,
'pending',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
ON CONFLICT (operation_key) DO NOTHING;
SELECT operation_key, request_hash, status, result_json, external_id
FROM agent_operations
WHERE operation_key = :operation_key
FOR UPDATE;
COMMIT;
После чтения запись обрабатывается по её состоянию. Если операция уже завершена, верните сохранённый результат. Если хеш не совпал, остановитесь с ошибкой конфликта. Если другой исполнитель держит действующую аренду, не запускайте второй вызов.
Не удерживайте транзакцию базы данных открытой во время сетевого запроса. Вместо этого атомарно выдайте короткую аренду:
UPDATE agent_operations
SET status = 'in_progress',
lease_until = :lease_until,
attempt_count = attempt_count + 1,
updated_at = CURRENT_TIMESTAMP
WHERE operation_key = :operation_key
AND (
status = 'pending'
OR status = 'unknown'
OR (
status = 'in_progress'
AND lease_until < CURRENT_TIMESTAMP
)
);
Выполнять внешний вызов можно только если обновлена ровно одна строка.
Шаг 5. Передавайте ключ внешнему сервису
Если API поддерживает собственный ключ идемпотентности, передавайте ваш стабильный ключ при каждой попытке. Название заголовка или поля зависит от конкретного API и должно быть взято из его документации.
POST /payments
Content-Type: application/json
Idempotency-Key: payment:order-817:intent-01
{
"order_id": "817",
"amount_minor": 490000,
"currency": "RUB"
}
Это демонстрационный запрос. Адрес, формат тела и заголовок не описывают конкретного провайдера.
Для писем и заявок, где сервер не поддерживает идемпотентность, используйте один из вариантов:
- передавайте ключ в доступное поле внешней ссылки и ищите по нему;
- создайте собственный шлюз с уникальным индексом по ключу;
- примените transactional outbox: транзакция создаёт локальное намерение, а отдельный доставщик выполняет отправку;
- если результат нельзя найти или доказать, переводите операцию на ручную сверку, а не повторяйте вслепую.
Шаг 6. Разделяйте точные ошибки и неопределённость
Обработчик должен классифицировать исход, а не сводить всё к success/error.
async function executeOperation(op) {
const claimed = await journal.claim(op.key);
if (!claimed) return journal.readCurrentResult(op.key);
try {
const response = await externalService.call({
idempotencyKey: op.key,
payload: op.payload
});
await journal.markSucceeded(
op.key,
response.externalId,
sanitize(response.result)
);
return response.result;
} catch (error) {
if (isConfirmedRejection(error)) {
await journal.markFailedFinal(op.key, safeErrorCode(error));
throw error;
}
await journal.markUnknown(op.key, safeErrorCode(error));
return { status: "verification_required", operationKey: op.key };
}
}
isConfirmedRejection должен признавать окончательной только ошибку, однозначно подтверждающую отсутствие эффекта. Тайм-аут, разрыв соединения, перезапуск процесса и неизвестный ответ прокси к этой категории не относятся.
Шаг 7. Проверяйте фактический результат
Для записей unknown запускайте отдельный процесс сверки. Он не повторяет действие немедленно, а сначала спрашивает внешний сервис о результате по ключу или сохранённому external_id.
async function reconcile(operation) {
const observed = await externalService.findByIdempotencyKey(
operation.operationKey
);
if (observed.found && observed.completed) {
await journal.markSucceeded(
operation.operationKey,
observed.externalId,
sanitize(observed.result)
);
return;
}
if (observed.definitivelyAbsent) {
await journal.releaseForRetry(operation.operationKey);
return;
}
await journal.keepUnknown(operation.operationKey);
}
Состояние definitivelyAbsent допустимо только тогда, когда API действительно гарантирует полноту и актуальность поиска. «Пока не найдено» может означать задержку индексации, поэтому для каждой интеграции задайте окно ожидания, интервалы сверки и момент передачи оператору.
Политика агента
Модель не должна самостоятельно решать, что новый ключ «поможет». Правила исполнения должны находиться в детерминированном слое инструментов.
tool_policy:
create_payment:
operation_key: required
retry:
reuse_operation_key: true
on_timeout: reconcile_before_retry
terminal_states:
- succeeded
- failed_final
unknown_after_seconds: 900
on_unresolved: require_human_review
send_email:
operation_key: required
retry:
reuse_operation_key: true
on_timeout: reconcile_before_retry
Конфигурация приведена как пример структуры. Значения тайм-аутов и правила ручной проверки выбираются по гарантиям конкретной системы и цене дубля.
Воспроизводимая проверка
Проверять следует не текст ответа агента, а количество внешних эффектов. Для локального стенда используйте тестовый адаптер, который записывает действия в изолированное хранилище и умеет имитировать потерю ответа после фиксации результата.
- Создайте операцию с ключом
payment:order-817:intent-01. - Настройте адаптер: сохранить результат, затем вернуть тайм-аут.
- Выполните вызов. В журнале должно появиться состояние
unknown. - Повторите запрос с тем же ключом и теми же параметрами.
- Запустите сверку и убедитесь, что запись стала
succeeded. - Проверьте, что во внешнем тестовом хранилище существует ровно один эффект.
- Повторите запрос с тем же ключом, но другой суммой. Ожидаемый результат — конфликт без внешнего вызова.
Безопасные запросы для просмотра результата:
SELECT operation_key, status, attempt_count, external_id
FROM agent_operations
WHERE operation_key = 'payment:order-817:intent-01';
SELECT operation_key, COUNT(*) AS rows_per_key
FROM agent_operations
GROUP BY operation_key
HAVING COUNT(*) > 1;
Вторая выборка должна вернуть пустой результат при корректно работающем первичном ключе. Она проверяет журнал, но не доказывает отсутствие дублей во внешней системе. Для полного доказательства отдельно посчитайте эффекты по внешней ссылке или ключу в тестовом адаптере.
Минимальная матрица сценариев
| Сценарий | Ожидаемое состояние | Внешних эффектов |
|---|---|---|
| Первый вызов успешен | succeeded |
1 |
| Повтор после успеха | succeeded, сохранённый ответ |
1 |
| Эффект создан, ответ потерян | unknown, затем succeeded |
1 |
| Два конкурентных воркера | Один получает аренду | 1 |
| Тот же ключ, другие параметры | Конфликт | 0 новых |
| Перезапуск после регистрации | Операция восстановлена из журнала | Не более 1 |
Типовые ошибки
Новый ключ при каждом retry
Провайдер видит разные операции и закономерно выполняет каждую. Ключ создаётся один раз при фиксации намерения и живёт дольше отдельного запуска агента.
Ключ генерирует модель
Модель может изменить формат, забыть значение или создать новый идентификатор. Генерация, сохранение и повторное использование ключа должны выполняться кодом оркестратора.
Запись в журнал после внешнего вызова
Сбой между вызовом и записью уничтожает сведения об операции. Сначала фиксируйте намерение, затем обращайтесь к сети.
Тайм-аут помечается как окончательная ошибка
Это разрешает опасный новый вызов. Используйте отдельное состояние unknown и сверку.
Проверка только по тексту ответа
Ответ «не удалось отправить» описывает наблюдение агента, а не состояние внешней системы. Источником истины служит API проверки, внешний идентификатор или журнал принимающей стороны.
Нет проверки хеша параметров
Повторно использованный ключ может скрыть изменение суммы, адресата или содержимого заявки. Одинаковый ключ с другим хешем должен приводить к конфликту.
Срок хранения слишком короткий
Если запись удалена раньше возможного повтора, старый запрос снова станет новым. Срок хранения выбирают с запасом относительно очередей, ручных повторов, офлайн-клиентов и политики внешнего API.
Кэш вместо долговечного журнала
Вытеснение, сброс или потеря кэша возвращают риск дубля. Кэш можно использовать для ускорения, но не как единственный источник истины для дорогих необратимых действий.
Ограничения
- Идемпотентность не равна атомарности. Если одна бизнес-операция вызывает несколько независимых систем, единый ключ сам по себе не откатит частично выполненный процесс.
- Exactly once обычно недоказуемо на всём пути. Практическая цель — at-least-once доставка с дедупликацией и подтверждаемым итогом.
- Внешний API может забывать ключи. Учитывайте его срок дедупликации и храните собственный журнал дольше допустимого окна повторов.
- Отправленное письмо нельзя «разотправить». Если провайдер не предоставляет поиск по пользовательскому ключу, после неопределённого исхода может потребоваться ручная проверка.
- Сверка бывает запаздывающей. Поиск может не видеть только что созданный объект. Нужны повторная проверка с ограниченной задержкой и конечная эскалация.
- Побочные эффекты внутри обработчика тоже считаются. Уведомление, аналитическое событие и webhook требуют собственных ключей либо outbox-механизма.
Контрольный список внедрения
- Ключ создаётся на бизнес-намерение, а не на сетевую попытку.
- Повторы используют тот же ключ.
- Ключ связан с хешем нормализованных параметров.
- Журнал фиксируется до внешнего вызова.
- Уникальное ограничение защищает от конкурентных исполнителей.
- Тайм-аут переводит операцию в
unknown. - Сверка проверяет фактический результат до повтора.
- Подтверждённый результат и внешний ID сохраняются.
- Зависшие операции имеют аренду, срок ожидания и путь эскалации.
- Тест имитирует потерю ответа после создания эффекта.
Итог
Безопасный агент не интерпретирует отсутствие ответа как отсутствие действия. Он присваивает намерению стабильный ключ, атомарно записывает его до вызова, повторяет только с тем же ключом и после неопределённого исхода проверяет внешний факт. Такая схема не устраняет сбои сети, но не позволяет им незаметно превратиться в повторный платёж, письмо или заявку.