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

04. Автономность и Циклы — ReAct Loop

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

В этой главе мы реализуем паттерн ReAct (Reason + Act) — сердце автономного агента.

Без автономного цикла агент работает как чат-бот: один запрос → один ответ. С автономным циклом агент может выполнить 10 действий подряд для решения одной задачи, самостоятельно принимая решения на основе результатов предыдущих действий.

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

Ситуация: Пользователь пишет: "У меня кончилось место на сервере. Разберись."

Без автономного цикла:

  • Агент: "Я проверю использование диска" → вызывает check_disk → получает "95%"
  • Агент: [Останавливается, ждет следующей команды пользователя]

С автономным циклом:

  • Агент: "Я проверю использование диска" → вызывает check_disk → получает "95%"
  • Агент: "Диск переполнен. Очищу логи" → вызывает clean_logs → получает "Освобождено 20GB"
  • Агент: "Проверю снова" → вызывает check_disk → получает "40%"
  • Агент: "Готово! Освободил 20GB."

Разница: Агент сам решает, что делать дальше, основываясь на результатах предыдущих действий.

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

ReAct Loop (Цикл Автономности)

ReAct — это аббревиатура от Reason + Act (Рассуждай + Действуй). Это паттерн, при котором агент:

  1. Reason (Рассуждает): Анализирует ситуацию и решает, что делать
  2. Act (Действует): Выполняет действие (вызывает инструмент)
  3. Observe (Наблюдает): Видит результат действия
  4. Повторяет: Снова рассуждает на основе результата

Это не магия — это просто цикл, где модель видит результаты предыдущих действий в контексте и генерирует следующий шаг.

Автономный агент работает в цикле:

While (Задача не решена):
  1. Отправить историю в LLM
  2. Получить ответ
  3. ЕСЛИ это текст → Показать пользователю и ждать нового ввода
  4. ЕСЛИ это вызов инструмента →
       a. Выполнить инструмент
       b. Добавить результат в историю
       c. GOTO 1 (ничего не спрашивая у пользователя!)

Ключевой момент: Пункт 4.c дает "магию" — агент сам смотрит на результат и решает, что делать дальше. Но это не настоящая магия: модель видит результат инструмента в контексте (messages[]) и генерирует следующий шаг на основе этого контекста.

Замыкание круга

После выполнения инструмента мы не спрашиваем пользователя, что делать дальше. Мы отправляем результат обратно в LLM. Модель видит результат своих действий и решает, что делать дальше.

Пример диалога внутри памяти:

Магия vs Реальность: Как работает цикл

❌ Магия (как обычно объясняют):

Агент сам решил вызвать clean_logs() после проверки диска

✅ Реальность (как на самом деле):

Итерация 1: Первый запрос

// messages перед первой итерацией
messages := []openai.ChatCompletionMessage{
    {Role: "system", Content: "You are an autonomous DevOps agent."},
    {Role: "user", Content: "Кончилось место."},
}

// Отправляем в модель
resp1, _ := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
    Model:    openai.GPT3Dot5Turbo,
    Messages: messages,
    Tools:    tools,
})

msg1 := resp1.Choices[0].Message
// msg1.ToolCalls = [{ID: "call_1", Function: {Name: "check_disk_usage", Arguments: "{}"}}]

// Добавляем ответ ассистента в историю
messages = append(messages, msg1)
// Теперь messages содержит:
// [system, user, assistant(tool_call: check_disk_usage)]

Итерация 2: Выполнение инструмента и возврат результата

// Выполняем инструмент
result1 := checkDiskUsage()  // "95% usage"

// Добавляем результат как сообщение с ролью "tool"
messages = append(messages, openai.ChatCompletionMessage{
    Role:       "tool",
    Content:    result1,  // "95% usage"
    ToolCallID: "call_1",
})
// Теперь messages содержит:
// [system, user, assistant(tool_call), tool("95% usage")]

// Отправляем ОБНОВЛЕННУЮ историю в модель снова
resp2, _ := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
    Model:    openai.GPT3Dot5Turbo,
    Messages: messages,  // Модель видит результат check_disk_usage!
    Tools:    tools,
})

msg2 := resp2.Choices[0].Message
// msg2.ToolCalls = [{ID: "call_2", Function: {Name: "clean_logs", Arguments: "{}"}}]

messages = append(messages, msg2)
// Теперь messages содержит:
// [system, user, assistant(tool_call_1), tool("95%"), assistant(tool_call_2)]

Итерация 3: Второй инструмент

