Почему реактивный агент ломается на сложных задачах

Рассмотрим задачу: «Подготовь тестовое окружение: подними PostgreSQL в Docker, создай БД, загрузи фикстуры, проверь подключение». Четыре зависимых шага. Вот что происходит с ReAct-агентом без плана:

ReAct без плана — типичные симптомы на длинных задачах:
  • Агент начинает со второго шага, пропустив первый (Docker не поднят)
  • После ошибки на шаге 3 — перезапускает шаг 1 (забыл, что уже сделал)
  • Выполнил «проверь подключение» до «создай БД» — получил ложный результат
  • При сбое на шаге 2 не знает, продолжать или откатить шаг 1

Проблема фундаментальная: ReAct думает одним шагом вперёд. Планировщик думает всей последовательностью сразу: определяет зависимости, резервирует точки восстановления и может перестроить маршрут при сбое, не начиная с нуля.

Связь с Plan-and-Execute. В уроке «Plan-and-Execute» мы разобрали архитектурный паттерн. Здесь — его реализация в коде: как именно устроены план как структура данных, цикл исполнения и механизм перепланирования.

Что такое план: теория декомпозиции

В классической AI план — это последовательность операторов (действий), переводящая систему из начального состояния в целевое. Каждый оператор имеет предусловия (что должно быть истинно до выполнения) и эффекты (что станет истинным после).

Для агента на LLM это означает: у каждого шага есть зависимости от других шагов. Шаг «загрузить фикстуры» не может выполниться раньше «создать БД» — это предусловие. Нарушение порядка гарантирует сбой.

ЗАДАЧА: «Подготовить тестовое окружение»
│
├── Шаг 1: docker run postgres          ← нет предусловий
│         ↓ после выполнения: Docker-контейнер запущен
│
├── Шаг 2: create database test_db      ← предусловие: Шаг 1 ✓
│         ↓ после выполнения: БД существует
│
├── Шаг 3: load fixtures test_data.sql  ← предусловие: Шаг 2 ✓
│         ↓ после выполнения: таблицы заполнены
│
└── Шаг 4: pg_isready / check_conn      ← предусловие: Шаг 3 ✓
          ↓ после выполнения: готово к тестам

В реальных задачах зависимости образуют DAG (directed acyclic graph), а не просто линейную цепочку. Некоторые шаги можно выполнять параллельно (если нет зависимостей между ними), другие — только последовательно.

Для большинства агентных задач достаточно линейного плана — упорядоченного списка шагов с явными зависимостями. Параллельное выполнение (граф) — усложнение, которое оправдано только при явной необходимости экономить время.

План как структура данных

Чтобы агент мог управлять планом — отслеживать прогресс, обновлять статусы, передавать контекст в перепланировщик — план должен быть объектом кода, а не строкой текста. Разберём структуру:

from dataclasses import dataclass, field
from enum import Enum
from typing import Any
import time
import json


class StepStatus(str, Enum):
    PENDING  = "pending"   # ожидает исполнения
    RUNNING  = "running"   # выполняется прямо сейчас
    DONE     = "done"      # успешно завершён
    FAILED   = "failed"    # завершился с ошибкой
    SKIPPED  = "skipped"   # пропущен (например, недостижимый шаг)


@dataclass
class PlanStep:
    """Один шаг плана агента."""
    id: str                          # уникальный идентификатор: "step_1", "step_2a"
    title: str                       # короткое название для логов и UI
    description: str                 # что именно нужно сделать
    tool: str | None = None          # инструмент для вызова (или None если LLM-решает)
    tool_args: dict[str, Any] = field(default_factory=dict)
    depends_on: list[str] = field(default_factory=list)  # id шагов-предшественников

    # Состояние (заполняется в ходе исполнения)
    status: StepStatus = StepStatus.PENDING
    result: str | None = None        # результат успешного выполнения
    error: str | None = None         # сообщение об ошибке при сбое
    attempts: int = 0                # сколько раз пытались выполнить
    started_at: float | None = None
    finished_at: float | None = None

    @property
    def is_ready(self) -> bool:
        """Шаг готов к выполнению: все зависимости завершены."""
        return self.status == StepStatus.PENDING

    @property
    def duration(self) -> float | None:
        if self.started_at and self.finished_at:
            return self.finished_at - self.started_at
        return None


