Масштабирование LLM с помощью Golang: как мы обслуживаем миллионы запросов LLM
- среда, 24 декабря 2025 г. в 00:00:15
Хотя экосистема LLM в основном ориентирована на Python, мы нашли Go исключительно подходящим для производственных развертываний. Наша инфраструктура на базе Go обрабатывает миллионы ежемесячных запросов LLM с минимальной настройкой производительности. Помимо хорошо документированных преимуществ Go (см. отличное изложение Роба Пайка о преимуществах Go), три возможности оказались особенно ценными для нагрузок LLM: статическая проверка типов для обработки выходных данных модели, горутины для управления параллельными вызовами API и интерфейсы для построения составных конвейеров ответов. Вот как мы реализовали каждую из них в нашем производственном стеке.
Одной из основных проблем с LLM является обработка их неструктурированных выходных данных. Поддержка структурированных выходных данных OpenAI стала значительным продвижением для нас, и система типов Go делает её особенно элегантной для реализации. Вместо написания отдельных определений схемы, мы можем использовать теги структур Go и рефлексию для генерации четко определенных схем. Вот пример, где мы автоматически конвертируем SupportResponse в формат JSON-схемы OpenAI с использованием библиотеки go-openai:
import (
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
)
type SupportResponse struct {
Answer string `json:"answer"`
RelatedDocs []string `json:"related_docs"`
}
func GetSupportResponse(messages []openai.ChatCompletionMessage) (*SupportResponse, error) {
var supportResponse SupportResponse
schema, err := jsonschema.GenerateSchemaForType(supportResponse)
if err != nil {
return nil, err
}
resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Messages: messages,
ResponseFormat: &openai.ChatCompletionResponseFormat{
Type: openai.ChatCompletionResponseFormatTypeJSONSchema,
JSONSchema: &openai.ChatCompletionResponseFormatJSONSchema{
Name: "support_response",
Schema: schema,
Strict: true,
},
},
})
if err != nil {
return nil, err
}
err = schema.Unmarshal(resp.Choices[0].Message.Content, &supportResponse)
if err != nil {
return nil, err
}
return &supportResponse, nil
}
Вышеприведенный код предоставит нам Answer и RelatedDocs, заполненные непосредственно из вызова LLM. Теперь SupportResponse можно легко передать на наш фронтенд или сохранить в нашей базе данных.
Обратите внимание, что поскольку у Golang есть встроенная система типов, вам не нужно тратить дополнительное время на определение структуры объекта (как вы бы сделали в Python) - она уже доступна через рефлексию, и вы можете потратить больше времени на промптинг, входы и выходы LLM.
Приложения LLM часто требуют параллельных вызовов API и сложной оркестрации. Горутины и каналы Go делают это удивительно просто.
Например, предположим, мы запускаем конвейер Retrieval Augmented Generation (RAG) и хотим выполнить гибридный поиск по трем различным поисковым бэкендам (см. нашу статью о Лучших результатах RAG с помощью Reciprocal Rank Fusion и гибридного поиска). Запуск этих поисков последовательно добавил бы их индивидуальные задержки, что привело бы к более медленным ответам. С Go мы можем относительно легко распараллелить поиски по нескольким бэкендам:
func ParallelSearch(query string) []SearchResult {
ctx, cancel := context.WithTimeout(context.Background(), 750*time.Millisecond)
defer cancel()
resultsChan := make(chan []SearchResult, len(backends))
var wg sync.WaitGroup
for _, backend := range backends {
wg.Add(1)
go func(backend func(string) ([]SearchResult, error)) {
defer wg.Done()
results, err := backend(query)
if err != nil {
return
}
select {
case resultsChan <- results:
case <-ctx.Done():
}
}(backend)
}
wg.Wait()
close(resultsChan)
var combined []SearchResult
for res := range resultsChan {
combined = append(combined, res...)
}
return combined
}
Этот паттерн снижает нашу общую задержку до задержки самого медленного бэкенда, с настраиваемым таймаутом для предотвращения блокировки всей системы одним медленным бэкендом. Результаты собираются через канал Go и объединяются после завершения всех горутин или истечения времени.
Выходные данные LLM часто нуждаются в нескольких преобразованиях перед тем, как они готовы для конечных пользователей. Например, если вы используете провайдера LLM с отличными способностями к рассуждению, но еще не имеющего структурированных выходных данных (например, Claude 3.5 Sonnet), вам, вероятно, захочется структурировать выходные данные в вашем промпте и разобрать выходные данные перед передачей конечному пользователю.
Мы построили составной конвейер, который делает эти преобразования как поддерживаемыми, так и тестируемыми:
type ResponseCleaner interface {
Clean(context.Context, string) (string, []ResponseDetails, error)
}
type ResponseDetails struct {
DetailType string `json:"detail_type"`
Content interface{} `json:"content"`
}
Каждый очиститель является дискретной единицей, которая обрабатывает одно конкретное преобразование. Это разделение ответственности делает тестирование простым и позволяет нам модифицировать индивидуальные преобразования без касания остальной части конвейера. Вот как мы обрабатываем цитирование источников:
type CitedSourceCleaner struct{}
func (c CitedSourceCleaner) Clean(ctx context.Context, message string) (string, []ResponseDetails, error) {
sourceRegex := regexp.MustCompile(`\[(Source|Ref):\s*([^\]]+)\]`)
var citations []ResponseDetails
matches := sourceRegex.FindAllStringSubmatch(message, -1)
for i, match := range matches {
citations = append(citations, ResponseDetails{
DetailType: "citation",
Content: map[string]interface{}{
"number": i + 1,
"source": match[2],
},
})
message = strings.Replace(message, match[0],
fmt.Sprintf("[%d]", i+1), 1)
}
return message, citations, nil
}
Используя вышеприведенный очиститель, когда LLM отвечает:
Согласно [Source: docs/onboarding.pdf] и [Source: kb/troubleshooting.md], лимит скорости API составляет 100 запросов в минуту для [Source: pricing.pdf] премиум-аккаунтов.
Очиститель проанализирует источники и передаст их на фронтенд как детали ответа. Он также преобразует сырые выходные данные LLM в:
Согласно [1] и [2], лимит скорости API составляет 100 запросов в минуту для [3] премиум-аккаунтов.
В то время как Go питает нашу производственную инфраструктуру, Python остается необходимым для экспериментов с ML и быстрого прототипирования. Экосистема Python превосходна в задачах вроде:
Кластеризация тикетов поддержки с scikit-learn (например, с AgglomerativeClustering)
Тонкая настройка LLM с transformers (особенно открытыми исходными моделями вроде Llama), особенно для кастомизации моделей на наших данных поддержки
Прототипирование RAG с sentence-transformers для тестирования моделей эмбеддингов и стратегий чанкинга
Эти задачи были бы значительно более сложными в Go, где ML-библиотеки либо не существуют, либо гораздо менее зрелые.
Чтобы преодолеть разрыв между Go и Python, мы поддерживаем легковесный Python-сервис, который наша Go-инфраструктура вызывает. Этот сервис обрабатывает вычислительно интенсивные ML-задачи (такие как генерация эмбеддингов или кластеризация), сохраняя нашу основную инфраструктуру на Go. На практике мы часто прототипируем фичи полностью на Python, затем постепенно портируем критически важные для производительности компоненты на Go после их доказательства. Этот подход позволяет нам поставлять улучшения инкрементально без ожидания полной Go-реализации.
Сильные стороны Go в типобезопасности, параллелизме и построении интерфейсов сделали его отличным выбором для нашей LLM-инфраструктуры. В то время как Python остается нашим языком выбора для ML-разработки, Go предоставляет производительность и надежность, необходимые нам в продакшене. Комбинация обоих языков позволяет нам двигаться быстро, сохраняя надежную, масштабируемую систему.