// Выполняем второй инструмент
result2 := cleanLogs()  // "Freed 20GB"

messages = append(messages, openai.ChatCompletionMessage{
    Role:       "tool",
    Content:    result2,  // "Freed 20GB"
    ToolCallID: "call_2",
})
// Теперь messages содержит:
// [system, user, assistant(tool_call_1), tool("95%"), assistant(tool_call_2), tool("Freed 20GB")]

// Отправляем снова
resp3, _ := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
    Model:    openai.GPT3Dot5Turbo,
    Messages: messages,  // Модель видит оба результата!
    Tools:    tools,
})

msg3 := resp3.Choices[0].Message
// msg3.ToolCalls = []  // Пусто! Модель решила ответить текстом
// msg3.Content = "Я почистил логи, теперь места достаточно."

// Это финальный ответ - выходим из цикла

Почему это не магия:

  1. Модель видит всю историю — она не "помнит" прошлое, она видит его в messages[]
  2. Модель видит результат инструмента — результат добавляется как новое сообщение с ролью tool
  3. Модель решает на основе контекста — видя "95% usage", модель понимает, что нужно освободить место
  4. Runtime управляет циклом — код проверяет len(msg.ToolCalls) и решает, продолжать ли цикл

Ключевой момент: Модель не "сама решила" — она увидела результат check_disk_usage в контексте и сгенерировала следующий tool call на основе этого контекста.

Визуализация: Кто что делает?

┌─────────────────────────────────────────────────────────┐
│ LLM (Модель)                                            │
│                                                         │
│ 1. Видит в контексте:                                   │
│    - System Prompt: "Ты DevOps агент"                   │
│    - User Input: "Кончилось место"                      │
│    - Tools Schema: [{name: "check_disk", ...}]          │
│                                                         │
│ 2. Генерирует tool_call:                                │
│    {name: "check_disk_usage", arguments: "{}"}          │
│                                                         │
│ 3. НЕ выполняет код! Только генерирует JSON.            │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Runtime (Ваш код на Go)                                 │
│                                                         │
│ 1. Получает tool_call из ответа модели                  │
│ 2. Валидирует: существует ли инструмент?                │
│ 3. Выполняет: checkDiskUsage() → "95% usage"            │
│ 4. Добавляет результат в messages[]:                    │
│    {role: "tool", content: "95% usage"}                 │
│ 5. Отправляет обновленную историю обратно в LLM         │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ LLM (Модель) - следующая итерация                       │
│                                                         │
│ 1. Видит в контексте:                                   │
│    - Предыдущий tool_call                               │
│    - Результат: "95% usage" ← Runtime добавил!          │
│                                                         │
│ 2. Генерирует следующий tool_call:                      │
│    {name: "clean_logs", arguments: "{}"}                │
│                                                         │
│ 3. Цикл повторяется...                                  │
└─────────────────────────────────────────────────────────┘

Ключевой момент: LLM не "помнит" прошлое. Она видит его в messages[], который собирает Runtime.

Реализация цикла

for i := 0; i < maxIterations; i++ {
    // 1. Отправляем запрос
    resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
        Model:    openai.GPT3Dot5Turbo,
        Messages: messages,
        Tools:    tools,
    })

    msg := resp.Choices[0].Message
    messages = append(messages, msg)  // Сохраняем ответ

    // 2. Проверяем тип ответа
    if len(msg.ToolCalls) == 0 {
        // Это финальный текстовый ответ
        fmt.Println("Agent:", msg.Content)
        break
    }

    // 3. Выполняем инструменты
    for _, toolCall := range msg.ToolCalls {
        result := executeTool(toolCall.Function.Name, toolCall.Function.Arguments)

        // 4. Добавляем результат в историю
        messages = append(messages, openai.ChatCompletionMessage{
            Role:       openai.ChatMessageRoleTool,
            Content:    result,
            ToolCallID: toolCall.ID,
        })
    }
    // Цикл продолжается автоматически!
    // Но это не магия: мы отправляем обновленную историю (с результатом инструмента)
    // в модель снова, и модель видит результат и решает, что делать дальше
}

Обработка ошибок в цикле

Важно: Не забудьте обрабатывать ошибки и добавлять их в историю! Если инструмент упал, LLM должна это узнать и попробовать что-то другое.

Правильная обработка ошибок:

