Проблема: когда реактивности недостаточно
Попросите ReAct-агента написать аналитический отчёт: «Сравни продажи нашей компании по кварталам за 2024 и 2023 годы, выяви тренды, найди лучший и худший продукт, дай рекомендации». Что происходит:
- Агент не знает, сколько шагов потребуется — и вы тоже не знаете
- Каждый запрос данных выполняется последовательно, хотя Q1–Q4 за оба года можно было запросить параллельно
- Если агент зашёл в тупик на шаге 7 из 10, непонятно, сколько работы уже сделано
- Нельзя показать пользователю прогресс-бар или дать оценку времени
- Если план изначально неоптимален — поздно это заметить
Корень проблемы: ReAct смешивает планирование и исполнение. Каждый шаг — одновременно и «решение, что делать дальше», и «само действие». Для исследовательских задач это достоинство. Для задач с известной структурой — ограничение.
Plan-and-Execute разделяет эти обязанности. Планировщик думает о задаче целиком и создаёт список шагов. Executor просто выполняет каждый шаг, не думая о стратегии. Результат: предсказуемость, параллелизм, возможность ревизии плана до старта.
Plan-and-Execute (также называемый Plan-then-Act) появился в нескольких работах 2023 года как ответ на ограничения чистого ReAct. В частности, «Plan-and-Solve Prompting» (Wang et al., 2023) показал, что явное планирование перед исполнением снижает ошибки при многошаговых математических и логических задачах.
Архитектура: две роли, две фазы
Plan-and-Execute — это трёхфазная система: сначала создаётся план, затем каждый шаг плана выполняется независимым executor-ом, наконец результаты синтезируются в финальный ответ.
Две роли — два разных промпта
Ключевое архитектурное решение: планировщик и executor получают разные системные промпты и могут использовать разные модели.
- Планировщик — думает стратегически. Не нужен доступ к инструментам. Нужна хорошая способность к декомпозиции. Промпт: «Разбей задачу на конкретные, независимые шаги». Может использовать более лёгкую модель.
- Executor — действует тактически. Видит один шаг + накопленный контекст. Нужен доступ к инструментам. Реализует mini-ReAct для выполнения шага. Промпт: «Выполни конкретный шаг, используй инструменты».
- Синтезатор — (опционально) отдельный LLM-вызов, который объединяет все результаты в финальный ответ. Получает: исходную задачу + результаты всех шагов.
Разделение ролей означает, что каждый LLM-вызов решает одну узкую задачу — и решает её лучше, чем если бы он делал всё сразу.
Планировщик — самый важный шаг, здесь стоит использовать самую мощную модель. Executor-ы для простых шагов (поиск, расчёт) можно запускать на более быстрой и дешёвой модели. Это снижает стоимость без потери качества.
Фаза планирования: структурированный вывод
Планировщик должен вернуть не просто текст, а машиночитаемый список шагов. Иначе придётся парсить свободный текст — хрупко и ненадёжно. Решение: использовать tool_choice: "tool", чтобы Claude заполнил JSON Schema плана.
Планировщику нужно знать:
- Что нужно сделать (задача)
- Какие инструменты доступны (чтобы создавать реалистичные шаги)
- Ограничения (максимум шагов, что можно параллелить)
JSON Schema плана с зависимостями
Добавим в схему поле depends_on — список шагов, которые должны выполниться раньше. Это позволит автоматически определять, какие шаги можно параллелить.
"""plan_and_execute.py — Plan-and-Execute агент"""
import anthropic
from dataclasses import dataclass, field
client = anthropic.Anthropic()
# ── Схема плана ──────────────────────────────────────────────────────────────
# Инструмент-планировщик: модель заполняет структуру, мы читаем block.input
PLAN_TOOL = {
"name": "create_plan",
"description": "Создаёт структурированный план выполнения задачи шагами",
"input_schema": {
"type": "object",
"properties": {
"goal": {
"type": "string",
"description": "Цель задачи — одно предложение"
},
"steps": {
"type": "array",
"description": "Шаги для достижения цели, в порядке исполнения",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "Порядковый номер шага (начиная с 1)"
},
"description": {
"type": "string",
"description": "Что конкретно нужно сделать в этом шаге"
},
"tool_hint": {
"type": "string",
"description": "Какой инструмент, вероятно, потребуется (необязательно)"
},
"depends_on": {
"type": "array",
"items": {"type": "integer"},
"description": "Номера шагов, результаты которых нужны для этого шага"
}
},
"required": ["id", "description", "depends_on"]
}
}
},
"required": ["goal", "steps"]
}
}
@dataclass
class Step:
id: int
description: str
depends_on: list[int]
tool_hint: str = ""
result: str = ""
@dataclass
class Plan:
goal: str
steps: list[Step]
# ── Планировщик ──────────────────────────────────────────────────────────────
PLANNER_SYSTEM = """Ты — планировщик задач для AI-агента.
Разбивай задачу на конкретные, выполнимые шаги. Каждый шаг:
- Решает одну конкретную подзадачу
- Описан как действие: «Получить...», «Рассчитать...», «Найти...»
- Имеет явные зависимости (depends_on) от других шагов
Доступные инструменты агента: search (поиск), calculate (вычисления), get_data (данные).
Указывай depends_on: [] если шаг не зависит от других — это позволит запустить его параллельно."""
def create_plan(task: str) -> Plan:
"""Планировщик: один LLM-вызов → структурированный план."""
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=2048,
system=PLANNER_SYSTEM,
tools=[PLAN_TOOL],
tool_choice={"type": "tool", "name": "create_plan"},
messages=[{"role": "user", "content": task}],
)
for block in response.content:
if block.type == "tool_use" and block.name == "create_plan":
raw = block.input
steps = [
Step(
id=s["id"],
description=s["description"],
depends_on=s.get("depends_on", []),
tool_hint=s.get("tool_hint", ""),
)
for s in raw["steps"]
]
return Plan(goal=raw["goal"], steps=steps)
raise RuntimeError("Планировщик не вернул план")
После вызова create_plan() у вас есть полный план ещё до начала исполнения. Его можно показать пользователю, залогировать или проверить вручную перед запуском.
Вот как выглядит сгенерированный план для задачи «Сравни продажи 2024 и 2023»:
Обратите внимание: шаги ① и ② не зависят друг от друга (depends_on: []). Это значит их можно выполнить параллельно — и сократить время вдвое.
Фаза исполнения: executor для каждого шага
Executor — это mini-ReAct агент, который видит только один шаг из плана и накопленный контекст. Он не думает о стратегии всей задачи — только о своём шаге.
Executor как mini-ReAct
Каждый шаг может потребовать нескольких вызовов инструментов. Поэтому executor — не одиночный LLM-вызов, а маленький цикл:
# ── Инструменты (заглушки) ───────────────────────────────────────────────────
TOOLS = [
{
"name": "search",
"description": "Поиск информации в интернете",
"input_schema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]
}
},
{
"name": "calculate",
"description": "Вычисляет математическое выражение Python",
"input_schema": {
"type": "object",
"properties": {"expression": {"type": "string"}},
"required": ["expression"]
}
}
]
def execute_tool(name: str, args: dict) -> str:
if name == "calculate":
try:
return str(eval(args["expression"])) # noqa: S307
except Exception as e:
return f"Ошибка: {e}"
if name == "search":
return f"[Заглушка] Результаты поиска: {args['query']}"
return f"Неизвестный инструмент: {name}"
# ── Executor ──────────────────────────────────────────────────────────────────
EXECUTOR_SYSTEM = """Ты — исполнитель конкретного шага в многошаговой задаче.
Тебе даётся один шаг и контекст выполненных предыдущих шагов.
Выполни ТОЛЬКО этот шаг, используя инструменты при необходимости.
Когда шаг выполнен — дай чёткий, конкретный ответ."""
def execute_step(step: Step, context: str, max_iterations: int = 5) -> str:
"""
Executor: выполняет один шаг плана.
Это mini-ReAct: LLM ↔ инструменты до получения результата.
"""
task_prompt = f"Выполни шаг: {step.description}"
if context:
task_prompt += f"\n\nКонтекст предыдущих шагов:\n{context}"
messages = [{"role": "user", "content": task_prompt}]
for _ in range(max_iterations):
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=2048,
system=EXECUTOR_SYSTEM,
tools=TOOLS,
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "end_turn":
# Шаг выполнен — вернуть текст ответа
for block in response.content:
if hasattr(block, "text") and block.text:
return block.text.strip()
return "(шаг выполнен)"
# Выполнить инструменты
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
messages.append({"role": "user", "content": tool_results})
return "(достигнут лимит итераций для шага)"
Накопление контекста между шагами
Каждый следующий шаг видит результаты всех предыдущих. Это критично: шаг «рассчитать рост» не может работать без данных из шагов «получить продажи 2024» и «получить продажи 2023».
Вот как выглядит трасса исполнения для нашего примера:
⚙️ search("продажи компании Q1 Q2 Q3 Q4 2024")
→ Q1: 1.2M, Q2: 1.5M, Q3: 1.8M, Q4: 2.1M. Итого: 6.6M
→ Q1: 1.0M, Q2: 1.2M, Q3: 1.4M, Q4: 1.6M. Итого: 5.2M
⚙️ calculate("(1.2-1.0)/1.0*100") → 20.0%
⚙️ calculate("(1.5-1.2)/1.2*100") → 25.0%
⚙️ calculate("(6.6-5.2)/5.2*100") → 26.9%
→ Рост Q1: +20%, Q2: +25%, Q3: +28.6%, Q4: +31.3%. Итого: +26.9%
Фаза синтеза: объединение результатов
После выполнения всех шагов — финальный LLM-вызов без инструментов. Синтезатор получает исходную задачу и результаты всех шагов, формулирует структурированный ответ.
def synthesize(task: str, plan: Plan) -> str:
"""Синтез: собирает результаты всех шагов в финальный ответ."""
steps_summary = "\n\n".join(
f"Шаг {s.id}: {s.description}\nРезультат: {s.result}"
for s in plan.steps
)
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=4096,
system="Ты — аналитик. Синтезируй финальный ответ на основе результатов всех шагов.",
messages=[{
"role": "user",
"content": (
f"Задача: {task}\n\n"
f"Цель плана: {plan.goal}\n\n"
f"Результаты шагов:\n{steps_summary}\n\n"
f"Сформулируй полный финальный ответ."
)
}]
)
for block in response.content:
if hasattr(block, "text") and block.text:
return block.text.strip()
return "(нет ответа)"
Синтез — отдельный LLM-вызов без инструментов. Это важно: к этому моменту все данные уже собраны, синтезатору нужно только грамотно их структурировать.
Полная реализация: оркестратор
Собираем три фазы в единый оркестратор:
def plan_and_execute(task: str) -> str:
"""
Plan-and-Execute оркестратор.
Фаза 1: создать план → Фаза 2: выполнить шаги → Фаза 3: синтезировать ответ.
"""
# ── Фаза 1: Планирование ─────────────────────────────────────────────────
print("🗺️ Планирование...")
plan = create_plan(task)
print(f" Цель: {plan.goal}")
print(f" Шагов: {len(plan.steps)}")
for s in plan.steps:
dep_str = f" (зависит от: {s.depends_on})" if s.depends_on else " (независимый)"
print(f" {s.id}. {s.description}{dep_str}")
# ── Фаза 2: Исполнение ───────────────────────────────────────────────────
print("\n⚙️ Исполнение шагов...")
context_parts: list[str] = []
for step in plan.steps:
print(f"\n Шаг {step.id}: {step.description}")
# Передаём контекст: результаты всех предыдущих шагов
context = "\n\n".join(context_parts) if context_parts else ""
step.result = execute_step(step, context)
context_parts.append(f"Шаг {step.id} [{step.description}]:\n{step.result}")
print(f" ✓ {step.result[:120]}{'...' if len(step.result) > 120 else ''}")
# ── Фаза 3: Синтез ───────────────────────────────────────────────────────
print("\n📝 Синтез финального ответа...")
answer = synthesize(task, plan)
return answer
if __name__ == "__main__":
task = (
"Сравни продажи компании по кварталам за 2024 и 2023 годы. "
"Вычисли рост по каждому кварталу, выяви тренд."
)
print(f"Задача: {task}\n")
result = plan_and_execute(task)
print(f"\n{'═' * 60}")
print(f"✅ Финальный ответ:\n{result}")
На уровне оркестратора: 1 вызов планировщика + N вызовов executor (один на шаг) + 1 вызов синтезатора. Внутри каждого executor-а может быть несколько вызовов (mini-ReAct). Контролируйте глубину через max_iterations в execute_step().
Репланирование: адаптация плана в процессе
Что если шаг вернул неожиданный результат — например, данных за 2023 год нет в системе, и агент нашёл только агрегированные данные за год? Первоначальный план (сравнение по кварталам) больше не работает. Нужно перепланировать.
Репланирование — опциональный шаг: после выполнения каждого N-го шага (или при обнаружении критического отклонения) запускаем планировщик снова с актуальным контекстом.
REPLAN_TOOL = {
"name": "update_plan",
"description": "Обновляет оставшиеся шаги плана с учётом полученных данных",
"input_schema": {
"type": "object",
"properties": {
"needs_replanning": {
"type": "boolean",
"description": "True если план нужно изменить"
},
"reason": {
"type": "string",
"description": "Почему нужно перепланировать"
},
"updated_steps": {
"type": "array",
"description": "Обновлённые оставшиеся шаги (если needs_replanning=True)",
"items": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"description": {"type": "string"},
"depends_on": {"type": "array", "items": {"type": "integer"}}
},
"required": ["id", "description", "depends_on"]
}
}
},
"required": ["needs_replanning"]
}
}
def maybe_replan(
original_task: str,
plan: Plan,
completed_steps: list[Step],
remaining_steps: list[Step],
) -> list[Step]:
"""
Проверяет, нужно ли обновить план.
Возвращает либо оригинальные оставшиеся шаги, либо обновлённые.
"""
context = "\n".join(
f"Шаг {s.id}: {s.description} → {s.result[:300]}"
for s in completed_steps
)
remaining = "\n".join(
f"{s.id}. {s.description}" for s in remaining_steps
)
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
system="Оцени, нужно ли изменить оставшиеся шаги плана с учётом новых данных.",
tools=[REPLAN_TOOL],
tool_choice={"type": "auto"}, # модель сама решает, вызывать ли инструмент
messages=[{
"role": "user",
"content": (
f"Задача: {original_task}\n\n"
f"Выполненные шаги:\n{context}\n\n"
f"Оставшиеся шаги:\n{remaining}\n\n"
f"Нужно ли изменить план? Вызови update_plan если да."
)
}]
)
for block in response.content:
if block.type == "tool_use" and block.name == "update_plan":
data = block.input
if data.get("needs_replanning") and data.get("updated_steps"):
print(f" ↻ Репланирование: {data.get('reason', '')}")
return [
Step(
id=s["id"],
description=s["description"],
depends_on=s.get("depends_on", []),
)
for s in data["updated_steps"]
]
return remaining_steps # план не изменился
Репланирование — дорогой LLM-вызов. Делайте его только при значимых отклонениях: после первого шага, после шага, который нашёл данных значительно меньше ожидаемого, или при явной ошибке выполнения. Триггер «каждые N шагов» — хорошее правило.
Параллельное исполнение независимых шагов
Главное преимущество явного плана с depends_on — возможность параллельного исполнения. Шаги ① и ② из нашего примера не зависят друг от друга и могут выполняться одновременно.
- Запускаются параллельно через
asyncio.gather() - Время выполнения = max(шаги) вместо sum(шаги)
- Типичный пример: сбор данных из нескольких источников
- Ждут результатов указанных шагов
- Получают в контексте только нужные результаты
- Типичный пример: расчёт, анализ, синтез
import asyncio
async def execute_step_async(step: Step, context: str) -> str:
"""Async-обёртка executor-а для параллельного запуска."""
return await asyncio.to_thread(execute_step, step, context)
async def plan_and_execute_parallel(task: str) -> str:
"""
Plan-and-Execute с параллельным исполнением независимых шагов.
Использует depends_on для определения порядка.
"""
plan = create_plan(task)
completed: dict[int, str] = {} # id шага → результат
# Обрабатываем шаги волнами: каждая волна — шаги, чьи зависимости выполнены
while len(completed) < len(plan.steps):
# Найти все шаги, которые можно запустить сейчас
ready = [
s for s in plan.steps
if s.id not in completed
and all(dep in completed for dep in s.depends_on)
]
if not ready:
# Зависимый граф нарушен — запустить оставшиеся принудительно
ready = [s for s in plan.steps if s.id not in completed]
print(f" Запуск параллельно: {[s.id for s in ready]}")
# Сформировать контекст для каждого шага из его зависимостей
async def run_with_context(step: Step) -> tuple[int, str]:
ctx = "\n\n".join(
f"Шаг {dep_id}: {completed[dep_id]}"
for dep_id in step.depends_on
if dep_id in completed
)
result = await execute_step_async(step, ctx)
return step.id, result
# Выполнить все готовые шаги параллельно
results = await asyncio.gather(*[run_with_context(s) for s in ready])
for step_id, result in results:
completed[step_id] = result
# Сохранить результат в шаг для синтеза
for s in plan.steps:
if s.id == step_id:
s.result = result
return synthesize(task, plan)
# Запуск: asyncio.run(plan_and_execute_parallel(task))
Алгоритм «волн» автоматически определяет, какие шаги можно запускать параллельно. Сначала выполняются все шаги без зависимостей, затем те, чьи зависимости выполнены, и так далее — до завершения всего плана.
В задаче из примера: 4 шага, шаги ①② занимают по 2–3 секунды каждый. Последовательно: 4–6 с. Параллельно: 2–3 с. При более сложных планах с 6–8 независимыми шагами выигрыш ещё больше. Это особенно важно для агентов с реальными поисковыми инструментами.
Plan-and-Execute vs ReAct: когда что применять
| Характеристика | Plan-and-Execute | ReAct |
|---|---|---|
| Стратегия | Сначала план целиком, потом исполнение | Реактивно: следующий шаг из текущего результата |
| Параллелизм | Легко — независимые шаги явно помечены | Сложно — нет знания о будущих шагах |
| Прогресс | Видим — «шаг 3 из 5 выполнен» | Непредсказуем — неизвестно, сколько шагов осталось |
| Гибкость | Средняя — план фиксирован, нужен реплан | Высокая — адаптируется к каждому результату |
| Дебаг | Проще — план можно проверить до исполнения | Сложнее — видно только в ходе работы |
| Кол-во LLM-вызовов | Больше — план + N executor + синтез | Меньше — один цикл без лишних вызовов |
| Задачи | Структурированные с понятными этапами | Исследовательские, непредсказуемые |
- Задача явно делится на подзадачи заранее
- Нужно показывать прогресс пользователю
- Есть независимые этапы для параллелизма
- Важно проверить план перед запуском
- Нужны разные модели/конфигурации для разных шагов
- Структура задачи неизвестна заранее
- Каждый следующий шаг зависит от предыдущего результата
- Задача исследовательская, открытая
- Нужно минимизировать число LLM-вызовов
- Задача решается за 2–4 шага (накладные расходы не оправданы)
Типичные ошибки
depends_on — шаг «рассчитать рост» получает пустой контекст, потому что данные за 2024 и 2023 ещё не собраны. Результат — галлюцинации или ошибки расчётов.depends_on, а не весь накопленный контекст.plan.goal) в промпт синтезатора.Шпаргалка
Три фазы Plan-and-Execute:
- Планировщик — один LLM-вызов с
tool_choice: "tool", возвращает JSON список шагов сdepends_on - Executor — mini-ReAct для каждого шага; видит шаг + контекст зависимостей
- Синтез — финальный LLM-вызов без инструментов; получает задачу + все результаты
Параллелизм:
- Шаги с
depends_on: []— независимые, запускать параллельно черезasyncio.gather() - Алгоритм «волн»: находить все готовые шаги → выполнить параллельно → повторить
- Executor-у передавать только результаты шагов из
depends_on, не весь контекст
Системные промпты:
- Планировщик: знает инструменты, мыслит стратегически, создаёт конкретные шаги-действия
- Executor: знает только свой шаг и контекст, действует тактически
- Синтезатор: получает цель + все результаты, формулирует финальный ответ
Plan-and-Execute лучше ReAct, когда: структура задачи известна заранее, есть независимые этапы для параллелизма, нужен прогресс-бар или предсказуемое время работы.
Практическое задание
Задача 1. Реализуйте create_plan() и запустите его на трёх разных задачах: (а) «напиши письмо-извинение клиенту», (б) «найди топ-5 библиотек для работы с PDF в Python», (в) «составь план обучения Django за 2 недели». Сравните планы: сколько шагов, есть ли независимые, разумны ли они? Настройте системный промпт планировщика, пока планы не станут качественными.
Задача 2. Добавьте в оркестратор прогресс-репортинг: перед запуском выведите план пользователю с предложением «Продолжить? [y/N]». После каждого выполненного шага выводите: «✓ Шаг N/M: [описание] — завершён». Это превратит агента в интерактивный инструмент с понятным статусом.
Задача 3 (продвинутая). Реализуйте механизм репланирования: после каждого 2-го выполненного шага вызывайте maybe_replan(). Создайте тест с задачей, где одна из «зависимостей данных» намеренно недоступна (инструмент возвращает «данные не найдены»). Проверьте, что maybe_replan() корректно предлагает адаптированный план: либо пропустить шаг, либо заменить источник данных.