Почему fallback между LLM может сломать семантику ответа

Когда проектируешь обычный backend, fallback обычно воспринимается как довольно понятный механизм.
Есть основной upstream. Если он недоступен, переключаемся на резервный. Если один pod умер, запрос обслужит другой. Если один PostgreSQL read replica недоступен, читаем с другой. Если основной endpoint партнёра отвечает ошибкой, можно попробовать резервный.
В большинстве таких случаев мы предполагаем, что резервный исполнитель реализует тот же контракт.
С LLM это предположение начинает ломаться.
Две модели могут принимать один и тот же JSON, возвращать JSON по одной и той же схеме и при этом фактически выполнять задачу по-разному.
Именно поэтому я бы не относился к fallback между моделями как к обычному инфраструктурному retry.
Для AI-сервисов fallback — это уже не только вопрос доступности.
Это изменение семантики исполнения.
Как выглядит обычный fallback
Допустим, в сервисе есть HTTP-клиент к внешнему API.
Логика вполне привычная:
resp, err := primary.Do(ctx, req)
if err == nil {
return resp, nil
}
return secondary.Do(ctx, req)Если оба backend реализуют одинаковый контракт, вызывающий код обычно не интересует, кто именно обработал запрос.
Для него важно:
request → responseА не:
request → replica-2 → responseЭта прозрачность — одно из преимуществ инфраструктурного failover.
С LLM она может оказаться опасной.
Формально одинаковый ответ не означает одинаковый результат
Представим SaaS, который анализирует договор.
Одна из задач выглядит примерно так:
type RiskAssessment struct {
RiskLevel string `json:"risk_level"`
Reasons []string `json:"reasons"`
}Основная модель вернула:
{
"risk_level": "high",
"reasons": [
"Unlimited liability",
"Automatic renewal without notice"
]
}Теперь допустим, что основной provider временно недоступен.
Gateway переключается на более дешёвую резервную модель.
Она тоже вернула валидный JSON:
{
"risk_level": "medium",
"reasons": [
"Automatic renewal clause"
]
}С точки зрения API всё отлично.
JSON валиден.
Schema соблюдена.
HTTP 200.
Но с точки зрения продукта результат уже другой.
Если downstream-код делает:
if assessment.RiskLevel == "high" {
requireManualReview()
}то инфраструктурное решение о fallback внезапно изменило бизнес-поведение системы.
И вызывающий код об этом даже не знает.
Главная проблема: мы смешиваем transport contract и semantic contract
У обычного API контракт в основном описывает данные.
Например:
GET /user/42
→ name
→ email
→ statusЕсли две реплики возвращают одни и те же данные, они взаимозаменяемы.
У LLM есть второй слой контракта — семантический.
Нам важно не только:
ответ должен соответствовать JSON Schemaно и:
модель должна достаточно хорошо решать именно эту задачуИ вот это уже невозможно выразить одним struct.
Например, две модели могут обе поддерживать:
structured output
context 128k
temperature 0
JSON Schemaно одна значительно лучше определяет риски в юридическом тексте, а другая лучше классифицирует обращения пользователей.
Технические capabilities совпадают.
Качество выполнения конкретной задачи — нет.
Поэтому маршрутизировать LLM только по capabilities недостаточно.
Я бы добавил ещё один уровень: quality profile
В предыдущей архитектуре AI Gateway у меня route выбирался примерно по таким критериям:
task
data class
provider
model capabilities
cost
latencyНо для fallback этого мало.
Нужно знать, что резервная модель действительно может заменить основную именно в этой задаче.
Например:
type QualityProfile string
const (
QualityFast QualityProfile = "fast"
QualityStandard QualityProfile = "standard"
QualityHigh QualityProfile = "high"
)У задачи может быть минимальное требование:
type TaskProfile struct {
MinQuality QualityProfile
}А у модели — профиль, подтверждённый внутренними тестами:
type ModelProfile struct {
Model string
Quality map[TaskType]QualityProfile
}Ключевой момент здесь в том, что quality относится не к модели вообще, а к модели в контексте конкретной задачи.
Например:
Model A
ClassifyTicket: high
ExtractFacts: standard
GenerateDraft: high
Model B
ClassifyTicket: high
ExtractFacts: high
GenerateDraft: standardЭто намного ближе к реальности, чем попытка сказать:
Model A лучше Model BТакого универсального порядка обычно просто нет.
Fallback должен быть частью route, а не глобальной настройкой
Очень опасная конфигурация выглядит так:
primary_model: model-a
fallback_model: model-bНа первый взгляд удобно.
Но что означает этот fallback?
Для каких задач?
При каких данных?
С каким допустимым ухудшением качества?
Для меня fallback логичнее описывать внутри маршрута.
Например:
type Target struct {
Provider string
Model string
}
type Route struct {
Primary Target
Fallbacks []FallbackTarget
}
type FallbackTarget struct {
Target Target
MaxDegradation QualityProfile
}Но на практике я бы пошёл даже дальше и не пытался кодировать всё одной цифрой.
У route должна быть явная политика.
Например:
type FallbackMode string
const (
FallbackEquivalentOnly FallbackMode = "equivalent_only"
FallbackAllowDegraded FallbackMode = "allow_degraded"
FallbackDisabled FallbackMode = "disabled"
)Для простой классификации email:
allow_degradedможет быть абсолютно нормальным режимом.
Для оценки финансового риска:
equivalent_onlyили вообще:
disabledИногда ошибка лучше плохого fallback
Это немного противоречит привычной backend-интуиции.
Обычно availability хочется максимизировать.
Если сервис можно спасти резервным upstream, почему бы не сделать это?
Потому что для некоторых AI-задач ложный успех хуже явной ошибки.
Представим систему, которая анализирует документ перед отправкой клиенту.
Основная модель временно недоступна.
Можно:
вернуть 503или:
тихо переключиться на слабую модель
и вернуть потенциально неправильный результатС точки зрения uptime второй вариант лучше.
С точки зрения продукта — не обязательно.
Если пользователь воспринимает результат как полноценный анализ, система скрыла от него факт деградации качества.
В таких сценариях честный:
analysis temporarily unavailableможет быть правильнее.
Это тот же fail-closed принцип, только теперь он применяется не к privacy, а к качеству.
Fallback не должен быть невидимым
Даже когда переключение допустимо, я бы не делал его полностью прозрачным.
Ответ AI Gateway должен нести provenance.
Например:
type AIResponse[T any] struct {
Data T
Meta ResponseMeta
}
type ResponseMeta struct {
Provider string
Model string
Route string
UsedFallback bool
Degraded bool
}Тогда вызывающий код получает не просто:
RiskAssessmentа:
RiskAssessment
+
information about how it was producedЭто позволяет downstream-сервису принимать решение.
Например:
result, err := gateway.Execute(ctx, req)
if err != nil {
return err
}
if result.Meta.Degraded {
requireManualReview()
}В некоторых системах это может быть лишним.
Но если результат LLM влияет на деньги, права пользователя, документы или автоматические действия, происхождение ответа становится частью бизнес-контекста.
Retry одной модели и fallback на другую — разные события
Я бы ещё разделял два механизма.
Первый:
retryМы повторяем запрос к тому же логическому исполнителю.
Например:
provider timeout
connection reset
HTTP 502Второй:
fallbackМы меняем исполнителя.
Это принципиально разные события.
Retry:
Task A
↓
Model X
↓
temporary error
↓
Model XFallback:
Task A
↓
Model X
↓
failure
↓
Model YВо втором случае меняется не только инфраструктура.
Может измениться результат.
Поэтому мне не нравится метрика вида:
retry_count = 2Она слишком мало говорит.
Я бы отдельно собирал:
retry_count
fallback_count
fallback_reason
primary_model
final_model
degradedТогда в observability можно увидеть, например:
5% GenerateDraft запросов уходят в fallbackи уже задать следующий вопрос:
а как меняется качество этих результатов?Cost optimization тоже может неожиданно стать semantic routing
Самая опасная версия fallback — когда он появляется не из-за аварии, а ради экономии.
Например:
если основной provider перегружен
→ используем дешёвую модельили:
после первого rate limit
→ переключаемся на модель в 5 раз дешевлеС точки зрения инфраструктуры логика понятна.
Но фактически router сказал:
стоимость сейчас важнее качестваЭто уже бизнес-решение.
Поэтому правила вроде:
if expensiveProviderUnavailable {
useCheapModel()
}я бы никогда не прятал внутри adapter слоя.
Provider adapter вообще не должен знать, можно ли ухудшить качество.
Он должен знать только:
как вызвать конкретный APIРешение о допустимой деградации должно находиться выше.
Как я бы разделил ответственность
В результате AI Gateway у меня выглядел бы примерно так:
Application
↓
Task
↓
Policy
↓
Router
↓
Execution Policy
↓
ProviderPolicy отвечает:
какие providers вообще разрешеныRouter:
какая модель лучше подходит задачеExecution Policy:
какие retries и fallback допустимыProvider:
как технически выполнить запросЭто небольшое разделение, но оно убирает очень неприятный класс ошибок.
Executor на Go
Упрощённо executor может выглядеть так:
type Executor struct {
providers map[string]Provider
}
func (e *Executor) Execute(
ctx context.Context,
route Route,
req Request,
) (Response, error) {
resp, err := e.executeTarget(ctx, route.Primary, req)
if err == nil {
return resp, nil
}
for _, fallback := range route.Fallbacks {
if !fallback.Allowed(err) {
continue
}
resp, fallbackErr := e.executeTarget(
ctx,
fallback.Target,
req,
)
if fallbackErr == nil {
resp.Meta.UsedFallback = true
resp.Meta.Degraded = fallback.Degraded
return resp, nil
}
}
return Response{}, err
}Здесь важен не сам код.
Важно, что:
fallback уже заранее разрешён routeExecutor не принимает решение:
эта модель вроде доступна — попробую еёОн просто исполняет заранее определённую policy.
Это очень полезное свойство.
Но кто определяет, какие модели эквивалентны?
Вот здесь начинается самая сложная часть.
Ответ:
не benchmark leaderboard.
По крайней мере, не только он.
Если мой SaaS делает:
ExtractFactsFromContractмне нужно тестировать именно:
ExtractFactsFromContractна моих данных.
Для каждой task я бы постепенно собирал небольшой eval dataset.
Например:
200 документов
ожидаемый structured result
несколько типов ошибок
несколько edge casesПосле этого можно сравнить:
Model A
Model B
Model Cне по общей позиции в benchmark, а по конкретному production use case.
И уже на основании этого разрешать fallback.
Например:
Model A → Model Bдопустим.
А:
Model A → Model Cнет.
Хотя Model C может быть выше в каком-нибудь общем рейтинге.
Самый интересный показатель — не fallback rate
Сам по себе:
fallback rate = 7%почти ничего не говорит.
Более интересная связка:
primary success rate
fallback success rate
accepted result rate
manual correction rate
regeneration rate
cost per accepted resultДопустим:
Model A
cost: $0.02
accepted without correction: 94%Fallback:
Model B
cost: $0.004
accepted without correction: 61%Технически Model B спасает availability.
Но если 39% результатов потом приходится генерировать заново или исправлять вручную, экономия может оказаться фиктивной.
Это ещё одна причина, почему я всё меньше люблю метрику:
price per million tokensсама по себе.
Для продукта важнее:
сколько стоит получить результат,
который действительно принялиЧто получилось в итоге
Для обычной инфраструктуры fallback обычно означает:
тот же контракт
+
другой исполнительДля LLM:
тот же API-контракт
+
возможно другая семантикаИ это достаточно большая разница.
Поэтому я бы не делал модельный fallback полностью прозрачным.
Минимум, который мне кажется разумным:
fallback задаётся на уровне task/routeа не глобально;
качество модели оценивается по конкретной задачеа не вообще;
response содержит provenanceесли результат влияет на downstream-логику;
retry и fallback наблюдаются отдельно;и иногда:
ошибка лучше деградированного ответа.Чем больше LLM становятся частью backend-систем, тем больше мне кажется, что самые интересные проблемы находятся не внутри prompt engineering.
Они находятся в обычной инженерии вокруг модели.
Routing.
Failure modes.
Observability.
Cost.
Data policies.
Semantic guarantees.
И fallback — хороший пример того, как привычный backend-механизм внезапно перестаёт быть привычным, как только исполнитель становится недетерминированным.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.