Production-safe агентный цикл: как не дать ИИ сжечь бюджет

Агентные циклы сжигают бюджет не из-за плохих моделей, а из-за нечёткого условия остановки. Разбираем, как собрать production-ready каркас на Python: спецификацию, предохранитель, аудит и человеческое согласование.

Обложка: Production-safe агентный цикл: как не дать ИИ сжечь бюджет

Если ваш ИИ-агент работает круглосуточно и не может остановиться — это не автономность, а биллинговая авария, которая уже идёт. В июле 2025 года рекурсивный агентный цикл в Claude Code сжёг от 16 000 до 50 000 долларов за пять часов. Агенты не падают и не выдают ошибку: они делают ровно то, что им сказали, — пока кто-то не скажет остановиться.

Через четыре месяца четырёхагентный пайплайн на LangChain крутился одиннадцать дней и стоил 47 000 долларов. Никто не заметил, пока не пришёл счёт. Тот же паттерн: цикл работал корректно, но у него не было условия выхода.

Проблема не в моделях, а в отсутствии условия остановки. В этой статье разберём, как собрать минимальный, но production-ready каркас агентного цикла: спецификацию до запуска, предохранитель по токенам и ходам, неизменяемый аудит и поверхность для человеческого согласования. Полный код и 80 тестов с 100% покрытием доступны в репозитории автора оригинала.

Ключевые выводы

Агентные циклы сжигают бюджет не из-за плохих моделей, а из-за нечёткого условия остановки.

Спецификация должна отвечать на три вопроса: что делает, что не делает и что значит «готово» — в одном предложении.

Circuit breaker режет цикл по жёстким потолкам: число ходов и суммарные токены. Проверка — до вызова модели, а не после.

Ledger в SQLite фиксирует каждый ход: хеш входа, дельту токенов, время, результат. Это аудит, а не лог для отладки.

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

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

Агентный цикл — это конструкция вида while True, внутри которой языковая модель получает задачу, вызывает инструменты, анализирует результат и решает, продолжать или закончить. Такие циклы лежат в основе оркестраторов вроде LangGraph, CrewAI и AutoGen, а также внутри coding-агентов. Если только начинаете разбираться с LLM, полезно сначала понять, как устроены большие языковые модели, а для практики — заглянуть в основы Python.

Цикл уходит в бесконечность не потому, что модель «глупая», а потому, что никто не определил, что значит «готово». Модель видит неоднозначность и пытается быть полезной: перефразирует вызов инструмента, запускает верифицирующего агента, тот находит «проблему», срабатывает корректирующий агент — и так далее. На дашбордах всё выглядит активно: растёт число вызовов инструментов, completion rate держится высоким, а бюджет течёт в пустоту.

Почему дорожает каждая итерация:
Агент не начинает с чистого листа. Он каждый раз перечитывает всё предыдущее окно контекста — все неудачные попытки, все промежуточные выводы. Итерация 1 стоит 100 токенов, итерация 10 — уже тысячи. Вы платите за каждый провал снова и снова.

Почему компании сначала платят за чатбота, а потом за агентный рабочий процесс

Gartner фиксирует разрыв в потреблении токенов между пилотными чатботами и production-агентными рабочими процессами в 5–30 раз. А отчёт FinOps Foundation за 2026 год говорит, что 73% компаний превысили изначальный бюджет на ИИ. Цифры взяты из оригинального туториала; полные отчёты Gartner и FinOps Foundation доступны по платным подпискам. Причина разрыва — в неправильном масштабировании: команда планировала стоимость чатбота (~0,04 USD за взаимодействие), а в прод ушёл мультиагентный оркестр (~1,20 USD за взаимодействие, до 70x на сложных задачах).

A loop that runs without an exit condition isn't autonomous. It's a billing event waiting to happen.
Daniel NwaneriFull-stack developer, автор туториала на freeCodeCamp

Пять примитивов, которые ловят большинство отказов

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

  1. Spec writer — заставляет ответить на три вопроса до первого вызова модели.
  2. Circuit breaker — режет цикл, если превышены потолки по ходам или токенам.
  3. Ledger — ведёт append-only журнал каждого хода в SQLite.
  4. Agent loop — связывает три компонента в единый цикл.
  5. Review surface — собирает пятиэлементный фрейм и требует человеческой аттестации перед выдачей результата.

Фаза 1. Определить «готово» до первой строчки кода

Самая дорогая ошибка в разработке агентов — не выбор модели, а начало кодинга до того, как команда может одним предложением описать условие завершения. «Агент проверит сайт» — не подходит. «Агент обходит целевой URL, извлекает все теги <title> и <meta name="description">, помечает отсутствующие или слишком длинные и останавливается» — подходит.

Spec writer интерактивно запрашивает три поля, сохраняет их значения в SQLite и возвращает неизменяемый SpecResult(frozen=True). Полученный session_id связывает спецификацию, строки журнала и итоговый результат в одну трассируемую сессию.

			@dataclass(frozen=True)
class SpecResult:
    what_it_does: str
    what_it_does_not: str
    done_looks_like: str
    session_id: str
		
