Идемпотентность действий AI-агента: защита от повторных писем, заявок и платежных поручений
После этого руководства вы сможете назначать внешним действиям стабильные ключи, фиксировать их в журнале операций и безопасно продолжать процесс после таймаута, не полагаясь на память модели.
Почему повторный запуск опасен
Идемпотентность означает, что повтор одного логического запроса не создаёт дополнительного эффекта. Для AI-агента это критично: оркестратор может не получить ответ от почтового, платёжного или внутреннего API, хотя операция уже была выполнена.
Таймаут сообщает только: «результат неизвестен». Он не доказывает, что действие не состоялось. Если агент сразу повторит вызов, адресат может получить два письма, в системе появятся две заявки, а в банк уйдут два поручения.
Минимальная модель безопасного действия
Каждое внешнее действие представьте отдельной операцией со следующими полями:
idempotency_key— стабильный идентификатор одного логического действия;action_type— тип действия, напримерemail.sendилиpayment_order.create;payload_hash— отпечаток нормализованного содержимого;status— состояние операции;attempt_count— число попыток связи с провайдером;provider_operation_id— идентификатор операции во внешней системе;result— сохранённый ответ или минимально необходимая его часть;lease_until— срок блокировки операции одним исполнителем;created_atиupdated_at— время создания и изменения записи.
Практичная машина состояний:
planned → executing → succeeded
↘ failed_retryable
↘ failed_final
↘ unknown
unknown принципиально отличается от ошибки. Оно означает: запрос мог достигнуть внешней системы, но подтверждение потеряно. Для платежей и других необратимых действий автоматический повтор из этого состояния обычно запрещают до сверки.
Воспроизводимая реализация
Шаг 1. Отделите идентичность процесса от идентичности действия
run_id меняется при каждом запуске и потому не подходит для защиты от дублей. Ключ действия должен воспроизводиться при возобновлении того же бизнес-намерения.
Пример входных данных:
{
"workflow_id": "case-1842",
"step": "send_approval_notice",
"business_object": "application-731",
"revision": 2
}
Составьте каноническую строку и вычислите HMAC или хеш на стороне приложения:
email.send|case-1842|send_approval_notice|application-731|revision:2
Пример на Python использует только стандартную библиотеку. Значение секрета здесь намеренно не приводится: в рабочей системе загрузите его из менеджера секретов.
import hashlib
import hmac
import json
def canonical_json(value: dict) -> bytes:
return json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
def make_idempotency_key(secret: bytes, action: dict) -> str:
digest = hmac.new(
secret,
canonical_json(action),
hashlib.sha256,
).hexdigest()
return f"v1:{digest}"
Включайте в ключ только поля, определяющие бизнес-действие. Время запуска, номер попытки, случайный UUID и формулировки модели сделают ключ нестабильным. Версию полезно включать, если изменённая заявка или исправленное письмо должны считаться новым действием.
Шаг 2. Зафиксируйте операцию до внешнего вызова
Ниже — пример схемы PostgreSQL. Он иллюстрирует ограничения, которые должны обеспечиваться базой данных, а не только кодом приложения.
CREATE TABLE agent_operations (
idempotency_key text PRIMARY KEY,
action_type text NOT NULL,
workflow_id text NOT NULL,
payload_hash text NOT NULL,
status text NOT NULL CHECK (
status IN (
'planned',
'executing',
'succeeded',
'failed_retryable',
'failed_final',
'unknown'
)
),
attempt_count integer NOT NULL DEFAULT 0,
provider_operation_id text,
result_json jsonb,
last_error_code text,
lease_until timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX agent_operations_resume_idx
ON agent_operations (status, lease_until);
Перед отправкой исполнитель создаёт запись с planned. Уникальный первичный ключ превращает конкурентные повторы в чтение уже существующей операции.
INSERT INTO agent_operations (
idempotency_key,
action_type,
workflow_id,
payload_hash,
status
)
VALUES ($1, $2, $3, $4, 'planned')
ON CONFLICT (idempotency_key) DO NOTHING;
После вставки обязательно прочитайте запись и сравните action_type и payload_hash. Совпавший ключ с другим содержимым — конфликт, а не разрешение повторить запрос.
Шаг 3. Захватите операцию атомарно
Несколько воркеров не должны одновременно отправлять одно действие. Используйте условное обновление с ограниченной по времени арендой:
UPDATE agent_operations
SET
status = 'executing',
attempt_count = attempt_count + 1,
lease_until = now() + interval '2 minutes',
updated_at = now()
WHERE idempotency_key = $1
AND status IN ('planned', 'failed_retryable')
AND (lease_until IS NULL OR lease_until < now())
RETURNING *;
Пустой результат означает, что операция уже завершена, выполняется другим воркером либо требует отдельного разбора. Не отправляйте внешний запрос в этом случае.
Шаг 4. Передайте ключ внешней системе
Если API получателя поддерживает ключ идемпотентности, передайте тот же ключ. Название заголовка и срок хранения результата зависят от конкретного API, поэтому их нужно проверить в документации используемой системы.
POST /v1/payment-orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: v1:computed-key
{
"account_id": "example-account",
"amount_minor": 125000,
"currency": "RUB",
"business_reference": "application-731"
}
Это только пример формата, не описание конкретного платёжного API. Не помещайте в ключ персональные данные, сумму или номер счёта в открытом виде: ключи часто попадают в логи.
Если внешняя система не поддерживает идемпотентность, используйте доступный уникальный бизнес-идентификатор: Message-ID для контролируемого почтового контура, внешний номер заявки или референс поручения. Попросите принимающую сторону обеспечить уникальность этого поля. Локальный журнал сам по себе не закрывает сбой между фактическим выполнением действия и записью succeeded.
Шаг 5. Сохраните подтверждение
После подтверждённого успеха сохраните идентификатор внешней операции и результат:
UPDATE agent_operations
SET
status = 'succeeded',
provider_operation_id = $2,
result_json = $3,
lease_until = NULL,
updated_at = now()
WHERE idempotency_key = $1
AND status = 'executing';
При однозначной отклонённой операции установите failed_final. При безопасной транспортной ошибке до отправки — failed_retryable. После таймаута, разрыва соединения во время ответа или неопределённого статуса используйте unknown.
Шаг 6. Возобновляйте процесс по журналу
Алгоритм возобновления должен быть детерминированным:
- Вычислить тот же ключ из сохранённого бизнес-контекста.
- Прочитать запись операции.
- При
succeededвернуть сохранённый результат, не вызывая API. - При действующей аренде
executingне запускать второго исполнителя. - При истёкшей аренде сначала запросить статус у внешней системы по ключу или бизнес-референсу.
- Повторять вызов только при доказанном отсутствии эффекта либо при гарантии идемпотентности получателя.
- Для
unknownбез возможности сверки остановить автоматизацию и создать задачу на ручную проверку.
def resume(operation, provider):
if operation.status == "succeeded":
return operation.saved_result
if operation.status == "executing" and operation.lease_is_active():
return {"state": "in_progress"}
remote = provider.find_by_reference(operation.idempotency_key)
if remote.is_succeeded:
return mark_succeeded(operation, remote)
if remote.is_absent and provider.retry_is_idempotent:
return execute_with_same_key(operation)
return mark_unknown_and_request_review(operation)
Это псевдокод: методы и ответы нужно адаптировать к реальному провайдеру. Важна последовательность решений, а не названия функций.
Политики для разных действий
| Действие | Стабильный референс | После таймаута |
|---|---|---|
| Письмо | Ключ операции или устойчивый Message-ID |
Проверить статус у почтового провайдера; без проверки не считать повтор безвредным |
| Заявка | Внешний номер заявки, уникальный для бизнес-объекта и версии | Найти заявку по номеру; создавать повторно только при подтверждённом отсутствии |
| Платёжное поручение | Уникальный референс поручения | Остановить автоматический повтор до сверки, если банк не гарантирует идемпотентность |
Чем тяжелее последствия дубля, тем строже политика. Для платежей лучше получить задержку и ручную проверку, чем оптимистично повторить неопределённую операцию.
Проверка результата
Проверку можно провести в тестовом окружении или с подменённым адаптером внешней системы. Не используйте реальные адреса, счета, секреты и продуктивные API.
- Создайте одно тестовое бизнес-намерение и вычислите ключ.
- Дважды запустите обработчик одновременно с одинаковым ключом.
- Убедитесь, что журнал содержит одну запись, а право на вызов получил один воркер.
- Сымитируйте таймаут после того, как тестовый адаптер принял запрос, но до возврата ответа.
- Перезапустите процесс с тем же бизнес-контекстом.
- Убедитесь, что исполнитель сначала выполняет сверку, а не немедленную повторную отправку.
- Измените содержимое, сохранив ключ, и убедитесь, что система возвращает конфликт.
Безопасные запросы для просмотра журнала:
SELECT
idempotency_key,
action_type,
status,
attempt_count,
provider_operation_id,
created_at,
updated_at
FROM agent_operations
WHERE workflow_id = 'case-1842'
ORDER BY created_at;
SELECT
idempotency_key,
count(*) AS rows_per_key
FROM agent_operations
GROUP BY idempotency_key
HAVING count(*) > 1;
Второй запрос должен вернуть пустой результат при сохранённом первичном ключе. Этого недостаточно для доказательства отсутствия внешних дублей: дополнительно сопоставьте записи журнала с данными тестового адаптера или песочницы провайдера.
Критерии готовности
- Повторный запуск получает прежний ключ для прежнего намерения.
- Один ключ нельзя использовать с другим содержимым.
- Одновременно операцию захватывает только один исполнитель.
- Успешный результат возвращается из журнала без нового вызова.
- Неопределённый результат не превращается автоматически в повтор платежа.
- В журнале есть достаточно данных для сверки, но нет секретов и лишних персональных данных.
Типовые ошибки
- Новый UUID на каждую попытку
- Он различает попытки, но не объединяет их в одно логическое действие. Ключ должен происходить из устойчивой идентичности операции.
- Запись в журнал только после успеха
- Два воркера успеют выполнить действие до появления записи. Намерение фиксируют до внешнего вызова.
- Повтор любого таймаута
- Таймаут не доказывает отсутствие эффекта. Нужны сверка, идемпотентный API или ручное решение.
- Одинаковый ключ для изменённого содержимого
- Система может вернуть старый результат для нового запроса. Сравнивайте хеш содержимого и отклоняйте конфликт.
- Опора только на промпт
- Инструкция «не отправляй дважды» не защищает от повторной доставки сообщения, рестарта процесса или параллельных воркеров.
- Бесконечное состояние
executing - После аварии запись останется заблокированной. Используйте аренду с истечением, но после её окончания сначала сверяйтесь с провайдером.
- Логи с полным телом письма или реквизитами
- Для диагностики обычно достаточно ключа, типа действия, хеша, статуса, технического кода ошибки и внешнего идентификатора.
Ограничения
Ключ идемпотентности не является распределённой транзакцией. Если внешняя система выполнила действие, а локальная база недоступна, остаётся окно неопределённости. Надёжно закрыть его можно только сотрудничеством получателя: хранением ключа, уникальным бизнес-референсом и API проверки статуса.
Срок хранения ключа тоже важен. Если провайдер забывает ключи через несколько часов, поздний повтор снова может создать эффект. Локальный журнал следует хранить не меньше максимального окна повторов и срока, в течение которого бизнес-процесс может быть возобновлён.
Для систем, не допускающих удалённую сверку, применяйте один из вариантов:
- человеческое подтверждение для операций в состоянии
unknown; - периодическую сверку по выгрузке или реестру;
- промежуточный шлюз, который сам обеспечивает дедупликацию;
- компенсирующее действие, если оно действительно обратимо и допустимо правилами процесса.
Компенсация не равна идемпотентности: второе письмо нельзя «разослать обратно», а отмена финансовой операции может быть отдельным юридически значимым действием.
Короткий чек-лист внедрения
- Определите границу одного логического действия.
- Сформируйте стабильный версионированный ключ.
- Добавьте уникальность ключа и хеш содержимого в базу.
- Записывайте намерение до внешнего вызова.
- Защищайте выполнение атомарным захватом и арендой.
- Передавайте ключ или бизнес-референс получателю.
- Разделяйте подтверждённую ошибку и неизвестный результат.
- Возобновляйте процесс через чтение журнала и сверку.
- Для необратимых действий запрещайте слепой повтор.
- Проверяйте конкурентный запуск и таймаут после принятия запроса.