Паттерн «Стратегия» без лишних классов: подключаем ИИ‑провайдеров на TypeScript

Архитектура Автор:
содержание
Общий контракт AIProvider и выбор между функциями askOpenAI и askGigaChat через реестр провайдеров

Представим сервис с одним ИИ‑провайдером. Запрос уходит в его SDK, ответ возвращается пользователю — архитектура помещается в одну функцию.

Потом появляется второй провайдер. У него другой метод, другой формат ответа и свои настройки. В функции вырастает if. Это всё ещё нормальный код.

Проблема начинается позже: провайдеров уже четыре, а тот же if знает об их SDK, ключах, моделях и форматах ошибок. Добавление пятого требует снова менять общий маршрут.

Здесь и помогает паттерн «Стратегия». Разберём его на упрощённой версии маршрутизации ИИ‑запросов из Koddo: сначала рабочее условие, затем один тип функции и реестр. Без UML и армии классов.

Сначала не паттерн, а обычный if

Пусть приложение умеет отправлять запрос в OpenAI или GigaChat. Клиенты SDK уже настроены, поэтому функция только вызывает нужный метод и приводит ответы к строке. Имена методов и полей здесь сокращены: нас интересует поток управления, а не конкретная версия SDK.

type ProviderName = 'openai' | 'gigachat'

async function askAI(provider: ProviderName, prompt: string): Promise<string> {
  if (provider === 'openai') {
    const response = await openai.responses.create({ input: prompt })
    return response.output_text
  }

  const response = await gigachat.complete({ prompt })
  return response.choices[0].message.content
}

Для двух коротких веток этого достаточно. Паттерн не нужен «на всякий случай»: прямой код легче читать, чем заранее построенная система расширений.

Но провайдеры редко различаются одной строкой вызова. Один принимает отдельные system-сообщения, второй ждёт другую модель, третий возвращает использование токенов в метаданных. Постепенно одна функция начинает делать две работы: выбирать исполнителя и знать устройство каждого исполнителя.

Разбор / 01

Центральная функция знает каждый чужой формат

Центральная функция знает каждый чужой форматaskAI(name) вызов откуда брать text if openai OpenAI SDK output_text if gigachat GigaChat SDK message.content if deepseek DeepSeek SDK choices[0].contentaskAI(name)вызовоткуда брать textif openaiOpenAI SDKoutput_textif gigachatGigaChat SDKmessage.contentif deepseekDeepSeek SDKchoices[0].content
Условие само по себе не проблема. Связность появляется, когда центральная функция одновременно выбирает провайдера и разбирается во всех его особенностях.

Где здесь «Стратегия»

Стратегия — взаимозаменяемый способ выполнить одну задачу. Клиент знает общую форму вызова, но не знает, какой SDK работает внутри.

В TypeScript для этого не обязателен класс. Функции можно хранить в объекте и передавать как значения, а тип функции задаёт нужный контракт. Для нашего примера контракт занимает одну строку:

type AIProvider = (prompt: string) => Promise<string>

Теперь каждая интеграция отдельно приводит свой ответ к строке:

const askOpenAI: AIProvider = async (prompt) => {
  const response = await openai.responses.create({ input: prompt })
  return response.output_text
}

const askGigaChat: AIProvider = async (prompt) => {
  const response = await gigachat.complete({ prompt })
  return response.choices[0].message.content
}

Обе функции принимают string и возвращают Promise<string>. Для остального приложения они одинаковы, хотя внутри работают с разными API.

Остаётся собрать стратегии в реестр:

const providers = {
  openai: askOpenAI,
  gigachat: askGigaChat,
} satisfies Record<string, AIProvider>

type ProviderName = keyof typeof providers

function askAI(provider: ProviderName, prompt: string): Promise<string> {
  return providers[provider](prompt)
}

satisfies проверит, что каждое значение подходит под AIProvider, но сохранит точные ключи openai и gigachat. Поэтому опечатка вроде askAI('giga', prompt) остановится при проверке типов.

Разбор / 02

Клиент зависит от контракта, адаптер — от SDK

Клиент зависит от контракта, адаптер — от SDK01 / клиенты 02 / контракт 03 / стратегии ИИ-наставник не знает SDK Проверка резюме не знает SDK router выбирает имя AIProvider prompt → text OpenAI свой адаптер GigaChat свой адаптер DeepSeek свой адаптер { text, usage } одинаковый ответ клиенту01 / клиенты02 / контракт03 / стратегииИИ-наставникне знает SDKПроверка резюмене знает SDKrouterвыбирает имяAIProviderprompt → textOpenAIсвой адаптерGigaChatсвой адаптерDeepSeekсвой адаптер{ text, usage }одинаковый ответ клиенту
Роутер отвечает на вопрос «кого вызвать», а каждая стратегия — «как вызвать этого провайдера». Остальной код получает ответ одной формы.

Типы не проверяют строку из базы

Пока имя провайдера приходит из типизированного кода, ProviderName защищает от опечаток. Но значение из URL, базы или переменной окружения в рантайме остаётся обычной строкой. TypeScript стирает типы после сборки и не отклонит неизвестное значение сам.

