Пятница, 20:00. Графики в Grafana краснеют. В логах сыпятся HTTP 502 Bad Gateway и Read timeout от популярного LLM-провайдера. Ваши пользователи жмут кнопку «Сгенерировать отчет», ждут 30 секунд и получают сообщение об ошибке. Они злятся и жмут кнопку снова. И снова.
А теперь посмотрите на это со стороны биллинга. Каждый такой клик отправляет провайдеру увесистый промпт на 10 000 токенов. Провайдер честно забирает этот промпт, начинает генерировать ответ, спотыкается о свои внутренние проблемы и рвет соединение. Вы платите за входящие токены. Ваш пользователь платит вам. Но результата нет. Клиент потратил деньги, но получил лишь разочарование.
В этой статье я расскажу, как мы в RouterAPI решили проблему «токсичных списаний», перестроили архитектуру биллинга и внедрили прозрачный failover, чтобы вы платили только за успешные генерации. Мы разберем анатомию сбоев, транзакционный биллинг и паттерн Circuit Breaker.
Иллюзия успешного запроса: как ломается стриминг
Работать с классическим REST API просто: отправил запрос, получил 200 OK — списал деньги. Получил 500 Internal Server Error — не списал. Но большие языковые модели в 99% случаев работают через Server-Sent Events (SSE) и потоковую передачу (streaming). И здесь начинается техническая боль.
Стриминг ломается иначе. Вы отправляете запрос. Провайдер отвечает HTTP 200 OK, и вы начинаете читать чанки (chunks) данных. Ваш примитивный скрипт биллинга фиксирует: «Ответ 200, можно списывать». Проходит пара секунд, вы получаете половину предложения, и внезапно сокет закрывается с RST пакетом или намертво виснет в Read timeout.
В этот момент классические системы биллинга делают одну из двух глупостей:
- Списывают всё. Запрос же был? Был. Промпт обработан? Да. Списываем деньги за промпт и за те 15 токенов, что успели прийти. Клиент в ярости: он не может использовать обрывок текста.
- Пытаются посчитать «по факту» без отмены. Биллинг считает токены на лету и просто останавливает счетчик при обрыве. Клиент платит меньше, но его бизнес-задача все равно не решена.
В обоих случаях вы перекладываете инфраструктурные проблемы апстрима на плечи вашего пользователя. Это разрушает доверие быстрее, чем медленная генерация.
Честный биллинг: транзакционная архитектура RouterAPI
Когда мы проектировали шлюз RouterAPI, мы заложили простое бизнес-правило: нет целого, успешного ответа — нет списания.
Реализация этого правила потребовала пересмотра того, как запросы проходят через систему. Мы разделили процесс оплаты на две фазы (Hold и Capture), вдохновившись паттернами из финтеха.
Фаза 1: Холдирование (Hold)
Когда запрос поступает в наш gateway (RouterAPI для максимальной производительности), мы парсим тело запроса, достаем messages и считаем токены промпта с помощью локального токенизатора (например, порта tiktoken). Мы смотрим на запрошенный параметр max_tokens (или берем хардлимит модели).
Далее мы делаем быстрый запрос в Redis, чтобы заморозить (захолдировать) на балансе пользователя сумму, равную (prompt_tokens цена_входящего) + (max_tokens цена_исходящего). Если денег не хватает — сразу отдаем HTTP 402 Payment Required. Запрос до апстрима даже не доходит, мы экономим ресурсы и время.
Фаза 2: Проксирование и парсинг SSE (Streaming Parser)
Запрос уходит к апстриму (OpenAI, Anthropic и т.д.). Мы не просто проксируем TCP-сокет, мы разбираем SSE-поток на лету. В памяти gateway работает стейт-машина, которая считает сгенерированные токены.
Фаза 3: Списание или Rollback (Capture / Void)
Здесь происходит магия честного биллинга. Если стрим завершается успешно (мы получаем маркер [DONE] или финальный чанк с finish_reason: "stop"), мы фиксируем успех. Gateway отправляет асинхронное событие: «Списать из холда сумму за фактические X входящих и Y исходящих токенов, остаток вернуть на баланс».
Но если на этапе 2 мы ловим context deadline exceeded, 502 Bad Gateway, обрыв TCP-соединения или провайдер присылает ошибку прямо внутри JSON (что часто бывает у нестабильных LLM), стейт-машина переходит в статус FAILED.
// Упрощенный пример логики стейт-машины на уровне шлюза
public function handleStreamClose(RequestState $state): void {
if ($state->hasReceivedDoneMarker) {
$this->billing->capture(
$state->getTransactionId,
$state->getPromptTokens,
$state->getCompletionTokens
);
} else {
// Поток оборвался до нормального завершения
$this->billing->void($state->getTransactionId);
Log::warning('Stream aborted, refunding tokens', [
'tx' => $state->getTransactionId,
'provider' => $state->getProvider
]);
}
}
Gateway отправляет команду VOID: полностью отменить холд. Пользователю возвращаются 100% средств за этот запрос.
Failover: спасаем запрос до того, как клиент заметит сбой
Вернуть деньги — это честно, но недостаточно. Клиент пришел не за возвратом, а за сгенерированным текстом. Если OpenAI лежит, ваш сервис тоже лежит, даже если вы не берете за это деньги.
Отмена списания — только первый шаг. Второй шаг — прозрачный ретрай (transparent retry) и переключение на резервный канал (failover).
В RouterAPI мы внедрили умную маршрутизацию. У нас есть понятие маршрутизацию. Для каждой модели (например, gpt-4o) настроено несколько апстримов: первичный (напрямую OpenAI), вторичный (Azure OpenAI), третичный (резервный провайдер).
Механика работы при сбое выглядит так:
- Запрос уходит в OpenAI.
- OpenAI отвечает HTTP 503 Service Unavailable (до начала стриминга).
- Наш gateway перехватывает 503. Холд средств остается активным.
- Срабатывает политика Retry. Gateway делает короткую паузу с использованием jittered exponential backoff (чтобы не устроить thundering herd при массовом сбое), и отправляет запрос в Azure OpenAI.
- Azure OpenAI успешно отвечает и начинает стриминг.
- Клиент на своей стороне видит лишь небольшую задержку перед первым токеном (Time to First Token, TTFT увеличивается на 1-2 секунды). Запрос спасен.
А что если стрим уже начался и оборвался посередине? Здесь кроется самая сложная инженерная задача. Если мы уже отправили клиенту HTTP 200 OK и начали отдавать чанки, мы не можем "отменить" HTTP-статус. Мы вынуждены закрыть поток с ошибкой. И вот именно здесь финансовая гарантия (Void) играет ключевую роль. Мы делаем полный Rollback холда. Да, генерация прервалась, но баланс клиента не пострадал. При следующем клике "Сгенерировать" сработает Circuit Breaker.
Метрики и Circuit Breaker: отключаем мертвые узлы
Чтобы не стучаться в мертвый апстрим и не увеличивать задержку (latency) для каждого нового запроса, мы используем паттерн Circuit Breaker (предохранитель).
В фоне Laravel-приложения работает консольная команда автоматический мониторинг моделей. Этот воркер собирает статистику по успешным и упавшим запросам, а также делает активные health-check пробы.
Если доля 5xx ошибок или таймаутов от конкретного апстрима превышает заданный порог (например, 10% за последние 2 минуты), предохранитель "размыкается" (Open state). Весь новый трафик для этой модели мгновенно переводится на запасной маршрут. Фоновый пингователь продолжает изредка посылать тестовые запросы (Half-Open state). Как только провайдер "выздоравливает", трафик плавно возвращается обратно.
400 vs 500: когда ретрай не имеет смысла
Важное уточнение: мы отличаем инфраструктурные проблемы от ошибок пользователя. Если клиент отправляет 200 000 токенов в модель, которая поддерживает только 128 000 (ошибка context_length_exceeded, HTTP 400), ретраить такой запрос бессмысленно — он упадет везде.
В этом случае мы также не списываем деньги (отменяем холд), но сразу проксируем 400-ю ошибку клиенту с понятным описанием, не тратя время на резервные каналы.
Выводы
Разработка отказоустойчивого шлюза с умным парсингом стримов, транзакционным биллингом и автоматами состояний требует серьезных инженерных ресурсов. Гораздо проще написать прокси-скрипт на 50 строк, который пересылает JSON туда-сюда, и списывать деньги по факту вызова.
Но когда API становится ядром бизнес-процессов, надежность инфраструктуры выходит на первый план. Каждая 502-я ошибка превращается в тикеты в саппорт, гневные отзывы и упущенную выгоду.
В RouterAPI мы решили, что инфраструктурные риски — это наша проблема, а не проблема наших клиентов. Мы маршрутизируем трафик в обход мертвых узлов, мы гасим ошибки, и главное — мы не берем деньги за неудачные генерации.
Потому что в конечном итоге, API — это не просто эндпоинты и JSON. Это доверие. И оно не должно обрываться вместе с TCP-соединением.