LLM-роутер на NestJS: как объединить 5 провайдеров через OpenAI SDK | AiManual
AiManual Logo Ai / Manual.
12 Июн 2026 Гайд

Прокачай свой AI-бэкенд: пишем LLM-роутер на NestJS, который выживет при любой аварии у провайдера

Пошаговый гайд по созданию отказоустойчивого LLM-роутера на NestJS. Код, стратегии роутинга, fallback, обработка ошибок. Экономьте до 50% на API.

Реклама
hor_partv1

Ты когда-нибудь просыпался в 3 часа ночи от сообщения в Slack: «OpenAI API опять лежит, всё упало»? Я — да. И после третьего такого раза твёрдо решил: хватит ставить крест на своём продукте из-за одного провайдера. Решение — написать свой LLM-роутер на NestJS, который умеет переключаться между пятью разными API как по щелчку. Заодно срезал счёт за токены почти вдвое. В этой статье я расскажу, как превратить ваш AI-бэкенд в неубиваемую машину.

ℹ️ Почему не взять готовый AI Gateway? Vercel, LiteLLM, OpenRouter — классные ребята, но когда нужно глубоко кастомизировать логику роутинга (стоимость, качество, задержка), своя реализация на NestJS даёт полный контроль. Я разбирал плюсы и минусы в статье AI Gateway против кастомных решений.

Проблема: один провайдер — одна точка отказа

Использовать только OpenAI — как ходить по минному полю с завязанными глазами. Сбои, rate limits, рост цен. Плюс vendor lock-in: если завтра OpenAI изменит API или поднимет цену в 10 раз, ты сядешь в лужу.

Нормальный подход — иметь в арсенале несколько провайдеров: OpenAI, Groq (дешёвый и быстрый), DeepSeek (китайский экономичный вариант), Mistral (европейские privacy-требования) и Google Gemini (мультимодальность). И роутер, который сам решает, куда кинуть запрос, а если что-то отвалилось — мгновенно переключится на другой.

Почему NestJS и OpenAI SDK?

NestJS — это мощь, структура и Dependency Injection. OpenAI SDK (openai npm-пакет) — де-факто стандарт. Но хитрость в том, что многие провайдеры (Groq, DeepSeek, Mistral, Gemini) выпускают свои API, совместимые с OpenAI SDK. Просто меняем baseURL и API key — и всё работает.

Даже Gemini с 2025 года поддерживает OpenAI-совместимый endpoint. Mistral и Groq — изначально. DeepSeek — да. Это снимает кучу головной боли с интеграцией.

Архитектура: как это будет выглядеть

Мы построим модуль LlmRouterModule, внутри которого:

  • Сервис-провайдеры — каждый со своим экземпляром OpenAI-клиента.
  • Роутер — выбирает провайдера по стратегии (Round-robin, по задержке, по стоимости).
  • Fallback-механизм — при ошибке одного провайдера запрос уходит к следующему.

Всё конфигурируется через YAML или переменные окружения. Никаких хардкодов.

1 Инициализация провайдеров

// providers.ts
export interface LlmProviderConfig {
  name: string;
  baseURL: string;
  apiKey: string;
  model: string;
  priority: number; // для fallback
  costPer1k: number; // для cost-based роутинга
}

// provider.factory.ts
import { OpenAI } from 'openai';

export class LlmProviderFactory {
  static create(config: LlmProviderConfig): OpenAI {
    return new OpenAI({
      baseURL: config.baseURL,
      apiKey: config.apiKey,
    });
  }
}

В реальном проекте я добавил бы ещё таймауты и ретраи в конструктор. Но давайте пока упростим, чтобы не растягивать.

2 Сервис роутера с fallback

// llm-router.service.ts
import { Injectable } from '@nestjs/common';
import { OpenAI } from 'openai';

@Injectable()
export class LlmRouterService {
  private providers: OpenAI[] = [];
  private configs: LlmProviderConfig[] = [];

  constructor(configs: LlmProviderConfig[]) {
    this.configs = configs.sort((a, b) => a.priority - b.priority);
    this.providers = configs.map(c => LlmProviderFactory.create(c));
  }

  async route(prompt: string, options?: { strategy?: string }) {
    const strategy = options?.strategy || 'fallback';

    if (strategy === 'fallback') {
      return this.fallback(prompt);
    }
    if (strategy === 'round-robin') {
      return this.roundRobin(prompt);
    }
    // ... другие
  }