@dataclass
class Plan:
    """Полный план агента для выполнения задачи."""
    task: str                        # исходная задача пользователя
    goal: str                        # конечная цель (что считать успехом)
    steps: list[PlanStep] = field(default_factory=list)
    created_at: float = field(default_factory=time.time)
    revision: int = 0                # 0 = первоначальный план, 1+ = после перепланирования

    # ── Методы навигации по плану ──

    def current_step(self) -> PlanStep | None:
        """Следующий шаг, готовый к исполнению."""
        for step in self.steps:
            if step.status == StepStatus.PENDING:
                return step
        return None

    def failed_steps(self) -> list[PlanStep]:
        return [s for s in self.steps if s.status == StepStatus.FAILED]

    def done_steps(self) -> list[PlanStep]:
        return [s for s in self.steps if s.status == StepStatus.DONE]

    def is_complete(self) -> bool:
        """Все шаги завершены (успешно или пропущены)."""
        return all(s.status in (StepStatus.DONE, StepStatus.SKIPPED)
                   for s in self.steps)

    def is_stuck(self) -> bool:
        """Нет доступных шагов, но план не завершён — нужно перепланирование."""
        return self.current_step() is None and not self.is_complete()

    def summary(self) -> str:
        """Краткий текстовый отчёт о состоянии плана."""
        total = len(self.steps)
        done = len(self.done_steps())
        failed = len(self.failed_steps())
        return (f"Plan v{self.revision}: {done}/{total} done, "
                f"{failed} failed, revision={self.revision}")

    def to_context_string(self) -> str:
        """Форматируем план для вставки в промпт перепланировщика."""
        lines = [f"ЗАДАЧА: {self.task}", f"ЦЕЛЬ: {self.goal}", ""]
        for s in self.steps:
            icon = {"done": "✓", "failed": "✗", "running": "→",
                    "pending": "…", "skipped": "~"}[s.status]
            lines.append(f"  [{icon}] {s.id}: {s.title}")
            if s.result:
                lines.append(f"       результат: {s.result[:100]}")
            if s.error:
                lines.append(f"       ошибка: {s.error[:100]}")
        return "\n".join(lines)

Пример того, как выглядит живой план с частично выполненными шагами:

● ПЛАН v1 · Подготовить тестовое окружение 1/4 выполнено · revision 0
1
done
Запустить PostgreSQL в Docker
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=test postgres:15
Container postgres-test started (id: a3f7c...)
2
failed
Создать базу данных test_db
CREATE DATABASE test_db; через psycopg2
ConnectionRefusedError: port 5432 not ready (attempts: 2)
3
pending
Загрузить фикстуры test_data.sql
psql -U postgres test_db < fixtures/test_data.sql
4
pending
Проверить подключение
pg_isready -h localhost -p 5432 -U postgres

Генерация плана через LLM

Сила LLM-планировщика — в том, что он понимает задачу на естественном языке и генерирует структурированный план как JSON. Задача промпта — потребовать конкретную структуру и запретить «размытые» шаги.

import anthropic
import json
import re

client = anthropic.Anthropic()

PLANNER_SYSTEM = """Ты — планировщик задач для AI-агента. Разбивай задачу на конкретные шаги.

Правила:
- Каждый шаг выполняет ОДНО действие (не «сделай A и B»)
- Порядок шагов учитывает зависимости
- Шаги должны быть проверяемыми: у каждого есть чёткий критерий успеха
- Максимум 8 шагов на задачу. Если нужно больше — укрупни шаги
- Не добавляй «запасных» шагов без явной необходимости

Доступные инструменты: bash, read_file, write_file, http_get, http_post, python_exec

Верни JSON без пояснений:
{
  "goal": "Что является конечным результатом",
  "steps": [
    {
      "id": "step_1",
      "title": "Короткое название",
      "description": "Что конкретно делает этот шаг",
      "tool": "bash",
      "tool_args": {"command": "docker run ..."},
      "depends_on": []
    }
  ]
}"""


