От LLM к агенту: Как заставить Go приложение думать и действовать

от автора

От автора: Эта статья родилась из желания разобраться в том, что осталось за кадром отличного доклада.

1. Введение

1.1. История создания проекта

Всё началось с доклада Антона Юрченко «Улучшаем качество отчётов нагрузочного тестирования с помощью Go, LangChain и GigaChat».

Доклад мне понравился: чёткая постановка проблемы, грамотный подход к автоматизации, отличная идея с использованием LLM для генерации человекопонятных отчётов. Но после просмотра осталась одна проблема — код интеграции так и не показали.

Было сказано лишь, что нужно «реализовать интерфейс» для подключения GigaChat к LangChain. Звучит просто, но когда ты открываешь документацию LangChainGo, которая к слову еще написана только наполовину и пытаешься понять, с чего начать — возник вопрос: Какой именно интерфейс реализовывать? Далее по изучению документации возникли и другие:

  • Что такое функции в LLM и как их реализовывать?

  • Как связать это всё с цепочками (chains) и зачем они вообще нужны?

  • Что такое шаблон запроса и нужно ли мне им пользоваться?

Так появился этот pet-проект. Я решил сам разобраться и создать рабочий пример, который можно потрогать, запустить и модифицировать.

1.2. Цель статьи

Показать рабочий код интеграции GigaChat с LangChainGo на Go. В нём я хочу реализовать приложение которое указано в примерах как библиотеки, так и документации GigaChat, а именно сервис по определению погоды, но сделать не просто запрос к модели, которая отдаст мне возможно галлюцинации, а возможно и правильные данные. Создать и использовать агента, который будет из запроса пользователя получать город и количество дней для прогноза, передавать получать реальный прогноз погоды с помощью mcp-сервера ,а затем уже имея все необходимые данные формировать прогноз и давать совет по одежде, которую одеть на улицу.

2. Подготовка: что понадобится

2.1. Инструменты и зависимости

Для создания агента мы используем библиотеку github.com/tmc/langchaingo, ещё в работе агента нам понадобится:

  1. mcp сервер погоды

  2. Библиотека для его подключения github.com/modelcontextprotocol/go-sdk/mcp

  3. GigaChat — непосредственно сама модель

  4. Node.js — для работы mcp сервера погоды.

2.2. Регистрация в Sber Developers

Для доступа к GigaChat API нам нужно создать аккаунт в Sber Developers и создать там свой проект.

Шаг 1:

Переходим на developers.sber.ru, регистрируемся и авторизуемся.

Переходим на developers.sber.ru, регистрируемся и авторизуемся.

Шаг 2:

Выбираем создать новый проект.

Выбираем создать новый проект.

Шаг 3:

Выбираем AI-модели -> GigaChat API

Выбираем AI-модели -> GigaChat API

Шаг 4:

Заходим в созданный проект

Заходим в созданный проект

Шаг 5:

Выбираем настроить API и нажимаем получить новый ключ

Выбираем настроить API и нажимаем получить новый ключ

2.3. Создание и структура проекта

Для начала инициализируем проект weather-agent

go mod init weather-agent

Cтруктура нашего проекта:

Структура проекта

Структура проекта

3. Архитектура приложения

3.1. Общая схема

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

  • Модель, которая будет основой агента (Model)

  • Функции которые она умеет выполнять (Tools)

  • Память модели (Brain)

  • Цепочки которые используются для определения последовательности действий агента (Chain)

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

Схема работы агента

Схема работы агента

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

GigaChatLLM — Реализация интерфейса llms.Model для GigaChat, Agent — Координатор инструментов и исполнитель цепочек (WeatherAgent), Chains — Цепочка запросов, Tools — Внешние функции (weather через MCP), MCP Client — Клиент для подключения к weather-серверу

4. Поток данных: от запроса до ответа

Разберём полный путь запроса на примере: «Какая погода в Москве на 3 дня?»

4.1. Шаг 1: HTTP-запрос пользователя

POST http://localhost:8080/api/weather/processContent-Type: application/json{  "prompt": "Какая погода в Новосибирске на 7 дней?"}

4.2. Шаг 2: Контроллер (controller.go)

Файл: controller.go

