Перейти к содержанию

Глава 10. Идемпотентность, повторы, лимиты запросов и границы отката

1. Почему самые дорогие сбои часто выглядят как “мы просто повторили вызов”

Когда агентная система начинает выполнять реальные действия, один из самых неприятных источников инцидентов оказывается очень прозаичным: повторный вызов.

Снаружи это часто выглядит безобидно:

  • запрос завис;
  • рантайм решил повторить;
  • интеграция ответила нестабильно;
  • агент попытался “помочь” и отправил действие еще раз.

Но в реальном мире это означает:

  • два одинаковых тикета;
  • два письма одному клиенту;
  • повторную оплату;
  • повторную запись в CRM;
  • несколько изменений одного и того же объекта.

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

2. Идемпотентность это не приятное дополнение, а базовая страховка

Идемпотентность нужна для простого вопроса: “Если система по ошибке повторит этот вызов, что произойдет?”

Для операций записи хороший ответ должен быть одним из двух:

  • повторный вызов не меняет результат;
  • система может надежно распознать дубль и не создать побочный эффект повторно.

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

3. Повтор без классификации ошибок только множит хаос

Очень плохая стратегия выглядит так: “если запрос не удался, повторить еще три раза”.

В эксплуатации это опасно, потому что не все ошибки одинаковы:

  • validation_failure почти никогда не нужно повторять;
  • permission_denied нельзя чинить количеством повторов;
  • retryable_failure как раз может требовать backoff;
  • side_effect_unknown требует осторожности, а не слепого повтора.

То есть политика повторов должна зависеть не от эмоции “ну вдруг получится”, а от класса исхода.

4. Самый неприятный статус это side_effect_unknown

Есть особенно неприятная категория сбоев: ты уже не знаешь, произошел побочный эффект или нет.

Примеры:

  • тайм-аут после отправки запроса во внешний сервис;
  • соединение оборвалось после commit;
  • адаптер упал, не сохранив подтверждение;
  • внешний API ответил так, что итоговое состояние неясно.

Это именно тот случай, где наивный повтор особенно опасен. Иногда правильное поведение тут не “повторить”, а:

  • проверить текущее состояние во внешней системе;
  • выполнить сверку;
  • запросить человека;
  • остановить рабочий процесс и зафиксировать неопределенность.

Сквозной кейс: неизвестный итог тикета

В кейсе сортировки обращений поддержки самый опасный момент — не ошибка в рассуждении, а тайм-аут после вызова create_support_ticket. Если helpdesk мог уже создать тикет, повторный вызов без ключа идемпотентности превратит один клиентский запрос в два инцидента. Правильная ветка сначала ищет тикет по correlation ID, затем либо привязывает найденный результат к трассе, либо останавливает запуск и просит оператора подтвердить состояние.

4.1. Ветка восстановления тоже должна проектироваться явно

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

Полезно заранее определить:

  • какие классы исходов требуют сверки, а не повтора;
  • где частичный успех должен создавать следующую задачу;
  • когда восстановление само требует подтверждения;
  • какие ветки должны явно попадать в оценочный набор данных;
  • где опасный путь восстановления лучше остановить, чем “помочь пользователю еще раз”.

Многие тяжелые инциденты происходят не в счастливом пути, а именно в плохо спроектированной ветке восстановления.

5. Лимиты запросов это тоже часть безопасного дизайна

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

На деле лимиты запросов нужны еще и для того, чтобы:

  • один сорвавшийся агент не заддосил внешнюю систему;
  • циклическое планирование не превратилось в лавину вызовов инструментов;
  • дорогая возможность не съедала весь бюджет;
  • шторм повторов не убивал интеграцию.

Именно поэтому лимит полезно задавать не только на уровне всего сервиса, но и:

  • на инструмент;
  • на арендатора;
  • на рабочий процесс;
  • на класс риска.

6. Граница отката должна быть определена заранее

Очень опасно обнаруживать только в инциденте, что операция “вообще-то неоткатываемая”.

У каждой возможности записи полезно заранее понимать:

  • можно ли отменить действие;
  • можно ли безопасно повторить действие;
  • где проходит точка невозврата;
  • какое компенсирующее действие допустимо;
  • когда нужна ручная сверка.

