РАЗБОР РЕАЛЬНОЙ ОШИБКИ

Как мы нашли лишние расходы AI-агента

Уровень: средний Время: 8 минут Результат: контроль стоимости и повторов

Агент завершал задачи, но бюджет расходовался быстрее ожидаемого, а итоговый отчёт не объяснял почему. Причина оказалась не в цене модели и не в одном длинном запросе: один и тот же этап незаметно запускался повторно после тайм-аута. Мы восстановили цепочку по журналу событий, добавили идентификатор операции, защиту от дублей и отдельный отчёт о стоимости каждой задачи.

Что именно происходило

Рабочий процесс выглядел просто: принять событие, собрать данные, вызвать модель, сохранить ответ и отметить задачу завершённой. Внешне всё работало — пользователь получал один результат. Но внутри отдельные задачи создавали несколько обращений к модели.

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

событие принято
  → задача взята из очереди
  → запрос к модели отправлен
  → ожидание превысило лимит
  → задача возвращена в очередь
  → тот же запрос отправлен повторно
  → сохранён только последний ответ

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

Почему обычного лога оказалось недостаточно

В журнале были сообщения «начали задачу» и «задача завершена», но не было общего идентификатора, связывающего входное событие, попытку, обращение к модели и списание. Такая запись показывает активность, но не даёт наблюдаемости.

Для расследования нам понадобились четыре разных идентификатора:

Если хранить только job_id, повторные попытки сливаются. Если хранить только request_id, непонятно, какую пользовательскую задачу обслуживал запрос. Для контроля расходов нужны оба уровня.

Шаг 1. Начните считать запросы до агрегации

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

{
  "event_id": "evt_example",
  "job_id": "job_example",
  "attempt": 1,
  "request_id": "req_example",
  "stage": "summarize",
  "model": "configured-model",
  "input_tokens": 0,
  "output_tokens": 0,
  "cost": null,
  "status": "started",
  "started_at": "ISO-8601 timestamp",
  "finished_at": null,
  "error_type": null
}

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

Статус started нужно сохранять до сетевого вызова. После ответа обновляйте ту же запись, а не создавайте новую. Тогда оборванная попытка останется видимой и её можно будет сопоставить со следующим запуском.

Шаг 2. Найдите повторяющиеся операции

Сгруппируйте журнал по задаче и этапу. Например, для SQLite или PostgreSQL основа проверки может выглядеть так:

SELECT
  job_id,
  stage,
  COUNT(*) AS request_count,
  SUM(COALESCE(cost, 0)) AS total_cost
FROM model_requests
GROUP BY job_id, stage
HAVING COUNT(*) > 1
ORDER BY request_count DESC;

Само наличие нескольких запросов ещё не доказывает ошибку. Агент может осознанно вызвать модель для планирования, проверки и финального ответа. Подозрителен повтор с одинаковыми job_id, stage и нормализованным входом.

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

input_hash = sha256(
  model + "\n" +
  stage + "\n" +
  normalized_system_prompt + "\n" +
  normalized_user_input
)

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

Шаг 3. Отделите повтор события от повторной попытки

У нас было два потенциальных источника дублей. Первый — повторная доставка входного вебхука. Второй — перезапуск уже созданной задачи после тайм-аута. Защищать нужно обе границы.

На входе создайте уникальное ограничение по источнику и идентификатору события:

CREATE UNIQUE INDEX uniq_source_event
ON inbound_events(source, event_id);

Перед платным этапом формируйте ключ операции из задачи, этапа и отпечатка входа. Один ключ должен соответствовать одному логическому результату:

operation_key = sha256(job_id + ":" + stage + ":" + input_hash)

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

Повторить доставку задачи безопасно только тогда, когда повтор не создаёт второе платное действие.

Шаг 4. Настройте повторы по типу ошибки

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

retry:
  max_attempts: 3
  backoff_seconds: [2, 10, 30]
  retry_on:
    - rate_limit
    - temporary_unavailable
    - connection_reset
  stop_on:
    - invalid_request
    - authentication_error
    - context_limit

budget:
  max_model_requests_per_job: 4
  max_input_tokens_per_job: 30000
  require_approval_above_configured_cost: true

Значения здесь — пример структуры, а не универсальные лимиты. Подставьте ограничения из своей модели нагрузки и тарифа. Ошибку формата или превышение допустимого контекста обычно нужно исправлять, а не отправлять без изменений повторно.

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

Шаг 5. Сделайте понятный отчёт

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

Задача: job_example
Статус: completed
Этапы: plan, retrieve, summarize
Запросов к модели: 4
Повторных попыток: 1
Причина повтора: temporary_unavailable
Подтверждённая стоимость: значение из биллинга
Незавершённые запросы: 0

Не смешивайте нулевую стоимость и неизвестную. Для запроса без биллинговых данных храните null или статус pending. Иначе отчёт будет выглядеть точным, хотя часть расходов ещё не учтена.

Как проверить защиту от повторов

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

  1. Дважды передайте одно входное событие. В базе должна остаться одна логическая задача.
  2. Оборвите выполнение после получения ответа, но до завершения worker. Повторный запуск не должен создавать вторую завершённую операцию.
  3. Верните временную ошибку до принятия запроса. Следующая попытка должна получить новый номер и сохранить прежний job_id.
  4. Верните постоянную ошибку формата. Агент должен остановиться без повторов.
  5. Доведите задачу до лимита запросов. Следующий платный этап должен быть заблокирован.
  6. Сверьте число записей model_requests с журналом или биллингом провайдера за тот же интервал.

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

Что может сломаться

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

Два worker одновременно увидели свободную задачу. Проверки «сначала прочитать, затем записать» недостаточно. Нужны уникальный индекс, транзакция или атомарная блокировка.

Отпечаток входа меняется на каждой попытке. Уберите динамические поля из нормализации или создавайте содержимое запроса один раз и сохраняйте его вместе с задачей.

Повторы запретили полностью. Это снижает расходы, но делает систему хрупкой при временных сетевых ошибках. Цель — не ноль повторов, а контролируемые и объяснимые повторы.

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

Ограничения подхода

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

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

← Все инструкции · Лабораторный словарь →

Мы публикуем то, что работает у нас — и внедряем это же для вашего бизнеса. Проектируем AI-автоматизации, Telegram-ботов, чаты и AI-агентов под реальные процессы. Обсудить задачу →