def generate_plan(task: str) -> Plan:
    """Генерирует план выполнения задачи через LLM."""
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=2048,
        system=PLANNER_SYSTEM,
        messages=[{"role": "user", "content": f"Задача: {task}"}],
    )

    text = response.content[0].text.strip()

    # Вырезаем JSON даже если модель добавила markdown-блок
    match = re.search(r'\{.*\}', text, re.DOTALL)
    if not match:
        raise ValueError(f"Planner did not return valid JSON: {text[:200]}")

    data = json.loads(match.group())

    steps = []
    for raw in data["steps"]:
        steps.append(PlanStep(
            id=raw["id"],
            title=raw["title"],
            description=raw["description"],
            tool=raw.get("tool"),
            tool_args=raw.get("tool_args", {}),
            depends_on=raw.get("depends_on", []),
        ))

    return Plan(
        task=task,
        goal=data["goal"],
        steps=steps,
    )
Ограничивай количество шагов в промпте. Без ограничения LLM генерирует планы из 15–20 шагов с микро-шагами вроде «проверить, что переменная не None». Максимум 8 шагов заставляет думать укрупнёнными действиями, что лучше для агентного исполнения.

Исполнение плана: пошаговый цикл

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

import subprocess
import time


# ── Реестр инструментов ──

def tool_bash(command: str, **_) -> str:
    result = subprocess.run(
        command, shell=True, capture_output=True, text=True, timeout=30
    )
    if result.returncode != 0:
        raise RuntimeError(result.stderr.strip() or f"exit code {result.returncode}")
    return result.stdout.strip()


def tool_python_exec(code: str, **_) -> str:
    namespace = {}
    exec(code, namespace)  # noqa: S102 — только для доверенных команд
    return str(namespace.get("result", "executed"))


TOOLS = {
    "bash":        tool_bash,
    "python_exec": tool_python_exec,
}


# ── Исполнитель одного шага ──

MAX_ATTEMPTS = 2


def execute_step(step: PlanStep, context: dict[str, str]) -> None:
    """
    Выполняет один шаг плана.
    context — словарь {step_id: result} из уже выполненных шагов.
    Изменяет step.status, step.result или step.error in-place.
    """
    step.status = StepStatus.RUNNING
    step.started_at = time.time()

    for attempt in range(1, MAX_ATTEMPTS + 1):
        step.attempts = attempt
        try:
            # Подставляем результаты предыдущих шагов в аргументы
            args = resolve_args(step.tool_args, context)

            if step.tool and step.tool in TOOLS:
                result = TOOLS[step.tool](**args)
            else:
                # Если инструмент не указан — используем LLM для исполнения
                result = llm_execute_step(step, context)

            step.status  = StepStatus.DONE
            step.result  = str(result)[:500]   # обрезаем длинные выводы
            step.error   = None
            break

        except Exception as exc:
            step.error = str(exc)
            if attempt == MAX_ATTEMPTS:
                step.status = StepStatus.FAILED
            else:
                time.sleep(2 ** attempt)   # exponential backoff

    step.finished_at = time.time()


def resolve_args(args: dict, context: dict[str, str]) -> dict:
    """
    Подставляет {{step_1.result}} шаблоны в аргументы шага.
    Позволяет передавать вывод одного шага как вход другого.
    """
    resolved = {}
    for k, v in args.items():
        if isinstance(v, str):
            for step_id, result in context.items():
                v = v.replace(f"{{{{{step_id}.result}}}}", result)
        resolved[k] = v
    return resolved


def llm_execute_step(step: PlanStep, context: dict[str, str]) -> str:
    """Просим LLM выполнить шаг без явного инструмента."""
    ctx_str = "\n".join(f"{k}: {v}" for k, v in context.items())
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        messages=[{
            "role": "user",
            "content": (
                f"Выполни следующий шаг плана:\n"
                f"Шаг: {step.title}\n"
                f"Описание: {step.description}\n"
                f"Контекст предыдущих шагов:\n{ctx_str}\n\n"
                f"Верни только результат шага."
            )
        }]
    )
    return response.content[0].text.strip()


