Инженерия AI-агентов

Идемпотентность действий AI-агента в бухгалтерских и операционных системах

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

Продвинутый уровень До 9 минут

Почему обычного retry недостаточно

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

Типичный сбой выглядит так:

  1. Агент просит учётную систему создать документ.
  2. Система создаёт документ и начинает отправлять ответ.
  3. Соединение обрывается или истекает тайм-аут.
  4. Агент видит ошибку транспорта, но не знает результат операции.
  5. Повторный вызов создаёт дубликат.

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

Целевая схема

Надёжный исполнитель отделяет рассуждение агента от совершения внешнего действия. Модель формирует намерение, а детерминированный слой исполнения:

  • назначает стабильный ключ идемпотентности;
  • фиксирует параметры и состояние операции в журнале;
  • проверяет, не выполнялось ли то же намерение раньше;
  • вызывает бизнес-систему;
  • сохраняет подтверждение или запускает сверку;
  • возвращает агенту структурированный результат.
AI-агент
   │  намерение: create_invoice
   ▼
Исполнитель действий
   ├── журнал операций
   ├── политика retry
   └── адаптер бизнес-системы
              │
              ▼
      бухгалтерская / ERP / CRM

Ключевой принцип: модель не генерирует новый ключ при каждой попытке. Все повторы одного бизнес-намерения используют один и тот же ключ.

Шаг 1. Определите границы бизнес-операции

Сначала опишите, что именно считается «одним разом». Для выставления счёта границей может быть комбинация:

  • организация;
  • тип действия;
  • внутренний идентификатор заказа;
  • версия намерения.

Пример канонической строки:

tenant-42|create_invoice|order-8172|v1

Из неё приложение вычисляет ключ. Следующая команда безопасна: она только печатает пример хеша и не обращается к бизнес-системам.

printf '%s' 'tenant-42|create_invoice|order-8172|v1' \
  | sha256sum

В прикладном коде это может выглядеть так:

function idempotencyKey(tenantId, action, businessId, version) {
  const canonical = [tenantId, action, businessId, version].join("|");
  return sha256(canonical);
}

Не включайте в основу ключа номер попытки, текущее время или случайный UUID, создаваемый непосредственно перед retry. Иначе каждый повтор станет новой операцией.

Если пользователь действительно изменил намерение — например, исправил сумму счёта, — увеличьте версию либо создайте новое намерение с собственным идентификатором. Не переиспользуйте старый ключ для другого содержимого.

Шаг 2. Создайте журнал выполнения

Ниже приведена примерная схема PostgreSQL. Названия таблиц и набор полей нужно адаптировать к вашей системе.

CREATE TABLE agent_action_log (
    id                  bigserial PRIMARY KEY,
    tenant_id           text NOT NULL,
    action_type         text NOT NULL,
    idempotency_key     text NOT NULL,
    request_hash        text NOT NULL,
    request_payload     jsonb NOT NULL,
    status              text NOT NULL CHECK (
        status IN (
            'started',
            'succeeded',
            'failed_final',
            'outcome_unknown',
            'reconciling'
        )
    ),
    attempt_count       integer NOT NULL DEFAULT 0,
    external_reference  text,
    response_payload    jsonb,
    last_error_code     text,
    lease_expires_at    timestamptz,
    created_at          timestamptz NOT NULL DEFAULT now(),
    updated_at          timestamptz NOT NULL DEFAULT now(),
    UNIQUE (tenant_id, action_type, idempotency_key)
);

request_hash защищает от опасной коллизии: одинаковый ключ не должен использоваться с разными параметрами. Сам payload полезен для аудита и восстановления, но перед сохранением из него следует удалить секреты и ограничить хранение персональных данных.

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

Шаг 3. Захватывайте операцию атомарно

До внешнего вызова исполнитель пытается создать запись. Уникальное ограничение разрешает гонку между воркерами на уровне базы данных.

BEGIN;

INSERT INTO agent_action_log (
    tenant_id,
    action_type,
    idempotency_key,
    request_hash,
    request_payload,
    status,
    attempt_count,
    lease_expires_at
)
VALUES (
    :tenant_id,
    :action_type,
    :idempotency_key,
    :request_hash,
    :request_payload,
    'started',
    1,
    now() + interval '2 minutes'
)
ON CONFLICT (tenant_id, action_type, idempotency_key)
DO NOTHING;

COMMIT;

После этого прочитайте существующую запись и примените правила:

  • succeeded — вернуть сохранённый результат без нового вызова;
  • тот же ключ, но другой request_hash — отклонить запрос как конфликт;
  • started с действующей арендой — не запускать второй воркер;
  • outcome_unknown — сначала выполнить сверку;
  • failed_final — не повторять автоматически без отдельного решения.

Нельзя удерживать транзакцию базы открытой во время сетевого вызова: это создаёт долгие блокировки и всё равно не превращает две независимые системы в одну транзакцию.

Шаг 4. Передавайте ключ во внешнюю систему

Если API назначения поддерживает ключ идемпотентности, передайте его без изменений:

POST /invoices
Idempotency-Key: 8f…c2
Content-Type: application/json

{
  "order_id": "order-8172",
  "amount": "12500.00",
  "currency": "RUB"
}

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

Дополнительно запишите ключ в доступное поле внешнего документа: например, external_request_id. Тогда после обрыва связи документ можно найти по точному техническому идентификатору, не полагаясь на сумму, дату или имя контрагента.

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

Шаг 5. Разделите ошибки до вызова и после него

Политика обработки должна учитывать момент сбоя:

