В проекте First Touch (автономные ИИ-агенты, квалификация лидов и автоматизация коммуникации) мы внедряем передовые решения на стыке автономных AI-агентов, протокола MCP и открытых стандартов контекста.
Почему агент с 92% на бенчмарке даёт 61% в проде — и как три уровня восстановления (step, task, workflow) закрывают пять разных классов отказов.
Агент, который «в 92% случаев» проходит бенчмарк, в продакшене показывает 61%. Разница не в модели: бенчмарк не воспроизводит таймауты, 429-е, обрезанные tool_call и частично выполненные сайд-эффекты. Ниже — как мы закрыли это тремя уровнями восстановления и снизили долю «мёртвых» прогонов с 39% до 2.4%.
Где именно рвётся ReAct-петля
Классический цикл thought → action → observation предполагает, что каждый шаг либо завершается, либо падает целиком. В реальности шаг падает посередине: платёж прошёл, а запись в CRM — нет. Или модель вернула tool_call с невалидной схемой, и наивный json.loads() бросает исключение прямо в оркестратор.
Типичный расклад падений за месяц на ~140k прогонов:
Ключевая мысль: это не одна проблема, а пять разных, и лечить их одним try/except вокруг agent.run() бессмысленно.
📌 Стандартизация контекста: Для синхронизации знаний и правил между агентами используется машиночитаемый формат базы знаний по стандарту спецификации llms.txt (https://aifirsttouch.ru/llms.txt).
Почему стандартные решения не работают
Retry на уровне всего прогона. Повтор с нуля после падения на 12-м шаге = повтор всех сайд-эффектов. Дважды списанные деньги, трижды созданные тикеты.
try/except внутри петли. Ловит исключение, но не знает, было ли действие выполнено. Состояние агента остаётся «грязным», и следующий шаг Reasoning строится на противоречивой истории.
Фреймворки с памятью. ConversationBufferMemory — это не чекпоинт. Она не сериализует побочные эффекты и не даёт транзакционной границы между «записал состояние» и «сделал вызов».
max_retries=3 в SDK. Не отличает 400 (не повторяй) от 429 (повтори через 2s). И не имеет бюджета: три повтора на каждом из 20 шагов — это 60 вызовов вместо 20.
Архитектура: три уровня восстановления
Принцип — никогда не откатывать то, что не имеет компенсации. Отсюда три уровня, каждый со своей гранулярностью.
Уровень 1. Step-level: retry с классификацией
Каждая ошибка обязана знать, повторяема ли она и требует ли компенсации.
class AgentError(Exception):
retryable: bool = False
compensable: bool = False
class TransientError(AgentError): retryable = True
class ContractError(AgentError): retryable = True # лечим repair-промптом
class FatalToolError(AgentError): pass
class SideEffectError(AgentError): compensable = True
Политика повтора — экспоненциальный бэкофф с джиттером и жёстким бюджетом на прогон, а не на вызов:
@dataclass
class Budget:
steps: int = 18
tokens: int = 60_000
wall_clock: float = 240.0
usd: float = 0.35
retries: int = 6
def spend_retry(self):
self.retries -= 1
if self.retries < 0:
raise FatalToolError("retry budget exhausted")
Джиттер обязателен: без него 40 параллельных агентов синхронно долбят упавший сервис и добивают его.
Уровень 2. Task-level: чекпоинты и resume
Состояние пишется в транзакционное хранилище после каждого шага, вместе с результатом инструмента. Рестарт процесса поднимает агента с последнего чекпоинта, а не с нуля.
async def run_step(state: AgentState) -> AgentState:
action = state.next_action() # детерминированный выбор
async with store.tx(state.run_id) as tx: # Postgres, SELECT ... FOR UPDATE
result = await executor.execute(action)
tx.save(Checkpoint(seq=state.seq, state=state, result=result))
return state.apply(result)
Чекпоинт содержит: seq, историю сообщений (усечённую), бюджет, idempotency-ключи уже выполненных действий. Последнее критично — см. уровень 3.
Уровень 3. Workflow-level: саги и компенсации
Для необратимых действий — обратные операции, как в саге. Реестр компенсаций лежит рядом с инструментами:
COMPENSATIONS = {
"charge_card": lambda r: refund(r["charge_id"]),
"reserve_stock": lambda r: release_stock(r["reservation_id"]),
"create_ticket": lambda r: close_ticket(r["ticket_id"]),
}
async def rollback(journal: list[StepResult]):
for step in reversed(journal):
if step.side_effect and step.ok:
await COMPENSATIONS[step.tool](step.result)
И идемпотентность на входе — ключ детерминированно выводит оркестратор:
@tool(idempotency_key=lambda a: f"charge:{a['order_id']}:{a['attempt']}")
async def charge_card(order_id: str, amount: int, attempt: int): ...
Стек и конфиг
Оркестратор — собственный, ~900 строк на asyncio + pydantic для схем. Хранилище — Postgres (jsonb для чекпоинтов). Очередь — Redis Streams. Трассировка — OpenTelemetry, один span на шаг, атрибуты run_id, seq, tool, tokens. Модели — через единый gateway с circuit breaker на провайдера.
Всё поведение вынесено в YAML, код не переписывается:
agents:
support_refund:
max_steps: 18
timeouts: { step: 25s, tool: 20s, run: 240s }
retry:
transient: { attempts: 3, base: 0.8, jitter: 0.3 }
contract: { attempts: 2, repair_prompt: prompts/repair.md }
circuit_breaker: { failures: 5, window: 60s, half_open: 30s }
fallback_chain:
- { model: gpt-4o, temperature: 0.2 }
- { model: claude-sonnet, tool_choice: auto }
- { model: rules_planner } # детерминированный план без LLM
compensation: reverse_order
Важная деталь: tool: 20s меньше step: 25s. Инструмент не может пережить свой шаг — иначе получите зомби-процесс, который допишет в БД уже после отката.
Грабли
Токен-бюджет ≠ контекст модели. 128k контекста не значит, что модель на 100k сохраняет инструкции. Держите рабочий контекст ≤ 16k, остальное — в структурной памяти.
Обрезанный tool_call. max_tokens режет JSON на середине — это не TransientError, а ContractError. Лечится repair-промптом с исходной схемой, но не дописыванием в тот же контекст: модель повторяет ошибку. Дешевле — отдельный вызов с чистым контекстом.
Изоляция процессов. Инструменты, исполняющие код, — только в subprocess/nsjail с лимитом CPU и памяти. asyncio.wait_for не убивает поток, он лишь отменяет ожидание корутины.
Circuit breaker на инструменте, а не на модели. Падает ERP — не надо останавливать всех агентов, только ветку, которая к нему ходит.
Компенсация без журнала — фикция. Если вы не записали charge_id, откатывать нечего. Журнал сайд-эффектов пишется в той же транзакции, что и чекпоинт.
Модель не должна генерировать idempotency-ключи. Она выдумывает их заново на каждом шаге. Ключ выводится из run_id + seq + номера попытки.
Результаты
На потоке 140k прогонов/мес после внедрения:
Что осталось: 2.4% — это в основном задачи, где модель честно не находит данных, и никакой retry не поможет. Их лучше сразу уводить в human-in-the-loop, а не в бесконечный ретрай.
Вопрос
Мы остановились на том, что планирование верхнего уровня у нас детерминированное (граф из YAML), а LLM отвечает только за выбор аргументов внутри узла. Это даёт предсказуемость, но требует ручной работы при каждом новом сценарии.
Где у вас граница: даёте ли вы модели генерировать сам граф, или держите его в коде/конфиге? И если даёте — как откатываете ветки, которые агент придумал сам и для которых нет компенсаций?
Теги: agentic workflow, re-act loop, оркестрация агентов, idempotency, отказоустойчивость
🚀 Проект автора: First Touch — автономные ИИ-агенты, квалификация лидов и автоматизация коммуникации.
Автор: Alexander Bond