# ── Главный цикл исполнения ──

def execute_plan(plan: Plan, on_failure=None) -> bool:
    """
    Исполняет план шаг за шагом.
    on_failure(plan, step) — колбэк при сбое (например, перепланировщик).
    Возвращает True если план завершился успешно.
    """
    context: dict[str, str] = {}   # накапливаем результаты

    while True:
        step = plan.current_step()
        if step is None:
            break

        print(f"[{step.id}] {step.title}...")
        execute_step(step, context)

        if step.status == StepStatus.DONE:
            context[step.id] = step.result
            print(f"  ✓ {step.result[:80]}")
        else:
            print(f"  ✗ {step.error}")
            if on_failure:
                # Передаём управление перепланировщику
                should_continue = on_failure(plan, step)
                if not should_continue:
                    return False
            else:
                return False

    return plan.is_complete()

Обратите внимание на механизм передачи результатов: шаблон {{step_1.result}} в аргументах шага 2 автоматически подставляется контекстом предыдущего вывода. Так шаг «создать БД» получает ID контейнера от шага «запустить Docker».

Типы сбоев: почему план ломается

Прежде чем разбирать стратегии восстановления — нужно понять, какие бывают сбои. У каждого типа — своя оптимальная реакция.

Транзиентный сбой
ConnectionRefusedError: port 5432 not ready
Сервис поднимается медленно. Стратегия: retry с задержкой. 2–3 попытки с экспоненциальным backoff обычно решают.
Ресурс недоступен
FileNotFoundError: fixtures/data.sql not found
Файл/сервис/API не существует. Стратегия: substitute или abort. Retry бессмысленен — нужен альтернативный путь.
Неверные аргументы
psql: error: invalid option --passsword
Опечатка или неправильный формат. Стратегия: replan текущего шага. LLM перегенерирует параметры с учётом ошибки.
Предусловие нарушено
OperationalError: database "test_db" does not exist
Пропущен или провалился зависимый шаг. Стратегия: replan from failed dependency. Нужно починить предшественника.
Таймаут
TimeoutExpired: command exceeded 30s
Операция слишком долгая. Стратегия: split step или async. Разбить шаг на «запустить» + «дождаться результата».
Шаг невалиден в контексте
Error: container already running
Действие неприменимо к текущему состоянию. Стратегия: skip или adapt. Часто можно продолжить с следующего шага.

Пять стратегий перепланирования

Перепланирование — это не просто «попробовать ещё раз». Выбор стратегии зависит от типа сбоя и важности провалившегося шага.

Retry
повтор
Тот же шаг снова, с теми же или исправленными параметрами. Подходит для транзиентных ошибок.
attempt 1 → wait 2s → attempt 2
Substitute
замена
Заменить проваленный шаг альтернативным подходом к той же цели. LLM генерирует альтернативу.
PostgreSQL → SQLite (временно)
Skip
пропуск
Пометить шаг как SKIPPED и продолжить. Подходит только если шаг некритичный (не имеет зависимых).
«загрузить опциональные тесты» → skip
Replan
перегенерация
Полная перегенерация оставшейся части плана с учётом текущего состояния и ошибки. Самая мощная стратегия.
generate_plan(task, context=current_state)
Abort
отмена
Остановить выполнение, откатить сделанное, сообщить пользователю. Когда продолжение невозможно или опасно.
«нет прав на сервер» → сообщить, откатить

Вот как выглядит план до и после применения стратегии Substitute на провалившемся шаге с PostgreSQL:

До перепланирования (revision 0)
  • ✓ step_1: Запустить PostgreSQL Docker
  • ✗ step_2: Создать БД test_db (PostgreSQL)
  • … step_3: Загрузить фикстуры
  • … step_4: Проверить подключение
После перепланирования (revision 1)
  • ✓ step_1: Запустить PostgreSQL Docker
  • ✗ step_2: Создать БД test_db (PostgreSQL)
  • + step_2a: Подождать готовности порта 5432 (pg_isready)
  • + step_2b: Создать БД test_db (с retry)
  • … step_3: Загрузить фикстуры
  • … step_4: Проверить подключение

