Перейти к содержанию

10. Planning и Workflow-паттерны

Зачем это нужно?

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

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

Реальный кейс

Ситуация: Пользователь просит: "Разверни новый микросервис: создай VM, установи зависимости, настрой сеть, разверни приложение, настрой мониторинг."

Проблема: Простой ReAct цикл может:

  • Прыгать между шагами случайно
  • Пропускать зависимости (пытаться развернуть до создания VM)
  • Не отслеживать, какие шаги завершены
  • Падать и начинать с нуля

Решение: Паттерн планирования: агент сначала строит план (шаги + зависимости), потом выполняет его по порядку, отслеживая состояние и корректно обрабатывая сбои.

Теория простыми словами

Что такое Planning?

Planning — это процесс разбиения сложной задачи на меньшие, управляемые шаги с чёткими зависимостями и порядком выполнения.

Ключевые компоненты:

  1. Декомпозиция задачи — Разбить большую задачу на шаги
  2. Граф зависимостей — Понять, какие шаги зависят от других
  3. Порядок выполнения — Определить последовательность (или параллельное выполнение)
  4. Отслеживание состояния — Знать, что сделано, что в процессе, что упало
  5. Обработка сбоев — Повтор, пропуск или прерывание при ошибках

Паттерны планирования

Паттерн 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"
)

Пример: "Развернуть микросервис" разбивается на:

  1. Создать VM (нет зависимостей)
  2. Установить зависимости (зависит от: Создать VM)
  3. Настроить сеть (зависит от: Создать VM)
  4. Развернуть приложение (зависит от: Установить зависимости, Настроить сеть)
  5. Настроить мониторинг (зависит от: Развернуть приложение)

Шаг 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. План — это динамика, поэтому он живёт в хвосте истории:

  1. При создании плана его текст добавляется в историю как обычное сообщение (или файл + ссылка на него).
  2. Дальше план обновляется через 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 для выбора стратегии

Не сдано:

  • Выполнение шагов без проверки зависимостей
  • Нет сохранения состояния
  • Бесконечные повторы без лимитов
  • Последовательное выполнение, когда возможно параллельное
  • План как свободный текст без отслеживания статусов

Связь с другими главами

ВАЖНО: Planning фокусируется на декомпозиции задач и графах зависимостей. Надёжность выполнения (persist, retries, дедлайны) описана в State Management.

Что дальше?

После освоения паттернов планирования переходите к:

  • 11. State Management — Узнайте, как гарантировать надёжное выполнение планов