  private async fallback(prompt: string) {
    const errors: { provider: string; error: any }[] = [];

    for (const [index, config] of this.configs.entries()) {
      try {
        const client = this.providers[index];
        const response = await client.chat.completions.create({
          model: config.model,
          messages: [{ role: 'user', content: prompt }],
          max_tokens: 1024,
        });
        return response;
      } catch (e) {
        errors.push({ provider: config.name, error: e.message });
        console.warn(`Fallback: ${config.name} failed - ${e.message}`);
      }
    }

    throw new Error(`All providers failed. Errors: ${JSON.stringify(errors)}`);
  }

  private roundRobin(prompt: string) {
    // простая реализация с индексом
    const idx = this.currentIndex++ % this.providers.length;
    return this.callProvider(idx, prompt);
  }
}

⚠️ Ошибка, которую я видел в 90% реализаций: не обрабатывают таймауты и 429 ошибки. Если провайдер возвращает 429 Too Many Requests, fallback должен срабатывать сразу, а не ждать ретрая. В коде выше мы просто ловим любое исключение — это правильно.

Стратегии роутинга: когда какая нужна

Стратегия Когда использовать Плюсы Минусы
Fallback (по приоритету) Отказоустойчивость Надёжность Возможен дорогой вызов при деградации
Round-robin Балансировка нагрузки Равномерное распределение Не учитывает качество
Cost-based Экономия бюджета До 50% снижение затрат Требует мониторинг цен
Latency-based Чат-боты real-time Минимальное время ответа Сложнее реализовать

Лично я в продакшне использую гибрид: основная стратегия — cost-based (дешёвые провайдеры для простых запросов), а если ответ содержит ошибку или превышает лимит — fallback на более дорогой и надёжный OpenAI.

Модуль NestJS: сборка всего вместе

// llm-router.module.ts
import { Module } from '@nestjs/common';
import { LlmRouterService } from './llm-router.service';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [ConfigModule],
  providers: [
    {
      provide: 'LLM_PROVIDERS',
      useFactory: (config: ConfigService): LlmProviderConfig[] => {
        return [
          {
            name: 'openai',
            baseURL: config.get('OPENAI_BASE_URL', 'https://api.openai.com/v1'),
            apiKey: config.get('OPENAI_API_KEY'),
            model: config.get('OPENAI_MODEL', 'gpt-4o'),
            priority: 1,
            costPer1k: 0.01,
          },
          {
            name: 'groq',
            baseURL: 'https://api.groq.com/openai/v1',
            apiKey: config.get('GROQ_API_KEY'),
            model: 'llama-3.3-70b-versatile',
            priority: 2,
            costPer1k: 0.005,
          },
          {
            name: 'deepseek',
            baseURL: 'https://api.deepseek.com/v1',
            apiKey: config.get('DEEPSEEK_API_KEY'),
            model: 'deepseek-chat',
            priority: 3,
            costPer1k: 0.002,
          },
          {
            name: 'mistral',
            baseURL: 'https://api.mistral.ai/v1',
            apiKey: config.get('MISTRAL_API_KEY'),
            model: 'mistral-large-latest',
            priority: 4,
            costPer1k: 0.008,
          },
          {
            name: 'gemini',
            baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai/',
            apiKey: config.get('GEMINI_API_KEY'),
            model: 'gemini-2.0-flash',
            priority: 5,
            costPer1k: 0.003,
          },
        ];
      },
      inject: [ConfigService],
    },
    LlmRouterService,
  ],
  exports: [LlmRouterService],
})
export class LlmRouterModule {}

Обратите внимание: я передаю конфигурацию через useFactory. Это даёт возможность легко менять провайдеров через env без пересборки. И да — я не кладу API-ключи в код, это базовое правило безопасности.

Нюансы, из-за которых я ночью не спал

1. Разные модели и токенизаторы

Не все модели считают токены одинаково. GPT-4 может «видеть» 128K токенов, а DeepSeek — только 32K. Ваш роутер должен знать лимит каждой модели и обрезать контекст или переключать провайдера.
Решение: добавить поле maxContextTokens в конфиг и проверять перед отправкой.

2. Rate limits

Groq обожает банить за превышение RPD (requests per day). Потому что у них бесплатный тир. В роутере я добавил счётчик и, если лимит исчерпан, временно исключаю провайдера из списка активных. Реализуется через Circuit Breaker pattern.

3. Стриминг

