
LLM API уже часто используется не как экспериментальная функция, а как часть продуктовой инфраструктуры: боты поддержки, AI-ассистенты, генерация контента, RAG, coding agents, аналитические пайплайны. Но как только модель оказывается в пользовательском сценарии, возникает инженерный вопрос: что делать, если API не отвечает, отвечает слишком долго, возвращает ошибку лимита или внезапно становится слишком дорогим для текущей нагрузки?
Обычно на этапе прототипа это игнорируют. Есть один провайдер, один SDK, одна модель и один endpoint. Пока запросов мало, схема кажется рабочей. В продакшене все иначе: появляются таймауты, rate limits, нестабильная latency, разные форматы ошибок, деградация качества, рост стоимости и необходимость переключать модель без релиза нового кода.
В этой статье разберем, как устроить fallback и автоматическое переключение между LLM API: от простого retry до маршрутизации по качеству, задержке, стоимости и доступности.
Почему обычного retry недостаточно
Первое, что обычно добавляют при ошибках API, это повтор запроса. Это полезно, но не решает всю проблему.
Повтор помогает, если ошибка временная: сетевой сбой, случайный 502, короткий всплеск нагрузки у провайдера. Но он не поможет, если провайдер долго недоступен, модель перегружена, закончился лимит, запрос слишком большой для выбранной модели, latency стабильно выше допустимого SLA или стоимость модели больше не подходит для массового сценария.
Retry отвечает на вопрос: попробовать еще раз? Fallback отвечает на другой вопрос: куда переключиться, если текущий маршрут не работает или не подходит?
Какие типы отказов бывают у LLM API
Технические ошибки
Это сетевые сбои, 5xx, обрывы соединения, проблемы DNS, некорректные ответы upstream-сервиса. Обычно такие ошибки можно обрабатывать retry с экспоненциальной задержкой.
Rate limit и quota
Провайдер может вернуть 429, ограничить число запросов в минуту или токенов в сутки. Повтор без задержки только ухудшит ситуацию. Здесь нужен cooldown и переключение на резервную модель или резервного провайдера.
Timeout
Модель может отвечать, но слишком медленно. Для интерактивного Telegram-бота 20 секунд ожидания уже выглядит как отказ, даже если API формально работает. Timeout должен быть частью routing-логики, а не просто технической ошибкой.
Ошибка совместимости
Не все модели одинаково поддерживают tool calling, JSON mode, streaming, длинный контекст, изображения или системные инструкции. Если запрос требует конкретной возможности, fallback должен идти только на модели с тем же capability.
Экономическая деградация
Иногда API работает, но использовать его стало невыгодно: изменилась цена, вырос output, prompt стал слишком длинным, часть массовых запросов уходит в дорогую модель. Это не авария, но это тоже причина для переключения.
Базовая схема маршрутизации
Самый простой вариант — хранить для каждой задачи список моделей в порядке приоритета.
ROUTES = {
"supportreply": [
{"model": "fast-cheap-llm", "timeout": 4},
{"model": "balanced-llm", "timeout": 6},
],
"complexreasoning": [
{"model": "strong-reasoning-llm", "timeout": 15},
{"model": "backup-reasoning-llm", "timeout": 20},
],
"code_review": [
{"model": "coding-model-a", "timeout": 12},
{"model": "coding-model-b", "timeout": 15},
],
}
Приложение вызывает не конкретную модель, а задачу.
def calltask(taskname: str, messages: list[dict]):
route = ROUTES[taskname]
lasterror = None
for candidate in route:
try:
return callmodel(
model=candidate["model"],
messages=messages,
timeout=candidate["timeout"],
)
except RetryableError as error:
lasterror = error
continue
raise RuntimeError(f"All routes failed: {last_error}")Это уже лучше, чем жестко зашитая модель в бизнес-логике. Если нужно заменить fallback, меняется конфигурация, а не код продукта.
Какие ошибки можно переключать
Переключать обычно можно timeout, 429 rate limit, 500, 502, 503, 504, временную сетевую ошибку, ошибку перегрузки модели, ошибку недоступности региона или маршрута, превышение внутреннего лимита стоимости.
Не стоит автоматически переключать 400 из-за неправильного формата запроса, ошибку валидации JSON, отсутствие обязательного параметра, слишком большой prompt, если резервная модель имеет такой же лимит, policy refusal, если бизнес-логика требует ручной обработки, и ошибку авторизации.
Иначе fallback будет маскировать реальные баги.
Retry, backoff и circuit breaker
Для устойчивости обычно используют три уровня защиты: retry, backoff и circuit breaker.
Retry нужен, чтобы повторить запрос один-два раза при временной ошибке.
import time
def retrycall(fn, attempts=2, basedelay=0.3):
for attempt in range(attempts):
try:
return fn()
except RetryableError:
if attempt == attempts - 1:
raise
time.sleep(base_delay (2 * attempt))Backoff увеличивает паузу между повторами, чтобы не добивать перегруженный endpoint. Для LLM API лучше использовать небольшой jitter, иначе много воркеров одновременно повторят запрос после одинаковой задержки.
Circuit breaker нужен, чтобы временно убрать модель или провайдера из маршрута, если он регулярно падает.
class CircuitBreaker:
def init(self, failurethreshold=5, cooldownseconds=60):
self.failures = {}
self.openuntil = {}
self.failurethreshold = failurethreshold
self.cooldownseconds = cooldown_seconds
def allow(self, model: str, now: float) -> bool:
return self.open_until.get(model, 0) <= now
def record_success(self, model: str):
self.failures[model] = 0
def recordfailure(self, model: str, now: float):
count = self.failures.get(model, 0) + 1
self.failures[model] = count
if count >= self.failurethreshold:
self.openuntil[model] = now + self.cooldownsecondsТакой механизм защищает систему от ситуации, когда каждый пользовательский запрос снова и снова идет в заведомо нерабочий маршрут.
Fallback должен учитывать capability
Главная ошибка в multi-model routing — считать все LLM взаимозаменяемыми. Это не так.
Если сценарий требует tool calling, fallback-модель тоже должна поддерживать tool calling. Если нужен JSON mode, длинный контекст или vision input, это должно быть частью фильтра.
Удобно описывать модели как capability profile.
MODELCAPABILITIES = {
"fast-cheap-llm": {
"json": True,
"tools": False,
"vision": False,
"maxcontext": 32000,
},
"agent-llm": {
"json": True,
"tools": True,
"vision": True,
"max_context": 128000,
},
}
Перед переключением нужно проверить, что резервная модель подходит под задачу.
def iscompatible(model: str, requirements: dict) -> bool:
caps = MODELCAPABILITIES[model]
for key, value in requirements.items():
if key == "max_context":
if caps[key] < value:
return False
elif caps.get(key) != value:
return False
return TrueИначе система может успешно переключиться на модель, которая не умеет вернуть нужный формат.
Стоимость как часть routing-логики
Для продуктовой системы важно маршрутизировать не только по доступности, но и по стоимости.
Например, есть три класса задач: короткие ответы пользователю, суммаризация длинного текста, сложный анализ с reasoning.
Если все отправлять в самую сильную модель, качество может быть хорошим, но экономика будет плохой. Правильнее выбрать модель под задачу.
TASKPOLICY = {
"shortreply": {
"maxlatencyms": 3000,
"maxcostusd": 0.002,
"quality": "medium",
},
"legalsummary": {
"maxlatencyms": 10000,
"maxcost_usd": 0.05,
"quality": "high",
},
}
Стоимость можно оценивать до запроса приблизительно: по длине prompt и цене входных токенов. После ответа нужно логировать фактический расход.
def estimatecost(inputtokens, outputtokenslimit, price):
return (
inputtokens / 1000_000 * price["input"]
outputtokenslimit / 1000000 * price["output"]
)Если оценка выше лимита задачи, можно выбрать более дешевую модель или попросить пользователя сократить контекст.
Что делать со streaming
Streaming усложняет fallback. Если модель уже начала отдавать токены пользователю, переключиться незаметно почти невозможно: часть ответа уже ушла.
Есть несколько подходов: не делать fallback после первого полученного токена, делать fallback только до начала stream, буферизовать первые токены и показывать ответ пользователю с задержкой, при обрыве stream показать короткое сообщение и предложить повторить запрос, для критичных сценариев использовать non-streaming режим.
Для чатов часто лучше честно обработать обрыв: ответ был прерван, попробуйте еще раз. Для backend-задач, где пользователь не видит поток, fallback можно делать агрессивнее.
Логи: без них fallback слепой
Fallback без наблюдаемости быстро превращается в черный ящик. Нужно логировать не только финальный ответ, но и все попытки маршрута.
Минимальный набор: requestid, taskname, выбранная модель, номер попытки, причина fallback, latency, inputtokens, outputtokens, estimatedcost, actualcost, status, errortype, providerrequest_id, если доступен.
Пример:
{
"requestid": "req123",
"task": "supportreply",
"attempt": 2,
"model": "balanced-llm",
"fallbackreason": "timeout",
"latencyms": 2310,
"inputtokens": 740,
"output_tokens": 180,
"status": "success"
}Такие данные позволяют ответить на практические вопросы: какая модель чаще всего падает, где latency выше SLA, сколько стоит fallback, какие задачи требуют дорогих моделей, какой процент запросов проходит не по primary-route.
Деградация качества лучше, чем полный отказ
Не всегда резервная модель должна быть равной основной. Иногда правильнее иметь уровни деградации.
Например: primary — сильная модель и полный ответ; fallback 1 — более дешевая модель и короткий ответ; fallback 2 — шаблонный ответ без LLM; fallback 3 — передача оператору.
Для поддержки пользователей это часто лучше, чем ошибка “попробуйте позже”. Пользователь получает хотя бы базовый результат, а система сохраняет управляемость.
Как тестировать fallback
Fallback нельзя считать рабочим, пока его не проверили искусственно.
Стоит протестировать такие сценарии: primary-модель возвращает 500, primary-модель отвечает дольше timeout, provider возвращает 429, резервная модель не поддерживает нужный формат, все маршруты недоступны, stream обрывается после первых токенов, estimated cost превышает лимит, circuit breaker открывается и закрывается после cooldown.
Полезно иметь chaos-тесты для LLM routing: искусственно подменять ответы провайдера и проверять, что система не зависает, не уходит в бесконечные повторы и корректно логирует причину переключения.
Простая архитектура для продакшена
В минимальном варианте LLM routing слой может состоять из нескольких компонентов: registry моделей и их возможностей, policy по задачам, price table, router, retry/backoff, circuit breaker, логирование попыток, метрики latency, ошибок и стоимости.
Бизнес-код не должен знать детали конкретного провайдера. Он должен вызывать задачу.
result = llmrouter.run(
task="supportreply",
messages=messages,
requirements={
"json": False,
"tools": False,
"max_context": 8000,
},
)А router уже решает, какая модель подходит, какой маршрут доступен, сколько это примерно стоит и что делать при ошибке.
Вывод
Автоматическое переключение между LLM API — это не просто список запасных моделей. Это отдельный инфраструктурный слой, где учитываются доступность, timeout, rate limits, capabilities, стоимость, latency и качество результата.
Хороший fallback не должен маскировать ошибки в коде. Он должен защищать пользовательский сценарий от внешних сбоев и давать команде возможность управлять модельной инфраструктурой без экстренных релизов.
Когда LLM становится частью продукта, вопрос “какую модель подключить?” быстро превращается в вопрос “как построить слой, который позволит менять модели, переживать отказы и контролировать расходы”. Именно здесь routing и fallback становятся не оптимизацией, а базовой инженерной практикой.