Зачем плодить сущности? Или почему Go, а не Python
Каждый раз, когда я вижу очередного "AI-репетитора", написанного на Python с помощью FastAPI и LangChain, меня передергивает. Нет, Python — отличный язык для прототипов. Но как только задача упирается в production-нагрузку, параллелизм, дешевизну инференса и четкую архитектуру — Go выигрывает всухую. А LangChain, извините, превращает код в спагетти-монстра с десятком уровней абстракции, которые не отлажишь без бутылки чего-то покрепче.
Поэтому я написал Lexis — open-source AI-репетитора по английскому на Go. Чистая архитектура, никакого лишнего жира, pluggable LLM-провайдеры (GPT-4o, Claude 3.5 Sonnet, Mistral Large, Gemini 2.5 Pro), MIT лицензия. Код выложен на GitHub, ссылка в конце.
В этой статье я раздеру архитектуру проекта по косточкам: от хендлеров до кастомных промптов для каждой LLM. Без воды, только код и грабли, на которые я наступил, пока проект не стал стабильным. Если вы хотите построить своего репетитора — берите лучшее, отбрасывайте худшее.
Clean Architecture в Go: не модно, а удобно
Скажу честно: я не фанат фанатичного следования догмам. Clean Architecture в Go часто вырождается в лишние слои ради "правильности". Но для LLM-приложений это спасение. Когда у тебя 4 разных AI-провайдера, каждый со своими заморочками (rate limits, форматы ошибок, разная структура ответа), без четкого разделения слоев ты утонишь в if-else.
В Lexis слои такие:
- Handlers — HTTP или gRPC, минимальная логика, только валидация входящих данных и вызов usecase.
- Usecases — бизнес-логика: составление промпта, работа с историей диалога, агрегация ответов от LLM.
- Repositories — хранилища (PostgreSQL для пользователей и статистики, Redis для кэша).
- LLM Providers — абстракция над API конкретных моделей.
И никакого domain без domain — все интерфейсы живут в пакете domain, реализации — в infrastructure. Звучит банально, но именно это спасает, когда нужно заменить OpenAI на локальную модель или добавить rate limiter.
1 Handler: не пиши логику, только коммутация
Вот как выглядит handler для генерации урока:
// internal/handler/lesson.go
func (h *Handler) GenerateLesson(w http.ResponseWriter, r *http.Request) {
var req dto.GenerateLessonRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
return
}
// валидация структуры
if req.Level == "" || req.Topic == "" {
http.Error(w, `{"error":"level and topic required"}`, http.StatusBadRequest)
return
}
ctx := r.Context()
lesson, err := h.uc.GenerateLesson(ctx, req.Level, req.Topic, req.LlmProvider)
if err != nil {
// логируем, отдаём 500
slog.Error("generate lesson", "err", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(lesson)
}
Обратите внимание: handler не знает, какая LLM будет использоваться. Он просто передаёт строку "gpt4o" или "claude-sonnet", а usecase сам решает, какой провайдер поднять.
⚠️ Типичная ошибка — тащить HTTP-зависимости (заголовки, sessionID) в usecase. У нас handler должен вычистить контекст: только domain-значения.
2 Usecase: мозг репетитора
В usecase сидит вся магия. Я вынес составление промпта в отдельный builder, потому что для разных моделей один и тот же промпт работает по-разному. GPT-4o любит структурированные инструкции в Markdown, Claude — естественный язык с набором правил, а Gemini — контекстные примеры.
// internal/usecase/lesson.go
type LessonUsecase struct {
llmRegistry *llm.Registry
promptBuilder *PromptBuilder
}
func (uc *LessonUsecase) GenerateLesson(ctx context.Context, level, topic, provider string) (*domain.Lesson, error) {
llm, err := uc.llmRegistry.Get(provider)
if err != nil {
return nil, fmt.Errorf("unknown LLM provider: %w", err)
}
prompt := uc.promptBuilder.BuildLessonPrompt(level, topic, provider)
response, err := llm.Generate(ctx, prompt, llm.WithTemperature(0.7))
if err != nil {
return nil, fmt.Errorf("LLM call failed: %w", err)
}
lesson := parseLessonResponse(response)
// здесь можно добавить валидацию структуры, проверку на пустые поля
return lesson, nil
}
Видите llm.Registry? Это наш pluggable-механизм. Он хранит маппинг строки-ключа на интерфейс LLMProvider:
// internal/llm/registry.go
type Registry struct {
providers map[string]LLMProvider
}
func NewRegistry(cfg config.LLMConfig) *Registry {
r := &Registry{providers: make(map[string]LLMProvider)}
r.providers["gpt4o"] = openai.NewProvider(cfg.OpenAIKey, cfg.OpenAIModel)
r.providers["claude-sonnet"] = anthropic.NewProvider(cfg.AnthropicKey, cfg.AnthropicModel)
r.providers["mistral-large"] = mistral.NewProvider(cfg.MistralKey, cfg.MistralModel)
r.providers["gemini-pro"] = gemini.NewProvider(cfg.GeminiKey, cfg.GeminiModel)
return r
}
И всё! Если завтра выходит новая модель — добавляем в конфиг и регистрируем. Никаких изменений в бизнес-логике. Это не просто удобно, это критично, когда нужно сравнить качество ответов разных LLM на одних и тех же упражнениях. Как раз недавно писал про LLM-судью для объективной оценки — с таким реестром тестировать модели в разы проще.
Четыре провайдера — четыре разных подхода к промпту
Если вы думаете, что OpenAI, Anthropic и Google используют один язык — вы глубоко заблуждаетесь. Даже формат ответа у них разный. GPT-4o (апрель 2026 года) отлично парсит JSON-схемы, Claude 3.5 Sonnet предпочитает XML-теги, Mistral Large — простой текст с разделителями, Gemini 2.5 Pro — flexible JSON, но с обязательным one-shot примером.
Я не стал унифицировать всё через один формат — это только замедлило бы интеграцию. Вместо этого мы пишем свой builder для каждого провайдера, но на выходе все отдают domain.Lesson. Вот пример промпта для Claude:
You are an English tutor. Generate a lesson for level {level} on topic "{topic}".
<instructions>
- 5 new vocabulary words with definitions and example sentences
- 3 fill-in-the-blank exercises
- 2 open-ended conversation questions
- Explain one grammar rule related to the topic
- Return ONLY valid XML matching the schema below
</instructions>
<schema>
<lesson>
<vocabulary><word>...</word></vocabulary>
<exercises type="fill-blank">...</exercises>
<questions>...</questions>
<grammar>...</grammar>
</lesson>
</schema>
А вот GPT-4o получает такой же по смыслу, но в формате Markdown на выходе с указателем JSON Schema. Разница в том, что Claude иногда игнорирует часть инструкций, если их слишком много — пришлось разбить на блоки. GPT-4o, наоборот, лучше выполняет детальные инструкции.
Забавный момент: Gemini 2.5 Pro без примера в промпте генерировал уроки только на американском английском, игнорируя британские варианты. Один few-shot пример с "lorry" и "flat" решил проблему. Статья про KEF как раз про то, как few-shot улучшает reasoning — здесь сработало аналогично.
Как НЕ надо проверять ответы ученика
Первая версия Lexis отправляла ответ ученика на проверку той же модели, которая генерировала упражнение. Логично? Нет. Потому что модель часто соглашалась с неправильным ответом (особенно если ученик уверенно отвечал) или давала обратную связь в духе "почти правильно, но можно лучше". Второе — недопустимо для обучения.
Решение: для проверки используем другую модель. Например, урок сгенерирован через Cluade (дорогой, но креативный), а проверку делает Mistral Large (бюджетный, но строгий к фактам). Это дало прирост точности проверки на 15% по нашим метрикам. Код слой проверки выглядит так:
// internal/usecase/check.go
func (uc *CheckUsecase) CheckAnswer(ctx context.Context, lessonID, userAnswer string) (*domain.Feedback, error) {
lesson, err := uc.lessonRepo.GetByID(ctx, lessonID)
if err != nil {
return nil, err
}
// используем другой провайдер для проверки
checkerLLM := uc.llmRegistry.Get("mistral-large")
prompt := uc.promptBuilder.BuildCheckPrompt(lesson, userAnswer)
response, err := checkerLLM.Generate(ctx, prompt, llm.WithTemperature(0.2))
// ... парсинг ответа
}
Температура 0.2 — чтобы модель не фантазировала. Ещё мы логируем все проверки и раз в неделю прогоняем через LLM-судью, о котором писал выше, чтобы выявить систематические ошибки конкретного провайдера. Подход AI-SETT с 600 критериями мы не внедряли — для репетитора хватает 20 метрик качества проверки.
Сравнение с альтернативами: LangChain vs n8n vs своя архитектура
Я прошёл через все круги ада. Начинал с LangChain — классическая история: пишешь три строчки, а под капотом — столько магии, что через месяц не понимаешь, почему падает production. LangChain удобен для прототипов, но когда нужно контролировать каждый промпт, таймауты, retry-стратегии — он мешает.
Потом была попытка сделать no-code через n8n. Статья про репетитора на n8n — отличный кейс, но для production он не годится: нет нормального мониторинга, сложно писать интеграционные тесты, цена на узлы n8n кусается. Наш Go-репетитор за ночь обрабатывает 10 000 уроков на одном инстансе, а n8n с таким же потоком начинает тормозить на 2000.
| Критерий | Lexis (Go) | LangChain | n8n | Готовые репетиторы |
|---|---|---|---|---|
| Контроль промптов | 💯 | 😐 | 🙂 | 😐 |
| Производительность | 🔥 | 😐 | 🐢 | 🙂 |
| Кастомизация | 💯 | 🙂 | 😐 | 😐 |
| Сложность разработки | 😐 | 🙂 | 💯 | 💯 |
| Масштабирование | 💯 | 😐 | 🙂 | 😐 |
Грабли, которые я собрал за месяц запуска
Расскажу о наиболее противных, чтобы вы не наступали.
- Rate limits от провайдеров. У OpenAI — 10 000 RPM на tier 5, у Anthropic — 5 000 RPM, у Mistral — 2 000. В час пик все одновременно дёргают софт-лимит. Решение: кэшируем типичные уроки (для уровня A1-A2) на сутки в Redis. Для нестандартных запросов — асинхронная очередь с приоритетами.
- Формат дат. Gemini возвращал даты в формате "Monday, 23rd March 2026", а Claude — "2026-03-23". Парсер сломался, когда ученик написал "March 23, 2026". Пришлось писать нормализатор.
- Стоимость. GPT-4o стоит $10 за миллион токенов на вход, Claude 3.5 Sonnet — $15, Mistral Large — $7, Gemini 2.5 Pro — $5. Для учебных целей лучше использовать Gemini для дешёвых уроков, а Claude — для сложных грамматических объяснений. Мы реализовали маршрутизатор, который автоматически выбирает провайдера для каждой задачи, учитывая бюджет.
⚠️ Если вы разворачиваете проект в production, обязательно добавьте circuit breaker для LLM-провайдеров. Когда OpenAI падает, мы автоматически переключаемся на запасной антропик — иначе ученики получают 500. Как в статье про 5 правил контроля Claude Code — те же принципы применимы к LLM-репетитору.
Кому это реально нужно (и не нужно)
Lexis — не silver bullet. Если у вас нет опыта в Go или вы не готовы выделить неделю на настройку пайплайнов — лучше возьмите готового репетитора типа Elsa Speak или купите подписку на LangChain. Но если вы:
- Хотите полный контроль над качеством контента;
- Планируете масштабировать на тысячи пользователей;
- Хотите C-уровень кастомизации (специфический вокабуляр, корпоративный стиль);
- Любите Go за его прозрачность
...тогда проект для вас.
Особенно рекомендую посмотреть на архитектуру тем, кто пишет AI-кодинг-агентов — принципы те же: регистрация инструментов, управление контекстом, fallback-механизмы. В Lexis я использовал похожий подход для выбора LLM под задачу.
Ссылка и призыв к действию
Полный код проекта с инструкцией по запуску доступен на GitHub (Lexis). Там же лежат конфиги для Docker Compose (PostgreSQL, Redis) и Makefile для быстрого старта. Если хотите протестировать на своём сервере — рекомендую VPS от Timeweb, недорого и для Go-проектов отлично подходит.
Я буду рад пул-реквестам и issues. Особенно если вы добавите поддержку локальных LLM через Ollama или vLLM — это следующий шаг для проекта. Потому что, давайте честно, не у всех есть $500 в месяц на API четырёх провайдеров.