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

03. Инструменты и Function Calling — "руки" агента

Примечание о терминологии: В этой главе используются термины "Function Calling" (название механизма в API) и "Tool Call" (конкретный вызов инструмента). Это одно и то же: механизм, при котором LLM возвращает структурированный запрос на вызов функции Go. В коде агента мы обычно говорим "tool" (инструмент), потому что это более общее понятие (инструмент может быть не только функцией Go, но и API-вызовом, командой и т.д.).

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

Инструменты превращают LLM из болтуна в работника. Без инструментов агент может только отвечать текстом, но не может взаимодействовать с реальным миром.

Function Calling — это механизм, который даёт модели доступ к реальным действиям: вызывать функции Go, выполнять команды, читать данные.

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

Ситуация: Вы создали чат-бота для DevOps. Пользователь пишет: "Проверь статус сервера web-01"

Проблема: Бот не может реально проверить сервер. Он только обещает: "Я проверю статус сервера web-01..." — но это просто текст.

Решение: Function Calling позволяет модели вызывать реальные функции Go. Модель генерирует структурированный JSON с именем функции и аргументами, ваш код выполняет функцию и возвращает результат обратно в модель.

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

Как работает Function Calling?

  1. Вы описываете функцию в формате JSON Schema
  2. LLM видит описание и решает: "Мне нужно вызвать эту функцию"
  3. LLM генерирует JSON с именем функции и аргументами
  4. Ваш код парсит JSON и выполняет реальную функцию
  5. Результат возвращается в LLM для дальнейшей обработки

Function Calling — механизм работы

Function Calling — это механизм, при котором LLM возвращает не текст, а структурированный JSON с именем функции и аргументами.

Полный цикл: от определения до выполнения

Разберём полный цикл на примере инструмента ping:

Шаг 1: Определение инструмента (Tool Schema)

tools := []openai.Tool{
    {
        Type: openai.ToolTypeFunction,
        Function: &openai.FunctionDefinition{
            Name:        "ping",
            Description: "Ping a host to check connectivity",
            Parameters: json.RawMessage(`{
                "type": "object",
                "properties": {
                    "host": {
                        "type": "string",
                        "description": "Hostname or IP address to ping"
                    }
                },
                "required": ["host"]
            }`),
        },
    },
}

Что происходит: Мы описываем инструмент в формате JSON Schema. Это описание отправляется в модель вместе с запросом.

Шаг 2: Запрос к модели

messages := []openai.ChatCompletionMessage{
    {Role: "system", Content: "You are a network admin. Use tools to check connectivity."},
    {Role: "user", Content: "Проверь доступность google.com"},
}

req := openai.ChatCompletionRequest{
    Model:    "gpt-4o-mini",
    Messages: messages,
    Tools:    tools,  // Модель видит описание инструментов!
    Temperature: 0,
}

resp, _ := client.CreateChatCompletion(ctx, req)
msg := resp.Choices[0].Message

Что происходит: Модель видит:

  • System prompt (роль и инструкции)
  • User input (запрос пользователя)
  • Tools schema (описание доступных инструментов)

Шаг 3: Ответ модели (Tool Call)

Модель не возвращает текст "Я проверю ping". Она возвращает структурированный tool call:

// msg.ToolCalls содержит:
[]openai.ToolCall{
    {
        ID: "call_abc123",
        Type: "function",
        Function: openai.FunctionCall{
            Name:      "ping",
            Arguments: `{"host": "google.com"}`,
        },
    },
}

Что происходит: Модель сгенерировала tool_call для инструмента ping и JSON с аргументами. Тут нет магии: модель увидела Description: "Ping a host to check connectivity" и сопоставила его с запросом пользователя.

Как модель выбирает между несколькими инструментами?

Расширим пример и добавим несколько инструментов:

tools := []openai.Tool{
    {
        Function: &openai.FunctionDefinition{
            Name:        "ping",
            Description: "Ping a host to check network connectivity. Use this when user asks about network reachability or connectivity.",
        },
    },
    {
        Function: &openai.FunctionDefinition{
            Name:        "check_http",
            Description: "Check HTTP status code of a website. Use this when user asks about website availability or HTTP errors.",
        },
    },
    {
        Function: &openai.FunctionDefinition{
            Name:        "traceroute",
            Description: "Trace the network path to a host. Use this when user asks about network routing or path analysis.",
        },
    },
}

userInput := "Проверь доступность google.com"

