Разработка ИИ-агентов · Модуль 1 · Урок 1.5

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

Цель капстоуна

Соберём всё из Модуля 1 в один маленький, но настоящий агент на Go: он принимает вопрос пользователя, при необходимости вызывает инструмент, и возвращает ответ. Никаких фреймворков — только стандартная библиотека и HTTP-вызов модели. Этого достаточно, чтобы своими руками потрогать всю механику: messages, схему инструмента, tool calling, цикл, стоп-сигнал и safety-cap.

Возьмём два простых инструмента без внешних зависимостей: add(a, b) — сложение и now() — текущее время. Они тривиальны намеренно: фокус не на инструментах, а на контуре агента.

Архитектура минимального агента

Структура такая:

  • Типы DTOMessage, Tool, разбор ответа (choices, finish_reason, tool_calls). Можно начать с map[string]any, но типы читаются яснее.
  • Клиент модели — функция, делающая HTTP POST к OpenRouter и возвращающая распарсенный choice.
  • Реестр инструментовdispatch(name, argsJSON) string: по имени вызывает нужную Go-функцию и возвращает результат строкой.
  • ЦиклrunAgent из урока 1.4: send → стоп-сигнал → выполнение → дозапись → safety-cap.

Дальше в курсе мы будем наращивать именно этот скелет: лучше описывать инструменты, управлять контекстом и памятью, добавлять надёжность.

Что проверить на работающем агенте

Хороший признак, что капстоун удался: на вопрос «сколько будет 17 плюс 25 и который сейчас час?» агент сделает два оборота цикла — закажет инструменты, получит результаты, и только потом ответит текстом (финальный stop). А на вопрос «привет, кто ты?» — ответит сразу, без вызова инструментов (один оборот). Если оба сценария работают, значит контур замкнут правильно: стоп-сигнал, дозапись и safety-cap на месте.

Реестр инструментов: имя → выполнение → строковый результат
// dispatch выполняет инструмент по имени и возвращает результат строкой,
// которую мы вернём модели сообщением с ролью "tool".
func dispatch(name, argsJSON string) string {
    switch name {
    case "add":
        var a struct{ A, B float64 }
        if err := json.Unmarshal([]byte(argsJSON), &a); err != nil {
            return "ошибка аргументов: " + err.Error()
        }
        return fmt.Sprintf("%g", a.A+a.B)
    case "now":
        return time.Now().Format(time.RFC3339)
    default:
        return "неизвестный инструмент: " + name
    }
}
Главная функция: собираем историю, инструменты и запускаем цикл
func main() {
    tools := []Tool{
        toolDef("add", "Складывает два числа a и b. Используй для арифметики.",
            map[string]any{
                "type": "object",
                "properties": map[string]any{
                    "a": map[string]any{"type": "number"},
                    "b": map[string]any{"type": "number"},
                },
                "required": []string{"a", "b"},
            }),
        toolDef("now", "Возвращает текущее время в формате RFC3339. Аргументов нет.",
            map[string]any{"type": "object", "properties": map[string]any{}}),
    }

    messages := []Message{
        {Role: "system", Content: "Ты агент. Для арифметики и времени используй инструменты, не угадывай."},
        {Role: "user", Content: "Сколько будет 17 плюс 25 и который сейчас час?"},
    }

    answer, err := runAgent(messages, tools) // цикл из урока 1.4
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(answer)
}

Anti-patterns

Анти-паттернПочему плохоКак правильно
Тянуть фреймворк ради двух инструментовЛишняя зависимость и магия скрывают механикуДля капстоуна хватает stdlib и одного HTTP-вызова
Возвращать из инструмента не-строку без сериализацииtool-сообщению нужен текстовый contentПриводить результат к строке/JSON перед возвратом модели
Игнорировать ошибку парсинга аргументовПаника на кривом JSON от моделиПроверять ошибку и возвращать понятное сообщение модели
Забыть системный промпт про инструментыМодель «угадывает» вместо вызоваЯвно инструктировать использовать инструменты для фактов/действий

Практическое задание (Капстоун)

  • Соберите рабочий агент из уроков 1.2–1.4 с двумя инструментами (add, now).
  • Проверьте сценарий с инструментами: вопрос, требующий арифметики и времени, → агент делает 2 оборота и отвечает.
  • Проверьте сценарий без инструментов: «кто ты?» → один оборот, ответ сразу (stop).
  • Добавьте третий собственный инструмент (например, reverse(text)) и убедитесь, что модель выбирает его по описанию.
  • Залогируйте номер оборота, finish_reason и каждый вызов инструмента — так виден весь контур.
  • Проверьте срабатывание safety-cap, временно понизив лимит до 1.

Проверка знаний

Агент с инструментами add и now получил вопрос «который час?».

Сколько оборотов цикла, скорее всего, потребуется до финального ответа?

  • A Ноль — модель ответит без вызова API
  • B Два: первый оборот — запрос now, второй — финальный ответ с учётом результата
  • C Ровно столько, сколько указано в safety-cap
  • D Бесконечно — без таймера агент не остановится

Что должна вернуть функция-инструмент, чтобы её результат корректно ушёл обратно модели?

  • A Любой Go-объект, модель сама разберётся
  • B Строковое (текстовое/JSON) содержимое для content сообщения с ролью tool
  • C Только число
  • D HTML-разметку