Revision 1 добавляет шаг ожидания готовности PostgreSQL перед созданием БД — правильная диагностика транзиентного сбоя (порт ещё не поднялся).

Реализация перепланировщика

REPLANNER_SYSTEM = """Ты — перепланировщик задач. Тебе дан текущий план с ошибкой на одном из шагов.
Твоя задача — исправить план так, чтобы задача всё равно была выполнена.

Стратегии (выбери одну):
- RETRY: повторить тот же шаг (если транзиентная ошибка)
- SUBSTITUTE: заменить проваленный шаг альтернативным подходом
- SKIP: пропустить шаг (если он некритичный)
- REPLAN: перегенерировать оставшуюся часть плана

Верни JSON:
{
  "strategy": "SUBSTITUTE",
  "reasoning": "Объяснение выбора стратегии",
  "steps": [ ... полный обновлённый список шагов ... ]
}

Правила:
- Сохраняй уже выполненные шаги (status: "done") без изменений
- Для новых/изменённых шагов — начинай id с оригинального + суффикс (step_2 → step_2a)
- Не меняй задачу и цель, только маршрут к ней"""


def replan(plan: Plan, failed_step: PlanStep) -> Plan | None:
    """
    Перепланирует задачу после сбоя на конкретном шаге.
    Возвращает обновлённый план или None если восстановление невозможно.
    """
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=2048,
        system=REPLANNER_SYSTEM,
        messages=[{
            "role": "user",
            "content": (
                f"Текущее состояние плана:\n{plan.to_context_string()}\n\n"
                f"Провалившийся шаг: {failed_step.id} — {failed_step.title}\n"
                f"Ошибка: {failed_step.error}\n"
                f"Попыток: {failed_step.attempts}\n\n"
                f"Исправь план."
            )
        }]
    )

    text = response.content[0].text.strip()
    match = re.search(r'\{.*\}', text, re.DOTALL)
    if not match:
        return None

    data = json.loads(match.group())
    strategy = data.get("strategy", "REPLAN")

    if strategy == "ABORT":
        print(f"Replanner decided to abort: {data.get('reasoning', '')}")
        return None

    # Обновляем шаги
    new_steps = []
    for raw in data["steps"]:
        # Ищем существующий шаг (чтобы не потерять статус DONE)
        existing = next((s for s in plan.steps if s.id == raw["id"]), None)
        if existing and existing.status == StepStatus.DONE:
            new_steps.append(existing)   # сохраняем выполненный шаг как есть
        else:
            new_steps.append(PlanStep(
                id=raw["id"],
                title=raw["title"],
                description=raw["description"],
                tool=raw.get("tool"),
                tool_args=raw.get("tool_args", {}),
                depends_on=raw.get("depends_on", []),
                status=StepStatus.PENDING,
            ))

    plan.steps = new_steps
    plan.revision += 1
    print(f"  Replanned (strategy={strategy}): {data.get('reasoning', '')[:80]}")
    return plan

Полный агент с планированием и перепланированием

Собираем всё вместе: генерация плана → исполнение с retry → перепланирование при сбоях → итоговый отчёт.

MAX_REPLAN_ATTEMPTS = 3


