В 10:14 агент поддержки получил заказ A-1842. CRM показывала оплату, служба доставки отвечала label_created, а клиент писал, что курьер звонил вчера. Первый прототип трижды запросил один и тот же статус, затем решил, что посылка потеряна. Модель не была особенно глупой. У программы просто не было явного состояния, признака прогресса и условия остановки.
Три одинаковых запроса подряд — это поведение не искусственного интеллекта, а человека, который дёргает закрытую дверь, надеясь, что на четвёртый раз она передумает.
Агентный цикл нужен там, где следующий шаг зависит от результата предыдущего. Это обычный управляющий цикл, только решение выбирает вероятностная модель.
цель + состояние
↓
observe → decide → policy check → act → record → verify
↑ │
└──────────── continue / stop ─────────┘Ничего экзотического в этой схеме нет. Ровно так работает диспетчер, дежурный врач и любой человек, которому дали задачу и не дали инструкции. Отличие одно: у диспетчера есть память, усталость и начальник, а у модели — только то, что вы положили ей в контекст на этом шаге.
Состояние важнее стенограммы
В состояние входят подтверждённые факты, уже выполненные вызовы, оставшийся бюджет и открытые вопросы. История сообщений может помочь модели, но не заменяет эти поля. Иначе после compaction агент забудет, что уже спрашивал доставку, и аккуратно спросит её ещё пять раз.
from dataclasses import dataclass, field
from typing import Any
@dataclass
class RunState:
goal: str
facts: dict[str, Any] = field(default_factory=dict)
calls: set[tuple[str, str]] = field(default_factory=set)
open_questions: list[str] = field(default_factory=list)
steps_left: int = 6
status: str = "running"observe собирает доступное состояние, но ничего не меняет снаружи. decide возвращает структурированное намерение. Между решением модели и инструментом стоит policy layer: он проверяет разрешения, аргументы и необходимость подтверждения. act выполняет ровно одно действие. verify сравнивает полученный результат с ожидаемым эффектом и решает, появился ли прогресс.
async def run_agent(state: RunState) -> RunState:
while state.status == "running" and state.steps_left > 0:
decision = await decide(state) # {tool, args, expected, reason} | {final}
if decision.get("final"):
state.status = "completed"
state.facts["answer"] = decision["final"]
break
key = (decision["tool"], canonical_json(decision["args"]))
if key in state.calls:
state.status = "stalled"
break
authorize(decision["tool"], decision["args"])
result = await tools[decision["tool"]](**decision["args"])
state.calls.add(key)
state.steps_left -= 1
record_result(state, decision, result)
if not verify_progress(state, decision, result):
state.open_questions.append(
f"Не подтверждён ожидаемый эффект: {decision['expected']}"
)
if state.steps_left == 0 and state.status == "running":
state.status = "budget_exhausted"
return stateТеперь про поле expected в решении модели. Требуя от модели заранее сказать, что она рассчитывает увидеть, вы получаете две вещи разом: критерий проверки и предохранитель от бессмысленных вызовов. «Вызову get_delivery, ожидаю статус доставки по A-1842» — проверяемо. «Вызову get_delivery, чтобы узнать больше» — заявка на бесконечность.
verify не обязан быть ещё одним LLM-вызовом. Для заказа полезнее проверить, что ответ инструмента имеет нужный order_id, метка времени свежая, а новое поле действительно закрывает открытый вопрос. Детерминированная проверка дешевле и не уговаривает сама себя. Модель, проверяющая собственную работу, — это студент, который сам себе ставит оценку за экзамен и удивительным образом всегда доволен результатом.
Что считать остановкой
У цикла должны быть положительные и аварийные выходы. Положительный выход наступает, когда цель достигнута и это подтверждено критерием. Аварийный нужен при исчерпании шагов или денег, повторе вызова без новых оснований, запрете policy layer, недоступности обязательного инструмента и противоречии, которое система не умеет разрешить.
Для A-1842 правильный ответ не обязан называться диагнозом. Если CRM и перевозчик расходятся, честный итог выглядит так: «Оплата подтверждена транзакцией p-91; перевозчик видит только созданную накладную; звонок курьера не подтверждён. Передать оператору доставки». Неизвестность здесь является состоянием системы, а не дефектом литературного стиля.
Заметьте, чего в этом ответе нет: слов «вероятно», «скорее всего» и «посылка утеряна». Агент, который умеет сказать «вот что известно, вот что противоречит, дальше нужен человек», полезнее агента, который всегда выдаёт вывод. Второй выглядит убедительнее ровно до первой проверки.
Бюджет запуска
Шаги — не единственный лимит и не самый важный. У запуска должно быть четыре ограничения, и все четыре проверяются в цикле:
- Шаги. Защита от зацикливания.
- Время. Защита от пользователя, который ушёл, и от инструмента, который отвечает три минуты.
- Токены. Защита от растущего контекста: каждый шаг тащит с собой предыдущие результаты.
- Деньги. Главное ограничение, потому что три предыдущих можно пережить, а это придёт счётом (глава 44).
Стоимость одного запуска считается заранее, до включения агента в продукт: шаги × средний размер контекста × цена. Если цифра получается неприятной, никакая оптимизация промпта её не спасёт — надо менять схему: меньше шагов, дешевле модель на рутинных решениях (глава 45), детерминированный код вместо решения модели там, где выбор очевиден.
Возобновление после падения
Процесс упадёт. Не «может упасть» — упадёт: деплой, OOM, перезапуск воркера, отменённая задача Celery. Вопрос только в том, что произойдёт дальше.
Если состояние живёт в памяти процесса, агент начнёт расследование заново, включая все побочные эффекты: второе письмо клиенту, второй возврат, второй комментарий в тикете. Если состояние сохраняется после каждого шага, запуск продолжается с того места, где остановился, а уже выполненные вызовы известны по calls.
Это значит: RunState сериализуется и пишется в базу после record_result, у запуска есть идентификатор, а инструменты с побочными эффектами идемпотентны (глава 10). Тот же приём, что в главе 16, только вместо шага пайплайна — шаг рассуждения.
Наблюдаемость запуска
Хороший лог агента читается как протокол, а не как поток сознания. На каждый шаг:
- номер шага,
decision.reason, выбранный инструмент и аргументы; expectedи результатverify: подтверждён эффект или нет;- решение policy layer;
- потраченные токены и деньги нарастающим итогом;
- итоговый статус запуска:
completed,stalled,budget_exhausted,denied,handoff.
Распределение статусов — главный дашборд агентной системы. Если stalled и budget_exhausted вместе дают заметную долю, у вас не «модель иногда тупит», а неверно нарезанные инструменты или недостижимая цель.
Failure modes
Цикл без прогресса. Аргументы немного меняются, смысл вызова остаётся тем же. Нужны canonicalization, дедупликация и проверка новых фактов.
Проверка намерения вместо эффекта. Агент вызвал send_email и считает письмо отправленным. Проверять надо message_id и статус провайдера, а при повторе использовать idempotency key.
Стоп по самооценке модели. Фраза «задача выполнена» ничего не доказывает. У цели должен быть машинно проверяемый postcondition или явное подтверждение человека.
Бесконечное уточнение. Модель всегда может придумать ещё один вопрос. Ограничьте число шагов, время, токены и стоимость запуска.
Скрытый side effect в observe. Инструмент с названием get_or_create_customer уже меняет мир. Читающие и пишущие операции должны различаться контрактом и правами. Имя, начинающееся с get, здесь работает как вывеска «музей» на здании налоговой: успокаивает ровно до входа.
Правило главы
Не запускайте модель в while True. Храните состояние отдельно от текста, разрешайте одно действие за шаг, проверяйте наблюдаемый эффект и завершайте цикл по явному критерию или бюджету.
Чеклист главы
Практикум
Задача 32.1 — расследование недоставленного заказа. ⭑⭑
Соберите цикл для расследования недоставленного заказа с инструментами get_order, get_payment и get_delivery. Подготовьте четыре сценария: нормальная доставка, неоплаченный заказ, противоречащие данные и таймаут перевозчика.
Критерии приёмки:
Подсказки:
- Инструменты замокайте: четыре сценария — это четыре набора заранее заданных ответов, и они прогоняются за секунды.
- Канонизация аргументов важнее, чем кажется:
{"order_id": "A-1842"}и{"order_id": "a-1842"}должны считаться одним вызовом.
Задача 32.2 — стоимость и лимиты запуска. ⭑⭑
Добавьте к циклу учёт четырёх бюджетов и посчитайте фактическую стоимость запуска на тех же четырёх сценариях.
Критерии приёмки:
Подсказки:
- Рост контекста удобно смотреть графиком «шаг → токены входа»: кривая объясняет счёт лучше любых рассуждений.
- Считайте деньги на реальных ценах из приложения А, а не «примерно»: цифра в отчёте меняет разговор с продуктом сильнее, чем аргументы.
Задача 32.3 — агент, который умеет молчать. ⭑⭑⭑
Возьмите свой цикл и добейтесь, чтобы на пятнадцати запусках он ни разу не выдал вывод, не подтверждённый инструментами.
Критерии приёмки:
Подсказки:
- Прослеживаемость проще сделать на уровне схемы ответа: каждый факт несёт ссылку на шаг, который его подтвердил.
- Если доля handoff кажется слишком высокой, посмотрите на инструменты: часто агенту просто нечем закрыть вопрос, и это ответ не про модель, а про интеграцию.