Cascade Routing

Штатный режим работы под нагрузкой, при котором весь трафик сначала идёт в дешёвую модель, а на дорогую эскалируется только то, что не прошло гейт уверенности. Не аварийный fallback, а основной путь запроса: 70–80% ответов закрывает младшая модель.

Суть

User Request → Tier-1 (дешёвая, быстрая) → Output → Quality/Confidence Gate
                                                     ├── PASS (70–80%) → пользователю
                                                     └── FAIL (20–30%) → Tier-2 (флагман) → пользователю

Ключевое отличие от привычного fallback: там переключение на другую модель — реакция на сбой (провайдер отдал 5xx, сломался формат). Здесь переключение — реакция на неуверенность в качестве, и происходит оно при полностью здоровой системе.

Заявленный эффект: снижение затрат на 65% при сохранении итогового качества — 99.2% от базовой флагманской модели на бенчмарке. Экономика работает потому, что порядок цен между тирами отличается на порядки: $0.0001 против $0.005 за вызов в примере.

Зачем это нужно

Отправлять весь трафик во флагман — самая распространённая и самая дорогая архитектура. При этом качество на большинстве задач упирается в плато: 80% задач классификации и извлечения сущностей выходят на него уже на младших моделях (см. Model Selection). Деньги за верхний тир платятся за оставшиеся проценты, которые нужны далеко не каждому запросу.

Каскад разделяет трафик по фактической сложности, а не по предположениям продакт-менеджера о ней.

Триггеры эскалации

Гейт — самая содержательная часть паттерна. Четыре подхода, от дешёвого к дорогому:

Тип Механика Цена
Эвристики и правила Длина ответа < N символов, стоп-слова («не могу ответить», «недостаточно данных»), сбой парсинга JSON ~0
Logprobs / perplexity Оценка внутренней уверенности модели: avg(logprobs) < -0.45 ~0, но нужны logprobs от провайдера
LLM-верификатор Сверхбыстрый вызов малой guard-модели с бинарным промптом: «Отвечает ли данный текст на вопрос? [YES/NO]» Один дешёвый вызов
Фидбэк пользователя «Повторить генерацию» или дизлайк → автоматический ретрай на Tier-2 0, но реагирует постфактум

Порог -0.45 — отправная точка, а не константа: его калибруют на своём трафике.

Где именно стоит LLM-верификатор — принципиально. Здесь он проверяет готовый ответ перед выдачей: один бинарный вопрос, один выход, решение «отдать или эскалировать». Это не то же самое, что судья внутри агентного цикла, между шагами, — от него в проде отказались, потому что он замедлял ответы и на части прогонов ухудшал результат, «споря с реальностью» (LLM as Judge, раздел «Где судью ставить нельзя»). Правило разграничения оттуда же: где решение проверяется кодом — схемой, бизнес-правилом, инвариантом — детерминированный гейт всегда предпочтительнее судьи, и поэтому в таблице выше он стоит ниже эвристик и logprobs, а не выше.

Пример

def answer(query: str) -> str:
    draft = call_tier1(query)                      # $0.0001; ответ + logprobs, не строка
    text = draft.text

    if parse_failed(text) or len(text) < MIN_LEN or has_refusal_phrase(text):
        return call_tier2(query)                   # $0.005, эскалация по эвристике
    if draft.avg_logprob < -0.45:
        return call_tier2(query)                   # эскалация по неуверенности модели

    return text                                    # 70–80% трафика заканчивается здесь

Условие на logprobs диктует, что должен вернуть первый рубеж. Эскалация по неуверенности возможна, только если tier-1 отдал не текст, а объект с полем вероятностей: у голой строки взять avg_logprob неоткуда. Это ограничение на выбор провайдера и режима вызова, а не деталь примера — часть API вероятности не возвращает вовсе, и тогда из таблицы гейтов выпадает целая строка, а её нагрузка переезжает на эвристики и верификатор.

Порядок проверок важен: сначала бесплатные, потом платные. Вызывать верификатор до того, как отработали правила, — значит платить за то, что и так видно.

