MAATRIX / Блог / Как ограничить агента, чтобы он не сжёг бюджет за ночь

Как ограничить агента, чтобы он не сжёг бюджет за ночь

MAATRIX

Вы запускаете автономного ИИ-агента на ночь или на выходные — пусть доделает задачу, пока вас нет за компьютером. Проблема не в том, что агент один раз ошибётся: ошибка на отдельном шаге — нормальная часть работы. Проблема в том, что у цикла «думать → действовать → проверить результат» по умолчанию нет верхней границы: без внешних ограничителей агент может прогнать лишние сотни итераций, локальная модель — часами греть GPU сверх разумного, а платный API-ключ — тратить деньги, пока никто не смотрит на дашборд. Разберём пять конкретных мер, которые стоит настроить ДО запуска без присмотра, а не после того, как счёт за API вырос или сервер простоял ночь на полной нагрузке. Как именно выглядит зацикливание изнутри и что делать, если предохранители уже не настроены и агент уже сжёг лимит, — отдельный разбор реального случая в статье «ИИ-агент зациклился и сжёг лимиты за ночь»; здесь — общая методика защиты, применимая к любому автономному агенту вне зависимости от того, на чём он написан.

Жёсткий лимит итераций — потолок без исключений

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

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

from langchain.agents import AgentExecutor

executor = AgentExecutor(
    agent=agent,
    tools=tools,
    max_iterations=25,       # жёсткий потолок шагов
    max_execution_time=1800, # и дублирующий лимит по времени, секунды
    early_stopping_method="force",
)

Если цикл написан вручную, потолок должен быть внешним по отношению к решению «достигнута ли цель» — счётчик, который растёт при любом исходе шага, а не только при неудаче:

for step in range(MAX_STEPS):
    if task.is_done():
        break
    action = model.decide_next_action(task)
    result = execute_tool(action)
    task.history.append((action, result))
else:
    escalate("step_limit_reached", steps=MAX_STEPS, task=task.id)

Ключевое слово — «без исключений». Соблазн добавить в код условие «если задача почти решена, дай ещё немного» превращает жёсткий лимит в необязательную рекомендацию, а именно это исключение потом и оказывается дырой, через которую агент выходит за разумные рамки. Если лимит сработал слишком рано для реальных задач — это сигнал пересмотреть само значение лимита (см. раздел о калибровке ниже), а не встраивать в код лазейку под конкретный случай.

Лимит по времени на всю задачу целиком

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

Разница принципиальна. Таймаут на шаг (timeout=30 на один вызов инструмента) защищает от зависания одного действия, но ничего не мешает выполнить тысячу таких быстрых шагов подряд. Таймаут на задачу целиком — это дедлайн, отсчитываемый от старта агента, и после его истечения выполнение прерывается вне зависимости от того, на каком шаге оно застало процесс:

import time

deadline = time.monotonic() + MAX_TASK_SECONDS  # например, 4 часа = 14400

while not task.is_done():
    if time.monotonic() >= deadline:
        escalate("time_limit_reached", task=task.id)
        break
    step_timeout = min(60, deadline - time.monotonic())
    result = execute_tool_with_timeout(action, timeout=step_timeout)

Этот предохранитель стоит продублировать на уровне ОС или оркестрации — как страховку на случай, если сам код агента завис настолько, что даже собственная проверка дедлайна не выполнилась. Если процесс запущен через systemd, RuntimeMaxSec убьёт юнит по истечении времени независимо от состояния кода внутри:

[Service]
ExecStart=/usr/bin/python3 /opt/agent/run.py
RuntimeMaxSec=14400

Для разового запуска из cron или вручную то же самое делает обёртка timeout:

timeout 4h python3 /opt/agent/run.py || echo "agent killed by timeout: $?"

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Развернуть ИИ на сервере

Для локальной модели: мониторинг реального потребления железа

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