Процесс выбора:

  1. Модель видит все три инструмента и их Description:

    • ping: "check network connectivity... Use this when user asks about network reachability"
    • check_http: "Check HTTP status... Use this when user asks about website availability"
    • traceroute: "Trace network path... Use this when user asks about routing"
  2. Модель сопоставляет запрос "Проверь доступность google.com" с описаниями:

    • ping — описание содержит "connectivity" и "reachability" → выбирает этот
    • check_http — про HTTP статус, не про сетевую доступность
    • traceroute — про маршрутизацию, не про проверку доступности
  3. Модель возвращает tool call для ping:

    {"name": "ping", "arguments": "{\"host\": \"google.com\"}"}
    

Пример с другим запросом:

userInput := "Проверь, отвечает ли сайт google.com"

// Модель видит те же 3 инструмента
// Сопоставляет:
// - ping: про сетевую доступность → не совсем то
// - check_http: "Use this when user asks about website availability" → ВЫБИРАЕТ ЭТОТ
// - traceroute: про маршрутизацию → не подходит

// Модель возвращает:
// {"name": "check_http", "arguments": "{\"url\": \"https://google.com\"}"}

Суть: Модель выбирает инструмент на основе семантического соответствия между запросом пользователя и Description. Чем точнее и конкретнее Description, тем лучше модель выбирает нужный инструмент.

Что делает LLM, а что делает Runtime?

LLM (модель):

  • Видит описание инструментов в tools[]
  • Генерирует JSON с именем инструмента и аргументами
  • Возвращает структурированный ответ в поле tool_calls

Runtime (ваш код):

  • Парсит tool_calls из ответа модели
  • Валидирует имя инструмента (существует ли он?)
  • Валидирует JSON аргументов (корректен ли?)
  • Выполняет реальную функцию Go
  • Добавляет результат в историю (messages[])
  • Отправляет обновленную историю обратно в модель

Важно: LLM не выполняет код. Она только генерирует JSON с запросом на выполнение. Ваш Runtime выполняет реальный код.

Шаг 4: Валидация (Runtime)

// Проверяем, что инструмент существует
if msg.ToolCalls[0].Function.Name != "ping" {
    return fmt.Errorf("unknown tool: %s", msg.ToolCalls[0].Function.Name)
}

// Валидируем JSON аргументов
var args struct {
    Host string `json:"host"`
}
if err := json.Unmarshal([]byte(msg.ToolCalls[0].Function.Arguments), &args); err != nil {
    return fmt.Errorf("invalid JSON: %v", err)
}

// Проверяем обязательные поля
if args.Host == "" {
    return fmt.Errorf("host is required")
}

Что происходит: Runtime валидирует вызов перед выполнением. Это критично для безопасности.

Шаг 5: Выполнение инструмента

func executePing(host string) string {
    cmd := exec.Command("ping", "-c", "1", host)
    output, err := cmd.CombinedOutput()
    if err != nil {
        return fmt.Sprintf("Error: %s", err)
    }
    return string(output)
}

result := executePing(args.Host)  // "PING google.com: 64 bytes from ..."

Что происходит: Runtime выполняет реальную функцию (в данном случае системную команду ping).

Шаг 6: Возврат результата в модель

// Добавляем результат в историю как сообщение с ролью "tool"
messages = append(messages, openai.ChatCompletionMessage{
    Role:       openai.ChatMessageRoleTool,
    Content:    result,  // "PING google.com: 64 bytes from ..."
    ToolCallID: msg.ToolCalls[0].ID,  // Связываем с вызовом
})

// Отправляем обновленную историю в модель снова
resp2, _ := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
    Model:    "gpt-4o-mini",
    Messages: messages,  // Теперь включает результат инструмента!
    Tools:    tools,
})

Что происходит: Модель видит результат выполнения инструмента и может:

  • Сформулировать финальный ответ пользователю
  • Вызвать другой инструмент, если нужно
  • Задать уточняющий вопрос

Шаг 7: Финальный ответ

finalMsg := resp2.Choices[0].Message
if len(finalMsg.ToolCalls) == 0 {
    // Это финальный текстовый ответ
    fmt.Println(finalMsg.Content)  // "google.com доступен, время отклика 10ms"
}

Что происходит: Модель видела результат ping и сформулировала понятный ответ для пользователя.

Сквозной протокол: полный запрос и два хода

Теперь разберем полный протокол с точки зрения разработчика агента: где что хранится, как собирается запрос, и как runtime обрабатывает ответы.

Где что хранится в коде агента?

