Не каждый 400 - ваша вина: классифицируйте ошибки LLM-провайдера до ретрая
Большинство AI-пайплайнов складывают все ошибки провайдера в одну кучу и ретраят всё подряд. Рассказываю про пять классов сбоев, которые я использую, и какую реакцию получает каждый.
Pavel Duglas
AI Automation & MVP Architect
Однажды мне позвонил клиент: его пайплайн обработки документов «разучился понимать счета». Со счетами все было в порядке. В субботу ночью закончился предоплаченный баланс API, провайдер начал отвечать 400, а пайплайн сделал ровно то, чему его научили: 400 значит кривые входные данные, помечаем документ как нечитаемый и идем дальше. К утру понедельника 1800 нормальных счетов лежали с пометкой «мусор», а бухгалтерия уже начала перебивать их руками.
Проблема была не в оплате. Проблема была в том, что система знала только один вид ошибки. В этой статье покажу классификацию сбоев, которую я теперь ставлю вокруг каждого вызова модели в продакшене, и объясню, почему реакция на ошибку важнее, чем количество ретраев.
HTTP-коды слишком грубые для LLM API
Статус-коды придумывали для обычных веб-ресурсов. LLM-провайдеры запихивают в те же несколько чисел совершенно разные ситуации:
- 400 может означать невалидную JSON-схему, слишком длинный контекст, нулевой баланс на аккаунте или отказ модели по контент-политике.
- 429 может означать «притормози на пару секунд», а может «месячная квота кончилась, увидимся через 19 дней».
- 500 или overloaded обычно значит «повтори», но иногда это сигнал, что конкретная версия модели сейчас лежит и лучше идти в другое место.
- Таймаут вообще ничего не говорит о том, обработал ли провайдер запрос и списал ли за него деньги.
Если логика ретраев ветвится только по статус-коду, вы будете повторять то, что никогда не пройдет, сдаваться там, где через десять секунд все бы сработало, и винить данные в проблемах, которые на самом деле живут в биллинге.
Пять классов сбоев
Любая ошибка от вызова модели сначала попадает ровно в один из этих классов, и только потом что-то происходит.
1. Transient (временный сбой)
У провайдера что-то моргнуло: 500, обрыв соединения до отправки запроса, overloaded. Тот же запрос, скорее всего, скоро пройдет. Это единственный класс, где обычный exponential backoff с jitter действительно правильный ответ.
2. Throttled (троттлинг)
Вы идете слишком быстро. Короткие лимиты на запросы или токены в минуту. С запросом все нормально, не в порядке темп. Решение - уважать retry-after и замедлять весь пул воркеров, а не одну конкретную задачу.
3. Exhausted (ресурс исчерпан)
Проблема в аккаунте, а не в запросе. Кончились кредиты, исчерпана месячная квота, ключ отозван, организация заблокирована. Ретраить бессмысленно, и каждая задача на этом ключе упадет точно так же. Именно этот класс и похоронил те самые счета.
4. Rejected (отклонен)
Сам запрос неправильный. Контекст не влезает, кривое описание tool, невалидный параметр, отказ по политике. Повторять идентичный запрос можно до бесконечности. Отправка на запасного провайдера обычно тоже не помогает: промпт на 400 тысяч токенов слишком большой почти везде.
5. Ambiguous (неизвестно)
Вы не знаете, что произошло. Запрос ушел, потом соединение отвалилось по таймауту или стрим оборвался на середине. Модель могла отработать, могла списать деньги, могла вызвать tool. Здесь нужна отдельная обработка, потому что наивный ретрай может удвоить и счет, и побочный эффект.
Проблемы с ответом, вроде невалидного JSON или выдуманного поля, я сознательно держу вне этой классификации. Это другой слой - валидация. Здесь нас интересует только одно: сработал ли сам вызов.
Классифицируйте по телу ошибки, а не по статусу
Любой серьезный провайдер возвращает структурированную ошибку с полем type или code и текстом сообщения. Классифицировать нужно по ним, статус-код использовать только как тай-брейкер, а весь маппинг держать в одном месте. Вот урезанная версия того, что я использую в Python-пайплайнах:
from enum import Enum
class Failure(Enum):
TRANSIENT = "transient"
THROTTLED = "throttled"
EXHAUSTED = "exhausted"
REJECTED = "rejected"
AMBIGUOUS = "ambiguous"
EXHAUSTED_HINTS = ("credit", "balance", "billing", "quota", "insufficient", "suspended")
REJECTED_HINTS = ("context length", "too long", "invalid", "schema", "policy")
def classify(status, body, sent=True):
if status is None:
return Failure.AMBIGUOUS if sent else Failure.TRANSIENT
text = (str(body.get("type", "")) + " " + str(body.get("message", ""))).lower()
if status in (401, 403) or any(h in text for h in EXHAUSTED_HINTS):
return Failure.EXHAUSTED
if status == 429:
return Failure.THROTTLED
if status >= 500:
return Failure.TRANSIENT
if any(h in text for h in REJECTED_HINTS):
return Failure.REJECTED
return Failure.REJECTED # неизвестный 4xx: безопасно падаем, но шлем алерт
Здесь важны три момента. Первый: проверка на exhausted идет раньше проверки на 429, потому что некоторые провайдеры отдают исчерпание квоты как 429 с сообщением про биллинг. Второй: флаг sent говорит классификатору, ушли ли байты с вашего сервера, и это ровно та граница, где безопасный ретрай превращается в неопределенность. Третий: неизвестные 4xx попадают в rejected, но поднимают алерт, потому что ошибка, которую вы раньше не видели, как раз та, на которую надо посмотреть глазами. Каждый раз, когда срабатывает алерт, я добавляю новую подстроку в маппинг. Через месяц корзина неизвестных почти пустая.
Матчинг по строкам в сообщениях выглядит хрупко, и он действительно хрупкий. Поэтому он живет в одной функции с тестами, а не размазан по двенадцати блокам except.
У каждого класса своя реакция
Вот в чем весь смысл. Классификация бесполезна, если реакция одинаковая:
- Transient: ретраим задачу с backoff и jitter, максимум 4-6 попыток, потом отправляем на запасную модель, если она есть.
- Throttled: ставим пул воркеров на паузу на время retry-after, снижаем concurrency, возвращаем задачу в очередь и не засчитываем это как неудачную попытку.
- Exhausted: открываем circuit breaker для этого ключа, перестаем забирать задачи, будим человека, задачи в очереди не трогаем.
- Rejected: тот же payload не повторяем. Либо трансформируем его (обрезаем, режем на части, упрощаем схему tools), либо отправляем в dead letter queue с указанием причины.
- Ambiguous: прежде чем повторять, проверяем, не была ли работа уже сделана, через idempotency key или собственный журнал.
Обратите внимание: только один класс из пяти означает «повтори». Большинство библиотек для ретраев исходят из обратного.
Exhausted - значит останавливаем конвейер
Самая дорогая ошибка с аккаунтными сбоями - позволить каждой задаче обнаружить проблему самостоятельно. Если в очереди 2000 задач, а баланс нулевой, вы получите 2000 падений, 2000 строк в логах и, возможно, 2000 писем клиентам об ошибке.
Вместо этого первая же ошибка exhausted переключает флаг в Redis или в базе: provider:anthropic:key_main = open. Воркеры проверяют флаг перед тем, как взять задачу. Пока он открыт, задачи спокойно лежат в очереди в исходном состоянии. Человек пополняет баланс или чинит ключ, одиночный пробный запрос проходит, флаг закрывается, и очередь разгребается так, будто ничего не случилось. Никто не перебивает счета руками.
Две вещи я добавляю всегда:
- Алерты по балансу до нуля. Если провайдер отдает usage, опрашивайте его. Если нет, считайте расходы в собственном журнале затрат и шлите алерт на 70% от предоплаты. Исчерпание баланса должно быть плановым пополнением, а не инцидентом.
- Отдельные ключи под разные нагрузки. Разогнавшийся батч не должен высушить ключ, на котором держится клиентский чат. Один ключ на нагрузку - один breaker на нагрузку.
Запасной провайдер имеет смысл не для всех классов
Сейчас все хотят мульти-провайдерный fallback, тем более что open-weight модели для многих задач уже вполне годятся. Идея отличная, но только для transient и exhausted. Если переключаться на rejected-запросе, вы просто соберете тот же отказ у второго вендора и заплатите за входные токены еще раз.
Прежде чем отдать задачу на запасную модель, я проверяю возможности, а не только доступность: держит ли модель такой размер контекста, такой формат tool calling, такой режим structured output. Это хранится в маленькой таблице возможностей по каждой модели. Если запасной вариант не вытянет задачу, она ждет закрытия breaker, а не превращается молча в результат похуже. В большинстве бизнес-процессов честная задержка лучше тихой деградации.
Неопределенным сбоям нужен журнал, а не ретрай
Когда запрос упал по таймауту уже после отправки, честный ответ звучит как «я не знаю». Так к нему и относитесь:
- Давайте каждому вызову модели idempotency key, собранный из ID задачи и назначения попытки.
- Если вызов может запускать побочные эффекты через tools, сами tools должны проверять этот ключ перед действием. Таймаут никогда не должен отправлять одно и то же письмо дважды.
- Записывайте неопределенный вызов в журнал затрат как «возможно, списано». Если вы берете с клиентов деньги за каждое AI-действие, не списывайте за неопределенные попытки, пока не подтвердите их.
- В стриминге сохраняйте частичный ответ. Иногда приходит 90% длинной генерации, и ее можно продолжить или спасти, а не оплачивать заново целиком.
Тестируйте сбои специально
Ждать реального обнуления баланса, чтобы проверить breaker, - плохая идея. У меня в тестах живет фейковый провайдер: крошечный HTTP-сервер, который отдает заготовленные ответы под каждый класс. 500, 429 с retry-after, 400 с сообщением про кредиты, 400 с сообщением про длину контекста и соединение, которое принимает тело запроса и зависает.
Тест проверяет поведение, а не только статус: exhausted открывает breaker и оставляет задачи в очереди, rejected уходит в dead letter queue с причиной, ambiguous не запускает tool повторно. Когда провайдер меняет формат ошибок, я сохраняю новое тело ответа, добавляю его в фикстуры, и тест сразу показывает, держится ли классификатор.
Чеклист
Если забрать из статьи что-то одно, заберите этот список и пройдитесь по нему со своим пайплайном сегодня:
- Все вызовы модели идут через одну обертку с одним классификатором.
- Классификация смотрит сначала на тело ошибки, потом на статус-код.
- Обычный backoff-ретрай получают только transient-ошибки.
- Троттлинг замедляет пул и не сжигает попытки.
- Exhausted открывает breaker на ключ и будит человека.
- Rejected-запросы трансформируются или уходят в dead letter queue, но никогда не повторяются как есть.
- Перед ретраем ambiguous-вызовы сверяются с idempotency key.
- Fallback-маршрутизация проверяет возможности модели, а не только аптайм.
- Фейковый провайдер в тестах покрывает каждый класс.
Ничего гламурного тут нет. Это примерно 200 строк кода и полдня на тесты. Но именно это отличает AI-пайплайн, который вежливо встает на паузу, когда кончились деньги, от пайплайна, который сообщает клиенту, что его счета не читаются.
Вопросы и ответы
Почему нельзя просто ретраить все ошибки с exponential backoff?
Потому что из пяти классов сбоев backoff помогает только временным. Ретраи при исчерпанном балансе или отклоненном запросе гарантированно проваливаются, жгут время и иногда деньги, а при неопределенном таймауте могут дважды выполнить побочный эффект, например отправить письмо или списать оплату.
Не слишком ли ненадежно определять тип ошибки по тексту сообщения?
Ненадежно, поэтому вся логика лежит в одной функции, покрытой тестами с реальными телами ошибок. Неизвестные 4xx уходят в безопасный класс rejected и поднимают алерт, после чего я добавляю новую подстроку в маппинг. Через несколько недель неизвестных ошибок почти не остается, а смена формата у провайдера ловится тестом.
Когда стоит переключаться на запасного LLM-провайдера?
Только при временных сбоях и исчерпании ресурса на основном ключе, и только если запасная модель поддерживает нужный размер контекста, формат tool calling и structured output. При отклоненном запросе fallback даст тот же отказ и лишний счет, поэтому такой payload нужно трансформировать или отправить в dead letter queue.
Похожие статьи
Сделаю под ключ
Соберу ИИ-агента под реальную задачу
С инструментами, памятью и логами, чтобы он работал в проде, а не только в демо.
от 70 000 ₽ · 1-2 недели