for _, toolCall := range msg.ToolCalls {
    result, err := executeTool(toolCall.Function.Name, toolCall.Function.Arguments)

    if err != nil {
        // Ошибка — это тоже результат! Добавляем её в историю
        result = fmt.Sprintf("Error: %v", err)
    }

    // Добавляем результат (или ошибку) в историю
    messages = append(messages, openai.ChatCompletionMessage{
        Role:       openai.ChatMessageRoleTool,
        Content:    result,  // Модель увидит ошибку!
        ToolCallID: toolCall.ID,
    })
}

Что происходит:

  1. Инструмент возвращает ошибку: Error: connection refused
  2. Ошибка добавляется в историю как результат инструмента
  3. Модель видит ошибку в контексте
  4. Модель может:
  5. Попробовать другой инструмент
  6. Сообщить пользователю о проблеме
  7. Эскалировать проблему

Пример:

Итерация 1:
Action: check_database_status("prod")
Observation: Error: connection refused

Итерация 2 (модель видит ошибку):
Thought: "База недоступна. Проверю сетевую связность"
Action: ping_host("db-prod.example.com")
Observation: "Host is unreachable"

Итерация 3:
Thought: "Сеть недоступна. Сообщу пользователю о проблеме"
Action: [Финальный ответ] "База данных недоступна. Проверьте сетевую связность."

Анти-паттерн: Не скрывайте ошибки от модели!

// ПЛОХО: Скрываем ошибку
if err != nil {
    log.Printf("Error: %v", err)  // Только в лог
    continue  // Пропускаем инструмент
}

// ХОРОШО: Показываем ошибку модели
if err != nil {
    result := fmt.Sprintf("Error: %v", err)
    messages = append(messages, ...)  // Добавляем в историю
}

Типовые ошибки

Ошибка 1: Зацикливание

Симптом: Агент повторяет одно и то же действие бесконечно.

Причина: Нет лимита итераций и детекции повторяющихся действий.

Решение:

// ХОРОШО: Лимит итераций + детекция застревания
for i := 0; i < maxIterations; i++ {
    // ...

    // Детекция повторяющихся действий
    if lastNActionsAreSame(history, 3) {
        break
    }
}

// ХОРОШО: Улучшите промпт
systemPrompt := `... If action doesn't help, try a different approach.`

Ошибка 2: Результат инструмента не добавляется в историю

Симптом: Агент не видит результат инструмента и продолжает выполнять то же действие.

Причина: Результат выполнения инструмента не добавляется в messages[].

Решение:

// ПЛОХО: Результат не добавляется
result := executeTool(toolCall)
// История не обновлена!

// ХОРОШО: ОБЯЗАТЕЛЬНО добавляйте результат!
messages = append(messages, openai.ChatCompletionMessage{
    Role:       openai.ChatMessageRoleTool,
    Content:    result,
    ToolCallID: toolCall.ID,  // Важно для связи!
})

Ошибка 3: Агент не останавливается

Симптом: Агент продолжает вызывать инструменты, даже когда задача решена.

Причина: System Prompt не инструктирует агента останавливаться, когда задача решена.

Решение:

// ХОРОШО: Добавьте в System Prompt
systemPrompt := `... If task is solved, answer user with text. Don't call tools unnecessarily.`

Мини-упражнения

Упражнение 1: Добавьте детекцию зацикливания

Реализуйте проверку, что последние 3 действия одинаковые:

func isStuck(history []ChatCompletionMessage) bool {
    // Проверьте, что последние 3 действия одинаковые
    // ...
}

Ожидаемый результат:

  • Функция возвращает true, если последние 3 действия одинаковые
  • Функция возвращает false в противном случае

Упражнение 2: Добавьте логирование

Логируйте каждую итерацию цикла:

fmt.Printf("[Iteration %d] Agent decided: %s\n", i, action)
fmt.Printf("[Iteration %d] Tool result: %s\n", i, result)

Ожидаемый результат:

  • Каждая итерация логируется с номером и действием
  • Результаты инструментов логируются

Критерии сдачи / Чек-лист

Сдано:

  • Агент выполняет цикл автономно
  • Результаты инструментов добавляются в историю
  • Агент останавливается, когда задача решена
  • Есть защита от зацикливания (лимит итераций + детекция)
  • Ошибки инструментов обрабатываются и добавляются в историю

Не сдано:

  • Агент не продолжает цикл после выполнения инструмента
  • Результаты инструментов не видны агенту (не добавляются в историю)
  • Агент зацикливается (нет защиты)
  • Агент не останавливается, когда задача решена

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

Что дальше?

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


Навигация: ← Инструменты | Оглавление | Безопасность →