Базовый слой — GPU-экспортёр поверх nvidia-smi в связке с Prometheus и Grafana, подробно разобранный в статье «Как мониторить нагрузку локальной LLM»: метрики nvidia_smi_utilization_gpu_ratio (загрузка ядер) и nvidia_smi_utilization_memory_ratio (занятость VRAM) собираются в тот же prometheus.yml, что и node_exporter. Для контроля именно автономного агента важна не разовая цифра, а длительность аномальной загрузки — правило алерта строится через for:

groups:
  - name: agent-runaway
    rules:
      - alert: AgentGPULongRunning
        expr: nvidia_smi_utilization_gpu_ratio > 0.85
        for: 3h   # дольше типичного времени успешного прогона (см. раздел про калибровку)
        labels:
          severity: critical
        annotations:
          summary: "GPU занята выше нормы дольше 3 часов — похоже, агент завис"

Порог for подбирается не произвольно, а от фактического времени успешного выполнения типовой задачи под наблюдением — тот же принцип калибровки, что и для лимита шагов и бюджета, разобран отдельным разделом ниже. Второй, более точечный сигнал — опрос самого движка инференса: ollama ps или curl -s http://127.0.0.1:11434/api/ps по крону покажут, что именно загружено в память прямо сейчас и как долго, а неожиданное отсутствие ожидаемой модели в ответе часто означает, что процесс уже упал или перезапустился в цикле. Alertmanager из той же связки Prometheus умеет не только слать уведомление, но и дёргать вебхук, который остановит процесс агента — тревога без автоматического действия ночью просто никем не будет прочитана вовремя.

Для гибридных агентов: денежный лимит на уровне API-ключа

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

Прежде чем выставлять конкретную сумму лимита, стоит понимать, из чего вообще складывается стоимость одного вызова вашей модели — разбор формулы и точки безубыточности локального сервера против облака есть в статье «Стоимость одного запроса к вашей LLM». Практический вариант ограничения на уровне ключа — выпускать не общий, а отдельный ключ на каждого автономного агента через шлюз с поддержкой бюджетов. Например, у LiteLLM Proxy виртуальный ключ создаётся сразу с потолком расходов и лимитом скорости запросов:

curl -X POST http://localhost:4000/key/generate \
  -H "Authorization: Bearer $LITELLM_MASTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"models":["fast","smart"],"max_budget":5,"budget_duration":"1d",
       "rpm_limit":30,"key_alias":"night-agent-run"}'

Как только накопленный расход по этому ключу за сутки достигает max_budget, шлюз просто отбивает дальнейшие запросы ошибкой — независимо от того, что в этот момент думает и планирует сам агент. key_alias вида night-agent-run дополнительно упрощает разбор биллинга постфактум: видно, какой именно процесс потратил деньги, без сопоставления по времени запуска. То же самое стоит завести и напрямую в консоли провайдера модели, если он поддерживает жёсткий потолок расходов или алерт на аккаунте, — как дублирующий предохранитель поверх шлюза, той же логики «два независимых уровня лучше одного», что и с лимитом по времени.

Логирование каждого шага — чтобы разобраться постфактум

Если сработал любой из перечисленных лимитов (или, хуже, не сработал вовремя), у вас должна остаться возможность понять, что именно произошло, а не гадать по обрывочным логам в терминале, который никто не сохранял. Логируйте каждый шаг агента в структурированном виде, в файл или базу — не только в stdout, который пропадёт вместе с сессией или контейнером:

import json, time

def log_step(run_id, step, action, result, cost_estimate, cumulative_cost):
    with open(f"/var/log/agent/{run_id}.jsonl", "a") as f:
        f.write(json.dumps({
            "ts": time.time(),
            "step": step,
            "tool": action.tool_name,
            "args": action.args,
            "result_summary": str(result)[:500],
            "cost_estimate": cost_estimate,
            "cumulative_cost": cumulative_cost,
        }) + "\n")

