Очереди и Rate Limits: Как мы научились не спамить API провайдеров

11.06.2026 17:00

Первый релиз AI-продукта всегда проходит по одному и тому же сценарию. Вы тестируете функционал локально: запросы к OpenAI или Anthropic улетают мгновенно, стриминг плавно отрисовывает текст, логи чисты. Продукт отправляется в продакшен. Приходят реальные пользователи. И тут случается столкновение с реальностью: кто-то запускает массовый импорт данных, десяток пользователей одновременно нажимают кнопку «Сгенерировать», и графики мониторинга окрашиваются в тревожный красный цвет.

Код ответа: 429 Too Many Requests.

Если архитектура не готова к жестким лимитам провайдеров, события развиваются по сценарию каскадного сбоя. Приложение захлебывается, возвращая пользователям пятисотые ошибки. Раздраженные клиенты начинают яростно обновлять страницу, генерируя еще больше паразитных запросов. Воркеры бэкенда забиваются повисшими тасками, исчерпывая пул соединений к базе данных. Локальная проблема одной интеграции убивает монолит целиком.

В этой статье я препарирую проблему балансировки AI-трафика. Мы пройдем путь от наивных sleep до гибридных алгоритмов троттлинга и разберем, как RouterAPI скрывает эту инженерную боль под капотом высокопроизводительного шлюз RouterAPIа.

Наивный подход: Sleep и блокировка воркеров

Самая первая, рефлекторная реакция инженера на 429-ю ошибку — добавить повторную попытку выполнения. Мы пишем бесхитростный цикл: получили отказ, ждем пару секунд, пробуем снова.

$maxRetries = 3;
$retryCount = 0;

while ($retryCount < $maxRetries) {
 $response = $httpClient->post('/chat/completions', $payload);
 
 if ($response->status !== 429) {
 return $response;
 }
 
 sleep(2 * ($retryCount + 1)); // Экспоненциальный бэкофф
 $retryCount++;
}

Этот код — архитектурная бомба замедленного действия. Он жестко блокирует поток выполнения. Если ваш бэкенд опирается на PHP-FPM или синхронные Python-воркеры (Gunicorn), каждый такой sleep выводит процесс из строя на несколько секунд. Имеете пул из 100 воркеров? Достаточно сотне пользователей упереться в лимиты, и весь ваш продукт перестанет отвечать на любые HTTP-запросы. Перестанет работать форма авторизации, отвалятся вебхуки от платежных шлюзов (например, T-Bank), сломается админка.

Фоновые очереди и коварство TPM

Логичный шаг изоляции — перенос LLM-вызовов в фоновые очереди (Redis, RabbitMQ). Веб-процесс моментально возвращает клиенту ID задачи, а тяжелую работу берет на себя воркер.

Это спасает веб-сервер от падения, но разрушает UX. Пользователь смотрит на крутящийся спиннер. Если очередь переполнена ретраями, ответ можно ждать минуту. Для интерактивного чата минутная пауза равносильна неработающему сервису.

Ситуация усугубляется тем, что LLM-провайдеры лимитируют не только количество запросов (RPM), но и количество токенов в минуту (TPM).

Контролировать RPM тривиально. А вот TPM — это уравнение с неизвестными. Вы можете токенизировать промпт на своей стороне (например, через библиотеку tiktoken), чтобы узнать входной объем. Но вы понятия не имеете, сколько токенов модель сгенерирует в ответе. Вынужденное резервирование max_tokens приводит к тому, что вы искусственно исчерпываете собственный лимит: бронируете 4096 токенов на каждый запрос, хотя модель отвечает короткими фразами по 100 токенов. Лимит выбран, запросы отклоняются, хотя реального перерасхода квоты у провайдера нет.

Token Bucket и проблема Thundering Herd

Для предварительного выравнивания трафика (traffic shaping) в распределенных системах мы внедряем Token Bucket (Маркерную корзину). Логика проста: в корзину с заданной скоростью капают токены. Запрос забирает токен. Нет токена — запрос ждет.

В первых итерациях мы реализовали Token Bucket через атомарные Lua-скрипты в Redis. Это спасало от состояния гонки (race conditions) между воркерами. Но выявилась новая проблема: Thundering Herd (шторм просыпающихся потоков).

Допустим, мы уперлись в лимит Anthropic. Провайдер отдает HTTP-заголовок x-ratelimit-reset-tokens: 60. Наша очередь покорно замирает на минуту, накапливая сотни новых запросов от пользователей. На 61-й секунде лимит обнуляется, все 500 воркеров просыпаются и одновременно бьют в API. Новая минутная квота сгорает за полсекунды. Мы снова получаем 429.

Решением стало внедрение "джитера" (jitter) — случайной временной девиации. Воркеры просыпаются не ровно через 60 секунд, а в случайной точке интервала от 60 до 65 секунд. Нагрузка размазывается, шторм утихает.

Кроме того, ходить в Redis на каждую генерацию токена в высоконагруженной системе — значит душить базу сетевыми задержками. Мы перешли к гибридной модели: In-Memory корзины внутри процессов, которые асинхронно синхронизируют счетчики с центральным кластером Redis раз в 100 миллисекунд.

Проблема стриминга и балансировка в RouterAPI

Реализация джитера, локальных бакетов и перехвата заголовков — это месяцы системной инженерии. Для большинства продуктовых команд это непрофильная трата ресурсов. И именно эту сложность мы инкапсулировали в архитектуре RouterAPI.

RouterAPI инкапсулирует всю инфраструктурную сложность: биллинг, маршрутизацию, очереди и обход rate limits — вам остаётся один OpenAI-compatible endpoint.

Более того, мы не ждем, пока пользователи столкнутся с отказом. RouterAPI проактивно мониторит доступность моделей и обновляет каталог, чтобы деградирующие маршруты отключались до того, как пострадает ваш трафик.

Если основной маршрут к модели перегружен или недоступен, RouterAPI автоматически переключает запрос на эквивалентную модель через резервный канал — без правок в вашем коде.

Со стримингом (Server-Sent Events) ситуация драматичнее. Соединение с клиентом уже установлено. Если целевой провайдер близок к исчерпанию лимитов, шлюз ставит запрос в краевую микро-очередь (edge queue). Но браузеры и балансировщики (nginx, AWS ALB) безжалостны к долгим паузам. Если в сокете нет активности 30-60 секунд, они рвут соединение (timeout). Вы оплачиваете генерацию токенов апстриму, но клиент получает обрыв сети.

Чтобы обойти это, RouterAPI использует механизм Keep-Alive. Пока запрос томится в очереди ожидая квоты, шлюз RouterAPI каждые 5 секунд шлет в стрим пустой комментарий (: ping\n\n). Это обманывает балансировщики, сокет остается открытым, а пользователь видит стабильный статус "Печатает..".

Заключение

Борьба с 429-ми ошибками — это не просто настройка retry_after. Это глубокая архитектурная задача, требующая контроля над TCP-соединениями, управления распределенным состоянием и тонкого понимания специфики LLM-трафика.

Наивные попытки решить проблему в лоб неизбежно ведут к деградации продукта и каскадным отказам. Оценивайте свои силы трезво. Если системная инфраструктура — не ваш ключевой бизнес, делегируйте эту задачу специализированным шлюзам вроде RouterAPI. Мы построили RouterAPI именно для того, чтобы вам больше не приходилось блокировать воркеры и терять лояльность пользователей из-за жестких лимитов апстримов.

Теги

Ещё по теме