Почему frozen=True:
Спецификация — это обязательство, а не черновик. frozen=True запрещает переприсваивать поля объекта SpecResult, поэтому код цикла не может «подвинуть» условие завершения посреди запуска.

Фаза 2. Принудить «готово» на лету

Circuit breaker задаёт два жёстких потолка: turn_limit — максимальное число обращений к модели, и token_limit — суммарное число токенов за всю сессию. Каждый потолок — «строго больше»: если лимит 5 ходов, пятый ещё разрешён, шестой выбросит исключение.

			class CircuitBreaker:
    def __init__(self, turn_limit: int = 5, token_limit: int = 15000):
        self.turn_limit = turn_limit
        self.token_limit = token_limit

    def check(self, turn_count: int, accumulated_tokens: int) -> None:
        if turn_count > self.turn_limit:
            self._trip("turn_ceiling", turn_count, accumulated_tokens)
        if accumulated_tokens > self.token_limit:
            self._trip("token_ceiling", turn_count, accumulated_tokens)
		

Ключевое правило: breaker.check() вызывается до запроса к модели, а не после. Постфактум проверка бессмысленна: токены уже сожжены. Исключение, а не код возврата, — чтобы нельзя было промолчать.

Как подобрать лимиты для продакшена

Демонстрационные значения 5 ходов / 15 000 токенов слишком жёсткие для реальных задач. Для продакшена автор предлагает настроить лимиты под свой бюджет; в туториале приведён пример breaker = CircuitBreaker(turn_limit=10, token_limit=50000). Если одна сессия должна стоить не дороже 1 USD, а средний ход — 0,10 USD, получается порядка 10 ходов. Конкретный token_limit выбирается исходя из прайсинга модели и среднего размера контекста: чем длиннее история диалога, тем раньше сработает потолок.

  • Стартуйте с жёсткими лимитами и разрешайте рост только по метрикам, не по интуиции.
  • Отдельно лимитируйте retry-политику: каждый повторный запрос увеличивает и turn_count, и объём контекста.
  • Не смешивайте лимит токенов с лимитом выходных токенов модели; circuit breaker считает сумму input + output.

Фаза 3. Записывать всё, что нельзя подделать

Circuit breaker защищает бюджет. Ledger защищает понимание того, что произошло. Это не лог для отладки, а журнал аудита: каждая строка — один ход, append-only, без обновлений и удалений.

			CREATE TABLE IF NOT EXISTS ledger (
    id                 INTEGER PRIMARY KEY AUTOINCREMENT,
    session_id         TEXT    NOT NULL,
    turn_count         INTEGER NOT NULL,
    state_origin       TEXT    NOT NULL,
    input_hash         TEXT    NOT NULL,
    token_delta        INTEGER NOT NULL,
    execution_time_ms  INTEGER NOT NULL,
    pass_fail          INTEGER NOT NULL,  -- 1=pass, 0=fail
    breach_reason      TEXT,              -- NULL unless circuit breaker fired
    created_at         TEXT    NOT NULL   -- ISO 8601, UTC
);
CREATE INDEX IF NOT EXISTS idx_ledger_session ON ledger(session_id);
		

Три решения стоит взять на заметку. Во-первых, вместо исходного текста сохраняется SHA-256 хеш входа: так не утекают персональные данные, а одинаковые входы разных запусков можно сравнивать. Во-вторых, pass_fail хранится как INTEGER (1/0), потому что у SQLite нет булева типа. В-третьих, временная метка — datetime.now(timezone.utc).isoformat(), так как datetime.utcnow() объявлен устаревшим в Python 3.12.

Фаза 4. Цикл, который уважает границы

Agent loop — единственный компонент, который обращается к языковой модели. Всё остальное работает локально: проверка потолков, запись в журнал, оценка условия выхода.

Анатомия одного хода простая и строгая: сначала breaker.check(), потом вызов модели, потом ledger.write(), потом проверка stop_reason. Если модель вернула end_turn — возвращаем результат. Если нет — добавляем сообщение continue и идём на следующий круг.

			def run(self, task: str) -> LoopResult:
    session_id = self.spec.session_id
    messages = [{"role": "user", "content": task}]
    turn = 0
    total_tokens = 0

    try:
        while True:
            turn += 1
            self.circuit_breaker.check(turn, total_tokens)

            started = time.perf_counter()
            response = self.client.messages.create(
                model=self.model,
                max_tokens=self.max_tokens,
                system=self._system_prompt(),
                messages=messages,
            )
            elapsed_ms = int((time.perf_counter() - started) * 1000)

            # getattr с fallback нужен, чтобы цикл не падал
            # на адаптерах для OpenAI, Gemini и других провайдеров,
            # где структура ответа может отличаться.
            turn_tokens = (
                getattr(response.usage, "input_tokens", 0)
                + getattr(response.usage, "output_tokens", 0)
            )
            total_tokens += turn_tokens
            text = self._text_from(response)
            messages.append({"role": "assistant", "content": text})

            self.ledger.write(...)

            # end_turn означает, что модель считает задачу завершённой.
            # Для агентов с инструментами здесь нужна отдельная ветка tool_use.
            if getattr(response, "stop_reason", "end_turn") == "end_turn":
                return LoopResult(success=True, ...)

            messages.append({"role": "user", "content": "continue"})

    except CircuitBreakerError as err:
        self.ledger.write(..., pass_fail=False, breach_reason=err.reason)
        return LoopResult(success=False, breach_reason=err.reason, ...)
		

