Почему fallback между LLM может сломать семантику ответа
- вторник, 29 сентября 2026 г. в 00:00:17
Когда проектируешь обычный backend, fallback обычно воспринимается как довольно понятный механизм.
Есть основной upstream. Если он недоступен, переключаемся на резервный. Если один pod умер, запрос обслужит другой. Если один PostgreSQL read replica недоступен, читаем с другой. Если основной endpoint партнёра отвечает ошибкой, можно попробовать резервный.
В большинстве таких случаев мы предполагаем, что резервный исполнитель реализует тот же контракт.
С LLM это предположение начинает ломаться.
Две модели могут принимать один и тот же JSON, возвращать JSON по одной и той же схеме и при этом фактически выполнять задачу по-разному.
Именно поэтому я бы не относился к fallback между моделями как к обычному инфраструктурному retry.
Для AI-сервисов 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 внезапно изменило бизнес-поведение системы.
И вызывающий код об этом даже не знает.
У обычного API контракт в основном описывает данные.
Например:
GET /user/42 → name → email → status
Если две реплики возвращают одни и те же данные, они взаимозаменяемы.
У LLM есть второй слой контракта — семантический.
Нам важно не только:
ответ должен соответствовать JSON Schema
но и:
модель должна достаточно хорошо решать именно эту задачу
И вот это уже невозможно выразить одним struct.
Например, две модели могут обе поддерживать:
structured output context 128k temperature 0 JSON Schema
но одна значительно лучше определяет риски в юридическом тексте, а другая лучше классифицирует обращения пользователей.
Технические capabilities совпадают.
Качество выполнения конкретной задачи — нет.
Поэтому маршрутизировать LLM только по capabilities недостаточно.
В предыдущей архитектуре 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
Такого универсального порядка обычно просто нет.
Очень опасная конфигурация выглядит так:
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
Это немного противоречит привычной backend-интуиции.
Обычно availability хочется максимизировать.
Если сервис можно спасти резервным upstream, почему бы не сделать это?
Потому что для некоторых AI-задач ложный успех хуже явной ошибки.
Представим систему, которая анализирует документ перед отправкой клиенту.
Основная модель временно недоступна.
Можно:
вернуть 503
или:
тихо переключиться на слабую модель и вернуть потенциально неправильный результат
С точки зрения uptime второй вариант лучше.
С точки зрения продукта — не обязательно.
Если пользователь воспринимает результат как полноценный анализ, система скрыла от него факт деградации качества.
В таких сценариях честный:
analysis temporarily unavailable
может быть правильнее.
Это тот же fail-closed принцип, только теперь он применяется не к privacy, а к качеству.
Даже когда переключение допустимо, я бы не делал его полностью прозрачным.
Ответ 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
Мы повторяем запрос к тому же логическому исполнителю.
Например:
provider timeout connection reset HTTP 502
Второй:
fallback
Мы меняем исполнителя.
Это принципиально разные события.
Retry:
Task A ↓ Model X ↓ temporary error ↓ Model X
Fallback:
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
и уже задать следующий вопрос:
а как меняется качество этих результатов?
Самая опасная версия fallback — когда он появляется не из-за аварии, а ради экономии.
Например:
если основной provider перегружен → используем дешёвую модель
или:
после первого rate limit → переключаемся на модель в 5 раз дешевле
С точки зрения инфраструктуры логика понятна.
Но фактически router сказал:
стоимость сейчас важнее качества
Это уже бизнес-решение.
Поэтому правила вроде:
if expensiveProviderUnavailable { useCheapModel() }
я бы никогда не прятал внутри adapter слоя.
Provider adapter вообще не должен знать, можно ли ухудшить качество.
Он должен знать только:
как вызвать конкретный API
Решение о допустимой деградации должно находиться выше.
В результате AI Gateway у меня выглядел бы примерно так:
Application ↓ Task ↓ Policy ↓ Router ↓ Execution Policy ↓ Provider
Policy отвечает:
какие providers вообще разрешены
Router:
какая модель лучше подходит задаче
Execution Policy:
какие retries и fallback допустимы
Provider:
как технически выполнить запрос
Это небольшое разделение, но оно убирает очень неприятный класс ошибок.
Упрощённо 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 уже заранее разрешён route
Executor не принимает решение:
эта модель вроде доступна — попробую её
Он просто исполняет заранее определённую 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 = 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-механизм внезапно перестаёт быть привычным, как только исполнитель становится недетерминированным.