graph TB
    A["System Prompt (в коде/конфиге)"] --> D[ChatCompletionRequest]
    B["Tools Registry (в коде runtime)"] --> D
    C["User Input (от пользователя)"] --> D
    D --> E[LLM API]
    E --> F{Ответ}
    F -->|tool_calls| G[Runtime валидирует]
    G --> H[Runtime выполняет]
    H --> I[Добавляет tool result]
    I --> D
    F -->|content| J[Финальный ответ]

Схема хранения:

  1. System Prompt — хранится в коде агента (константа или конфиг):

    • Инструкции (Role, Goal, Constraints)
    • Few-shot примеры (если используются)
    • SOP (алгоритм действий)
  2. Tools Schema — хранится в registry runtime (не в промпте!):

    • Определения инструментов (JSON Schema)
    • Функции-обработчики инструментов
    • Валидация и выполнение
  3. User Input — приходит от пользователя:

    • Текущий запрос
    • История диалога (хранится в messages[])
  4. Tool Results — генерируются runtime'ом:

    • После выполнения инструмента
    • Добавляются в messages[] с Role = "tool"

Полный протокол: JSON запросы и ответы (2 хода)

Ход 1: Request с несколькими инструментами

{
  "model": "gpt-3.5-turbo",
  "messages": [
    {
      "role": "system",
      "content": "Ты DevOps инженер. Используй инструменты для проверки сервисов.\n\nПримеры использования:\nUser: \"Проверь статус nginx\"\nAssistant: возвращает tool_call check_status(\"nginx\")\n\nUser: \"Перезапусти сервер\"\nAssistant: возвращает tool_call restart_service(\"web-01\")"
    },
    {
      "role": "user",
      "content": "Проверь статус nginx"
    }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "check_status",
        "description": "Check if a service is running. Use this when user asks about service status.",
        "parameters": {
          "type": "object",
          "properties": {
            "service": {
              "type": "string",
              "description": "Service name"
            }
          },
          "required": ["service"]
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "restart_service",
        "description": "Restart a systemd service. Use this when user explicitly asks to restart a service.",
        "parameters": {
          "type": "object",
          "properties": {
            "service_name": {
              "type": "string",
              "description": "Service name to restart"
            }
          },
          "required": ["service_name"]
        }
      }
    }
  ],
  "tool_choice": "auto"
}

Где что находится:

  • System Prompt (инструкции + few-shot примеры) → messages[0].content
  • User Inputmessages[1].content
  • Tools Schema (2 инструмента с полными JSON Schema) → отдельное поле tools[]

Response #1 (tool call):

{
  "id": "chatcmpl-abc123",
  "choices": [{
    "message": {
      "role": "assistant",
      "content": null,
      "tool_calls": [
        {
          "id": "call_xyz789",
          "type": "function",
          "function": {
            "name": "check_status",
            "arguments": "{\"service\": \"nginx\"}"
          }
        }
      ]
    }
  }]
}

Runtime обрабатывает tool call:

  1. Валидирует: tool_calls[0].function.name существует в registry
  2. Парсит: json.Unmarshal(tool_calls[0].function.arguments){"service": "nginx"}
  3. Выполняет: check_status("nginx") → результат: "Service nginx is ONLINE"
  4. Добавляет tool result в messages[]

Ход 2: Request с tool result

{
  "model": "gpt-3.5-turbo",
  "messages": [
    {
      "role": "system",
      "content": "Ты DevOps инженер. Используй инструменты для проверки сервисов.\n\nПримеры использования:\nUser: \"Проверь статус nginx\"\nAssistant: возвращает tool_call check_status(\"nginx\")\n\nUser: \"Перезапусти сервер\"\nAssistant: возвращает tool_call restart_service(\"web-01\")"
    },
    {
      "role": "user",
      "content": "Проверь статус nginx"
    },
    {
      "role": "assistant",
      "content": null,
      "tool_calls": [
        {
          "id": "call_xyz789",
          "type": "function",
          "function": {
            "name": "check_status",
            "arguments": "{\"service\": \"nginx\"}"
          }
        }
      ]
    },
    {
      "role": "tool",
      "content": "Service nginx is ONLINE",
      "tool_call_id": "call_xyz789"
    }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "check_status",
        "description": "Check if a service is running. Use this when user asks about service status.",
        "parameters": {
          "type": "object",
          "properties": {
            "service": {
              "type": "string",
              "description": "Service name"
            }
          },
          "required": ["service"]
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "restart_service",
        "description": "Restart a systemd service. Use this when user explicitly asks to restart a service.",
        "parameters": {
          "type": "object",
          "properties": {
            "service_name": {
              "type": "string",
              "description": "Service name to restart"
            }
          },
          "required": ["service_name"]
        }
      }
    }
  ]
}