Этот вариант цикла — минимальный текстовый. Если агент использует инструменты, в Anthropic API stop_reason может быть tool_use: тогда нужно выполнить инструмент, вернуть его результат в messages и только потом решать, продолжать или завершать.

Системный промпт обязательно включает все три поля спецификации, а не только done_looks_like. Модели нужна негативная область — то, что агент делать не должен (what_it_does_not), — не меньше, чем позитивная: иначе она начнёт «добавлять ценность» за рамками задачи.

Фаза 5. Поверхность согласования: цикл бежит к человеку

Circuit breaker и ledger решают технические проблемы, но не отвечают на вопрос: «Соответствует ли результат тому, что обещали?» Именно здесь ошибки проходят в прод: вывод выглядит аккуратным, дашборд зелёный, ревьюер ставит галочку.

Review surface собирает пятиэлементный фрейм из SQLite и требует явной аттестации:

  1. Исходное обещание — три поля спецификации.
  2. Критерий приёмки — поле done_looks_like как явный бенчмарк.
  3. Diff — вход первого хода, выход последнего, число ходов, токены, сработал ли breaker.
  4. Доказательства — все строки ledger за сессию.
  5. Неразрешённые допущения — строки с breach_reason и failed-ходами.

После согласования ревьюер вызывает attest(). Функция собирает пятиэлементный фрейм в каноническом порядке и считает от него SHA-256 — получается frame_hash. Это аудиторская квитанция: она доказывает, что ревьюер видел именно этот фрейм, а не краткое резюме.

Практический пример: SEO-аудит по расписанию

Автор приводит пример SEO-аудита. SEO-аудит имеет естественный ритм: обход, выявление проблем, исправление, ожидание переиндексации. Запускать агента 24/7 бессмысленно — он будет сжигать токены в паузах между событиями. Честная архитектура — cron-задача, которая запускает цикл по расписанию.

			def crawl_url(url: str) -> str:
    response = requests.get(url, timeout=10)
    soup = BeautifulSoup(response.text, "html.parser")
    title = soup.find("title")
    meta_desc = soup.find("meta", attrs={"name": "description"})
    h1_tags = soup.find_all("h1")
    return (
        f"URL: {url}\n"
        f"Title: {title.text if title else 'MISSING'}\n"
        f"Meta description: {meta_desc['content'] if meta_desc else 'MISSING'}\n"
        f"H1 count: {len(h1_tags)}"
    )
		
Пример упрощён:
В production-варианте стоит проверять URL (допустимые схемы и хосты) и оборачивать requests.get в try/except requests.RequestException, чтобы агент не падал при недоступности сайта.

Cron-строка выглядит так:

			0 3 * * 2 python examples/seo_audit_example.py https://yourdomain.com
		

Агент выполняет работу, записывает ходы в ledger, и если circuit breaker сработал — результат уходит на человеческую проверку, а не в прод.

Провайдер-независимость через адаптер

Цикл работает с любым клиентом, удовлетворяющим протоколу LLMClient. По умолчанию используется Anthropic, но через адаптер можно подключить OpenAI, Gemini, Ollama, локальные модели или собственный сервер. В репозитории автора показан иллюстративный пример адаптера для OpenAI. Главное — привести ответ к форме, которую ожидает AgentLoop: usage.input_tokens, usage.output_tokens, content[0].text, stop_reason.

Часто задаваемые вопросы
1
Что такое agent loop и чем он отличается от обычного цикла?

Agent loop — это цикл, в котором языковая модель многократно вызывается для решения задачи, анализа результата и принятия решения о продолжении. От обычного цикла он отличается тем, что условие остановки не всегда формализовано: модель сама решает, закончила ли она, что приводит к риску бесконечного выполнения.

2
Почему circuit breaker проверяет лимиты до вызова модели?

Потому что после вызова токены уже израсходованы. Постфактумная проверка защищает только от следующего хода, но не возвращает уже потраченный бюджет. Pre-flight проверка гарантирует, что запрос к модели вообще не произойдёт, если лимит исчерпан.

3
Зачем хранить хеш входа в ledger вместо самого текста?

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

4
Можно ли использовать SQLite-ledger в распределённой системе?

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

5
Как подобрать начальные лимиты для своего агента?

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

Выводы: дисциплина дороже модели

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

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

Define what done looks like before you start. That's the job, and always has been.
Daniel NwaneriFull-stack developer, автор туториала на freeCodeCamp

Полный код и 80 тестов с 100% покрытием доступны в репозитории автора оригинала: github.com/dannwaneri/production-safe-agent-loop.

Источник: How to Build a Production-Safe Agent Loop — From Exit Conditions to Audit Trails, freeCodeCamp.