const (weatherProcessRoute = "/weather/process" )// AgentRequest представляет запрос к агенту с произвольным промптом.type AgentRequest struct {Prompt string `json:"prompt"` // Текстовый запрос пользователя}// llmRoutes хранит зависимости для обработки запросов к LLM.type llmRoutes struct {l     *slog.Logger        // l — логгер для записи событийagent *agent.WeatherAgent // agent — агент для обработки запросов о погоде}// newLLMRoutes регистрирует все маршруты для работы с LLM.func newLLMRoutes(api fiber.Router, l *slog.Logger, agent *agent.WeatherAgent) {// Инициализируем структуру с зависимостямиlr := llmRoutes{l, agent}// Регистрируем обработчикapi.Post(weatherProcessRoute, lr.ProcessWeather)}// ProcessWeather обрабатывает запрос о прогнозе погоды.func (lr *llmRoutes) ProcessWeather(c fiber.Ctx) error {// Создаём контекст с таймаутом 60 секунд для получения прогнозаctx, cancel := context.WithTimeout(c.Context(), 60*time.Second)defer cancel()// Парсим JSON из тела запроса в структуру AgentRequestvar req AgentRequestraw := c.BodyRaw()if err := json.Unmarshal(raw, &req); err != nil {// Возвращаем HTTP 400 при невалидном JSONreturn c.Status(http.StatusBadRequest).JSON(fiber.Map{"success": false,"error":   "Невалидный JSON",})}// Проверяем, что поле prompt не пустоеif req.Prompt == "" {return c.Status(http.StatusBadRequest).JSON(fiber.Map{"success": false,"error":   "Поле 'prompt' обязательно",})}// Логируем полученный запрос о погоде с длиной промптаlr.l.Info("[ProcessWeather] Processing weather request", "prompt_length", len(req.Prompt))// Передаём запрос агенту для обработкиresponse, err := lr.agent.ProcessWeather(ctx, req.Prompt)if err != nil {// Логируем ошибку обработкиlr.l.Error("[ProcessWeather] Agent execution failed", "error", err)// Возвращаем HTTP 500 с описанием ошибкиreturn c.Status(http.StatusInternalServerError).JSON(fiber.Map{"success": false,"error":   "Ошибка обработки запроса: " + err.Error(),})}// Логируем успешную обработку с длиной ответаlr.l.Info("[ProcessWeather] Success", "response_length", len(response))// Отправляем ответ клиентуreturn c.Status(http.StatusOK).JSON(fiber.Map{"success":  true,"response": response,"message":  "Запрос успешно обработан",})}

4.3. Шаг 3: Агент (agent/weather_agent.go)

Файл: agent/weather_agent.go

// интерфейс для логирования.type Logger interface {Debug(msg string, args ...any)Info(msg string, args ...any)Warn(msg string, args ...any)Error(msg string, args ...any)}// основная структура агента для обработки запросов о погоде.type WeatherAgent struct {llm    llms.Model                   // языковая модельtool   *weather.WeatherForecastTool // инструмент для работы агентаlogger Logger                       // логгер}// NewWeatherAgent создаёт и инициализирует новый экземпляр WeatherAgent.func NewWeatherAgent(llm llms.Model, l Logger) *WeatherAgent {l.Info("[WeatherAgent] Agent initialized")weatherTool := weather.NewWeatherForecastTool()return &WeatherAgent{llm:    llm,tool:   weatherTool,logger: l,}}// ProcessWeather обрабатывает запрос о прогнозе погоды.func (wa *WeatherAgent) ProcessWeather(ctx context.Context, input string) (string, error) {wa.logger.Info("[WeatherAgent] Starting weather processing", "input_preview", input)// отдаем на обработку промта цепочкой агентаresult, err := weather.HandleWeatherRequest(ctx, wa.llm, wa.tool, wa.logger, input)if err != nil {wa.logger.Error("[WeatherAgent] Weather processing failed", "error", err)return "", err}wa.logger.Info("[WeatherAgent] Weather processing completed successfully")return result, nil}

4.4. Шаг 4: Обработчик погоды (weather/handler.go)

Файл: weather/handler.go