Где что находится:

  • System Promptmessages[0].content (тот же)
  • User Inputmessages[1].content (тот же)
  • Tool Callmessages[2] (добавлен runtime'ом после первого ответа)
  • Tool Resultmessages[3].content (добавлен runtime'ом после выполнения)
  • Tools Schema → поле tools[] (тот же)

Response #2 (финальный ответ):

{
  "id": "chatcmpl-def456",
  "choices": [{
    "message": {
      "role": "assistant",
      "content": "nginx работает нормально, сервис ONLINE",
      "tool_calls": null
    }
  }]
}

Эволюция messages[] массива (JSON)

До первого запроса:

[
  {"role": "system", "content": "Ты DevOps инженер..."},
  {"role": "user", "content": "Проверь статус nginx"}
]

После первого запроса (runtime добавляет tool call):

[
  {"role": "system", "content": "Ты DevOps инженер..."},
  {"role": "user", "content": "Проверь статус nginx"},
  {
    "role": "assistant",
    "content": null,
    "tool_calls": [{
      "id": "call_xyz789",
      "type": "function",
      "function": {
        "name": "check_status",
        "arguments": "{\"service\": \"nginx\"}"
      }
    }]
  }
]

После выполнения инструмента (runtime добавляет tool result):

[
  {"role": "system", "content": "Ты DevOps инженер..."},
  {"role": "user", "content": "Проверь статус nginx"},
  {
    "role": "assistant",
    "content": null,
    "tool_calls": [{
      "id": "call_xyz789",
      "type": "function",
      "function": {
        "name": "check_status",
        "arguments": "{\"service\": \"nginx\"}"
      }
    }]
  },
  {
    "role": "tool",
    "content": "Service nginx is ONLINE",
    "tool_call_id": "call_xyz789"
  }
]

После второго запроса (модель видит tool result и формулирует ответ):

  • Модель видит весь контекст (system + user + tool call + tool result)
  • Генерирует финальный ответ: "nginx работает нормально, сервис ONLINE"

Примечание: Для реализации на Go см. примеры в Lab 02: Tools и Lab 04: Autonomy

Ключевые моменты для разработчика

  1. System Prompt и Tools Schema — разные вещи:

    • System Prompt — текст в Messages[0].Content (может содержать few-shot примеры)
    • Tools Schema — отдельное поле Tools в запросе (JSON Schema)
  2. Few-shot примеры — внутри System Prompt:

    • Это текст, показывающий модели формат ответа или выбор инструментов
    • Отличается от Tools Schema (которая описывает структуру инструментов)
  3. Runtime управляет циклом:

    • Валидирует tool_calls
    • Выполняет инструменты
    • Добавляет результаты в messages
    • Отправляет следующий запрос
  4. Tools не "внутри промпта":

    • В API они передаются отдельным полем Tools
    • Модель видит их вместе с промптом, но это разные части запроса

См. как писать инструкции и примеры: Глава 02: Промптинг

Практика: Lab 02: Tools, Lab 04: Autonomy

Что происходит на деле?

Коротко:

  1. Модель видит описание ВСЕХ инструментов — она не "знает" про инструменты из коробки, она видит их Description в JSON Schema. Модель выбирает нужный инструмент, сопоставляя запрос пользователя с описаниями.

  2. Механизм выбора основан на семантике — модель ищет соответствие между:

    • Запросом пользователя ("Проверь доступность")
    • Описанием инструмента ("Use this when user asks about network reachability")
    • Контекстом предыдущих результатов (если есть)
  3. Модель возвращает структурированный JSON — это не текст "я вызову ping", а конкретный tool call с именем инструмента и аргументами

  4. Runtime делает всю работу — парсинг, валидация, выполнение, возврат результата

  5. Модель видит результат — она получает результат как новое сообщение в истории и продолжает работу

Пример выбора между похожими инструментами:

tools := []openai.Tool{
    {
        Function: &openai.FunctionDefinition{
            Name:        "check_service_status",
            Description: "Check if a systemd service is running. Use this for Linux services like nginx, mysql, etc.",
        },
    },
    {
        Function: &openai.FunctionDefinition{
            Name:        "check_http_status",
            Description: "Check HTTP response code of a web service. Use this for checking if a website or API is responding.",
        },
    },
}

// Запрос 1: "Проверь статус nginx"
// Модель выбирает: check_service_status (nginx - это systemd service)

// Запрос 2: "Проверь, отвечает ли сайт example.com"
// Модель выбирает: check_http_status (сайт - это HTTP сервис)

Обрати внимание: Если описания инструментов слишком похожи или неясны, модель может выбрать неправильный инструмент. Поэтому Description должен быть конкретным и различимым.

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

tools := []openai.Tool{
    {
        Type: openai.ToolTypeFunction,
        Function: &openai.FunctionDefinition{
            Name:        "ping",
            Description: "Ping a host to check connectivity",
            Parameters: json.RawMessage(`{
                "type": "object",
                "properties": {
                    "host": {
                        "type": "string",
                        "description": "Hostname or IP address to ping"
                    }
                },
                "required": ["host"]
            }`),
        },
    },
}

Главное: Description — это самое важное поле! LLM ориентируется именно по нему, решая, какой инструмент вызвать.

Примеры инструментов в разных доменах

DevOps

// Проверка статуса сервиса
{
    Name: "check_service_status",
    Description: "Check if a systemd service is running",
    Parameters: {"service_name": "string"}
}

// Перезапуск сервиса
{
    Name: "restart_service",
    Description: "Restart a systemd service. WARNING: This will cause downtime.",
    Parameters: {"service_name": "string"}
}

// Чтение логов
{
    Name: "read_logs",
    Description: "Read the last N lines of service logs",
    Parameters: {"service": "string", "lines": "number"}
}

Support

// Получение тикета
{
    Name: "get_ticket",
    Description: "Get ticket details by ID",
    Parameters: {"ticket_id": "string"}
}

// Поиск в базе знаний
{
    Name: "search_kb",
    Description: "Search knowledge base for solutions",
    Parameters: {"query": "string"}
}

// Черновик ответа
{
    Name: "draft_reply",
    Description: "Draft a reply to the ticket",
    Parameters: {"ticket_id": "string", "message": "string"}
}

Data Analytics

// SQL запрос (read-only!)
{
    Name: "sql_select",
    Description: "Execute a SELECT query on the database. ONLY SELECT queries allowed.",
    Parameters: {"query": "string"}
}

// Описание таблицы
{
    Name: "describe_table",
    Description: "Get table schema and column information",
    Parameters: {"table_name": "string"}
}

// Проверка качества данных
{
    Name: "check_data_quality",
    Description: "Check for nulls, duplicates, outliers in a table",
    Parameters: {"table_name": "string"}
}

Security

// Запрос к SIEM
{
    Name: "query_siem",
    Description: "Query security information and event management system",
    Parameters: {"query": "string", "time_range": "string"}
}

// Изоляция хоста (требует подтверждения!)
{
    Name: "isolate_host",
    Description: "CRITICAL: Isolate a host from the network. Requires confirmation.",
    Parameters: {"host": "string"}
}

// Проверка IP репутации
{
    Name: "check_ip_reputation",
    Description: "Check if an IP address is known malicious",
    Parameters: {"ip": "string"}
}

Обработка ошибок инструментов

Если инструмент вернул ошибку, агент должен это увидеть и обработать.

Пример:

// Агент вызывает ping("nonexistent-host")
result := ping("nonexistent-host")
// result = "Error: Name or service not known"

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

// Модель получит ошибку и может:
// 1. Попробовать другой хост
// 2. Сообщить пользователю о проблеме
// 3. Эскалировать проблему

Запомни: Ошибка — это тоже результат! Не скрывайте ошибки от модели.

Валидация вызова инструментов

Перед выполнением инструмента нужно валидировать аргументы.

Пример валидации:

func executeTool(name string, args json.RawMessage) (string, error) {
    switch name {
    case "restart_service":
        var params struct {
            ServiceName string `json:"service_name"`
        }
        if err := json.Unmarshal(args, &params); err != nil {
            return "", fmt.Errorf("invalid args: %v", err)
        }

        // Валидация
        if params.ServiceName == "" {
            return "", fmt.Errorf("service_name is required")
        }

        // Проверка безопасности
        if params.ServiceName == "critical-db" {
            return "", fmt.Errorf("Cannot restart critical service without confirmation")
        }

        return restartService(params.ServiceName), nil
    }
    return "", fmt.Errorf("unknown tool: %s", name)
}

Repair аргументов tool call (если JSON/схема невалидны)

В продакшене это случается регулярно: модель иногда возвращает:

  • синтаксически невалидный JSON (пропущена }, кавычки и т.д.),
  • валидный JSON, но не проходящий вашу JSON Schema (не те поля/типы).

Если это случилось, не выполняйте инструмент в таком виде. Вместо этого добавьте repair-шаг: отдельный вызов LLM (или отдельный режим/модель), который получает:

  • имя инструмента,
  • JSON Schema аргументов,
  • исходные аргументы (как строку),

и возвращает только исправленный JSON, без объяснений.

Скелет логики:

if !json.Valid([]byte(call.Function.Arguments)) {
    // 1) repairArgs := repairWithLLM(call.Function.Arguments, toolSchema)
    // 2) повторить валидацию (json.Valid + schema)
    // 3) если всё ок — выполнять, иначе вернуть понятную ошибку и не выполнять tool
}

Ключевое правило: repair-шаг не меняет намерение, он чинит формат под схему.

Стратегия выбора модели для разных этапов

Зачем нужны разные модели?

В примерах выше мы использовали одну модель (GPT3Dot5Turbo) для всех этапов: и для выбора инструмента, и для анализа результатов. В учебном примере это нормально. В продакшене — нет.

Разные этапы работы агента требуют разных способностей:

Этап Что делает модель Нужная способность Подходящая модель
Tool Selection Сопоставляет запрос с описаниями инструментов Следование инструкциям, structured output Быстрая и дешёвая (GPT-4o-mini, Claude Haiku)
Argument Generation Заполняет JSON Schema аргументов JSON generation, следование схеме Быстрая и дешёвая
Result Analysis Анализирует результат инструмента, формулирует ответ Рассуждение, синтез информации Мощная (GPT-4o, Claude Sonnet)
Complex Planning Решает, какие инструменты вызвать и в каком порядке Планирование, multi-step reasoning Мощная

Суть: Tool selection — это простая задача классификации. Анализ результатов — это задача рассуждения. Платить за GPT-4o на этапе "выбрать ping из трёх инструментов" — расточительство.

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

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

"Используйте лучшую модель для всего. Она справится."

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

Агент делает 5-15 вызовов LLM за одну задачу. Если каждый вызов стоит $0.01 (GPT-4o) вместо $0.0002 (GPT-4o-mini), разница — 50x за задачу. При 10 000 задач в день это $1 000 vs $20.

Практика: используем дешёвую модель там, где задача простая, мощную — где нужно думать.

Реализация: ModelRouter

// ModelSelector выбирает модель в зависимости от этапа работы агента.
type ModelSelector struct {
    ToolCallModel   string // Модель для выбора инструмента и генерации аргументов
    AnalysisModel   string // Модель для анализа результатов и финального ответа
    ComplexModel    string // Модель для сложных задач (планирование, multi-tool)
}

func NewModelSelector() *ModelSelector {
    return &ModelSelector{
        ToolCallModel: "gpt-4o-mini",      // Быстрая, дешёвая, хорошо следует инструкциям
        AnalysisModel: "gpt-4o",            // Мощная, хорошо рассуждает
        ComplexModel:  "gpt-4o",            // Для сложного планирования
    }
}

// SelectModel определяет, какую модель использовать на текущей итерации.
// needsToolCall: ожидаем ли мы вызов инструмента на этом шаге.
// toolCount: сколько инструментов доступно (влияет на сложность выбора).
func (ms *ModelSelector) SelectModel(needsToolCall bool, toolCount int) string {
    if !needsToolCall {
        // Этап анализа результатов — нужна мощная модель
        return ms.AnalysisModel
    }
    if toolCount > 20 {
        // Много инструментов — сложный выбор, нужна мощная модель
        return ms.ComplexModel
    }
    // Обычный tool call — хватит дешёвой модели
    return ms.ToolCallModel
}

Как определить этап?

Runtime знает, на каком этапе находится агент. Правило простое:

func isToolCallExpected(messages []openai.ChatCompletionMessage) bool {
    if len(messages) == 0 {
        return true // Первый вызов — скорее всего нужен tool call
    }
    lastMsg := messages[len(messages)-1]

    // Если последнее сообщение — результат инструмента,
    // модель будет анализировать результат.
    // Но она МОЖЕТ вызвать ещё один инструмент.
    if lastMsg.Role == openai.ChatMessageRoleTool {
        return true // Может быть и tool call, и финальный ответ
    }
    return true
}

Важный нюанс: На практике мы не всегда знаем заранее, вызовет ли модель инструмент или ответит текстом. Поэтому часто используют более простую стратегию — переключение после N tool calls:

// Простая стратегия: первые N итераций — дешёвая модель,
// финальная итерация (когда tool calls закончились) — мощная.
func (ms *ModelSelector) SelectByIteration(iteration int, lastResponseHadToolCalls bool) string {
    if lastResponseHadToolCalls {
        // Ещё выполняем инструменты — дешёвая модель
        return ms.ToolCallModel
    }
    // Инструменты закончились — переключаемся на мощную для финального ответа
    return ms.AnalysisModel
}

Использование в Agent Loop

selector := NewModelSelector()
lastHadToolCalls := true // Первая итерация — ожидаем tool call

for i := 0; i < maxIterations; i++ {
    model := selector.SelectByIteration(i, lastHadToolCalls)

    resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
        Model:    model,  // Разная модель на разных этапах!
        Messages: messages,
        Tools:    tools,
    })
    if err != nil {
        return fmt.Errorf("LLM call failed: %w", err)
    }

    msg := resp.Choices[0].Message
    messages = append(messages, msg)
    lastHadToolCalls = len(msg.ToolCalls) > 0

    if !lastHadToolCalls {
        fmt.Printf("[model=%s] Final: %s\n", model, msg.Content)
        break
    }

    fmt.Printf("[model=%s] Tool calls: %d\n", model, len(msg.ToolCalls))
    // ... выполнение инструментов ...
}

