Очередь не спасёт LLM-сервис: как backpressure не даёт перегрузке съесть прод
После таймаутов, ретраев и circuit breaker кажется, что LLM-интеграция уже защищена от перегрузки. Но есть ещё один сценарий: провайдер работает, ответы приходят, ошибок почти нет — а очередь внутри приложения растёт быстрее, чем система успевает её разбирать.
Через несколько минут пользователь ждёт ответ не 8 секунд, а 4 минуты. Воркеры заняты устаревшими задачами, база получает тысячи проверок статуса, а после восстановления система сама устраивает провайдеру вторую волну нагрузки.
Очередь не устраняет перегрузку. Она только переносит её во времени. Чтобы сервис действительно оставался управляемым, нужен backpressure — механизм, который не позволяет принимать работу быстрее, чем система способна её обработать.
Почему «просто поставим в очередь» не работает
Допустим, LLM-провайдер стабильно обрабатывает 20 запросов в секунду. В обычное время приложение получает 12–15 запросов, и всё выглядит нормально.
После запуска рассылки или импорта документов входящий поток вырастает до 50 запросов в секунду. Разница — примерно 30 задач каждую секунду — начинает накапливаться.
За десять минут в очереди окажется около 18 тысяч задач. Даже если трафик полностью прекратится, на разбор хвоста потребуется ещё 15 минут. А трафик обычно не прекращается.
Самая опасная метрика здесь — не длина очереди сама по себе, а возраст самой старой задачи. Очередь из тысячи коротких запросов может быть нормальной. Очередь из ста задач, где первая ждёт десять минут, уже означает нарушение пользовательского сценария.
Backpressure — это отказ принимать бесконечный долг
Backpressure отвечает на простой вопрос: что делать, когда скорость поступления задач выше реальной пропускной способности?
Варианты неприятные, но честные:
— замедлить приём новых задач; — ограничить параллелизм; — отклонить низкоприоритетные запросы; — перевести часть функций в упрощённый режим; — предложить пользователю асинхронный сценарий; — объединить или удалить устаревшие задачи.
Без backpressure система принимает весь входящий поток и обещает выполнить его позже. На практике это обещание быстро становится техническим долгом с таймером.
Три лимита вместо одного
Для LLM-нагрузки одного rate limit недостаточно. Я обычно разделяю три ограничения.
1. Лимит входящих запросов
Он защищает API приложения от резкого всплеска. Например, не больше 30 новых задач в секунду для одного клиента и не больше 100 для всего сервиса.
Но этот лимит ничего не знает о длительности генерации. Десять коротких классификаций и десять больших отчётов создают совершенно разную нагрузку.
2. Лимит параллельных операций
Он ограничивает количество запросов, которые одновременно занимают соединения, память и квоту провайдера.
Например, у нас может быть максимум 40 активных генераций: 20 для интерактивного трафика, 15 для фоновых задач и 5 в резерве. Если весь пул отдать одному импорту, обычный чат перестанет отвечать.
3. Лимит стоимости работы
Два запроса могут занимать одинаковое время, но отличаться по стоимости в десять раз. Поэтому полезно ограничивать не только число задач, но и ожидаемые токены или условные единицы работы.
Перед постановкой в очередь можно оценивать вес задачи по размеру контекста, максимальному output, числу инструментов и выбранной модели. Тяжёлая задача занимает несколько слотов, короткая — один.
Ограниченная очередь лучше бесконечной
Бесконечная очередь удобна только тем, что долго скрывает проблему.
У очереди должны быть:
— максимальная длина; — максимальный возраст задачи; — дедлайн, после которого результат уже не нужен; — политика вытеснения; — отдельные лимиты для разных классов трафика.
Если задача ждала пять минут, а пользователь уже закрыл страницу, её выполнение только потратит токены. Перед запуском воркер должен проверить дедлайн и актуальность операции.
Для некоторых сценариев полезно удалять старые задачи в пользу новых. Например, если пользователь трижды попросил пересобрать один отчёт, часто нужен только последний вариант. Но для платежей, писем или изменений в CRM такая дедупликация требует бизнес-ключа и строгой идемпотентности.
Приоритеты: интерактивный запрос не равен ночному импорту
Одна общая FIFO-очередь почти всегда приводит к инциденту. Большая пакетная задача занимает все воркеры, а пользовательский запрос ждёт за тысячами документов.
Минимальное разделение:
— interactive — чат, подсказки, действия пользователя; — background — отчёты, индексация, пакетная обработка; — critical — короткие операции, от которых зависит основной бизнес-процесс; — retry — повторные попытки после временных ошибок.
Для каждого класса нужны свои лимиты параллелизма и максимальное время ожидания. Воркеры можно распределить статически или использовать взвешенную справедливость: например, на каждые пять интерактивных задач выполнять одну фоновую.
Важно не дать приоритетному трафику полностью вытеснить остальные очереди. Иначе фоновая обработка никогда не завершится, а отложенный долг будет расти незаметно.
Почему автоскейлинг может сделать хуже
Обычная реакция на растущую очередь — добавить воркеры. Для собственного CPU-сервиса это часто правильно. Для внешнего LLM API увеличение воркеров может просто быстрее упереться в лимит провайдера.
Если поднять параллелизм с 20 до 100 при той же квоте, система получит больше 429, таймаутов и ретраев. Ретраи снова попадут в очередь и увеличат нагрузку. Получается положительная обратная связь: чем хуже работает сервис, тем больше запросов мы ему отправляем.
Поэтому автоскейлинг должен учитывать не только длину очереди, но и:
— доступную квоту провайдера; — долю 429 и Retry-After; — latency первого токена; — число активных запросов; — токены в минуту; — возраст задач; — состояние circuit breaker.
Если ограничение находится снаружи, новые воркеры не создают новую пропускную способность.
Load shedding: иногда правильный ответ — «не сейчас»
Когда очередь достигла лимита, нужно заранее решить, кого не обслуживать.
Можно:
— отключить необязательную вторую проверку ответа; — сократить контекст и максимальную длину генерации; — использовать более дешёвую и быструю модель; — отказаться от фоновой перегенерации; — вернуть 429 или 503 с понятным Retry-After; — предложить уведомление о готовности вместо удержания HTTP-соединения.
Главное — не отвечать пользователю «задача принята», если система не может назвать реалистичный срок выполнения.
Для интерактивного чата честная ошибка через две секунды часто лучше ответа через пять минут. Для отчёта или обработки документа асинхронный режим, наоборот, вполне приемлем.
Стартовая конфигурация для небольшого проекта
Универсальных цифр нет, но начать можно с такой схемы:
— общий параллелизм — 30 LLM-вызовов; — 15 слотов для interactive; — 10 для background; — 5 резервных слотов для critical и half-open проверок; — максимальная очередь interactive — 100 задач; — максимальное ожидание interactive — 20 секунд; — максимальная очередь background — 2000 задач; — максимальный возраст background — 30 минут; — одна repair- или retry-попытка только при наличии бюджета; — снижение параллелизма при росте 429 и Retry-After; — удаление задачи, если её бизнес-дедлайн истёк.
Это не оптимальные значения, а безопасная точка, с которой можно собирать статистику. Лимиты нужно пересматривать по реальным latency, токенам и доле подтверждённых результатов.
Какие метрики действительно нужны
Я бы вынес на один дашборд:
— входящий поток задач в секунду; — фактическую скорость завершения; — число активных запросов по провайдерам; — длину и возраст каждой очереди; — время от постановки до начала выполнения; — долю задач, отменённых по дедлайну; — долю load shedding; — 429, Retry-After и состояние breaker; — токены и стоимость на один завершённый результат; — количество повторных попыток, вернувшихся в очередь.
Если скорость поступления постоянно выше скорости завершения, это не временный всплеск, а дефицит мощности или неверный продуктовый контракт. Никакой размер очереди это не исправит.
Итог
Очередь сглаживает короткий пик и отделяет приём задачи от её выполнения. Но без лимитов она превращает перегрузку в длинный и дорогой хвост.
Backpressure ограничивает объём обещанной работы. Раздельные очереди защищают интерактивный трафик от фонового. Дедлайны не дают выполнять устаревшие запросы. Load shedding сохраняет основную функцию, когда обслужить всех уже невозможно.
Главный принцип простой: система должна уметь не только выполнять запросы, но и вовремя переставать их принимать.
А что вы используете как главный сигнал перегрузки LLM-сервиса: длину очереди, возраст задачи, токены в минуту или рост latency?