На границе нужна короткая проверка, которая заодно уточнит тип:

function isProviderName(value: string): value is ProviderName {
  return Object.hasOwn(providers, value)
}

function getProvider(value: string): AIProvider {
  if (!isProviderName(value)) {
    throw new Error(`Unknown AI provider: ${value}`)
  }

  return providers[value]
}

Record и satisfies ловят ошибки разработчика при сборке. Object.hasOwn() проверяет данные, которые пришли снаружи. Это две разные линии защиты; паттерн не отменяет ни одну из них.

Как добавить третьего провайдера

Без реестра мы открыли бы askAI() и добавили ещё одну ветку. Со стратегией новая интеграция живёт отдельно:

const askDeepSeek: AIProvider = async (prompt) => {
  const response = await deepseek.chat.completions.create({
    model: 'deepseek-chat',
    messages: [{ role: 'user', content: prompt }],
  })

  return response.choices[0].message.content ?? ''
}

const providers = {
  openai: askOpenAI,
  gigachat: askGigaChat,
  deepseek: askDeepSeek,
} satisfies Record<string, AIProvider>

Общий маршрут не изменился. Мы добавили одну функцию и одну запись там, где система собирает доступные стратегии.

Это не магическое соблюдение принципа «открытости — закрытости». Реестр всё равно пришлось изменить, и это нормально: именно он хранит состав приложения. Выигрыш в другом — код OpenAI не смешался с кодом DeepSeek, а клиенты не узнали о новом SDK.

Когда функции уже мало

Функция подходит, пока у стратегии одна операция и почти нет состояния. В боевой маршрутизации Koddo провайдер ещё должен сообщать о доступности, хранить клиент и возвращать нормализованный ответ с метаданными. Тогда общий контракт честнее описать объектом:

interface LLMProvider {
  isAvailable(): Promise<boolean>
  chatCompletion(request: ChatRequest): Promise<ChatResponse>
}

const providers = new Map<ProviderName, LLMProvider>([
  ['openai', new OpenAIProvider()],
  ['gigachat', new GigaChatProvider()],
])

В TypeScript реализация подходит интерфейсу по форме. Ей не обязательно наследоваться от базового класса: достаточно иметь совместимые isAvailable() и chatCompletion(). Это следствие структурной типизации TypeScript.

Класс здесь появляется не потому, что «так положено в Strategy». Он хранит настроенный SDK‑клиент, ключи и связанные операции. Если состояние не нужно, функция остаётся более короткой стратегией.

Что паттерн намеренно не решает

Strategy изолирует способы выполнить запрос, но не выбирает продуктовую политику. Отдельный роутер всё ещё решает:

  • какая модель назначена ИИ‑наставнику;
  • в каком порядке пробовать резервных провайдеров;
  • можно ли повторить запрос после ошибки;
  • как учитывать лимиты, стоимость и метрики;
  • когда начинать потоковую передачу ответа.

Это полезная граница. Если заставить каждую стратегию самой выбирать следующую стратегию, провайдеры снова узнают друг о друге, только теперь связь спрячется по разным файлам.

Разбор / 03

Минимальная стратегия зависит от задачи

Минимальная стратегия зависит от задачичто есть граница решение один способ нет взаимозаменяемости прямой вызов / if одна операция нет состояния функция + Record состояние и методы общий контракт объект + Mapчто естьграницарешениеодин способнетвзаимозаменяемостипрямой вызов / ifодна операциянет состоянияфункция + Recordсостояние и методыобщий контрактобъект + Map
Strategy нужна после появления взаимозаменяемых способов. Одна операция помещается в функцию; состояние и несколько связанных операций оправдывают объект.

Проверка без SDK и моков

Общий контракт даёт ещё один практический бонус: бизнес‑логику можно проверить на маленькой подставной стратегии.

async function buildHint(provider: AIProvider, error: string): Promise<string> {
  return provider(`Объясни ошибку без готового решения: ${error}`)
}

const fakeProvider: AIProvider = async () => 'Проверь границу цикла'

const hint = await buildHint(fakeProvider, 'IndexError')
console.assert(hint === 'Проверь границу цикла')

Тесту не нужны сеть, API‑ключ и знание конкретного SDK. Он проверяет только собственную логику приложения.

Когда Strategy лишняя

Не выносите каждую ветку в отдельную стратегию. Оставьте прямой код, если:

  • способ выполнения один;
  • вторая ветка отличается одной константой, а не алгоритмом;
  • варианты никогда не выбираются во время работы;
  • реестр сложнее исходного if;
  • новое решение потребует менять все стратегии одновременно.

Хороший сигнал для рефакторинга — не число строк, а повторяющаяся причина изменения. Если ветки растут из-за появления новых исполнителей одной задачи, Strategy разрежет код по естественной границе.

В нашем примере итог прост: провайдер знает, как обратиться к своему API; роутер знает, какого провайдера выбрать; клиент знает только общий ответ.

Если хочется сравнить Strategy с фабрикой, адаптером и декоратором, откройте разбор восьми паттернов проектирования. А в истории выбора модели для ИИ‑наставника видно, зачем продукту вообще нужна возможность менять провайдера без переписывания клиентского кода.

Источники