Когда НЕ нужны разные модели

  • Прототип / MVP: Одна модель проще. Оптимизируйте потом.
  • Мало вызовов: Если агент делает 1-2 вызова в день — экономия не оправдает сложность.
  • Критичная согласованность: Некоторые задачи требуют, чтобы одна модель "помнила" свои рассуждения между итерациями. Смена модели может нарушить цепочку рассуждений.

Подробнее о стоимости и оптимизации см. Главу 20: Cost & Latency Engineering.

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

Ошибка 1: Модель не генерирует tool_call

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

Причина:

  • Модель не обучена на function calling
  • Плохое описание инструмента (Description неясное)
  • Temperature > 0 (слишком случайно)

Решение:

// ХОРОШО: Используйте модель с поддержкой tools
// Проверьте через Lab 00: Capability Check

// ХОРОШО: Улучшите Description
Description: "Check the status of a server by hostname. Use this when user asks about server status or availability."

// ХОРОШО: Temperature = 0
Temperature: 0,  // Детерминированное поведение

Ошибка 2: Сломанный JSON в аргументах

Симптом: json.Unmarshal возвращает ошибку. JSON в аргументах некорректен.

Причина: Модель генерирует некорректный JSON (пропущены скобки, неправильный формат).

Решение:

// ХОРОШО: Валидация JSON перед парсингом
if !json.Valid([]byte(call.Function.Arguments)) {
    return "Error: Invalid JSON", nil
}

