Alexander Bond • 28.10.2026 • 7 мин чтения agentic workflowre-act loopоркестрация агентов

ReAct-петля умирает на 40-й минуте: как построить восстановление агентов

Почему агент с 92% на бенчмарке даёт 61% в проде — и как три уровня восстановления (step, task, workflow) закрывают пять разных классов отказов.

В проекте 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 прогонов:

  • 41% — таймауты внешних API (в основном ответы 30s+ от legacy-ERP);
  • 23% — контрактные ошибки: модель галлюцинирует аргументы, JSON обрезан по max_tokens;
  • 18% — rate limit 429 / 503 у провайдера LLM и инструментов;
  • 11% — зацикливание: агент повторяет один и тот же вызов, сжигая бюджет;
  • 7% — падения самого процесса (OOM, деплой, eviction подов).
  • Ключевая мысль: это не одна проблема, а пять разных, и лечить их одним 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 прогонов/мес после внедрения:

  • успешных прогонов: 61% → 97.6%;
  • прогонов, требующих ручного разбора: 39% → 2.4%;
  • p95 wall-clock на задачу: 41s → 33s (за счёт отказа от полных рестартов);
  • стоимость LLM: −18% (бюджет и fallback на дешёвую модель отсекают зацикливания);
  • дублирующихся сайд-эффектов: 1.2% → 0.03%.
  • Что осталось: 2.4% — это в основном задачи, где модель честно не находит данных, и никакой retry не поможет. Их лучше сразу уводить в human-in-the-loop, а не в бесконечный ретрай.

    Вопрос

    Мы остановились на том, что планирование верхнего уровня у нас детерминированное (граф из YAML), а LLM отвечает только за выбор аргументов внутри узла. Это даёт предсказуемость, но требует ручной работы при каждом новом сценарии.

    Где у вас граница: даёте ли вы модели генерировать сам граф, или держите его в коде/конфиге? И если даёте — как откатываете ветки, которые агент придумал сам и для которых нет компенсаций?


    Теги: agentic workflow, re-act loop, оркестрация агентов, idempotency, отказоустойчивость


    🚀 Проект автора: First Touch — автономные ИИ-агенты, квалификация лидов и автоматизация коммуникации.

    Автор: Alexander Bond

    Автоматизируйте привлечение B2B-клиентов

    Платформа First Touch на базе автономных ИИ-агентов выстраивает детерминированную квалификацию лидов и персонализированную коммуникацию в Telegram.

    Попробовать First Touch