То есть граница отката — это не “потом посмотрим”. Это часть контракта инструмента и рабочего процесса.

После побочного эффекта слой выполнения должен различать безопасный повтор, сверку и остановку

flowchart TD
    A["Запрос инструмента"] --> B["Выполнить действие записи"]
    B --> C{"Исход известен?"}
    C -->|Да, успех| D["Сохранить результат и продолжить"]
    C -->|Ошибка, которую можно повторить| E["Повторить по политике с backoff"]
    C -->|Неизвестный побочный эффект| F["Сверить или запросить проверку человеком"]
    C -->|Ошибка валидации или доступа| G["Остановиться и показать ошибку"]

7. Хороший контракт выполнения хранит операционную семантику явно

Недостаточно знать только схему входа и форму ответа. Для опасных операций контракт должен хранить операционную семантику:

  • идемпотентно ли действие;
  • нужен ли ключ идемпотентности;
  • какие ошибки допустимы для повтора;
  • где предел повторов;
  • какие лимиты на частоту вызова;
  • что делать при неизвестном исходе;
  • возможен ли откат или компенсирующее действие.

Ниже очень практичный шаблон:

tools:
  create_ticket:
    mode: write
    idempotent: true
    idempotency_key_required: true
    retry:
      max_attempts: 3
      backoff: exponential
      retry_on: ["retryable_failure"]
    rate_limit:
      per_tenant_per_minute: 20
    rollback:
      strategy: "none"
      reconcile_on_unknown: true

  send_email:
    mode: write
    idempotent: false
    idempotency_key_required: false
    retry:
      max_attempts: 1
      retry_on: []
    rate_limit:
      per_user_per_hour: 10
    rollback:
      strategy: "manual_only"
      reconcile_on_unknown: true

Такой YAML помогает команде заранее признать неприятную правду: не все действия одинаково безопасны для автоматизации.

8. Простой кодовый пример логики решений для повторов

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

from dataclasses import dataclass


@dataclass
class ExecutionOutcome:
    status: str
    attempts: int
    max_attempts: int


def next_step(outcome: ExecutionOutcome) -> str:
    if outcome.status in {"validation_failure", "permission_denied"}:
        return "stop"
    if outcome.status == "retryable_failure" and outcome.attempts < outcome.max_attempts:
        return "retry_with_backoff"
    if outcome.status == "side_effect_unknown":
        return "reconcile"
    if outcome.status == "success":
        return "continue"
    return "escalate"

Важен не код сам по себе, а то, что у системы появляется явная операционная таблица решений.

9. У цикла агента должны быть явные условия остановки

Еще один практический совет из гайда OpenAI, который очень полезно формализовать: у цикла запуска должны быть понятные условия остановки.1

Хороший рантайм завершает запуск не “когда модель вроде бы успокоилась”, а по явным причинам:

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

Это звучит скучно, но именно такие правила не дают агенту уйти в бесконечное планирование, бессмысленные повторы или декоративные переходы между инструментами.

10. Ключ идемпотентности должен быть частью протокола, а не опциональной договоренностью

Если инструмент записи формально “поддерживает идемпотентность”, но ключ:

  • не обязателен;
  • по-разному генерируется в разных местах;
  • не доживает до пути повтора;
  • не логируется,

то это почти не настоящая идемпотентность.

Хорошая практика:

  • генерировать ключ на границе рабочего процесса или действия;
  • передавать его через весь путь выполнения;
  • логировать его в журнале аудита;
  • использовать для сверки и расследований.

11. Частые ошибки

Проблемы здесь довольно типовые:

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

Все это означает одно: слой выполнения еще не дорос до модели отказов эксплуатационного уровня.

12. Что сделать сразу

Сначала пройди по короткому списку и отдельно отметь все ответы «нет»:

  • Есть ли у инструментов записи явная стратегия идемпотентности?
  • Различает ли система retryable_failure и side_effect_unknown?
  • Привязаны ли повторы к политике, а не к общему вспомогательному обработчику?
  • Есть ли лимиты запросов на инструмент или на арендатора?
  • Понимаешь ли ты границу отката для каждого опасного действия?
  • Может ли рантайм делать сверку вместо слепого повтора?
  • Видно ли ключ идемпотентности в трассах и журналах аудита?

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

13. Что делать дальше

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