// ХОРОШО: Temperature = 0 для детерминированного JSON
Temperature: 0,

Ошибка 3: Галлюцинации инструментов

Симптом: Агент вызывает несуществующий инструмент. Модель генерирует tool_call с именем, которого нет в списке.

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

Решение:

// ХОРОШО: Валидация имени инструмента перед выполнением
allowedFunctions := map[string]bool{
    "get_server_status": true,
    "ping":              true,
}
if !allowedFunctions[call.Function.Name] {
    return "Error: Unknown function", nil
}

Примечание: Если у вас большое пространство инструментов (сотни или тысячи), используйте tool retrieval — динамически выбирайте только релевантные инструменты перед планированием. Подробнее см. Главу 06: RAG.

Ошибка 4: Плохое описание инструмента

Симптом: Модель выбирает неправильный инструмент или не выбирает его вообще.

Причина: Description слишком общее или не содержит ключевых слов из запроса пользователя.

Решение:

// ПЛОХО
Description: "Ping a host"

// ХОРОШО
Description: "Ping a host to check network connectivity. Use this when user asks about network reachability or connectivity."

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

Упражнение 1: Создайте инструмент

Создайте инструмент check_disk_usage, который проверяет использование диска:

tools := []openai.Tool{
    {
        Type: openai.ToolTypeFunction,
        Function: &openai.FunctionDefinition{
            Name:        "check_disk_usage",
            Description: "...",  // Ваш код здесь
            Parameters:  json.RawMessage(`{
                // Ваш код здесь
            }`),
        },
    },
}

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

  • Description содержит ключевые слова: "disk", "usage", "space"
  • JSON Schema корректна
  • Обязательные поля указаны в required