Отдельный run_id на каждый запуск и единый JSON-формат позволяют потом искать по логу инструментом вроде jq, а не читать построчно тысячу записей. Обратите внимание: полезнее всего для разбора именно первые шаги прогона, где видно исходное решение и первую ошибку, — к сотому повтору лог обычно вырождается в почти одинаковые строки. Полный подробный лог держите локально в файле, а короткое уведомление о срабатывании лимита шлите туда, где его увидят быстро — например, ботом в Telegram по инструкции из статьи «Как настроить алерты в Telegram на VPS»: полный curl до sendMessage с текстом причины остановки и run_id для дальнейшего разбора в логе.

Если в проекте уже используется трассировка вызовов модели (Langfuse и подобные), она хорошо покрывает сами обращения к LLM — промпт, ответ, задержку, число токенов, — но обычно не фиксирует действия агента вне вызова модели: запуск команд, изменение файлов, причину остановки по лимиту. Структурированный лог шагов агента и трассировка вызовов модели дополняют друг друга, а не заменяют.

Сводно все пять мер и уровень, на котором их настраивать:

МераЧто ловитГде настраивается
Лимит шаговБесконечный цикл при примерно одинаковой стоимости шагаКод агента / параметр фреймворка
Лимит по времени задачиДолгие единичные шаги, зависания на I/OКод агента + systemd/timeout как дублирующий уровень
Мониторинг GPU/CPUАномально долгую нагрузку железа для локальной моделиPrometheus + Alertmanager
Бюджет на API-ключеФинансовый ущерб независимо от внутренней логики агентаШлюз (LiteLLM и подобные) или консоль провайдера
Логирование каждого шагаВозможность разобраться постфактум, если лимит сработалФайл/БД, отдельно от stdout

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

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

Самая частая ошибка при настройке лимитов — выбрать число «на глаз»: 50 шагов, час времени, 10 долларов — потому что цифра выглядит разумно. Разумные лимиты получаются не так. Прежде чем оставлять агента без присмотра на длительный период, прогоните несколько характерных для вашей задачи сценариев ПОД НАБЛЮДЕНИЕМ и зафиксируйте фактические цифры:

  1. Возьмите 3–5 типовых задач того же класса, что предстоит решать агенту без присмотра.
  2. Запустите каждую с включённым подробным логом, наблюдая за процессом (не обязательно смотреть в терминал каждую секунду — достаточно проверять раз в несколько минут).
  3. Зафиксируйте для каждого успешного прогона: число шагов до завершения, время до завершения, фактическую стоимость по счётчику API или занятости GPU.
  4. Возьмите максимум среди успешных прогонов и умножьте на разумный запас — обычно в 2–3 раза, не больше: слишком щедрый запас превращает лимит в формальность, которая никогда не сработает вовремя.
  5. Полученные числа и станут значениями max_iterations, RuntimeMaxSec, max_budget и порога for в алерте на GPU — не более и не менее.

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Развернуть ИИ на сервере

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

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

Останавливайте всё равно, без исключений, а «почти решено» разбирайте руками после. Формальный лимит с оговоркой «но если почти готово — разреши» перестаёт быть лимитом и превращается в необязательную рекомендацию — ровно то, от чего вы защищаетесь.

Хватит ли одного лимита по времени, чтобы не настраивать отдельно бюджет и лимит шагов?

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

Как выбрать порог for в алерте на GPU, если продолжительность задач у вас разная?

Группируйте алерты по классу задачи, а не заводите один общий порог на всё: калибровка из раздела выше делается отдельно для каждого типа задачи, и порог for берётся с запасом от типичного времени именно этого класса, а не среднего по всем сразу.

Обязательно ли выпускать отдельный API-ключ на каждого агента, если удобнее один общий?

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

Достаточно ли трассировки в Langfuse вместо собственного лога шагов агента?

Нет, это разные слои. Трассировка покрывает сами вызовы модели — промпт, ответ, токены, задержку. Собственный структурированный лог нужен для действий агента вне вызова модели (запуск команд, изменение файлов) и для явной фиксации причины остановки по лимиту — в трассировке модели этого обычно нет.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →