- BrainTools - https://www.braintools.ru -

Пять LLM-провайдеров через один openai-клиент

У нас почти каждая заметная операция в продукте идёт через 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 [1].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

openai/gpt-oss-120b

reasoning, сильный текст, ~500 tok/s

large

llama-3.3-70b-versatile

мультиязычность, 131k контекста

structured

qwen/qwen3-32b

JSON-mode и structured output

fast

llama-3.1-8b-instant

0.05/0.08 за миллион, 560 tok/s

Mistral идёт кросс-провайдерным fallback’ом, дальше — OpenAI/DeepSeek/xAI, если их ключи заданы.

Сам fallback — это цикл, без магии

Идём по цепочке. На первом провайдере один раз ретраим с паузой. Успех — логируем стоимость и возвращаем. Не вышло — едем к следующему. Легли все — кидаем последнюю ошибку [2] наверх:

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 [3].


Это кусок стека, который мы в wiin.agency [4] сначала поставили себе, а потом стали внедрять клиентам — ИИ-агентов в отделы продаж. Пишем про то, что реально крутится в проде; остальное — в блоге [5].

Автор: Reller

Источник [6]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/31649

URLs in this post:

[1] client.chat: http://client.chat

[2] ошибку: http://www.braintools.ru/article/4192

[3] github.com/ai-sales-agency/wiin-examples: https://github.com/ai-sales-agency/wiin-examples/tree/main/llm-router

[4] wiin.agency: http://wiin.agency

[5] блоге: https://wiin.agency/blog

[6] Источник: https://habr.com/ru/articles/1046902/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1046902

www.BrainTools.ru

Rambler's Top100