Почему реактивный агент ломается на сложных задачах
Рассмотрим задачу: «Подготовь тестовое окружение: подними PostgreSQL в Docker, создай БД, загрузи фикстуры, проверь подключение». Четыре зависимых шага. Вот что происходит с ReAct-агентом без плана:
- Агент начинает со второго шага, пропустив первый (Docker не поднят)
- После ошибки на шаге 3 — перезапускает шаг 1 (забыл, что уже сделал)
- Выполнил «проверь подключение» до «создай БД» — получил ложный результат
- При сбое на шаге 2 не знает, продолжать или откатить шаг 1
Проблема фундаментальная: ReAct думает одним шагом вперёд. Планировщик думает всей последовательностью сразу: определяет зависимости, резервирует точки восстановления и может перестроить маршрут при сбое, не начиная с нуля.
Что такое план: теория декомпозиции
В классической 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)
Пример того, как выглядит живой план с частично выполненными шагами:
Генерация плана через 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,
)
Исполнение плана: пошаговый цикл
Исполнитель берёт план и выполняет шаги один за другим, обновляя статусы и передавая результаты следующим шагам. Ключевой момент: результат каждого шага может стать аргументом следующего.
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».
Типы сбоев: почему план ломается
Прежде чем разбирать стратегии восстановления — нужно понять, какие бывают сбои. У каждого типа — своя оптимальная реакция.
Пять стратегий перепланирования
Перепланирование — это не просто «попробовать ещё раз». Выбор стратегии зависит от типа сбоя и важности провалившегося шага.
Вот как выглядит план до и после применения стратегии Substitute на провалившемся шаге с PostgreSQL:
- ✓ step_1: Запустить PostgreSQL Docker
- ✗ step_2: Создать БД test_db (PostgreSQL)
- … step_3: Загрузить фикстуры
- … step_4: Проверить подключение
- ✓ 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")
Полный цикл планирования: архитектурная схема
Схема показывает полный цикл: задача порождает план, исполнитель берёт шаг 2, получает ошибку, передаёт её перепланировщику, тот добавляет шаги 2а и 2б в оригинальный план (revision 1), и цикл продолжается с новыми шагами.
Связь планирования с памятью агента
Планирование не изолировано — оно опирается на другие виды памяти агента, которые мы разбирали в этом разделе:
| Вид памяти | Роль в планировании |
|---|---|
| Краткосрочная | История текущей сессии — контекст для планировщика и перепланировщика |
| Долгосрочная | Прошлые планы похожих задач — обучение на опыте, примеры успешных шагов |
| Scratchpad (CoT) | «Думай пошагово» при генерации плана улучшает качество декомпозиции сложных задач |
| Entity Memory | Факты о проекте/окружении инжектируются в промпт планировщика — он знает стек и ограничения |
Типичные ошибки
step.status и step.result.
Контекст context передаётся в каждый следующий шаг.
MAX_ATTEMPTS попыток с exponential backoff.
Перепланировщик подключается только если все попытки исчерпаны.
MAX_REPLAN_ATTEMPTS = 3.
После исчерпания — abort с ясным сообщением пользователю.
{{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() — итог
Практика
PlanStep поле rollback_command: str | None —
команду для отмены шага. Реализуй метод Plan.rollback(up_to_step_id),
который выполняет rollback-команды всех шагов, выполненных после указанного шага,
в обратном порядке. Используй это при стратегии Abort.
plan.save("plan_state.json")).
Добавь метод Plan.load("plan_state.json") и
PlanningAgent.resume(path) — возобновление прерванного плана
с того шага, где остановились. Это особенно полезно для долгих задач.
Plan метод ready_steps() -> list[PlanStep]
— шаги, все зависимости которых уже DONE.
Используй asyncio.gather() для параллельного исполнения
независимых шагов. Измерь ускорение на задаче из 6 шагов,
где 3 из них не зависят друг от друга.