От автора: Эта статья родилась из желания разобраться в том, что осталось за кадром отличного доклада.
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, ещё в работе агента нам понадобится:
-
mcp сервер погоды
-
Библиотека для его подключения github.com/modelcontextprotocol/go-sdk/mcp
-
GigaChat – непосредственно сама модель
-
Node.js – для работы mcp сервера погоды.
2.2. Регистрация в Sber Developers
Для доступа к GigaChat API нам нужно создать аккаунт в Sber Developers и создать там свой проект.
Шаг 1:
Шаг 2:
Шаг 3:
Шаг 4:
Шаг 5:
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/process
Content-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 из тела запроса в структуру AgentRequest
var req AgentRequest
raw := c.BodyRaw()
if err := json.Unmarshal(raw, &req); err != nil {
// Возвращаем HTTP 400 при невалидном JSON
return 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, накапливает ключи между шагами,
// передавая результаты каждого предыдущего этапа следующему
// здесь мы просто реализуем интерфейс Сhain
type 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
}
// Создаём сообщение для LLM
messages := []llms.MessageContent{
llms.TextParts(llms.ChatMessageTypeHuman, prompt),
}
// Настраиваем ToolChoice для принудительного вызова weather_forecast
toolChoice := llms.ToolChoice{
Type: "function",
Function: &llms.FunctionReference{Name: "weather_forecast"},
}
l.Info("[WeatherArgsExtract] Calling LLM with ToolChoice", "function", "weather_forecast")
// Вызываем LLM с инструментами и ToolChoice
resp, 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")
}
// Получаем ответ от LLM
llmResponse := resp.Choices[0].Content
l.Info("[WeatherArgsExtract] LLM response received", "content_preview", llmResponse)
// Извлекаем JSON из ответа LLM
jsonStr := 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")
}
// Логируем сырой вывод LLM
l.Debug("[ParseArgs] Raw LLM output", "raw", rawArgs, 200)
// Извлекаем JSON из строки (на случай markdown-разметки)
jsonStr := extractJSON(rawArgs)
l.Debug("[ParseArgs] Extracted JSON", "json", jsonStr)
// Парсим JSON в структуру weatherArgs
var args weatherArgs
if 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 error
mcpSessionOnce.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@latest
cmd := exec.Command("npx", "-y", "@dangahagan/weather-mcp@latest")
transport := &mcp.CommandTransport{Command: cmd}
var err error
mcpSession, 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")
}
// Преобразуем количество дней в int
days := 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.go
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_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-цепочку с шаблоном FinalWeatherPromptTemplate
chain := chains.NewLLMChain(
llm,
FinalWeatherPromptTemplate,
)
// Устанавливаем имя выходного ключа
chain.OutputKey = "text"
return chain
}
После прохождения всей цепочки модель возвращает свой ответ:
### Прогноз погоды на неделю в Новосибирскеnn**Город:** Новосибирск nn**Понедельник, 29 апреля 2026 года:** nПогода: облачная nТемпература воздуха: около +3°C nСкорость ветра: до 26 м/с nРекомендации: Возьмите теплый свитер или куртку, ветер сильный, возможен дискомфорт от холода.nn**Вторник, 30 апреля 2026 года:** nПогода: облачная nТемпература воздуха: около +4°C nСкорость ветра: до 18 м/с nРекомендации: Легкая куртка будет достаточно, ветрено, лучше одеться теплее.nn**Среда, 1 мая 2026 года:** nПогода: облачная nТемпература воздуха: около +7°C nСкорость ветра: до 21 м/с nРекомендации: Теплая одежда обязательна, ветер ощутимый, одевайтесь тепло.nn**Четверг, 2 мая 2026 года:** nПогода: облачная nТемпература воздуха: около +8°C nСкорость ветра: до 26 м/с nРекомендации: Наденьте легкую куртку или ветровку, температура комфортная, но ветер сильный.nn**Пятница, 3 мая 2026 года:** nПогода: облачная nТемпература воздуха: около +9°C nСкорость ветра: до 24 м/с nРекомендации: Одевайтесь легко, ветер умеренный, легкая верхняя одежда подойдет.nn**Суббота, 4 мая 2026 года:** nПогода: облачная nТемпература воздуха: около +8°C nСкорость ветра: до 26 м/с nРекомендации: Берите теплую одежду, погода прохладная, ветер порывистый.
Далее идет возврат ответа клиенту в виде JSON
{"message":"Запрос успешно обработан","response":"### Прогноз погоды на неделю в Новосибирскеnn**Город:** Новосибирск nn**Понедельник, 29 апреля 2026 года:** nПогода: облачная nТемпература воздуха: около +3°C nСкорость ветра: до 26 м/с nРекомендации: Возьмите теплый свитер или куртку, ветер сильный, возможен дискомфорт от холода.nn**Вторник, 30 апреля 2026 года:** nПогода: облачная nТемпература воздуха: около +4°C nСкорость ветра: до 18 м/с nРекомендации: Легкая куртка будет достаточно, ветрено, лучше одеться теплее.nn**Среда, 1 мая 2026 года:** nПогода: облачная nТемпература воздуха: около +7°C nСкорость ветра: до 21 м/с nРекомендации: Теплая одежда обязательна, ветер ощутимый, одевайтесь тепло.nn**Четверг, 2 мая 2026 года:** nПогода: облачная nТемпература воздуха: около +8°C nСкорость ветра: до 26 м/с nРекомендации: Наденьте легкую куртку или ветровку, температура комфортная, но ветер сильный.nn**Пятница, 3 мая 2026 года:** nПогода: облачная nТемпература воздуха: около +9°C nСкорость ветра: до 24 м/с nРекомендации: Одевайтесь легко, ветер умеренный, легкая верхняя одежда подойдет.nn**Суббота, 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 := tokenUrl
method := tokenMethod
payload := 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 Tok
err = 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)
}
// получаем acessToken
acessToken, err := getToken(token)
if err != nil {
l.Error("не удалось получить токен доступа!", "code", 404)
// panic(err)
}
// подключаемся к LLM
llm, 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, // включаем чувствительность к регистру в URL
StrictRouting: 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 %sn", route.Method, route.Path)
}
t := 3 * time.Second
err = 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_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)
}
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:
Шаг 3:
Шаг 4:
7.3. Ожидаемый ответ
{"message":"Запрос успешно обработан","response":"### Прогноз погоды на неделю в Новосибирскеnn**Город:** Новосибирск nn**Понедельник, 29 апреля 2026 года:** nПогода: облачная nТемпература воздуха: около +3°C nСкорость ветра: до 26 м/с nРекомендации: Возьмите теплый свитер или куртку, ветер сильный, возможен дискомфорт от холода.nn**Вторник, 30 апреля 2026 года:** nПогода: облачная nТемпература воздуха: около +4°C nСкорость ветра: до 18 м/с nРекомендации: Легкая куртка будет достаточно, ветрено, лучше одеться теплее.nn**Среда, 1 мая 2026 года:** nПогода: облачная nТемпература воздуха: около +7°C nСкорость ветра: до 21 м/с nРекомендации: Теплая одежда обязательна, ветер ощутимый, одевайтесь тепло.nn**Четверг, 2 мая 2026 года:** nПогода: облачная nТемпература воздуха: около +8°C nСкорость ветра: до 26 м/с nРекомендации: Наденьте легкую куртку или ветровку, температура комфортная, но ветер сильный.nn**Пятница, 3 мая 2026 года:** nПогода: облачная nТемпература воздуха: около +9°C nСкорость ветра: до 24 м/с nРекомендации: Одевайтесь легко, ветер умеренный, легкая верхняя одежда подойдет.nn**Суббота, 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-проектов. Берите, модифицируйте, добавляйте свои инструменты. Буду рад обсудить ваш опыт с созданием агентов.
Приложения
А. Полезные ссылки
-
LangChainGo Docs: https://github.com/tmc/langchaingo
-
GigaChat API: https://developers.sber.ru/docs/ru/gigachat
-
MCP Protocol: https://modelcontextprotocol.io/
-
Fiber v3: https://docs.gofiber.io/
-
Bruno: https://www.usebruno.com/
В. Исходный код
Репозиторий: https://github.com/art9276/weather-agent-mcp
Автор: artemkaVlg