Упражнение 2: Валидация аргументов

Реализуйте функцию валидации аргументов для инструмента:

func validateToolCall(call openai.ToolCall) error {
    // Проверьте имя инструмента
    // Проверьте валидность JSON
    // Проверьте обязательные поля
}

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

  • Функция возвращает ошибку, если имя инструмента неизвестно
  • Функция возвращает ошибку, если JSON некорректен
  • Функция возвращает ошибку, если отсутствуют обязательные поля

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

Сдано:

  • Description конкретное и понятное (содержит ключевые слова)
  • JSON Schema корректна
  • Обязательные поля указаны в required
  • Валидация аргументов реализована
  • Ошибки обрабатываются и возвращаются агенту
  • Критические инструменты требуют подтверждения
  • Модель успешно генерирует tool_call

Не сдано:

  • Модель не генерирует tool_call (плохое описание или неподходящая модель)
  • JSON в аргументах сломан (нет валидации)
  • Модель вызывает несуществующий инструмент (нет валидации имени)
  • Description слишком общее (модель не может выбрать правильный инструмент)

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

Полный пример: от начала до конца

Вот минимальный рабочий пример агента с одним инструментом:

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "os"

    "github.com/sashabaranov/go-openai"
)

func main() {
    // 1. Инициализация клиента
    config := openai.DefaultConfig(os.Getenv("OPENAI_API_KEY"))
    if baseURL := os.Getenv("OPENAI_BASE_URL"); baseURL != "" {
        config.BaseURL = baseURL // Для локальных моделей
    }
    client := openai.NewClientWithConfig(config)

    // 2. Определение инструмента
    tools := []openai.Tool{
        {
            Type: openai.ToolTypeFunction,
            Function: &openai.FunctionDefinition{
                Name:        "check_status",
                Description: "Check the status of a server by hostname. Use this when user asks about server status.",
                Parameters: json.RawMessage(`{
                    "type": "object",
                    "properties": {
                        "hostname": {
                            "type": "string",
                            "description": "Server hostname"
                        }
                    },
                    "required": ["hostname"]
                }`),
            },
        },
    }

    // 3. Запуск агента
    ctx := context.Background()
    userInput := "Проверь статус сервера web-01"

    messages := []openai.ChatCompletionMessage{
        {Role: "system", Content: "Ты DevOps инженер. Используй инструменты для проверки сервисов."},
        {Role: "user", Content: userInput},
    }

    // 4. Цикл агента
    for i := 0; i < 10; i++ {
        resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
            Model:       "gpt-4o-mini",
            Messages:    messages,
            Tools:       tools,
            Temperature: 0, // Детерминированное поведение
        })
        if err != nil {
            fmt.Printf("Error: %v\n", err)
            return
        }

        msg := resp.Choices[0].Message
        messages = append(messages, msg)

        // 5. Проверка: это tool_call или финальный ответ?
        if len(msg.ToolCalls) == 0 {
            // Финальный ответ
            fmt.Printf("Agent: %s\n", msg.Content)
            break
        }

        // 6. Выполнение инструмента (Runtime)
        for _, toolCall := range msg.ToolCalls {
            if toolCall.Function.Name != "check_status" {
                result := fmt.Sprintf("Error: Unknown tool %s", toolCall.Function.Name)
                messages = append(messages, openai.ChatCompletionMessage{
                    Role:       openai.ChatMessageRoleTool,
                    Content:    result,
                    ToolCallID: toolCall.ID,
                })
                continue
            }

            // Парсим аргументы
            var args struct {
                Hostname string `json:"hostname"`
            }
            if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
                result := fmt.Sprintf("Error: Invalid JSON: %v", err)
                messages = append(messages, openai.ChatCompletionMessage{
                    Role:       openai.ChatMessageRoleTool,
                    Content:    result,
                    ToolCallID: toolCall.ID,
                })
                continue
            }

            // Выполняем реальную функцию
            result := checkStatus(args.Hostname)

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

// Реальная функция, которую вызывает Runtime
func checkStatus(hostname string) string {
    // В реальном приложении здесь был бы вызов API или команда
    return fmt.Sprintf("Server %s is ONLINE", hostname)
}

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

  1. Агент получает запрос "Проверь статус сервера web-01"
  2. Модель видит инструмент check_status и генерирует tool_call
  3. Runtime парсит tool_call, валидирует и выполняет checkStatus("web-01")
  4. Результат добавляется в историю
  5. Модель видит результат и формулирует финальный ответ

Запуск:

export OPENAI_API_KEY="your-key"
export OPENAI_BASE_URL="http://localhost:1234/v1"  # Для локальных моделей
go run main.go

Что дальше?

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