У нас почти каждая заметная операция в продукте идёт через LLM: генерация follow-up, сборка КП, скоринг, саммари звонков. Пока провайдер один — это бомба замедленного действия. Он ложится по 503, упирается в рейт-лимит, или цена улетает, потому что дешёвый разбор команды почему-то крутится через флагманскую модель.
Поэтому мы сделали тонкий роутер. Не фреймворк, не «оркестратор агентов» — примерно 500 строк на NestJS, которые переезжают между нашими продуктами без правок. Расскажу, что внутри и на чём набили шишки.
Один клиент вместо зоопарка SDK
Почти все провайдеры отдают OpenAI-совместимый API. Значит, тащить пять разных SDK незачем — берём официальный openai и подменяем baseURL:
const groq = new OpenAI({ apiKey: GROQ_KEY, baseURL: 'https://api.groq.com/openai/v1' });const mistral = new OpenAI({ apiKey: MISTRAL_KEY, baseURL: 'https://api.mistral.ai/v1' });const deepseek= new OpenAI({ apiKey: DEEPSEEK_KEY, baseURL: 'https://api.deepseek.com' });const xai = new OpenAI({ apiKey: XAI_KEY, baseURL: 'https://api.x.ai/v1' });const openai = new OpenAI({ apiKey: OPENAI_KEY, baseURL: 'https://api.openai.com/v1' });
Дальше один и тот же client.chat.completions.create(...) работает для всех. Клиент создаётся только при наличии ключа, иначе null. Побочный бонус: нет ключа — провайдер просто выпадает из работы, ничего не падает. Конфиг с тремя провайдерами и конфиг со всеми пятью гоняют один и тот же код.
Вызывающему коду незачем знать про модели
Ему важен класс задачи: написать качественно, распарсить дёшево, отдать строгий JSON, расшифровать аудио. Под каждый класс — своя стратегия, а за стратегией прячется упорядоченная цепочка «провайдер + модель»:
this.strategyChains = { // лучший русский (КП, follow-up): reasoning-модель впереди QUALITY: [groqQuality, groqLarge, mistralMedium, openai, deepseek, xai], // скоринг, сравнения, саммари: мультиязычность + структура BALANCED: [groqLarge, groqStructured, mistralMedium, groqQuality, openai, xai], // парсинг интента, извлечение JSON: что подешевле FAST: [groqFast, groqStructured, mistralSmall, openai], // аудио → текст: Whisper turbo, потом base, потом OpenAI whisper-1 TRANSCRIBE: [groqWhisperTurbo, groqWhisperBase, whisperOai],}.mapValues(c => c.filter(Boolean)); // неконфигурированные выкидываем
В большинстве цепочек первым стоит Groq. Клиент один, но моделей под ним четыре — каждая под свою роль:
|
Роль |
Модель |
За что отвечает |
|---|---|---|
|
quality |
|
reasoning, сильный текст, ~500 tok/s |
|
large |
|
мультиязычность, 131k контекста |
|
structured |
|
JSON-mode и structured output |
|
fast |
|
|
Mistral идёт кросс-провайдерным fallback’ом, дальше — OpenAI/DeepSeek/xAI, если их ключи заданы.
Сам fallback — это цикл, без магии
Идём по цепочке. На первом провайдере один раз ретраим с паузой. Успех — логируем стоимость и возвращаем. Не вышло — едем к следующему. Легли все — кидаем последнюю ошибку наверх:
for (let i = 0; i < chain.length; i++) { const provider = chain[i]; try { const res = await this.callChat(provider, opts); if (i > 0) this.logger.warn(`AI fallback → ${provider.providerName} (${provider.model})`); await this.logCall(provider, opts, res, null); // учёт стоимости return res; } catch (err) { lastError = err; if (i === 0) { // ретраим только первого await sleep(2000); try { return await this.callChat(provider, opts); } catch (e) { lastError = e; } } }}throw lastError;
Отдельно стоит сказать про пустой ответ. Модель может вернуть finish_reason и при этом пустой content — формально успех, по факту мусор. Если такое отдать наверх как пустую строку, клиенту уходит follow-up из воздуха. Поэтому пустой контент мы считаем ошибкой и уходим к следующему провайдеру.
Где reasoning-модели кусаются
gpt-5 и gpt-oss-* не едят привычные max_tokens и temperature. У них max_completion_tokens и reasoning_effort, и reasoning-токены списываются из того же completion-бюджета. Поставишь бюджет впритык — модель «думает», упирается в лимит и не доходит до ответа. Поэтому закладываем с запасом:
const isReasoning = m.startsWith('gpt-5') || m.startsWith('openai/gpt-oss');if (isReasoning) { params.max_completion_tokens = (opts.maxTokens ?? 2048) * 2; params.reasoning_effort = 'low'; // для нетворческих задач — дёшево и быстро} else { params.temperature = opts.temperature ?? 0.5; params.max_tokens = opts.maxTokens ?? 2048;}
И ещё одна мелочь, на которую уходит полчаса недоумения: Qwen 3 подмешивает в ответ <think>…</think>. Это его внутренние рассуждения, не ответ пользователю. Вырезаем регуляркой. А когда просишь JSON, а модель отвечает «Конечно, вот ваш JSON: {…}» — отдельный stripMarkdown достаёт из этой преамбулы первый валидный объект или массив.
Whisper врёт, и это надо ловить
На тишине и шуме Whisper галлюцинирует — обычно повторяет одну фразу десятки раз. Принимать такое за транскрипт нельзя. Проверяем длину и долю уникальных слов; если повторов слишком много — бракуем и идём дальше по цепочке:
isTranscriptValid(text) { if (text.length < 50) return false; return this.uniqueWordRatio(text) >= 0.15;}
Деньги считаем на каждый вызов
Каждый успешный запрос пересчитывается по прайсу провайдера (вход и выход за миллион токенов) и пишется в LLMCallLog: операция, стратегия, провайдер, модель, токены, стоимость в центах, длительность. Плюс в Prometheus. Без этого не видно, во сколько обходится конкретная фича и какой провайдер реально работает, а какой просто числится в цепочке для красоты.
const costUsd = tokensIn/1e6 * pricing.inputPerM + tokensOut/1e6 * pricing.outputPerM;await prisma.lLMCallLog.create({ data: { operation, provider, model, tokensIn, tokensOut, costCents } });
Самое полезное здесь — добавить провайдера стоит одну строку new OpenAI({ baseURL }) плюс место в нужной цепочке. Вендор лёг ночью — утром это видно одной строкой AI fallback → … в логах, а не сорока сообщениями от клиентов.
Минимальный рабочий роутер из этой статьи (без БД, фреймворка и секретов) выложил на гитхаб — можно склонировать и погонять: github.com/ai-sales-agency/wiin-examples.
Это кусок стека, который мы в wiin.agency сначала поставили себе, а потом стали внедрять клиентам — ИИ-агентов в отделы продаж. Пишем про то, что реально крутится в проде; остальное — в блоге.
ссылка на оригинал статьи https://habr.com/ru/articles/1046902/