Продвинутый уровень До 10 минут Практическое руководство

Идемпотентность действий 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. Возобновляйте процесс по журналу

Алгоритм возобновления должен быть детерминированным:

  1. Вычислить тот же ключ из сохранённого бизнес-контекста.
  2. Прочитать запись операции.
  3. При succeeded вернуть сохранённый результат, не вызывая API.
  4. При действующей аренде executing не запускать второго исполнителя.
  5. При истёкшей аренде сначала запросить статус у внешней системы по ключу или бизнес-референсу.
  6. Повторять вызов только при доказанном отсутствии эффекта либо при гарантии идемпотентности получателя.
  7. Для 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.

  1. Создайте одно тестовое бизнес-намерение и вычислите ключ.
  2. Дважды запустите обработчик одновременно с одинаковым ключом.
  3. Убедитесь, что журнал содержит одну запись, а право на вызов получил один воркер.
  4. Сымитируйте таймаут после того, как тестовый адаптер принял запрос, но до возврата ответа.
  5. Перезапустите процесс с тем же бизнес-контекстом.
  6. Убедитесь, что исполнитель сначала выполняет сверку, а не немедленную повторную отправку.
  7. Измените содержимое, сохранив ключ, и убедитесь, что система возвращает конфликт.

Безопасные запросы для просмотра журнала:

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;
  • периодическую сверку по выгрузке или реестру;
  • промежуточный шлюз, который сам обеспечивает дедупликацию;
  • компенсирующее действие, если оно действительно обратимо и допустимо правилами процесса.

Компенсация не равна идемпотентности: второе письмо нельзя «разослать обратно», а отмена финансовой операции может быть отдельным юридически значимым действием.

Короткий чек-лист внедрения

  1. Определите границу одного логического действия.
  2. Сформируйте стабильный версионированный ключ.
  3. Добавьте уникальность ключа и хеш содержимого в базу.
  4. Записывайте намерение до внешнего вызова.
  5. Защищайте выполнение атомарным захватом и арендой.
  6. Передавайте ключ или бизнес-референс получателю.
  7. Разделяйте подтверждённую ошибку и неизвестный результат.
  8. Возобновляйте процесс через чтение журнала и сверку.
  9. Для необратимых действий запрещайте слепой повтор.
  10. Проверяйте конкурентный запуск и таймаут после принятия запроса.