Почему LLMProvider недостаточно: проектируем AI Gateway для SaaS на Go
- вторник, 22 сентября 2026 г. в 00:00:11
Когда начинаешь делать 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.
Речь не о 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 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, со временем бизнес-правила, требования безопасности и оптимизация стоимости превращаются в один трудно тестируемый комок.
Допустим, документ содержит данные, которые разрешено обрабатывать только локально.
Локальная модель недоступна.
Есть два варианта.
Первый:
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
То есть сам факт наличия второго провайдера ещё не означает, что на него разрешено переключаться.
В конфигурации 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: false
Gateway сопоставляет профиль задачи с моделями, которые сейчас доступны.
Так можно заменить модель без изменения доменного кода и, что ещё важнее, без изменения смысла задачи.
Следующая проблема — особенности 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.
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.
У этой архитектуры есть очевидная опасность.
Очень легко создать AIGatewayService, который через полгода будет на три тысячи строк и станет новым god object.
Я бы разделил ответственность примерно так:
Gateway ↓ PolicyEngine ↓ Router ↓ Executor ↓ Provider
PolicyEngine работает только с ограничениями.
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?
Я бы не отдавал эту ответственность 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 похожа на «ядро системы» и тем больше — на ещё один внешний инфраструктурный компонент, который приходится нормально проектировать.