class PlanningAgent:
    def __init__(self):
        self.client = anthropic.Anthropic()
        self.plan: Plan | None = None
        self._replan_count = 0

    def run(self, task: str) -> dict:
        """
        Полный цикл: план → исполнение → перепланирование при сбоях → отчёт.
        """
        print(f"\n=== Planning Agent: {task[:60]} ===\n")

        # Генерируем начальный план
        self.plan = generate_plan(task)
        print(f"Generated plan with {len(self.plan.steps)} steps:\n")
        for s in self.plan.steps:
            print(f"  {s.id}: {s.title}")
        print()

        # Запускаем исполнение с перепланировщиком как колбэком
        success = execute_plan(self.plan, on_failure=self._handle_failure)

        return self._build_report(success)

    def _handle_failure(self, plan: Plan, failed_step: PlanStep) -> bool:
        """
        Колбэк при сбое шага. Возвращает True если исполнение можно продолжить.
        """
        self._replan_count += 1
        if self._replan_count > MAX_REPLAN_ATTEMPTS:
            print(f"\nMax replan attempts ({MAX_REPLAN_ATTEMPTS}) exceeded. Aborting.")
            return False

        print(f"\n  [replan #{self._replan_count}] Failed: {failed_step.title}")
        updated = replan(plan, failed_step)

        if updated is None:
            print("  Replanner decided to abort.")
            return False

        # Продолжаем исполнение обновлённого плана
        return True

    def _build_report(self, success: bool) -> dict:
        """Итоговый отчёт о выполнении задачи."""
        done = self.plan.done_steps()
        failed = self.plan.failed_steps()
        total_duration = sum(
            s.duration for s in self.plan.steps
            if s.duration is not None
        )

        return {
            "success":      success,
            "task":         self.plan.task,
            "plan_revision": self.plan.revision,
            "steps_done":   len(done),
            "steps_failed": len(failed),
            "duration_sec": round(total_duration, 1),
            "results": {s.id: s.result for s in done},
            "errors":  {s.id: s.error for s in failed},
        }


# ── Использование ──

agent = PlanningAgent()
report = agent.run(
    "Подготовь тестовое окружение: подними PostgreSQL в Docker, "
    "создай БД test_db, загрузи фикстуры из fixtures/test_data.sql, "
    "проверь что сервис отвечает на порту 5432"
)

print("\n=== Report ===")
print(f"Success: {report['success']}")
print(f"Plan revisions: {report['plan_revision']}")
print(f"Steps done: {report['steps_done']}, failed: {report['steps_failed']}")
print(f"Total time: {report['duration_sec']}s")

Полный цикл планирования: архитектурная схема

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
ГЕНЕРАЦИЯ ПЛАНА ИСПОЛНЕНИЕ И ПЕРЕСМОТР Задача пользователя "Подготовить тестовое окружение" generate_plan() LLM Планировщик JSON → структурированный план создаёт ПЛАН DONE Запустить PostgreSQL Docker docker run -d postgres:15 FAIL Создать БД test_db ConnectionRefusedError: port 5432 PENDING Загрузить фикстуры psql test_db < fixtures/data.sql PENDING Проверить подключение pg_isready -h localhost revision 0 · 1 done · 1 failed · 2 pending Задача выполнена ✓ Исполнитель шага execute_step(step, context) ошибка СБОЙ ConnectionRefusedError: port 5432 not ready yet attempts: 2/2 replan() LLM Перепланировщик стратегия: SUBSTITUTE ПЕРЕСМОТРЕННЫЙ ПЛАН (revision 1) + step_2a: pg_isready (ждём готовности) + step_2b: Создать БД (с retry) выполняет шаг обновляет план

Схема показывает полный цикл: задача порождает план, исполнитель берёт шаг 2, получает ошибку, передаёт её перепланировщику, тот добавляет шаги 2а и 2б в оригинальный план (revision 1), и цикл продолжается с новыми шагами.

Связь планирования с памятью агента

Планирование не изолировано — оно опирается на другие виды памяти агента, которые мы разбирали в этом разделе:

Вид памяти Роль в планировании
Краткосрочная История текущей сессии — контекст для планировщика и перепланировщика
Долгосрочная Прошлые планы похожих задач — обучение на опыте, примеры успешных шагов
Scratchpad (CoT) «Думай пошагово» при генерации плана улучшает качество декомпозиции сложных задач
Entity Memory Факты о проекте/окружении инжектируются в промпт планировщика — он знает стек и ограничения
Самый мощный паттерн: планировщик + entity memory + CoT. Entity memory даёт планировщику контекст («стек проекта — FastAPI + PostgreSQL, нельзя использовать Docker в prod-окружении»), а CoT / Extended Thinking позволяет ему рассуждать о зависимостях перед генерацией JSON. Это три слоя памяти, работающие вместе.

Типичные ошибки