try {
  markAttemptStarted(key);

  const result = externalSystem.execute({
    idempotencyKey: key,
    payload: request
  });

  markSucceeded(key, result.externalReference, result);
  return savedResult(key);

} catch (error) {
  if (definitelyRejectedBeforeExecution(error)) {
    scheduleRetryWithSameKey(key);
  } else if (isPermanentBusinessRejection(error)) {
    markFailedFinal(key, error.code);
  } else {
    markOutcomeUnknown(key, normalizeErrorCode(error));
    scheduleReconciliation(key);
  }
}

Тайм-аут, разрыв соединения после отправки тела запроса и ответ с повреждённым содержимым обычно не доказывают отсутствие эффекта. Их следует переводить в outcome_unknown, а не немедленно повторять создание.

Автоматический retry допустим, когда система надёжно сообщает, что запрос не дошёл до исполнения, либо когда принимающая сторона гарантирует идемпотентность по вашему ключу.

Шаг 6. Восстанавливайтесь через сверку

Для неопределённого результата используйте отдельный обработчик:

  1. Переведите запись из outcome_unknown в reconciling с короткой арендой.
  2. Найдите внешний объект по ключу идемпотентности или external_request_id.
  3. Если найден ровно один объект и его существенные поля совпадают, сохраните его идентификатор и отметьте успех.
  4. Если объект не найден, учтите задержку индексации и повторите только чтение с ограниченной отсрочкой.
  5. Только после установленного окна согласованности разрешите повтор команды — с тем же ключом.
  6. Если найдено несколько объектов или данные расходятся, остановите автоматизацию и создайте задачу ручного разбора.
SELECT id, status, request_hash, external_reference
FROM agent_action_log
WHERE tenant_id = :tenant_id
  AND action_type = :action_type
  AND idempotency_key = :idempotency_key
FOR UPDATE;

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

Уведомления и смена состояния

Для уведомления ключ должен обозначать смысл сообщения, например:

tenant-42|notify_invoice_ready|invoice-551|recipient-93|v1

Повтор доставки одного уведомления использует тот же ключ. Новое напоминание через день — отдельное бизнес-намерение и новый ключ.

Переходы состояния защищайте не только ключом, но и предусловием:

UPDATE business_process
SET state = 'approved',
    version = version + 1
WHERE id = :process_id
  AND state = 'review'
  AND version = :expected_version;

Успешно должна измениться ровно одна строка. Ноль строк означает, что состояние уже изменилось либо ожидание устарело. Агенту нужно перечитать процесс, а не принудительно повторять переход.

Проверка результата

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

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

Диагностический запрос к журналу:

SELECT
    tenant_id,
    action_type,
    idempotency_key,
    status,
    attempt_count,
    external_reference,
    updated_at
FROM agent_action_log
WHERE idempotency_key = :idempotency_key;

Критерий успеха — не количество HTTP-вызовов, а один подтверждённый бизнес-эффект, одна итоговая ссылка на внешний объект и воспроизводимая история решений.

Типовые ошибки

Новый UUID на каждую попытку
Retry превращается в новую операцию. Идентификатор должен принадлежать намерению, а не сетевому запросу.
Ключ только из параметров
Два намеренно отдельных платежа с одинаковой суммой могут слиться. Нужен стабильный идентификатор бизнес-намерения.
Одинаковый ключ для изменённого payload
Система может вернуть старый результат для нового содержания. Сравнивайте канонический хеш параметров.
Повтор сразу после тайм-аута
Первый вызов мог завершиться. Сначала сверяйте внешний эффект.
Статус «ошибка» вместо неопределённого результата
Теряется важное различие между подтверждённым отказом и отсутствием ответа.
Поиск документа по сумме и дате
Такой поиск неоднозначен. Используйте уникальный технический идентификатор.
Долгая транзакция вокруг HTTP-вызова
Она удерживает блокировки, но не обеспечивает атомарность между базой и внешней системой.
Бесконечная аренда
После падения воркера операция навсегда останется занятой. Аренда должна истекать и безопасно перехватываться.

Ограничения

  • Идемпотентность не заменяет авторизацию, проверку лимитов и бизнес-валидацию.
  • Она не обеспечивает атомарность нескольких независимых внешних систем. Для многошаговых процессов нужны оркестрация, подтверждения и компенсирующие действия.
  • Компенсация не равна откату: аннулирование документа само является новой аудируемой операцией.
  • Удаление ключей по сроку хранения может сделать очень поздний retry опасным. Срок должен соответствовать жизненному циклу бизнес-действия.
  • Системы с eventual consistency могут временно не показывать созданный объект. Сверка обязана учитывать документированное окно видимости.
  • Если принимающая система не позволяет передать и найти уникальный ключ, абсолютную защиту от дубликатов обеспечить нельзя. Такие случаи требуют ручного контроля или изменения интеграции.
  • Журнал содержит чувствительные операционные данные. Применяйте минимизацию payload, разграничение доступа, шифрование и установленную политику хранения.

Контрольный список перед запуском

  • Одно бизнес-намерение всегда даёт один ключ.
  • Повтор с тем же ключом и другим содержимым отклоняется.
  • Уникальность ключа обеспечивается базой данных.
  • Результат успешной операции сохраняется и возвращается повторно.
  • Тайм-аут переводит операцию в неопределённое состояние.
  • Сверка ищет объект по уникальному техническому идентификатору.
  • Повторное создание разрешено только после безопасной проверки.
  • Конкурирующие воркеры используют аренду и блокировку записи.
  • Неоднозначность отправляется на ручной разбор.
  • Логи не содержат секретов и лишних персональных данных.

Итог

Надёжный AI-агент не пытается угадать, выполнилась ли команда. Он формирует стабильное намерение, передаёт его детерминированному исполнителю и получает один из явных результатов: подтверждённый успех, окончательный отказ или необходимость сверки.

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