Почему LLMProvider недостаточно: проектируем AI Gateway для SaaS на Go

Когда начинаешь делать AI-фичу в backend-приложении, первая архитектурная идея обычно выглядит очень здраво: спрятать конкретного провайдера за интерфейсом.
Что-то вроде:
type LLMProvider interface {
Generate(ctx context.Context, req Request) (Response, error)
}Сегодня за ним OpenAI-compatible API, завтра другой провайдер, послезавтра локальная модель. Код бизнес-логики от конкретного SDK не зависит, значит архитектурная задача вроде бы решена.
Я тоже начинал примерно с этого. Но по мере того как AI-фича перестаёт быть игрушкой и начинает работать с реальными пользовательскими данными, выясняется, что выбор модели — это далеко не единственное решение, которое нужно принимать перед каждым запросом.
Нужно понять, можно ли вообще отправлять конкретные данные во внешний API. Нужно выбрать модель по типу задачи, цене и требованиям к качеству. Иногда запрос должен идти только в локальную модель. Иногда внешний провайдер недоступен. Иногда fallback допустим, а иногда он будет нарушением политики данных.
В этот момент простой LLMProvider начинает решать не ту задачу.
Где ломается обычная абстракция
Представим backend, который работает с пользовательскими документами. Неважно, юридические это документы, финансовые отчёты или внутренние корпоративные файлы.
Приложению нужно извлечь текст, определить тип документа, найти факты, а затем попросить LLM построить краткое резюме.
Наивная реализация выглядит примерно так:
resp, err := llm.Generate(ctx, Request{
Model: "some-model",
Prompt: extractedText,
})С технической точки зрения всё нормально. С архитектурной — нет.
В момент этого вызова код уже молча принял сразу несколько решений: исходный текст можно отправить наружу, конкретный провайдер разрешён для таких данных, эта модель подходит для задачи, fallback допустим, а содержимое запроса безопасно логировать и трассировать.
Ни одно из этих решений не относится к ответственности интерфейса LLMProvider.
Если они постепенно размазываются по сервисам, через несколько месяцев получается примерно такое:
if containsSensitiveData(doc) {
return local.Generate(ctx, req)
}
if cfg.UseCheapModel {
return cheap.Generate(ctx, req)
}
resp, err := primary.Generate(ctx, req)
if err != nil {
return fallback.Generate(ctx, req)
}Сначала это выглядит терпимо. Потом подобный код появляется ещё в пяти местах, правила начинают немного отличаться, а понять фактический маршрут пользовательских данных становится сложно.
Поэтому в своём проекте я пришёл к другой границе ответственности: бизнес-код должен обращаться не к конкретному LLM-провайдеру, а к AI Gateway.
Что такое AI Gateway в данном случае
Речь не о reverse proxy перед API модели. Для меня AI Gateway — это отдельный application layer, который принимает не просто prompt, а описание AI-задачи и её ограничений.
Вместо:
llm.Generate(ctx, req)получается что-то ближе к:
result, err := ai.Execute(ctx, AIRequest{
Task: TaskExtractFacts,
DataClass: DataClassPersonal,
Input: text,
})Здесь бизнес-код сообщает две важные вещи.
Первая — что именно нужно сделать. ExtractFacts, Summarize, ClassifyDocument, GenerateDraft — это разные задачи, и им необязательно использовать одну и ту же модель.
Вторая — какие данные передаются. От этого уже зависит, какие маршруты вообще разрешены.
Сам gateway после этого решает, какой provider и какая модель подходят.
Архитектура становится примерно такой:
Business logic
│
▼
AI Gateway
│
├── Policy
├── Router
├── Provider Registry
├── Retry / Fallback
├── Observability
└── Cost Accounting
│
┌──────┼──────┐
▼ ▼ ▼
Local Provider A Provider BГлавное изменение здесь не в количестве слоёв. Меняется место, где принимаются решения.
Сначала policy, потом routing
Одна из ошибок, которую легко сделать, — сначала выбрать модель, а потом думать, можно ли ей отправлять данные.
Мне кажется логичнее разделить этот процесс на два этапа.
Сначала policy engine отвечает на вопрос:
Какие маршруты вообще допустимы для этого запроса?
Например, у нас есть четыре класса данных:
type DataClass uint8
const (
DataClassPublic DataClass = iota
DataClassInternal
DataClassPersonal
DataClassRestricted
)Это не универсальная классификация, а внутренняя модель конкретного приложения.
Для публичного текста могут быть разрешены любые провайдеры. Для персональных данных — только определённые. Для Restricted внешний API может быть запрещён полностью.
Результат policy можно представить не как выбор модели, а как ограничение пространства вариантов:
type PolicyDecision struct {
AllowExternal bool
AllowedProviders []string
RequireZDR bool
}Только после этого запускается router.
Это важное разделение. Policy отвечает за то, что можно делать. Router — за то, что выгоднее сделать из разрешённого.
Если объединить их в один switch, со временем бизнес-правила, требования безопасности и оптимизация стоимости превращаются в один трудно тестируемый комок.
Почему я предпочитаю fail-closed
Допустим, документ содержит данные, которые разрешено обрабатывать только локально.
Локальная модель недоступна.
Есть два варианта.
Первый:
local failed
↓
try external providerВторой:
local failed
↓
return errorДля пользовательского UX первый вариант выглядит привлекательнее. Запрос всё-таки выполнится.
Но для чувствительных данных я бы выбрал второй.
Если политика говорит LOCAL_ONLY, ошибка инфраструктуры не должна автоматически расширять права доступа к данным.
Это обычный принцип fail-closed, просто применённый к AI routing.
Поэтому fallback у меня является не свойством провайдера, а частью route:
type Route struct {
Primary Target
Fallback []Target
}Policy может вернуть:
Primary: local-qwen
Fallback: []а для публичной классификации:
Primary: provider-a/cheap-model
Fallback:
- provider-b/cheap-model
- local-qwenТо есть сам факт наличия второго провайдера ещё не означает, что на него разрешено переключаться.
Task-based routing полезнее, чем model-based routing
В конфигурации AI-приложений часто встречается что-то вроде:
LLM_MODEL=some-large-modelДля самого первого прототипа нормально. Для продукта довольно быстро становится ограничением.
Не все задачи требуют самой сильной модели.
Классификация документа может выглядеть так:
вход: несколько тысяч символов
выход: один из пяти типовОтправлять её в дорогую reasoning-модель особого смысла нет.
При этом генерация сложного документа может быть чувствительна к качеству модели намного сильнее.
Поэтому вместо одной переменной LLM_MODEL я бы описывал требования к задаче:
type TaskProfile struct {
MinQuality Quality
MaxLatency time.Duration
PreferCheap bool
Structured bool
}Например:
ClassifyDocument
quality: medium
structured: true
preferCheap: true
ExtractFacts
quality: high
structured: true
preferCheap: false
GenerateDraft
quality: high
structured: false
preferCheap: falseGateway сопоставляет профиль задачи с моделями, которые сейчас доступны.
Так можно заменить модель без изменения доменного кода и, что ещё важнее, без изменения смысла задачи.
Provider Registry вместо if provider == ...
Следующая проблема — особенности API.
Даже если два провайдера объявляют OpenAI-compatible API, различия всё равно появляются: structured output поддерживается по-разному, отличаются лимиты контекста, tool calling, streaming и набор доступных параметров.
Поэтому я бы хранил capabilities рядом с provider/model registry.
Упрощённо:
type ModelCapabilities struct {
StructuredOutput bool
ToolCalling bool
Streaming bool
MaxContext int
}А сам model descriptor:
type Model struct {
Provider string
Name string
Capabilities ModelCapabilities
Price Price
}Тогда задача, требующая JSON Schema, просто не увидит модель без StructuredOutput.
Это лучше, чем узнать о несовместимости уже после HTTP 400 от внешнего API.
Gateway — хорошее место для нормальной observability
AI-интеграции довольно быстро становятся непрозрачными.
Пользователь нажал кнопку, backend сделал три LLM-вызова, один ретраился, второй переключился на fallback, третий неожиданно использовал дорогую модель. В обычных логах приложения это легко потерять.
Через единый gateway удобно собирать технические метрики:
task
provider
model
latency
input_tokens
output_tokens
retry_count
route
status
estimated_costПри этом содержимое prompt логировать совсем необязательно.
Я бы даже сделал это правилом по умолчанию: gateway логирует метаданные вызова, но не payload.
Тогда можно ответить на вполне практические вопросы.
Почему обработка документа стала занимать 14 секунд вместо пяти? Какая задача съедает больше всего токенов? Сколько запросов уходит во внешний API? Как часто используется fallback? Сколько стоит обработка одного пользовательского кейса?
Без централизованной точки такие данные приходится собирать по всему приложению.
Стоимость лучше считать на уровне задачи
У провайдера обычно есть цена за миллион входных и выходных токенов.
Но для SaaS этого недостаточно.
Мне как разработчику полезнее знать:
ExtractFacts → 0.004 $
ClassifyDocument → 0.0003 $
BuildRecommendation → 0.018 $
GenerateDraft → 0.012 $Тогда уже можно увидеть настоящую экономику фичи.
Например, оказывается, что красивый финальный ответ стоит совсем немного, а 60% расходов съедает промежуточный extraction, который вызывается несколько раз для одного и того же документа.
После этого оптимизация становится очевидной: сохранить структурированный результат и больше не гонять исходный документ через модель.
Именно поэтому accounting тоже удобно держать рядом с gateway.
Но не стоит превращать gateway в новую мегасущность
У этой архитектуры есть очевидная опасность.
Очень легко создать AIGatewayService, который через полгода будет на три тысячи строк и станет новым god object.
Я бы разделил ответственность примерно так:
Gateway
↓
PolicyEngine
↓
Router
↓
Executor
↓
ProviderPolicyEngine работает только с ограничениями.
Router выбирает маршрут среди разрешённых моделей.
Executor занимается retries, timeout и fallback.
Provider знает конкретный API.
Сам gateway просто связывает эти компоненты.
В Go это особенно удобно, потому что каждый слой может оставаться маленьким интерфейсом без сложного DI-фреймворка.
Например:
type PolicyEngine interface {
Evaluate(ctx context.Context, req AIRequest) (PolicyDecision, error)
}
type Router interface {
Route(ctx context.Context, req AIRequest, policy PolicyDecision) (Route, error)
}
type Executor interface {
Execute(ctx context.Context, route Route, req AIRequest) (AIResponse, error)
}В итоге основной метод gateway получается скучным:
func (g *Gateway) Execute(
ctx context.Context,
req AIRequest,
) (AIResponse, error) {
policy, err := g.policy.Evaluate(ctx, req)
if err != nil {
return AIResponse{}, err
}
route, err := g.router.Route(ctx, req, policy)
if err != nil {
return AIResponse{}, err
}
return g.executor.Execute(ctx, route, req)
}И это хороший признак.
Сложность системы никуда не исчезла, но она перестала быть спрятана в случайных if внутри бизнес-сервисов.
Где должен находиться prompt
Ещё один вопрос, который появился почти сразу: кто вообще должен собирать prompt?
Я бы не отдавал эту ответственность provider layer.
Provider должен уметь отправить уже сформированный запрос в конкретный API.
Но и собирать prompt прямо в handler мне не нравится:
prompt := fmt.Sprintf(`
Ты эксперт...
Вот документ:
%s
`, text)Удобнее привязать prompt к задаче.
Например:
tasks/
extract_facts.go
classify_document.go
generate_draft.goКаждая задача знает свою схему входа и выхода, а gateway уже решает, где её выполнить.
Это ещё сильнее отделяет domain/application logic от конкретной модели.
Что в итоге даёт дополнительный слой
На первый взгляд AI Gateway выглядит как обычная архитектурная перестраховка.
Но ценность становится заметна, когда меняется хотя бы одно внешнее условие.
Провайдер поднял цену. Одна модель стала недоступна. Появилась более дешёвая модель для classification. Для определённого типа документов изменились требования к обработке. Нужно временно запретить внешние API. Один endpoint начал давать 20% ошибок.
Если бизнес-код напрямую работает с LLMProvider, каждое такое изменение постепенно протекает во всё приложение.
Если между ними стоит policy-aware gateway, большая часть изменений остаётся внутри AI-слоя.
Для меня это и есть полезная архитектурная граница.
Не:
наше приложение использует Model Xа:
нашему приложению нужно выполнить Task X
с такими-то ограничениями.Какая именно модель выполнит задачу — уже инфраструктурная деталь.
Вывод
Сам интерфейс LLMProvider всё ещё нужен. Просто он находится ниже, чем мне казалось в начале.
Domain
↓
AI Task
↓
AI Gateway
↓
Policy
↓
Routing
↓
Provider
↓
ModelДля маленького pet project всё это, конечно, можно не строить. Один клиент OpenAI-compatible API и пара функций будут значительно проще.
Но если AI становится частью SaaS, через которую проходят пользовательские данные и реальные деньги, выбор модели перестаёт быть обычной конфигурацией.
Это уже полноценное backend-решение: с routing, policy, observability, стоимостью, отказами и понятными границами ответственности.
И, пожалуй, это одна из вещей, которые мне сейчас нравятся в AI-разработке больше всего. Чем глубже интегрируешь LLM в продукт, тем меньше сама LLM похожа на «ядро системы» и тем больше — на ещё один внешний инфраструктурный компонент, который приходится нормально проектировать.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.