Если ваш сервис использует streaming (для чатов), fallback усложняется: нужно дождаться первого чанка от первичного провайдера, иначе переключение вызовет задержку. Я решил так: для streaming применяю latency-based стратегию с таймаутом 500ms на первый чанк.

Похожий подход использую в проекте, описанном в статье LLMRouter: Как снизить расходы на LLM API на 30-50% — но там библиотека, а здесь своя реализация на NestJS.

Тестируем: как понять, что роутер работает

Недостаточно просто написать код. Нужно симулировать отказы. Я пишу юнит-тесты с моками каждого провайдера, а также интеграционные тесты с реальными API (на маленьких промптах, чтобы не разориться).

// llm-router.service.spec.ts
describe('LlmRouterService', () => {
  let service: LlmRouterService;
  const mockConfigs = [
    { name: 'openai', baseURL: '...', apiKey: '...', model: 'gpt-4o', priority: 1, costPer1k: 0.01 },
    { name: 'groq', baseURL: '...', apiKey: '...', model: 'llama', priority: 2, costPer1k: 0.005 },
  ];

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        { provide: 'LLM_PROVIDERS', useValue: mockConfigs },
        LlmRouterService,
      ],
    }).compile();
    service = module.get(LlmRouterService);
  });

  it('should fallback on first provider failure', async () => {
    // Мокаем первый клиент бросить исключение
    const mockClient = service['providers'][0];
    jest.spyOn(mockClient.chat.completions, 'create').mockRejectedValue(new Error('Simulated failure'));

    const result = await service.route('test', { strategy: 'fallback' });
    expect(result.provider).toBe('groq'); // д.б. второй провайдер
  });
});

Развёртывание: что важно учесть

Роутер — это критический компонент. Он должен быть без состояния, легко масштабироваться. NestJS с этим справляется. Я деплою его в Kubernetes с горизонтальным автомасштабированием. На every replica настраиваю свой currentIndex для round-robin — это нормально, так как синхронизация не требуется.

💡
Совет: добавьте health-check endpoints для каждого провайдера. Тогда ваш мониторинг (Prometheus + Grafana) сможет отслеживать живые метрики и даже вручную отключать проблемного провайдера через feature toggle.

Если вы думаете, что такая архитектура сложнее готовых gateway — да, это так. Но вы получаете полный контроль, никакой vendor lock-in, и возможность точечной оптимизации. А если вам не хочется писать всё с нуля, посмотрите open-source решения вроде ClawRouter, который тоже умеет семантический роутинг. Но NestJS-реализация — это база, на которой можно строить что угодно.

Ошибки, которые я совершил (чтобы ты их не повторял)

  • Не проверял timeout по умолчанию. OpenAI SDK ждёт 10 минут, а Groq отвечает за секунду. Если Groq завис, запрос повиснет надолго. Добавьте timeout: 5000 в конфиг клиента.
  • Round-robin без учёта ошибок. Если один провайдер упал, round-robin продолжит в него стучаться. Решение — вести список «здоровых» провайдеров и исключать упавших на время.
  • Игнорировал заголовки retry-after. При 429 ответе многие шлют заголовок Retry-After. Читайте его и приостанавливайте вызовы к этому провайдеру на указанное время.

Кстати, если вы пишете AI-приложение на чистом TypeScript, могу порекомендовать статью Создаём AI-репетитора на Go с Clean Architecture и четырьмя LLM — там похожий подход, но на Go.

Итог: что ты получишь

У тебя будет сервис, который:

  • Переключается между 5+ провайдерами с единым OpenAI-интерфейсом.
  • Автоматически падает на следующий источник при сбое.
  • Оптимизирует затраты (стоимость, задержку).
  • Готов к продакшену за счёт тестов, метрик и обработки ошибок.

И главное — ты перестанешь бояться, что завтра какой-то провайдер поднимет цены или уйдёт в даунтайм. Твой бэкенд станет резильентным. А когда придут новые провайдеры (а они придут), ты просто добавишь одну строчку в конфиг.

⚠️ Если ты думаешь, что всё это сложно — попробуй сначала запустить простой fallback на двух провайдерах. Как только увидишь, что даже при падении OpenAI твой сервис живёт, захочешь допилить всё остальное.

На сегодня NestJS — один из лучших фреймворков для построения продакшен-API. А LLM-роутер — это его killer feature, которая выделит твой проект на фоне конкурентов. Если хочешь копнуть глубже в семантический роутинг — прочитай мою статью Семантический роутинг в продакшене. Там мы связываем vLLM, KServe и выбор модели на лету.

Подписаться на канал