Ты когда-нибудь просыпался в 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 — это нормально, так как синхронизация не требуется.
Если вы думаете, что такая архитектура сложнее готовых 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 и выбор модели на лету.