10. Planning и Workflow-паттерны¶
Зачем это нужно?¶
Простые ReAct циклы хорошо работают для прямолинейных задач. Но как только задача становится многошаговой, обычно нужно планирование: разбить работу на шаги, учесть зависимости, пережить сбои и не потерять прогресс.
В этой главе разберём паттерны планирования, которые помогают агентам справляться со сложными и долго выполняющимися задачами.
Реальный кейс¶
Ситуация: Пользователь просит: "Разверни новый микросервис: создай VM, установи зависимости, настрой сеть, разверни приложение, настрой мониторинг."
Проблема: Простой ReAct цикл может:
- Прыгать между шагами случайно
- Пропускать зависимости (пытаться развернуть до создания VM)
- Не отслеживать, какие шаги завершены
- Падать и начинать с нуля
Решение: Паттерн планирования: агент сначала строит план (шаги + зависимости), потом выполняет его по порядку, отслеживая состояние и корректно обрабатывая сбои.
Теория простыми словами¶
Что такое Planning?¶
Planning — это процесс разбиения сложной задачи на меньшие, управляемые шаги с чёткими зависимостями и порядком выполнения.
Ключевые компоненты:
- Декомпозиция задачи — Разбить большую задачу на шаги
- Граф зависимостей — Понять, какие шаги зависят от других
- Порядок выполнения — Определить последовательность (или параллельное выполнение)
- Отслеживание состояния — Знать, что сделано, что в процессе, что упало
- Обработка сбоев — Повтор, пропуск или прерывание при ошибках
Паттерны планирования¶
Паттерн 1: Plan→Execute
- Агент создаёт полный план заранее
- Выполняет шаги последовательно
- Просто, но негибко
Паттерн 2: Plan-and-Revise
- Агент создаёт начальный план
- Пересматривает план по мере обучения (например, шаг упал, обнаружена новая информация)
- Более адаптивно, но сложнее
Паттерн 3: DAG/Workflow
- Шаги образуют направленный ациклический граф
- Некоторые шаги могут выполняться параллельно
- Обрабатывает сложные зависимости
- Это опция для batch-пайплайнов (CI, ETL, массовая обработка), а не дефолт для интерактивного агента
Как это работает (пошагово)¶
Шаг 1: Декомпозиция задачи¶
Агент получает задачу высокого уровня и разбивает её на шаги:
type Plan struct {
Steps []Step
}
type Step struct {
ID string
Description string
Dependencies []string // ID шагов, которые должны завершиться первыми
Status StepStatus
Result any
Error error
}
type StepStatus string
const (
StepStatusPending StepStatus = "pending"
StepStatusRunning StepStatus = "running"
StepStatusCompleted StepStatus = "completed"
StepStatusFailed StepStatus = "failed"
StepStatusSkipped StepStatus = "skipped"
)
Пример: "Развернуть микросервис" разбивается на:
- Создать VM (нет зависимостей)
- Установить зависимости (зависит от: Создать VM)
- Настроить сеть (зависит от: Создать VM)
- Развернуть приложение (зависит от: Установить зависимости, Настроить сеть)
- Настроить мониторинг (зависит от: Развернуть приложение)
Шаг 2: Создать план¶
Агент использует LLM для декомпозиции задачи — одним запросом к структурированному выводу. JSON Schema генерируется из Go-структуры Plan, а ломаный JSON от модели чинится repair-проходом:
func createPlan(ctx context.Context, llm *llm.Client, task string) (*Plan, error) {
return llm.CompleteStructured(ctx, llm.StructuredRequest{
System: "Ты агент планирования. Разбивай задачи на шаги с зависимостями.",
User: "Задача: " + task,
// Схема ответа генерируется из Go-структуры Plan (теги json):
// steps[{id, description, dependencies}]
Into: new(Plan),
Temperature: 0.2, // качество декомпозиции; «0 ради детерминизма» — миф
MaxRepair: 2, // до двух repair-попыток ломаного JSON
})
}
Что здесь важно:
- Один запрос → типизированный объект. Никакого ручного
json.Unmarshalсырого текста ответа: без ремонта ломаного JSON (незакрытые кавычки, markdown-обёртка ```json) парсинг падает на ровном месте. - Schema из Go-структуры. Изменили
Plan— схема обновилась сама; отдельного JSON-контракта, который можно рассинхронизировать, нет. - Temperature 0.2. Низкая температура — про качество повторяемой декомпозиции, а не про «детерминизм»: LLM недетерминирована в принципе.
Шаг 3: Выполнить план¶
Выполняем шаги с учётом зависимостей:
func executePlan(ctx context.Context, plan *Plan, executor StepExecutor) error {
for {
// Находим шаги, готовые к выполнению (все зависимости завершены)
readySteps := findReadySteps(plan)
if len(readySteps) == 0 {
// Проверяем, все ли завершены или застряли
if allStepsCompleted(plan) {
return nil
}
if allRemainingStepsBlocked(plan) {
return fmt.Errorf("план заблокирован: некоторые шаги упали")
}
// Ждём асинхронные шаги или повторяем упавшие шаги
continue
}
// Выполняем готовые шаги (могут быть параллельными)
for _, step := range readySteps {
step.Status = StepStatusRunning
result, err := executor.Execute(ctx, step)
if err != nil {
step.Status = StepStatusFailed
step.Error = err
// Решаем: повторить, пропустить или прервать
if shouldRetry(step) {
step.Status = StepStatusPending
continue
}
} else {
step.Status = StepStatusCompleted
step.Result = result
}
}
}
}
func findReadySteps(plan *Plan) []*Step {
ready := make([]*Step, 0, len(plan.Steps))
for i := range plan.Steps {
step := &plan.Steps[i]
if step.Status != StepStatusPending {
continue
}
// Проверяем, все ли зависимости завершены
allDepsDone := true
for _, depID := range step.Dependencies {
dep := findStep(plan, depID)
if dep == nil || dep.Status != StepStatusCompleted {
allDepsDone = false
break
}
}
if allDepsDone {
ready = append(ready, step)
}
}
return ready
}
Шаг 4: Обработка сбоев¶
Транзиентные ошибки (сеть, лимиты API) — не забота планировщика: их ретраит сам LLM-клиент, экспоненциальным backoff с jitter (см. Главу 04). На уровне плана остаётся политика для шага, который упал после всех клиентских retry:
type StepExecutor interface {
Execute(ctx context.Context, step *Step) (any, error)
}
// Политика плана: терминальные ошибки (дедлайн, лимит итераций)
// не ретраятся вообще — ход останавливается и передаётся человеку.
func onStepError(step *Step, err error) Decision {
if isTerminal(err) {
return Abort
}
step.Retries++
if step.Retries <= maxStepRetries {
return Retry // повторить шаг, уже выполненные результаты сохранены
}
return SkipOrAbort
}
Границы — дедлайн на весь run и лимит итераций — терминальны: планирование их не отменяет. При превышении ход завершается с ошибкой и сообщением человеку, а не маскируется бесконечными повторами. Как хранить прогресс для возобновления — Глава 11.
Шаг 5: Сохранение состояния плана¶
ВАЖНО: Сохранение состояния для возобновления выполнения описано в State Management. Здесь описывается только структура состояния плана.
// Состояние плана используется для отслеживания прогресса
// Сохранение и возобновление описано в State Management
type PlanState struct {
PlanID string
Steps []Step
UpdatedAt time.Time
}
LivePlan: план как program state¶
В типичной реализации план хранится в messages — как текст, сгенерированный моделью. Проблема: при конденсации (сжатии контекста) план теряется вместе с другими старыми сообщениями.
На практике план живёт в Go-структуре, а не в messages:
type LivePlan struct {
Goal string
Steps []PlanStep
Notes string
}
type PlanStep struct {
ID int
Description string
Status StepStatus // pending, in_progress, completed, cancelled
Result string
}
type StepStatus string
const (
StepPending StepStatus = "pending"
StepInProgress StepStatus = "in_progress"
StepCompleted StepStatus = "completed"
StepCancelled StepStatus = "cancelled"
)
Куда план попадает в контексте? Не в system prompt. System prompt неизменяем от итерации к итерации — иначе ломается prompt cache. План — это динамика, поэтому он живёт в хвосте истории:
- При создании плана его текст добавляется в историю как обычное сообщение (или файл + ссылка на него).
- Дальше план обновляется через tool: модель вызывает
update_plan, а результат tool call'а — свежий рендер плана — встаёт в историю перед следующей итерацией.
func (p *LivePlan) Render() string {
var sb strings.Builder
sb.WriteString("План (актуальный):\n")
sb.WriteString("Цель: " + p.Goal + "\n")
for _, step := range p.Steps {
sb.WriteString(fmt.Sprintf("%d. [%s] %s", step.ID, step.Status, step.Description))
if step.Result != "" {
sb.WriteString(" → " + step.Result)
}
sb.WriteString("\n")
}
return sb.String()
}
Обновление плана как tool result в истории:
{"role": "assistant", "tool_calls": [{"name": "update_plan",
"arguments": {"step": 2, "status": "completed", "result": "401 на /api/auth"}}]}
{"role": "tool", "name": "update_plan",
"content": "План (актуальный):\nЦель: ...\n1. [completed] ...\n2. [completed] ...\n3. [pending] ..."}
Статус шага меняется автоматически при запуске субагента:
- Перед выполнением →
in_progress - После успеха →
completed - После ошибки →
pending(откат)
Правило: только один шаг in_progress одновременно. Когда все шаги completed или cancelled — план автоматически очищается, освобождая место в истории.
Антипаттерн: вставлять LivePlan в system prompt на каждой итерации. System prompt должен быть неизменным — это условие работы prompt cache. Всё, что меняется от шага к шагу (план, промежуточные результаты), добавляется в конец истории сообщением или через tool results.
Сравнение с Crush (Charmbracelet): Crush хранит план в
todos— UI-чеклисте, который живёт в messages. При суммаризации он теряется. Здесь план живёт в Go-структуре, которая переживает condensation, и рендерится в хвост истории.
Task Routing: выбор стратегии планирования¶
Не каждая задача требует плана. Простой вопрос "Какая версия Go?" не нуждается в декомпозиции. Правило выбора:
- Тривиальный вопрос (один ответ, без побочных эффектов) — отвечаем сразу, без плана.
- Многошаговая задача (инструменты, изменения, зависимости) — строим план.
Само решение можно доверить модели (структурированная классификация одним запросом) или не делать вовсе: агент с update_plan сам построит план, когда задача окажется сложной. А вот жёсткие эвристики вида «len(words) <= 15 && нет слова "рефакторинг"» хрупки — они ломаются от формулировки и молча отправляют сложные задачи в direct.
| Результат routing | Что делает агент |
|---|---|
direct |
Выполняет задачу сразу, без планирования |
plan |
Сначала вызывает plan tool, потом выполняет |
plan_and_subagent |
Создаёт план, запускает субагенты на каждый шаг |
Два режима планирования¶
| Режим | Кто управляет | Когда использовать |
|---|---|---|
| Гибкий (guided loop) | LLM следует плану в хвосте истории | Дефолт для агента: средние задачи, адаптивность |
| Жёсткий (orchestrator) | Go-код итерирует по шагам | Batch-пайплайны (CI, ETL) — не дефолт для агента |
Гибкий режим: план живёт в конце истории сообщений. LLM сам решает, какой шаг выполнять, и обновляет статусы через update_plan.
Жёсткий режим: Go-код берёт следующий pending шаг, формирует задачу, запускает субагента, получает результат, обновляет статус. LLM не контролирует порядок — его контролирует runtime. Это надёжно для воспроизводимых batch-пайплайнов, но для интерактивного агента жёсткий оркестратор отнимает адаптивность — не делайте его дефолтом.
Task Routing выбирает режим: plan → гибкий, plan_and_subagent → жёсткий.
Типовые ошибки¶
Ошибка 1: Нет отслеживания зависимостей¶
Симптом: Агент пытается выполнить шаги не по порядку, вызывая сбои.
Причина: Не отслеживаются зависимости между шагами.
Решение:
// ПЛОХО: Выполняем шаги по порядку без проверки зависимостей
for _, step := range plan.Steps {
executor.Execute(ctx, step)
}
// ХОРОШО: Сначала проверяем зависимости
readySteps := findReadySteps(plan)
for _, step := range readySteps {
executor.Execute(ctx, step)
}
Ошибка 2: Нет сохранения состояния¶
Симптом: Агент начинает с нуля после сбоя, теряя прогресс.
Причина: Состояние плана не сохраняется.
Решение: Используйте техники из State Management для сохранения и возобновления выполнения плана.
Ошибка 3: Бесконечные повторы¶
Симптом: Агент повторяет упавший шаг вечно, тратя ресурсы.
Причина: Нет лимитов повторов или backoff.
Решение: Транзиентные ошибки ретраит LLM-клиент (backoff с jitter, см. Главу 04); на уровне плана — лимит повторов шага, а дедлайн и лимит итераций — терминальные: ход останавливается и передаётся человеку.
Ошибка 4: Нет параллельного выполнения¶
Симптом: Агент выполняет независимые шаги последовательно, тратя время.
Причина: Не определяются шаги, которые могут выполняться параллельно.
Решение: Используйте findReadySteps для получения всех готовых шагов, выполняйте их конкурентно:
// Выполняем готовые шаги параллельно
var wg sync.WaitGroup
for _, step := range readySteps {
wg.Add(1)
go func(s *Step) {
defer wg.Done()
executor.Execute(ctx, s)
}(step)
}
wg.Wait()
Ошибка 5: План как свободный текст¶
Симптом: Модель перескакивает шаги, начинает Step 3 не завершив Step 2. Нет объективного критерия "шаг выполнен".
Причина: План генерируется как свободный текст без машиночитаемой структуры. Нет отслеживания статусов шагов.
Решение: Используйте структурированный план с явными статусами:
// ПЛОХО: план как текст в messages
plan := "1. Проверить логи\n2. Найти ошибку\n3. Исправить\n4. Протестировать"
// ХОРОШО: LivePlan с отслеживанием
plan := &LivePlan{
Goal: "Исправить ошибку авторизации",
Steps: []PlanStep{
{ID: 1, Description: "Проверить логи nginx", Status: StepCompleted, Result: "401 на /api/auth"},
{ID: 2, Description: "Найти причину в коде", Status: StepInProgress},
{ID: 3, Description: "Исправить middleware", Status: StepPending},
{ID: 4, Description: "Протестировать", Status: StepPending},
},
}
Паттерн: Controller + Processor (оркестратор + нормализатор)¶
Когда workflow сложный и инструментов много, заманчиво разделить роли: Controller выбирает следующий шаг, а Processor нормализует результаты инструментов в «патчи состояния» — state_patch, known_facts, confidence: 0.9 — прямо внутри контекста LLM.
Это антипаттерн. Скоринг фактов и патчи состояния внутри контекста — скрытая логика в обход модели: её нельзя протестировать, она тихо деградирует при конденсации и поощряет «выбор релевантного состояния» вручную. Состояние агента должно жить снаружи контекста — в Go-структурах и БД (Глава 11), большие результаты инструментов — в артефактах вне истории, с короткой выдержкой в контексте. Тогда Controller превращается в обычный agent loop из Главы 04, и отдельный Processor не нужен.
Мини-упражнения¶
Упражнение 1: Декомпозиция задачи¶
Реализуйте функцию, которая разбивает задачу на шаги:
func decomposeTask(task string) (*Plan, error) {
// Используйте LLM для создания плана
// Верните Plan с шагами и зависимостями
}
Ожидаемый результат:
- План содержит логические шаги
- Зависимости правильно определены
- Шаги могут выполняться в валидном порядке
Упражнение 2: Разрешение зависимостей¶
Реализуйте findReadySteps самостоятельно, не подглядывая в реализацию из «Шага 3» (сигнатура: func findReadySteps(plan *Plan) []*Step). Затем сравните с вариантом из главы.
Ожидаемый результат:
- Возвращает только шаги со всеми удовлетворёнными зависимостями
- Обрабатывает циклические зависимости (обнаруживает и ошибки)
Упражнение 3: Выполнение плана с повторами¶
Реализуйте выполнение плана с логикой повторов:
func executePlanWithRetries(ctx context.Context, plan *Plan, executor StepExecutor, maxRetries int) error {
// Выполните план с логикой повторов
// Обработайте сбои корректно
}
Ожидаемый результат:
- Шаги выполняются с учётом зависимостей
- Упавшие шаги повторяются до maxRetries
- План завершается или корректно падает
Критерии сдачи / Чек-лист¶
Сдано:
- Можете разбить сложные задачи на шаги
- Понимаете графы зависимостей
- Можете выполнять планы с учётом зависимостей
- Обрабатываете сбои с повторами
- Сохраняете состояние плана для возобновления
- Понимаете, почему план живёт в хвосте истории, а не в system prompt
- Знаете когда нужен routing для выбора стратегии
Не сдано:
- Выполнение шагов без проверки зависимостей
- Нет сохранения состояния
- Бесконечные повторы без лимитов
- Последовательное выполнение, когда возможно параллельное
- План как свободный текст без отслеживания статусов
Связь с другими главами¶
- Глава 04: Автономность и Циклы — Планирование расширяет ReAct цикл для сложных задач
- Глава 07: Multi-Agent Systems — Планирование может координировать несколько агентов
- Глава 11: State Management — Надёжное выполнение планов (идемпотентность, retries, persist)
- Глава 21: Workflow и State Management в продакшене — Прод-паттерны workflow
ВАЖНО: Planning фокусируется на декомпозиции задач и графах зависимостей. Надёжность выполнения (persist, retries, дедлайны) описана в State Management.
Что дальше?¶
После освоения паттернов планирования переходите к:
- 11. State Management — Узнайте, как гарантировать надёжное выполнение планов