golang

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

  • вторник, 29 сентября 2026 г. в 00:00:17
https://habr.com/ru/articles/1087522/

Когда проектируешь обычный 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 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

и уже задать следующий вопрос:

а как меняется качество этих результатов?

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
     ↓
Provider

Policy отвечает:

какие 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 уже заранее разрешён 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

Сам по себе:

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-механизм внезапно перестаёт быть привычным, как только исполнитель становится недетерминированным.