В пятницу основной провайдер начал отвечать 429. Команда переключила переменную MODEL, но резервная модель не поддерживала прежнюю схему tools, иначе считала токены и возвращала другой тип ошибки. Переключатель существовал только на слайде.
Это самый распространённый вид отказоустойчивости в отрасли: запасной парашют, который никто не раскрывал, лежащий в шкафу с биркой «резерв». Бирка на месте, стропы не проверены, инструкция написана человеком, который уже уволился.
Провайдера выбирают под измеримый workload: качество на своём eval-наборе, задержку, лимиты, доступность региона, политику данных и полную стоимость запроса. Название модели без версии и параметров недостаточно для воспроизводимости.
Что именно различается
«Мы поменяем провайдера за час» звучит правдоподобно, пока не выписан список того, что отличается на практике:
- Формат tools и structured output. Разные ограничения схемы, разное поведение при обрезке, разные названия полей.
- Токенизация. Один и тот же текст стоит разное число токенов; бюджеты и лимиты контекста надо пересчитывать, а не переносить.
- Ошибки. Коды, тела, заголовки лимитов и стратегия ретраев различаются. Ваш
exceptнаписан под одного провайдера. - Лимиты. Считаются на организацию, проект или ключ; на запросы, токены или конкурентность. Приложение думает не то.
- Политика данных и регион. То, что разрешено договором для одного провайдера, может быть прямо запрещено для другого.
- Поведение модели. Тот же промпт даёт другое распределение ответов. Это ловится только evals (глава 38), никакой абстракцией не чинится.
Первые пять пунктов — инженерная работа. Шестой — причина, по которой переключение провайдера не бывает «прозрачным».
Адаптер на границе
Внутренний контракт должен описывать потребность приложения, сохраняя значимые возможности провайдера.
from typing import Protocol
from pydantic import BaseModel
class GenerateRequest(BaseModel):
messages: list[dict]
max_output_tokens: int
response_schema: dict | None = None
idempotency_key: str
class GenerateResult(BaseModel):
text: str
model: str
input_tokens: int
output_tokens: int
provider_request_id: str | None = None
class ModelProvider(Protocol):
async def generate(self, request: GenerateRequest) -> GenerateResult: ...Streaming, prompt caching, computer use и особенности tool calling не надо прятать под фиктивным общим знаменателем. Для них лучше capability flags и специализированные методы. Иначе абстракция обещает переносимость, которой нет.
Обратите внимание на provider_request_id: это поле, которое хочется выбросить как «служебное», а потом оно оказывается единственным способом обсудить с поддержкой провайдера конкретный запрос. Храните его рядом с записью в llm_call_log.
Gateway: одна точка удобства и одна точка отказа
Gateway централизует ключи, маршрутизацию, бюджеты и telemetry. За это он добавляет собственный отказ и может обрезать провайдерские поля. Нужны timeout до gateway и до upstream, корреляция request ID и путь прямого вызова для диагностики.
Вопросы к продавцу gateway, которые задают до внедрения, а не после первого инцидента:
- Что происходит, когда падает сам gateway? Ответ «он не падает» означает, что вы разговариваете с продавцом, а не с инженером.
- Видно ли, какой именно апстрим обработал запрос? Если нет, расследование деградации превращается в гадание.
- Что он делает с промптом? Логирование полного промпта на стороне gateway — это ваш PII в чужой системе (глава 29).
Gateway отлично решает задачу «много команд, один бюджет, единая телеметрия» и плохо решает задачу «мы хотим не думать о провайдерах».
Локальные модели
Локальная модель оправдана, когда требования к данным, стабильная нагрузка или специализированное качество покрывают стоимость GPU и эксплуатации. «Токены бесплатны» обычно не включает idle capacity, batching, обновления драйверов и дежурство.
Честная формула сравнения выглядит так:
стоимость_локальной = аренда_GPU_в_месяц / успешные_результаты_в_месяц
+ инженерное_время / успешные_результаты_в_месяцВторое слагаемое и решает исход. GPU, который загружен на 8% в будни и на 0% в выходные, стоит столько же, сколько загруженный полностью; облачный провайдер в это время не берёт ничего. Локальная модель начинает выигрывать при ровной высокой нагрузке — и почти никогда при пилоте на сто запросов в день, ради которого её обычно и предлагают.
Ещё одна статья расходов, о которой забывают: локальную модель нужно кем-то обновлять, а её качество на вашем наборе — заново измерять после каждого обновления. Это не «поставили и работает», это ещё один сервис в дежурстве.
Failure modes
Резервная модель не проходила evals; fallback удвоил запрос после неопределённого timeout; регион не соответствует договору; alias модели незаметно обновился; gateway логирует prompt; лимиты считаются на организацию, а приложение думает, что на ключ. Переключение следует репетировать, включая structured output и tools.
Отдельно про alias: latest в имени модели — это подписка на изменение поведения продакшена в момент, который выбираете не вы. Версия фиксируется явно, обновление проходит через evals и релиз (глава 42).
Правило главы
Переносимость является проверенным сценарием, а не интерфейсом с методом generate. Фиксируйте версии и возможности, измеряйте workload, прогоняйте резервный маршрут до аварии.
Чеклист главы
Практикум
Задача 25.1 — сравнение трёх маршрутов на своих данных. ⭑⭑
Возьмите 50 производственных примеров и сравните два облачных провайдера и одну локальную модель.
Критерии приёмки:
Подсказки:
- Пятьдесят примеров должны включать граничные случаи, а не только удобные: именно на них провайдеры расходятся.
- Цену считайте по приложению А и по фактическому usage из ответов, а не по прикидке «примерно столько же».
Задача 25.2 — репетиция переключения. ⭑⭑⭑
Отключите основной маршрут и проведите переключение так, как оно произойдёт в аварии.
Критерии приёмки:
Подсказки:
- Репетируйте на реальном трафике малой когорты, а не только в staging: лимиты и региональные особенности проявляются под нагрузкой.
- Отдельно проверьте tools: именно они чаще всего ломают резервный маршрут, а замечают это последними.