func HandleWeatherRequest(ctx context.Context, llm llms.Model, tool *WeatherForecastTool, l Logger, userInput string) (string, error) {// Логируем начало обработки погодного запросаl.Info("[WeatherHandler] Starting weather forecast chain", "input", userInput)// Создаём цепочки для каждого этапа обработки// createExtractArgsChain — извлекает город и количество дней из запросаextractChain := createExtractArgsChain(llm, l)// createParseArgsChain — парсит JSON с аргументами в структуруparseArgsChain := createParseArgsChain(l)// createWeatherToolChain — вызывает инструмент погоды с полученными аргументамиweatherToolChain := createWeatherToolChain(tool, l)// createFinalResponseChain — форматирует финальный ответ с рекомендациямиfinalResponseChain := createFinalResponseChain(llm)// Создаём последовательную цепочку из всех этаповaccumulatingChain := NewAccumulatingSequentialChain([]chains.Chain{extractChain,parseArgsChain,weatherToolChain,finalResponseChain,})l.Info("[WeatherHandler] AccumulatingSequentialChain created, executing...")// Запускаем цепочку с пользовательским запросомresult, err := accumulatingChain.Call(ctx, map[string]any{"userInput": userInput,})if err != nil {// Логируем ошибку выполнения цепочкиl.Error("[WeatherHandler] AccumulatingSequentialChain execution failed", "error", err)return "", err}// Извлекаем текстовый результат из выходных данныхfinalOutput, ok := result["text"].(string)if !ok {// Логируем ошибку типа данныхl.Error("[WeatherHandler] Invalid output type from AccumulatingSequentialChain")return "", fmt.Errorf("invalid output from AccumulatingSequentialChain")}// Логируем успешное завершениеl.Info("[WeatherHandler] AccumulatingSequentialChain completed successfully")return finalOutput, nil}

4.5. Шаг 5: Цепочка 1 — ExtractArgsChain

Задача: Извлечь город и количество дней из текстового запроса.

Здесь и далее мы будем использовать шаблоны, на основании которых и создаются наши промпты к модели добавляя в них необходимые нам данные

Файл: weather/template.go

var (// WeatherPromptTemplate — шаблон для извлечения аргументов из запроса пользователя.WeatherPromptTemplate prompts.PromptTemplate// FinalWeatherPromptTemplate — шаблон для генерации финального ответа с рекомендациями.FinalWeatherPromptTemplate prompts.PromptTemplate)// initTemplates инициализирует все шаблоны промптов.// Каждый шаблон определяется с текстом промпта и списком требуемых переменных.func InitTemplates() {// Инструктирует LLM определить город и количество дней для прогнозаWeatherPromptTemplate = prompts.NewPromptTemplate(`Ты — помощник по погоде. Твоя задача — помочь пользователю с прогнозом погоды и дать рекомендации по одежде.У тебя есть доступ к function weather_forecast, который возвращает прогноз погоды на указанное количество дней для заданного города.Инструкции:1. Проанализируй запрос пользователя и сгенерируй аргументы для weather_forecast. Если количество дней не указано, по умолчанию возьми 1.Верни ответ в виде jsonЗапрос пользователя: {{.userInput}}`,[]string{"userInput"},)// Преобразует сырые данные прогноза в понятный текст с рекомендациямиFinalWeatherPromptTemplate = prompts.NewPromptTemplate(`Ты — помощник по погоде. Получен прогноз погоды для города {{.city}} на {{.days}} дней:{{.forecast}}Проанализируй этот прогноз выведи название этого города и дай пользователю краткий сводный прогноз на каждый день, а также рекомендации по одежде (например, если ожидается дождь, возьми зонт; если холодно, надень теплую куртку). Выведи все в виде текста без специальных символов чтобы человек мог это понять.`,[]string{"city", "days", "forecast"},)}

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

Файл: weather/chains.go

// AccumulatingSequentialChain — кастомная цепочка для последовательного выполнения нескольких цепочек.// В отличие от стандартной SequentialChain, накапливает ключи между шагами,// передавая результаты каждого предыдущего этапа следующему// здесь мы просто реализуем интерфейс Сhaintype AccumulatingSequentialChain struct {chains []chains.Chain}// Call выполняет все цепочки последовательно, передавая результаты от одной к другой.func (c *AccumulatingSequentialChain) Call(ctx context.Context, inputs map[string]any, options ...chains.ChainCallOption) (map[string]any, error) {// Инициализируем результат входными даннымиresult := inputs// Последовательно выполняем каждую цепочкуfor _, chain := range c.chains {// Вызываем текущую цепочку с накопленными результатамиstepResult, err := chains.Call(ctx, chain, result, options...)if err != nil {// Возвращаем ошибку при неудаче любой цепочкиreturn nil, err}// Объединяем результаты с предыдущими (накопление ключей)result = mergeMaps(result, stepResult)}return result, nil}

Реализация самих цепочек:

// createExtractArgsChain создаёт цепочку для извлечения аргументов из запроса.func createExtractArgsChain(llm llms.Model, l Logger) chains.Chain {return chains.NewTransform(func(ctx context.Context, input map[string]any, _ ...chains.ChainCallOption) (map[string]any, error) {// Извлекаем пользовательский запрос из входных данныхuserInput, ok := input["userInput"].(string)if !ok {return nil, fmt.Errorf("invalid userInput type")}// Логируем начало извлечения аргументовl.Info("[WeatherArgsExtract] Extracting city and days from user input", "input", userInput)// Формируем промпт для LLM используя шаблон// WeatherPromptTemplate.Format подставляет userInput в шаблонprompt, err := WeatherPromptTemplate.Format(map[string]any{"userInput": userInput})if err != nil {l.Error("[WeatherArgsExtract] Failed to format prompt", "error", err)return nil, err}// Создаём сообщение для LLMmessages := []llms.MessageContent{llms.TextParts(llms.ChatMessageTypeHuman, prompt),}// Настраиваем ToolChoice для принудительного вызова weather_forecasttoolChoice := llms.ToolChoice{Type:     "function",Function: &llms.FunctionReference{Name: "weather_forecast"},}l.Info("[WeatherArgsExtract] Calling LLM with ToolChoice", "function", "weather_forecast")// Вызываем LLM с инструментами и ToolChoiceresp, err := llm.GenerateContent(ctx, messages,llms.WithTools(AvailableTools),llms.WithToolChoice(toolChoice),)if err != nil {l.Error("[WeatherArgsExtract] LLM.GenerateContent failed", "error", err)return nil, err}// Проверяем, что LLM вернула ответif len(resp.Choices) == 0 {return nil, fmt.Errorf("no response from LLM")}// Получаем ответ от LLMllmResponse := resp.Choices[0].Contentl.Info("[WeatherArgsExtract] LLM response received", "content_preview", llmResponse)// Извлекаем JSON из ответа LLMjsonStr := extractJSON(llmResponse)l.Debug("[WeatherArgsExtract] Extracted JSON", "json", jsonStr)// Возвращаем сырой JSON для следующего этапа парсингаreturn map[string]any{"raw_args": jsonStr,}, nil},[]string{"userInput"},[]string{"raw_args"},)}

Пример работы: Вход: "Какая погода в Москве на 3 дня?" Выход: {"city": "Москва", "days": 3}

Вход: "Покажи погоду в Питере" Выход: {"city": "Санкт-Петербург", "days": 1}

4.6. Шаг 6: Цепочка 2 — ParseArgsChain

Задача: Распарсить JSON в структуру и установить значения по умолчанию.

Файл: weather/handler.go

// createParseArgsChain создаёт цепочку для парсинга JSON аргументов.// Преобразует сырой JSON от LLM в структурированные данные (город и дни).func createParseArgsChain(l Logger) chains.Chain {return chains.NewTransform(func(ctx context.Context, input map[string]any, _ ...chains.ChainCallOption) (map[string]any, error) {// Извлекаем сырой JSON из предыдущего этапаrawArgs, ok := input["raw_args"].(string)if !ok {return nil, fmt.Errorf("invalid raw_args type")}// Логируем сырой вывод LLMl.Debug("[ParseArgs] Raw LLM output", "raw", rawArgs, 200)// Извлекаем JSON из строки (на случай markdown-разметки)jsonStr := extractJSON(rawArgs)l.Debug("[ParseArgs] Extracted JSON", "json", jsonStr)// Парсим JSON в структуру weatherArgsvar args weatherArgsif err := json.Unmarshal([]byte(jsonStr), &args); err != nil {// При ошибке парсинга используем значения по умолчаниюl.Warn("[ParseArgs] Failed to parse JSON, using fallback", "error", err)args.City = "Волгоград"args.Days = 1}// Устанавливаем значения по умолчанию для пустых полейif args.City == "" {args.City = "Волгоград"}if args.Days < 1 || args.Days > 7 {args.Days = 1}// Логируем распарсенные аргументыl.Info("[ParseArgs] Parsed arguments", "city", args.City, "days", args.Days)// Возвращаем структурированные данные для следующего этапаreturn map[string]any{"city": args.City,"days": args.Days,}, nil},[]string{"raw_args"},[]string{"city", "days"},)}

Валидация:

  • Пустой city → "Волгоград"

  • days < 1 или days > 7 → 1

4.7. Шаг 7: Цепочка 3 — WeatherToolChain

Задача: Вызвать MCP-инструмент weather_forecast и получить прогноз.

Инициализация MCP сервера: Файл: weather/mcp.go

// InitMCPSession инициализирует MCP сессию один раз при старте приложенияfunc InitMCPSession(ctx context.Context) error {var initErr errormcpSessionOnce.Do(func() {mcpSessionMu.Lock()defer mcpSessionMu.Unlock()// Создаём MCP клиентаclient := mcp.NewClient(&mcp.Implementation{Name: "weather-client", Version: "1.0.0"},nil,)// Подключаемся к MCP weather серверу через stdio// Сервер должен быть запущен отдельно: npx -y @dangahagan/weather-mcp@latestcmd := exec.Command("npx", "-y", "@dangahagan/weather-mcp@latest")transport := &mcp.CommandTransport{Command: cmd}var err errormcpSession, err = client.Connect(ctx, transport, nil)if err != nil {initErr = fmt.Errorf("failed to connect to MCP weather server: %w", err)return}})return initErr}

Применение инструмента:

Файл: weather/tool.go

// createWeatherToolChain создаёт цепочку для вызова инструмента погоды.// Получает прогноз погоды для указанного города на заданное количество дней.func createWeatherToolChain(weatherTool *WeatherForecastTool, l Logger) chains.Chain {return chains.NewTransform(func(ctx context.Context, input map[string]any, _ ...chains.ChainCallOption) (map[string]any, error) {// Извлекаем город из предыдущего этапаcity, ok := input["city"].(string)if !ok {return nil, fmt.Errorf("invalid city type")}// Преобразуем количество дней в intdays := convertToInt(input["days"])if days <= 0 {days = 1}// Логируем вызов инструмента погодыl.Info("[WeatherTool] Calling weather_forecast via LLM", "city", city, "days", days)// Формируем JSON-аргументы для вызова инструментаargsJSON, _ := json.Marshal(map[string]any{"city": city,"days": days,})l.Info("[WeatherTool] Executing weather_forecast tool", "args", string(argsJSON))// Вызываем инструмент получения прогноза погодыtoolResult, err := weatherTool.Call(ctx, string(argsJSON))if err != nil {l.Error("[WeatherTool] Tool call failed", "error", err)return nil, err}// Логируем результат работы инструментаl.Info("[WeatherTool] Tool completed", "result_preview", toolResult)// Возвращаем прогноз для следующего этапаreturn map[string]any{"forecast": toolResult,}, nil},[]string{"city", "days"},[]string{"forecast"},)}

MCP вызов:

// weather/mcp.gofunc fetchWeatherViaMCP(ctx context.Context, city string, days int) ([]DailyForecast, error) { session, err := getMCPSession() if err != nil {  return nil, fmt.Errorf("failed to get MCP session: %w", err) } // Сначала ищем координаты города через search_location searchParams := &mcp.CallToolParams{  Name: "search_location",  Arguments: map[string]any{   "query": city,   "limit": 1,  }, } searchResult, err := session.CallTool(ctx, searchParams) if err != nil {  return nil, fmt.Errorf("search_location failed: %w", err) } if searchResult.IsError {  return nil, fmt.Errorf("search_location returned error: %v", searchResult) } // Парсим результат поиска из Markdown формата lat, lon := parseLocationFromMarkdown(searchResult) if lat == 0 && lon == 0 {  return nil, fmt.Errorf("city '%s' not found", city) } // Получаем прогноз через get_forecast forecastParams := &mcp.CallToolParams{  Name: "get_forecast",  Arguments: map[string]any{   "latitude":    lat,   "longitude":   lon,   "days":        days,   "granularity": "daily",  }, } forecastResult, err := session.CallTool(ctx, forecastParams) if err != nil {  return nil, fmt.Errorf("get_forecast failed: %w", err) } if forecastResult.IsError {  return nil, fmt.Errorf("get_forecast returned error: %v", forecastResult) } // Парсим результат прогноза return parseForecastResult(forecastResult, days)}

4.8. Шаг 8: Цепочка 4 — FinalResponseChain

Задача: Сформировать человекочитаемый ответ с рекомендациями.

// createFinalResponseChain создаёт цепочку для генерации финального ответа.func createFinalResponseChain(llm llms.Model) *chains.LLMChain {// Создаём LLM-цепочку с шаблоном FinalWeatherPromptTemplatechain := chains.NewLLMChain(llm,FinalWeatherPromptTemplate,)// Устанавливаем имя выходного ключаchain.OutputKey = "text"return chain}

После прохождения всей цепочки модель возвращает свой ответ:

### Прогноз погоды на неделю в Новосибирске\n\n**Город:** Новосибирск  \n\n**Понедельник, 29 апреля 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +3°C  \nСкорость ветра: до 26 м/с  \nРекомендации: Возьмите теплый свитер или куртку, ветер сильный, возможен дискомфорт от холода.\n\n**Вторник, 30 апреля 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +4°C  \nСкорость ветра: до 18 м/с  \nРекомендации: Легкая куртка будет достаточно, ветрено, лучше одеться теплее.\n\n**Среда, 1 мая 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +7°C  \nСкорость ветра: до 21 м/с  \nРекомендации: Теплая одежда обязательна, ветер ощутимый, одевайтесь тепло.\n\n**Четверг, 2 мая 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +8°C  \nСкорость ветра: до 26 м/с  \nРекомендации: Наденьте легкую куртку или ветровку, температура комфортная, но ветер сильный.\n\n**Пятница, 3 мая 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +9°C  \nСкорость ветра: до 24 м/с  \nРекомендации: Одевайтесь легко, ветер умеренный, легкая верхняя одежда подойдет.\n\n**Суббота, 4 мая 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +8°C  \nСкорость ветра: до 26 м/с  \nРекомендации: Берите теплую одежду, погода прохладная, ветер порывистый.

Далее идет возврат ответа клиенту в виде JSON

{"message":"Запрос успешно обработан","response":"### Прогноз погоды на неделю в Новосибирске\n\n**Город:** Новосибирск  \n\n**Понедельник, 29 апреля 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +3°C  \nСкорость ветра: до 26 м/с  \nРекомендации: Возьмите теплый свитер или куртку, ветер сильный, возможен дискомфорт от холода.\n\n**Вторник, 30 апреля 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +4°C  \nСкорость ветра: до 18 м/с  \nРекомендации: Легкая куртка будет достаточно, ветрено, лучше одеться теплее.\n\n**Среда, 1 мая 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +7°C  \nСкорость ветра: до 21 м/с  \nРекомендации: Теплая одежда обязательна, ветер ощутимый, одевайтесь тепло.\n\n**Четверг, 2 мая 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +8°C  \nСкорость ветра: до 26 м/с  \nРекомендации: Наденьте легкую куртку или ветровку, температура комфортная, но ветер сильный.\n\n**Пятница, 3 мая 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +9°C  \nСкорость ветра: до 24 м/с  \nРекомендации: Одевайтесь легко, ветер умеренный, легкая верхняя одежда подойдет.\n\n**Суббота, 4 мая 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +8°C  \nСкорость ветра: до 26 м/с  \nРекомендации: Берите теплую одежду, погода прохладная, ветер порывистый.","success":true}

5. Реализация ключевых интерфейсов

5.1. Интерфейс llms.Model для GigaChat

LangChainGo требует реализации интерфейса llms.Model, о чём собственно и говорилось в докладе:

type Model interface {    GenerateContent(        ctx context.Context,         messages []Message,         options ...CallOption,    ) (*Response, error)}

Я не стал реализовывать свою версию, а воспользовался уже готовой из библиотеки openai.LLM так как GigaChat совместим с запросами типа openai. Нужно сказать, что необязательно использовать именно GigaChat, можно брать и другие модели, которые будут совместима с запросами к openai,так как сейчас почти все современные ллм поддерживают или частично поддерживают его. Но даже если модель несовместима, то есть уже готовые реализации других моделей, такие как Ollama, Mistral и другие. Если же и реализации не подходят для вашей модели, то тогда необходимо реализовать самому интерфейс Model.

5.2. llm.go — создание адаптера GigaChat

Cоздаем метод в котором получаем подключение к необходимой нам модели.

Файл: llm.go

const ( model = "GigaChat-2" //данная модель самая младшая в линейке, ее хватает чтобы решить данную задачу // если вы решаете что то более сложное целесообразно использовать более старшие модели url   = "https://gigachat.devices.sberbank.ru/api/v1")// func getGigaChatLLM(token string) (*openai.LLM, error) {    // подключение к модели по url, также есть возможность делать это через grpc llm, err := openai.New(openai.WithToken(token), openai.WithBaseURL(url), openai.WithModel(model)) if err != nil {  return nil, err } return llm, nil}

5.3. token.go — OAuth 2.0 токенизация

GigaChat использует OAuth 2.0. В моем проекте я получаю токен 1 раз, но если вы хотите чтобы приложение работало постоянно необходимо предусмотреть обновление токена, согласно документации срок его жизни 30 минут.

Файл: token.go

// getToken получает acessToken на основе нашего ключаfunc getToken(authKey string) (string, error) {//готовим данные для запросаurl := tokenUrlmethod := tokenMethodpayload := strings.NewReader(scope)client := &http.Client{}req, err := http.NewRequest(method, url, payload)if err != nil {return "", err}req.Header.Add("Content-Type", "application/x-www-form-urlencoded")req.Header.Add("Accept", "application/json")req.Header.Add("RqUID", uuid.New().String())req.Header.Add("Authorization", "Basic "+authKey)// делаем сам запрос на сревера Сбербанка для получения токенаres, err := client.Do(req)if err != nil {return "", err}defer res.Body.Close()body, err := io.ReadAll(res.Body)if err != nil {return "", err}//разбираем полученные данныеvar tok Tokerr = json.Unmarshal(body, &tok)if err != nil {return "", err}return tok.AcessToken, nil}// описание структуры токенаtype Tok struct {AcessToken string `json:"access_token" db:"access_token"`ExpiresAt  int64  `json:"expires_at" db:"expires_at"`}

5.4. main.go — инициализация

Собираем и запускаем наш сервис

Файл: main.go

func main() {//инициализируем логгерl := newLogger(0, true)//получаем переменные для работы приложенияtoken, ip, port, _, err := getConfig()if err != nil {l.Error("не удалось получить токен авторизации!", "code", 404)panic(err)}// получаем acessTokenacessToken, err := getToken(token)if err != nil {l.Error("не удалось получить токен доступа!", "code", 404)//panic(err)}// подключаемся к LLMllm, err := getGigaChatLLM(acessToken)if err != nil {l.Error("не удалось подключиться к ллм модели!", "code", 500)}// Инициализируем инструменты и шаблоны weather-пакетаweather.InitTools()weather.InitTemplates()// Инициализируем MCP сессию для weather инструментаctx := context.Background()if err := weather.InitMCPSession(ctx); err != nil {l.Warn("не удалось инициализировать MCP weather сессию", "error", err)l.Info("weather инструмент будет недоступен")} else {l.Info("MCP weather сессия успешно инициализирована")// Регистрируем закрытие MCP сессии при остановкеdefer func() {if err := weather.CloseMCPSession(); err != nil {l.Error("ошибка закрытия MCP сессии", "error", err)}}()}// Создаём агентаagentInstance := agent.NewWeatherAgent(llm, l)app := fiber.New(fiber.Config{AppName:        "llm Service",ServerHeader:   "llm Service", // добавляем заголовок для идентификации сервераCaseSensitive:  true,          // включаем чувствительность к регистру в URLStrictRouting:  true,RequestMethods: []string{"POST"}, // включаем строгую маршрутизацию})api := app.Group("/api") // /api//не даем падать нашему сервису при паникеapi.Use(recover.New())api.Use(cors.New(cors.Config{AllowHeaders: []string{"Origin, Content-Type, Accept, Authorization"},AllowMethods: []string{"POST"},}))api.Use(compress.New(compress.Config{Level: compress.LevelBestSpeed, // 1}))// Передаём агент в роутыnewLLMRoutes(api, l, agentInstance)routes := app.GetRoutes()for _, route := range routes {fmt.Printf("%s %s\n", route.Method, route.Path)}t := 3 * time.Seconderr = serveServer(app, ip, port, t, l)if err != nil {l.Error("Server ListenAndServe error")panic(err)}}

6. Настройка MCP для weather-инструмента

6.1. Что такое MCP (Model Context Protocol)

MCP — это протокол для подключения внешних инструментов к LLM-приложениям.

Представьте, что ваша LLM — это мозг. MCP — это руки, которые могут:

  • Делать HTTP-запросы к API

  • Читать файлы

  • Выполнять код

  • Работать с базами данных

В нашем случае MCP подключается к weather-серверу, который возвращает прогноз погоды.

6.2. weather/mcp.go — инициализация сессии

Файл: weather/mcp.go

Помимо функции инициализации, которую мы уже рассмотрели выше также используются функции получения и закрытия сессии с mcp сервером.

// getMCPSession возвращает существующую MCP сессиюfunc getMCPSession() (*mcp.ClientSession, error) {mcpSessionMu.Lock()defer mcpSessionMu.Unlock()if mcpSession == nil {return nil, fmt.Errorf("MCP session not initialized. Call InitMCPSession first")}return mcpSession, nil}// CloseMCPSession закрывает MCP сессию (вызывать при остановке приложения)func CloseMCPSession() error {mcpSessionMu.Lock()defer mcpSessionMu.Unlock()if mcpSession != nil {mcpSession.Close()mcpSession = nil}return nil}

Также у нас есть основная функция получения данных по mcp:

// fetchWeatherViaMCP получает прогноз погоды через MCP серверfunc fetchWeatherViaMCP(ctx context.Context, city string, days int) ([]DailyForecast, error) {session, err := getMCPSession()if err != nil {return nil, fmt.Errorf("failed to get MCP session: %w", err)}// Сначала ищем координаты города через search_locationsearchParams := &mcp.CallToolParams{Name: "search_location",Arguments: map[string]any{"query": city,"limit": 1,},}searchResult, err := session.CallTool(ctx, searchParams)if err != nil {return nil, fmt.Errorf("search_location failed: %w", err)}if searchResult.IsError {return nil, fmt.Errorf("search_location returned error: %v", searchResult)}// Парсим результат поиска из Markdown форматаlat, lon := parseLocationFromMarkdown(searchResult)if lat == 0 && lon == 0 {return nil, fmt.Errorf("city '%s' not found", city)}// Получаем прогноз через get_forecastforecastParams := &mcp.CallToolParams{Name: "get_forecast",Arguments: map[string]any{"latitude":    lat,"longitude":   lon,"days":        days,"granularity": "daily",},}forecastResult, err := session.CallTool(ctx, forecastParams)if err != nil {return nil, fmt.Errorf("get_forecast failed: %w", err)}if forecastResult.IsError {return nil, fmt.Errorf("get_forecast returned error: %v", forecastResult)}// Парсим результат прогнозаreturn parseForecastResult(forecastResult, days)}

7. Примеры использования

7.1. Тестирование через curl

Базовый запрос:

curl -X POST http://localhost:8080/api/weather/process \  -H "Content-Type: application/json" \  -d '{"prompt": "Какая погода в Москве на 3 дня?"}'

7.2. Тестирование через Bruno

Шаг 1: Устанавливаем Bruno (open-source альтернатива Postman).

Шаг 2:

Создаём новую коллекцию GigaChat Weather API

Создаём новую коллекцию GigaChat Weather API

Шаг 3:

Добавляем запрос

Добавляем запрос

Шаг 4:

Отправляем запрос и видим ответ

Отправляем запрос и видим ответ

7.3. Ожидаемый ответ

{"message":"Запрос успешно обработан","response":"### Прогноз погоды на неделю в Новосибирске\n\n**Город:** Новосибирск  \n\n**Понедельник, 29 апреля 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +3°C  \nСкорость ветра: до 26 м/с  \nРекомендации: Возьмите теплый свитер или куртку, ветер сильный, возможен дискомфорт от холода.\n\n**Вторник, 30 апреля 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +4°C  \nСкорость ветра: до 18 м/с  \nРекомендации: Легкая куртка будет достаточно, ветрено, лучше одеться теплее.\n\n**Среда, 1 мая 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +7°C  \nСкорость ветра: до 21 м/с  \nРекомендации: Теплая одежда обязательна, ветер ощутимый, одевайтесь тепло.\n\n**Четверг, 2 мая 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +8°C  \nСкорость ветра: до 26 м/с  \nРекомендации: Наденьте легкую куртку или ветровку, температура комфортная, но ветер сильный.\n\n**Пятница, 3 мая 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +9°C  \nСкорость ветра: до 24 м/с  \nРекомендации: Одевайтесь легко, ветер умеренный, легкая верхняя одежда подойдет.\n\n**Суббота, 4 мая 2026 года:**  \nПогода: облачная  \nТемпература воздуха: около +8°C  \nСкорость ветра: до 26 м/с  \nРекомендации: Берите теплую одежду, погода прохладная, ветер порывистый.","success":true}

8. Итоги

8.1. Что было сделано

Реализована полная интеграция GigaChat + LangChainGo

  • Функция получения модели для интерфейса llms.Model через openai.LLM

  • Работа с цепочками (chains)

  • Вызов функции агента

Создан рабочий HTTP-сервер

Добавлена поддержка MCP

  • Подключение внешних инструментов

8.2. Что можно улучшить

  • Добавить другие инструменты

  • Реализовать кэширование ответов

  • Поддержать streaming ответов

  • Добавить память и историю чатов

Нужно сказать, что согласно GigaChat API также возможна реализация этого функционала по протоколу GRPC.

9. Заключение

Доклад Антона Юрченко дал отличную идею, но не хватало практического кода. Этот проект — попытка восполнить этот пробел.

Главный вывод: интеграция GigaChat с LangChainGo — это не так страшно, как кажется. Этот код — готовая основа для pet-проектов. Берите, модифицируйте, добавляйте свои инструменты. Буду рад обсудить ваш опыт с созданием агентов.

Приложения

А. Полезные ссылки

В. Исходный код

Репозиторий: https://github.com/art9276/weather-agent-mcp

ссылка на оригинал статьи https://habr.com/ru/articles/1033718/