10. Planning и Workflow-паттерны¶
Зачем это нужно?¶
Простые ReAct циклы хорошо работают для прямолинейных задач. Но как только задача становится многошаговой, обычно нужно планирование: разбить работу на шаги, учесть зависимости, пережить сбои и не потерять прогресс.
В этой главе разберём паттерны планирования, которые помогают агентам справляться со сложными и долго выполняющимися задачами.
Реальный кейс¶
Ситуация: Пользователь просит: "Разверни новый микросервис: создай VM, установи зависимости, настрой сеть, разверни приложение, настрой мониторинг."
Проблема: Простой ReAct цикл может:
- Прыгать между шагами случайно
- Пропускать зависимости (пытаться развернуть до создания VM)
- Не отслеживать, какие шаги завершены
- Падать и начинать с нуля
Решение: Паттерн планирования: агент сначала строит план (шаги + зависимости), потом выполняет его по порядку, отслеживая состояние и корректно обрабатывая сбои.
Теория простыми словами¶
Что такое Planning?¶
Planning — это процесс разбиения сложной задачи на меньшие, управляемые шаги с чёткими зависимостями и порядком выполнения.
Ключевые компоненты:
- Декомпозиция задачи — Разбить большую задачу на шаги
- Граф зависимостей — Понять, какие шаги зависят от других
- Порядок выполнения — Определить последовательность (или параллельное выполнение)
- Отслеживание состояния — Знать, что сделано, что в процессе, что упало
- Обработка сбоев — Повтор, пропуск или прерывание при ошибках
Паттерны планирования¶
Паттерн 1: Plan→Execute
- Агент создаёт полный план заранее
- Выполняет шаги последовательно
- Просто, но негибко
Паттерн 2: Plan-and-Revise
- Агент создаёт начальный план
- Пересматривает план по мере обучения (например, шаг упал, обнаружена новая информация)
- Более адаптивно, но сложнее
Паттерн 3: DAG/Workflow
- Шаги образуют направленный ациклический граф
- Некоторые шаги могут выполняться параллельно
- Обрабатывает сложные зависимости
Как это работает (пошагово)¶
Шаг 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 для декомпозиции задачи:
func createPlan(ctx context.Context, client *openai.Client, task string) (*Plan, error) {
prompt := fmt.Sprintf(`Разбей эту задачу на шаги с зависимостями:
Задача: %s
Верни JSON с массивом steps. Каждый шаг имеет: id, description, dependencies (массив ID шагов).
Пример:
{
"steps": [
{"id": "step1", "description": "Создать VM", "dependencies": []},
{"id": "step2", "description": "Установить зависимости", "dependencies": ["step1"]}
]
}`, task)
messages := []openai.ChatCompletionMessage{
{Role: "system", Content: "Ты агент планирования. Разбивай задачи на шаги."},
{Role: "user", Content: prompt},
}
resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Model: "gpt-4o-mini",
Messages: messages,
Temperature: 0, // Детерминированное планирование
})
if err != nil {
return nil, err
}
// Парсим JSON ответ в Plan
var plan Plan
json.Unmarshal([]byte(resp.Choices[0].Message.Content), &plan)
return &plan, nil
}
Шаг 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: Обработка сбоев¶
Реализуем логику повторных попыток с экспоненциальным backoff:
type StepExecutor interface {
Execute(ctx context.Context, step *Step) (any, error)
}
func executeWithRetry(ctx context.Context, executor StepExecutor, step *Step, maxRetries int) (any, error) {
var lastErr error
backoff := time.Second
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
// Экспоненциальный backoff
time.Sleep(backoff)
backoff *= 2
}
result, err := executor.Execute(ctx, step)
if err == nil {
return result, nil
}
lastErr = err
// Проверяем, можно ли повторить ошибку
if !isRetryableError(err) {
return nil, err
}
}
return nil, fmt.Errorf("не удалось после %d попыток: %w", maxRetries, lastErr)
}
Шаг 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"
)
LivePlan инжектируется в system prompt на каждой итерации:
func (p *LivePlan) Render() string {
var sb strings.Builder
sb.WriteString("[PLAN]\n")
sb.WriteString("Goal: " + 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 call update_plan. Статус шага меняется автоматически при запуске субагента:
- Перед выполнением →
in_progress - После успеха →
completed - После ошибки →
pending(откат)
Правило: только один шаг in_progress одновременно. Когда все шаги completed или cancelled — план автоматически очищается, освобождая место в system prompt.
Сравнение с Crush (Charmbracelet): Crush хранит план в
todos— UI-чеклисте, который живёт в messages. При суммаризации он теряется. Atlas хранит план в Go-структуре, которая переживает condensation.
Task Routing: выбор стратегии планирования¶
Не каждая задача требует плана. Простой вопрос "Какая версия Go?" не нуждается в декомпозиции. Task Routing определяет стратегию до начала работы:
3-уровневый routing¶
func routeTask(msg string, llmRouter Router) RoutingResult {
// Уровень 1: Pre-filter (без LLM)
if isSimpleRequest(msg) {
return RoutingResult{Strategy: "direct"}
}
// Уровень 2: LLM-классификация
result, err := llmRouter.Classify(msg)
if err == nil {
return result
}
// Уровень 3: Keyword fallback
return keywordFallback(msg)
}
func isSimpleRequest(msg string) bool {
words := strings.Fields(msg)
return len(words) <= 15 &&
len(msg) < 150 &&
containsFilePath(msg) &&
!containsBroadWords(msg) // "все файлы", "весь проект", "рефакторинг"
}
| Результат routing | Что делает агент |
|---|---|
direct |
Выполняет задачу сразу, без планирования |
plan |
Сначала вызывает plan tool, потом выполняет |
plan_and_subagent |
Создаёт план, запускает субагенты на каждый шаг |
Два режима планирования¶
| Режим | Кто управляет | Когда использовать |
|---|---|---|
| Гибкий (guided loop) | LLM следует плану в system prompt | Средние задачи, нужна адаптивность |
| Жёсткий (orchestrator) | Go-код итерирует по шагам | Большие задачи, нужна надёжность |
Гибкий режим: план инжектируется в [PLAN] секцию system prompt. LLM сам решает, какой шаг выполнять, и обновляет статусы через update_plan.
Жёсткий режим: Go-код берёт следующий pending шаг, формирует задачу, запускает субагента, получает результат, обновляет статус. LLM не контролирует порядок — его контролирует runtime.
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.
Решение: Реализуйте максимальное количество повторов и экспоненциальный backoff.
Ошибка 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 (аналитик/нормализатор) преобразует результаты инструментов и ответы пользователя в структурное обновление состояния (например: "добавь факты", "обнови план", "добавь открытые вопросы").
Так вы снижаете "хаос" в agent loop: controller не тонет в больших данных, а processor не принимает решений о сайд-эффектах.
Мини-трасса (read-only поиск + чтение файла):
1) Controller вызывает поиск.
2) ToolRunner сохраняет сырой результат как артефакт и возвращает короткий payload (top-k совпадений).
3) Processor возвращает state_patch:
{
"replace_plan": [
"Прочитать файл с лучшим совпадением",
"Сформировать краткое объяснение пользователю"
],
"append_known_facts": [
{
"key": "client_error_candidate",
"value": "pkg/errors/client_error.go:12",
"source": "tool",
"artifact_id": "srch_123",
"confidence": 0.9
}
]
}
4) Controller читает файл по плану и формирует финальный ответ.
Мини-упражнения¶
Упражнение 1: Декомпозиция задачи¶
Реализуйте функцию, которая разбивает задачу на шаги:
func decomposeTask(task string) (*Plan, error) {
// Используйте LLM для создания плана
// Верните Plan с шагами и зависимостями
}
Ожидаемый результат:
- План содержит логические шаги
- Зависимости правильно определены
- Шаги могут выполняться в валидном порядке
Упражнение 2: Разрешение зависимостей¶
Реализуйте findReadySteps, который возвращает шаги, все зависимости которых завершены:
Ожидаемый результат:
- Возвращает только шаги со всеми удовлетворёнными зависимостями
- Обрабатывает циклические зависимости (обнаруживает и ошибки)
Упражнение 3: Выполнение плана с повторами¶
Реализуйте выполнение плана с логикой повторов:
func executePlanWithRetries(ctx context.Context, plan *Plan, executor StepExecutor, maxRetries int) error {
// Выполните план с логикой повторов
// Обработайте сбои корректно
}
Ожидаемый результат:
- Шаги выполняются с учётом зависимостей
- Упавшие шаги повторяются до maxRetries
- План завершается или корректно падает
Критерии сдачи / Чек-лист¶
Сдано:
- Можете разбить сложные задачи на шаги
- Понимаете графы зависимостей
- Можете выполнять планы с учётом зависимостей
- Обрабатываете сбои с повторами
- Сохраняете состояние плана для возобновления
- Понимаете разницу между планом в messages и LivePlan
- Знаете когда нужен 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 — Узнайте, как гарантировать надёжное выполнение планов