В 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 stateverify не обязан быть ещё одним LLM-вызовом. Для заказа полезнее проверить, что ответ инструмента имеет нужный order_id, метка времени свежая, а новое поле действительно закрывает открытый вопрос. Детерминированная проверка дешевле и не уговаривает сама себя.
Что считать остановкой
У цикла должны быть положительные и аварийные выходы. Положительный выход наступает, когда цель достигнута и это подтверждено критерием. Аварийный нужен при исчерпании шагов или денег, повторе вызова без новых оснований, запрете policy layer, недоступности обязательного инструмента и противоречии, которое система не умеет разрешить.
Для A-1842 правильный ответ не обязан называться диагнозом. Если CRM и перевозчик расходятся, честный итог выглядит так: «Оплата подтверждена транзакцией p-91; перевозчик видит только созданную накладную; звонок курьера не подтверждён. Передать оператору доставки». Неизвестность здесь является состоянием системы, а не дефектом литературного стиля.
Failure modes
Цикл без прогресса. Аргументы немного меняются, смысл вызова остаётся тем же. Нужны canonicalization, дедупликация и проверка новых фактов.
Проверка намерения вместо эффекта. Агент вызвал send_email и считает письмо отправленным. Проверять надо message_id и статус провайдера, а при повторе использовать idempotency key.
Стоп по самооценке модели. Фраза «задача выполнена» ничего не доказывает. У цели должен быть машинно проверяемый postcondition или явное подтверждение человека.
Бесконечное уточнение. Модель всегда может придумать ещё один вопрос. Ограничьте число шагов, время, токены и стоимость запуска.
Скрытый side effect в observe. Инструмент с названием get_or_create_customer уже меняет мир. Читающие и пишущие операции должны различаться контрактом и правами.
Правило главы
Не запускайте модель в while True. Храните состояние отдельно от текста, разрешайте одно действие за шаг, проверяйте наблюдаемый эффект и завершайте цикл по явному критерию или бюджету.
Практикум
Соберите цикл для расследования недоставленного заказа с инструментами get_order, get_payment и get_delivery. Подготовьте четыре сценария: нормальная доставка, неоплаченный заказ, противоречащие данные и таймаут перевозчика.
Критерии приёмки:
- повторный tool call с теми же аргументами блокируется;
- запуск прекращается не позднее шестого шага;
- каждый шаг сохраняет decision, аргументы, результат и результат проверки;
- при противоречии агент перечисляет evidence и передаёт задачу человеку;
- тест с упавшим процессом возобновляется из сохранённого состояния, а не начинает расследование заново.