Порог 0.85 нельзя переносить на эмбеддинговый кэш. Он стоит на косинусной близости TF-IDF-векторов по замкнутому корпусу FAQ, где сравниваются в основном совпадения слов, и шкала сжата иначе. В Semantic Cache тот же по названию порог стоит на эмбеддингах и равен 0.92, а в чувствительном домене поднимается до 0.96. Числа не противоречат друг другу, потому что измеряют разное; универсального значения нет ни у одного из них, и калибруются они одинаково — по размеченным парам «одинаковый вопрос / разный вопрос» из своего домена.

Полный пайплайн: четыре рубежа

FinOps-разбор собирает каскад не из двух моделей, а из четырёх рубежей, где вызов модели появляется только на третьем:

# Рубеж 0 — правила, $0
CHITCHAT_PATTERNS = ["спасибо", "привет", "добрый день", "ок, понял", "до свидания", "спс", "ясно"]

def is_chitchat_rule(query: str) -> bool:
    clean = query.lower().strip()
    return any(p in clean for p in CHITCHAT_PATTERNS) and len(clean.split()) <= 4

# Рубеж 1 — семантический кэш на TF-IDF по эталонным вопросам FAQ
#             порог 0.85 стоит на TF-IDF, а не на эмбеддингах — см. оговорку ниже
def check_semantic_cache(query: str, threshold=0.85):
    sims = cosine_similarity(vectorizer.transform([query]), faq_vectors)[0]
    best = int(np.argmax(sims))
    return (True, sims[best], faq_corpus[best]) if sims[best] >= threshold else (False, sims[best], None)

# Рубеж 2 — Tier-1 модель;  Рубеж 3 — Tier-2 по confidence gate

Ограничение длины в is_chitchat_rule (len(clean.split()) <= 4) — важная деталь: без него «спасибо, а теперь посчитай мою задолженность» уйдёт в шаблон вежливости. Правило нулевого рубежа должно быть узким, иначе экономия оплачивается пропущенными реальными вопросами.

Порог кэша в том разборе занижен до 0.85, потому что TF-IDF на маленьком корпусе даёт более низкие абсолютные значения близости, чем эмбеддинг-модель. На проде порог калибруется под конкретный энкодер (Semantic Cache).

Если logprobs недоступны. Часть провайдеров их не отдаёт, и это не тупик: строка с logprobs в таблице выше — лишь один из четырёх гейтов, причём средний по цене. Без неё каскад собирается из оставшихся трёх — эвристики (длина, стоп-слова, сбой парсинга) стоят ноль и снимают заметную долю, а на сомнительном остатке ставится дешёвый LLM-верификатор. Терять при этом приходится не качество гейта, а его дешевизну: verifier — это вызов, а logprobs шли бесплатно вместе с ответом.

Про эскалацию по фидбэку. Она реагирует постфактум и по своей природе редкое событие, поэтому как самостоятельный гейт работает только на потоке, где отрицательных сигналов набирается достаточно, чтобы отличать их от случайного раздражения — на маленьком трафике это шум. Порядки величин здесь те же, что для канареечных выводов (Canary Release LLM, раздел про статистику). Практически фидбэк применяют не как гейт, а как источник кейсов: каждый дизлайк отправляется в eval-набор, и решение принимается по накопленному набору, а не по отдельному сигналу.

Чем каскад ломается

  • Гейт слишком мягкий — плохие ответы уходят пользователю, экономия оплачена качеством.
  • Гейт слишком строгий — эскалирует почти всё, каскад превращается в флагман плюс лишний вызов сверху.
  • Считают Cost per Request вместо Cost per Outcome — дешёвая модель отвечает плохо, пользователь переспрашивает четыре раза, и суммарно выходит дороже одного точного вызова (см. Unit Economics AI).

Отсюда правило: каскад нельзя внедрять без эвалов. Стоимость — метрика, которая валидируется тестами качества, иначе это не оптимизация, а деградация с отчётом об экономии.

Связано с

  • FinOps AI — каскад как крупнейший рычаг снижения затрат
  • Inference Optimization — спекулятивное декодирование: похожая идея без потери качества
  • Agent Routing — маршрутизация по намерению; каскад маршрутизирует по уверенности
  • Model Selection — сетка тиров, между которыми ходит каскад
  • Agent CostControl — измерение эффекта и атрибуция затрат
  • Request Coalescing — слой ещё раньше: схлопывание дублирующих вызовов
  • Semantic Cache — слой перед каскадом: часть трафика не доходит и до Tier-1
  • LLM as Judge — верификатор в роли гейта и ограничения этого приёма