LLM-провайдер — это внешняя зависимость с худшим сочетанием свойств: медленная, дорогая, с жёсткими квотами и регулярными деградациями. Всё, что backend-инженерия придумала для ненадёжных внешних API, здесь применяется в полный рост — плюс одна особенность, которой нет у обычных API: каждый неудачный вызов может стоить денег.
Ситуация из реальной команды
Вечером у провайдера начинается частичная деградация: 20% запросов отвечают за 60+ секунд. Дальше цепная реакция в собственном backend-е:
- у HTTP-клиента таймаут по умолчанию — считай, бесконечность; запросы копятся;
- пул соединений исчерпан, обычные (не-AI) endpoint-ы тоже начинают ждать;
- ретраи настроены «3 попытки сразу» — нагрузка на провайдера утраивается, начинаются 429;
- на 429 стоит ещё один retry — теперь провайдер получает вшестеро больше запросов от «полумёртвого» клиента;
- Celery-очередь забита ретраями, свежие задачи ждут по 40 минут.
Утром счёт: инцидент провайдера длился 50 минут, инцидент продукта — 4 часа, из них 3 — самострел.
Timeouts: два разных бюджета
Первое, что нужно понять про LLM: время до первого токена и время всего ответа — разные величины с разной диагностикой. Долгий первый токен — перегрузка провайдера или огромный prompt; долгая генерация — просто длинный ответ.
client = httpx.AsyncClient(
timeout=httpx.Timeout(
connect=3.0,
write=5.0,
read=30.0, # streaming: пауза между chunk-ами, не весь ответ
pool=5.0, # ожидание соединения из пула — тоже лимит!
),
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
)Правила:
- таймаут задаётся на каждый вызов явно и наследуется из бюджета use case: интерактивный триаж — 15 секунд, ночной batch — 120;
pool timeoutобязателен: без него при деградации провайдера запросы бесконечно ждут соединения, и это выглядит как «зависло всё»;- со streaming read-timeout контролирует паузу между chunk-ами — это лучший детектор «модель умерла посреди ответа»;
- таймауты согласованы по цепочке:
llm_timeout < celery_soft_limit < nginx/gateway timeout(глава 15).
И неприятная правда, которую надо принять при проектировании: таймаут по вашей инициативе не отменяет генерацию у провайдера. Input-токены вы оплатили, output — сколько успело сгенерироваться. Агрессивный таймаут + агрессивный retry = платить за одну задачу трижды.
Rate limits: свой лимитер против чужого
У провайдера лимиты обычно в трёх измерениях: запросы в минуту, input-токены в минуту, output-токены в минуту. Реагировать на 429 — плохая стратегия: вы уже отвергнуты, а заголовок retry-after — единственное, что у вас есть. Правильная стратегия — клиентский лимитер, который не даёт упереться в квоту:
class TokenRateLimiter:
"""Скользящее окно по запросам и токенам, общее для всех воркеров (Redis)."""
async def acquire(self, est_tokens: int) -> None:
while not await self._try_reserve(est_tokens):
await asyncio.sleep(self._suggested_wait())Ключевые решения:
- лимитер общий для процесса и для флота воркеров — иначе каждый воркер честен в одиночку, а вместе они устраивают 429; на многих воркерах это Redis-счётчик со скользящим окном;
- резервируйте оценку токенов до вызова (input посчитан, output — по
max_output_tokens), корректируйте фактом после; - 429 всё равно случится — обрабатывайте с уважением к
retry-afterи с jitter; - квота — это тоже бюджетный ресурс: интерактивный трафик имеет приоритет над batch (две очереди из главы 15 — сюда же).
Retry: только транзиентное, только с backoff, только с бюджетом
RETRYABLE = (LLMTimeoutError, LLMRateLimitError, LLMServerError)
async def call_with_retry(fn, *, max_attempts: int = 3) -> LLMResult:
for attempt in range(max_attempts):
try:
return await fn()
except RETRYABLE as exc:
if attempt == max_attempts - 1:
raise
delay = min(2 ** attempt + random.uniform(0, 1), 30)
if isinstance(exc, LLMRateLimitError) and exc.retry_after:
delay = max(delay, exc.retry_after)
await asyncio.sleep(delay)Дополнение, специфичное для LLM: у retry должен быть денежный бюджет, а не только счётчик попыток. Три ретрая запроса на 100 000 input-токенов — это другой класс решения, чем три ретрая на 500. Лимит max_cost_per_event из главы 44 обязан учитывать неудачные попытки.
Ошибки 400 (невалидная схема), 401/403, отказы по контенту — не ретраятся никогда: результат не изменится, а деньги уйдут.
Circuit breaker: перестать платить за отказ
Retry защищает от единичного сбоя. От длящейся деградации защищает circuit breaker — автомат с тремя состояниями:
- closed — норма, ошибки считаются в скользящем окне;
- open — доля ошибок превысила порог (например, 50% за 30 секунд): вызовы отклоняются мгновенно, без похода к провайдеру. Система сразу уходит в деградацию: другой провайдер, дешёвая модель, очередь на потом, честное «недоступно»;
- half-open — по таймеру пропускается несколько пробных запросов; успех закрывает контур, неудача — снова open.
Специфика LLM: breaker должен быть на пару (провайдер, модель), а не на провайдера целиком — деградация часто затрагивает одну модель. И открытый breaker — это не только защита latency, это прямая экономия: каждый непосланный запрос в мёртвый endpoint — это неоплаченные input-токены и не съеденная квота.
Fallback-цепочка при открытом breaker-е — архитектурное решение уровня use case (глава 26 об абстракциях — здесь она окупается): основной провайдер → резервный провайдер → деградированный режим без LLM. Резервный провайдер означает заранее проверенные промпты и evals на обеих моделях, а не «переключим, если что».
Правило главы
Устойчивость к сбоям провайдера собирается из четырёх слоёв, и каждый следующий бесполезен без предыдущего: явные таймауты (включая пул) → клиентский rate limiter, общий для флота → retry только транзиентного с backoff, jitter и денежным бюджетом → circuit breaker с заранее спроектированной деградацией. Провайдер будет падать. Вопрос лишь в том, чей это будет инцидент — его или ваш.
Чеклист главы
- Все четыре таймаута httpx заданы явно; pool timeout существует.
- Таймауты согласованы по цепочке: LLM-вызов < Celery soft limit < gateway.
- Клиентский лимитер учитывает и запросы, и токены, и общий для всех воркеров.
- Ретраится только транзиентное; backoff экспоненциальный, с jitter и уважением к
retry-after. - Бюджет ретраев считается в деньгах, а не только в попытках.
- Circuit breaker на пару (провайдер, модель); поведение при open-состоянии спроектировано и протестировано.
- Метрики: error rate по типам, latency первого токена, состояние breaker-ов, доля 429.
Практикум
Задача 27.1 — хаос-стенд для LLM-клиента. ⭑⭑⭑
Напишите мок-сервер провайдера (FastAPI, 100 строк), который по конфигу воспроизводит сценарии: норма, медленные ответы, 429 с retry-after, 500-шторм, «завис после первого chunk-а». Оберните свой LLMClient из главы 4 полным стеком: таймауты, лимитер, retry, circuit breaker. Прогоните все сценарии под параллельной нагрузкой (50–100 одновременных запросов).
Критерии приёмки:
- Сценарий «20% запросов виснут»: p95 latency здоровых запросов не деградирует (пул не исчерпывается), висящие обрываются по таймауту.
- Сценарий «429-шторм»: суммарный исходящий RPS падает, а не растёт; после окончания шторма система восстанавливается сама.
- Сценарий «500-шторм»: breaker открывается за заданное окно, вызовы отклоняются локально, half-open корректно закрывает его после «выздоровления» мока.
- По каждому сценарию — счётчик «сколько денег сожгли бы ретраи» без денежного бюджета против с ним.
- Ни в одном сценарии не ретраится ошибка 400.
Подсказки:
- Начните с тестов на каждый слой отдельно, потом собирайте стек: отлаживать всё сразу под нагрузкой — мучение.
- Состояние breaker-а выведите в лог при каждом переходе — картина инцидента станет читаемой.
Задача 27.2 — деградация как продуктовое решение. ⭑⭑
Для триаж-сервиса из задачи 24.1 спроектируйте и реализуйте полную fallback-цепочку: основная модель → дешёвая модель → правила по ключевым словам → ручная очередь. Переключение — по состоянию circuit breaker-а.
Критерии приёмки:
- При «падении» основной модели (мок) сервис продолжает отвечать; в ответе и логе честно указан использованный уровень деградации.
- Качество каждого уровня измерено заранее на golden dataset: вы знаете цену деградации в accuracy, а не только в деньгах.
- Возврат на основную модель происходит автоматически и виден в метриках.
- Написан одностраничный runbook: как дежурный видит деградацию, что проверять, когда вмешиваться.
Подсказки:
- Уровень «правила по ключевым словам» кажется унизительным после LLM — пока не спасает ночной инцидент. Пусть он покрывает хотя бы два самых частых класса тикетов.