1. Генерировать план один раз и не обновлять состояние
Агент генерирует JSON в начале, затем исполняет шаги как строки из списка — без обновления статусов. При сбое не знает, на каком шаге остановился, и что уже было сделано.
План — живой объект. Каждый шаг меняет step.status и step.result. Контекст context передаётся в каждый следующий шаг.
2. Перепланировать при каждой ошибке, включая транзиентные
Порт ещё поднимается, но агент уже запускает дорогой LLM-вызов перепланировщика. Это излишне — транзиентные ошибки решаются retry с задержкой за 2–3 попытки.
Сначала MAX_ATTEMPTS попыток с exponential backoff. Перепланировщик подключается только если все попытки исчерпаны.
3. Бесконечный цикл перепланирования
Перепланировщик генерирует шаг → шаг снова падает → снова перепланирование. Без лимита это бесконечный цикл, который сжигает токены и ничего не решает.
Жёсткий лимит MAX_REPLAN_ATTEMPTS = 3. После исчерпания — abort с ясным сообщением пользователю.
4. Слишком детальный план с микро-шагами
Промпт без ограничений порождает планы из 20 шагов: «проверить переменную», «создать директорию», «открыть файл». Каждый шаг — отдельный LLM-вызов. Стоимость растёт линейно, а ценность — нет.
Ограничь в промпте: «максимум 8 шагов, каждый шаг — одно значимое действие». Микро-шаги объединяй в один шаг с bash-скриптом.
5. Не передавать результаты шагов следующим
Шаг 1 поднимает контейнер и возвращает его ID. Шаг 2 hardcode-ит имя контейнера в аргументах — и ломается, если имя отличается.
Используй шаблоны {{step_1.result}} в аргументах. Функция resolve_args() подставляет их из контекста.

Шпаргалка

Планирование агента — краткая выжимка
  • Зачем: ReAct ломается на задачах с зависимостями и сбоями; план даёт структуру и точку восстановления
  • Структура: Plan (задача, цель, шаги) + PlanStep (id, статус, результат, зависимости)
  • Генерация: LLM с structured output промптом → JSON → Plan объект
  • Исполнение: цикл current_step → execute → update_status → передать результат
  • Передача данных: шаблоны {{step_1.result}} в аргументах следующих шагов
  • Сбои сначала: 2–3 retry с backoff, только потом — перепланировщик
  • Стратегии: Retry / Substitute / Skip / Replan / Abort — выбор по типу сбоя
  • Лимит перепланирований: MAX_REPLAN_ATTEMPTS = 3, после — abort
  • С памятью: entity memory → планировщик знает контекст; CoT → лучшая декомпозиция
# Минимальный рабочий паттерн планировщика

agent = PlanningAgent()
report = agent.run("Задача на естественном языке")

# Внутри происходит:
# 1. plan = generate_plan(task)         — LLM генерирует JSON-план
# 2. execute_plan(plan, on_failure=...) — цикл исполнения
#    a. step = plan.current_step()
#    b. execute_step(step, context)     — retry встроен
#    c. if failed → replan(plan, step)  — LLM перепланирует
# 3. build_report()                     — итог

Практика

Задание 1. Добавь в PlanStep поле rollback_command: str | None — команду для отмены шага. Реализуй метод Plan.rollback(up_to_step_id), который выполняет rollback-команды всех шагов, выполненных после указанного шага, в обратном порядке. Используй это при стратегии Abort.
Задание 2. Реализуй сохранение плана в JSON-файл после каждого обновления статуса (plan.save("plan_state.json")). Добавь метод Plan.load("plan_state.json") и PlanningAgent.resume(path) — возобновление прерванного плана с того шага, где остановились. Это особенно полезно для долгих задач.
Задание 3 (продвинутый). Реализуй параллельное выполнение шагов без зависимостей. Добавь в Plan метод ready_steps() -> list[PlanStep] — шаги, все зависимости которых уже DONE. Используй asyncio.gather() для параллельного исполнения независимых шагов. Измерь ускорение на задаче из 6 шагов, где 3 из них не зависят друг от друга.