MAATRIX / Блог / Очередь запросов к локальной LLM: чтобы никто не ждал вечно

Очередь запросов к локальной LLM: чтобы никто не ждал вечно

MAATRIX

Вы подняли локальную модель на одной видеокарте, всё работало отлично, пока пользоваться ей не начали одновременно два-три человека или пара приложений. И вдруг ответы, которые раньше приходили за секунды, стали приходить через минуту-две, а то и дольше. Дело не в том, что модель «сломалась» — она просто обрабатывает запросы по очереди, а очередь при неправильной архитектуре растёт быстрее, чем модель успевает её разгребать. Разберём, откуда берётся эта очередь и что с ней делать на практике.

Почему очередь вообще возникает

Запрос к обычной базе данных или REST API — это операция на миллисекунды: индекс, диск, сеть, ответ. Запрос к LLM устроен иначе: модель генерирует ответ токен за токеном, и каждый токен требует полного прохода через все веса модели. Даже короткий ответ в сто токенов — это сто последовательных шагов вычислений, и каждый шаг занимает заметное, измеримое время (у вас оно будет своё, в зависимости от модели и железа — не берусь называть конкретные цифры без вашего теста, ориентир смотрите в статье про узкое место, из-за которого локальная LLM отвечала минуту).

Отсюда простое, но важное следствие из теории массового обслуживания: если запросы приходят чаще, чем сервер успевает их обрабатывать, очередь не стабилизируется — она растёт без ограничений, пока не рухнет либо клиент (таймаут), либо сервер (переполнение памяти под очередь). При GPU-инференсе это ощущается особенно резко: видеопамять и вычислительные блоки — один физический ресурс, который нельзя временно «занять взаймы» у соседнего процесса, как иногда получается с CPU.

Ключевая характеристика, которую стоит держать в голове: время ожидания ответа для нового запроса растёт пропорционально не только загрузке модели, но и длине очереди перед ним. Если движок обрабатывает запросы строго последовательно (первый пришёл — первый обслужен, и только один запрос одновременно), десятый в очереди человек ждёт время девяти чужих генераций плюс своей собственной. При наивной реализации (простой скрипт, который в цикле дергает model.generate() для каждого HTTP-запроса) именно так и происходит.

Что чувствует пользователь при параллельной нагрузке

Представим утрированный, но реалистичный сценарий: у вас развёрнут чат-бот на локальной модели, отвечающий в среднем условные 20-30 секунд на запрос (у вас цифра будет своя). Днём десять сотрудников одновременно открывают чат и спрашивают что-то у бота. При последовательной обработке:

  • 1-й пользователь получает ответ через ~20-30 секунд;
  • 5-й — только после того, как обработаны 4 предыдущих, то есть спустя несколько минут;
  • 10-й — в конце очереди, и его ожидание может растянуться на много минут.

Формула для наивной последовательной очереди грубо такая: время_ожидания(N) ≈ N × среднее_время_генерации, где N — позиция в очереди. Это линейный рост, и субъективно он воспринимается гораздо хуже, чем то же суммарное время, размазанное параллельно.

Ситуация усугубляется, если среди запросов затесался один «прожорливый» — например, кто-то попросил модель написать длинную статью на 4000 токенов или прислал огромный контекст (весь текст договора на анализ). Пока движок разбирает этот запрос, все остальные, стоящие в очереди позади него, простаивают целиком — классическая проблема head-of-line blocking, знакомая по сетевым протоколам и очередям сообщений (похожий эффект разобран в статье как устроена очередь сообщений). Три практических рычага, которые реально решают эту боль — параллельная обработка, ограничение размера запроса и приоритизация, — разберём по порядку.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Развернуть ИИ на сервере

Батчинг: параллельная обработка вместо строгой очереди

Главный практический ответ на проблему — не обрабатывать запросы строго один за другим, а объединять их в батчи (batch) и считать параллельно. Современные production-движки инференса — vLLM, SGLang, TGI (Text Generation Inference) — реализуют так называемый continuous batching (иногда его называют iteration-level scheduling или in-flight batching): на каждом шаге генерации токена движок собирает в один проход все активные запросы, у которых ещё не сгенерирован токен, и считает их вместе, эффективно используя параллелизм GPU.

Важное отличие от наивного «статического» батчинга (когда сервер ждёт, пока накопится пачка из N запросов, и только потом их запускает): в continuous batching новый запрос может «подсесть» в уже выполняющийся батч на следующей же итерации, не дожидаясь, пока завершатся более длинные запросы, которые уже обрабатываются. Это резко улучшает и общую пропускную способность (throughput), и справедливость распределения времени ожидания между пользователями — никто не ждёт, пока досчитается чужой длинный ответ, прежде чем его собственный запрос вообще возьмут в работу.

Практически это означает, что при выборе движка инференса стоит сознательно избегать связки «голая модель через transformers + цикл по запросам» в проде — она обрабатывает запросы строго последовательно. Пример быстрого поднятия production-движка:

# Установка и запуск vLLM с OpenAI-совместимым API
pip install vllm
python -m vllm.entrypoints.openai.api_server \
  --model meta-llama/Llama-3.1-8B-Instruct \
  --max-num-seqs 16 \
  --gpu-memory-utilization 0.9

Параметр --max-num-seqs задаёт, сколько запросов движок держит в активном батче одновременно — это ваш главный рычаг компромисса между задержкой (latency) отдельного запроса и суммарной пропускной способностью. Слишком маленькое значение — очередь снова начинает расти при параллельной нагрузке; слишком большое — каждый отдельный запрос замедляется, потому что делит GPU с большим числом соседей. Подбирайте эмпирически под свою видеокарту и типичный размер запросов; подробный разбор настройки самого vLLM — в статье как установить и настроить vLLM на VPS, а про то, как один «жадный» пользователь без батчинга съедает всю GPU в одиночку — в статье про то, почему один пользователь тратит GPU.

Лимит на длину и сложность одного запроса

Даже с батчингом один аномально большой запрос способен надолго занять непропорциональную долю ресурса — например, если ему разрешено сгенерировать неограниченное число токенов или принять контекст в 100 000 токенов, пока остальные ждут в очереди на подключение к батчу (слоты батча тоже не бесконечны, их число задаётся тем же --max-num-seqs и объёмом VRAM). Поэтому вторая практическая мера — явные, жёсткие ограничения на входе:

  • Максимальная длина ответа (max_tokens в запросе к API) — не давайте клиенту заказывать генерацию без ограничения сверху. Разумный дефолт на уровне вашего API-шлюза, который клиент может уменьшить, но не превысить.
  • Максимальная длина контекста — ограничивайте объём входного текста на уровне приложения (например, обрезка/чанкинг документа перед отправкой в промпт), а не только полагайтесь на context window модели.
  • Таймаут на запрос — если генерация не уложилась в разумное время (например, из-за зацикливания модели или чрезмерной длины), сервер обрывает запрос и отдаёт клиенту понятную ошибку, а не держит слот батча занятым бесконечно.
  • Rate limiting на клиента/API-ключ — ограничение числа одновременных запросов от одного источника, чтобы один скрипт с багом (например, ретраи в цикле без backoff) не забил всю очередь для остальных.

Пример ограничения на уровне обратного прокси (nginx) в дополнение к лимитам самого движка:

# ограничение параллельных запросов на один IP/ключ к LLM-эндпоинту
limit_req_zone $http_x_api_key zone=llm_zone:10m rate=2r/s;

location /v1/chat/completions {
    limit_req zone=llm_zone burst=5 nodelay;
    proxy_pass http://127.0.0.1:8000;
    proxy_read_timeout 300s;
}

Это не отменяет батчинг — это его дополняет: батчинг решает вопрос «как эффективно считать несколько запросов параллельно», а лимиты решают вопрос «как не дать одному источнику монополизировать очередь». Оба механизма нужны одновременно.

Приоритизация: не все запросы равны

Если ваша система обслуживает разные типы нагрузки — например, интерактивный чат в реальном времени и фоновую пакетную обработку (массовая суммаризация тысяч документов ночью), — простое FIFO (первый пришёл — первый обслужен) для всех подряд плохо подходит. Пользователь, ожидающий ответа в чате прямо сейчас, не должен стоять в очереди позади фоновой batch-задачи, у которой нет жёстких требований к задержке.

Практические варианты реализации приоритетов (выбирайте по ситуации, это не взаимоисключающие подходы):

ПодходКогда уместенСложность
Отдельный инстанс модели под каждый тип нагрузки (interactive vs batch)Есть запас GPU/бюджета на второй под-серверНизкая
Очередь с приоритетами перед единым движком (Redis/RabbitMQ с приоритетными очередями, диспетчер сам решает, что отправить движку следующим)Один GPU, нагрузка смешанная, нужен контроль на уровне приложенияСредняя
Встроенная приоритизация запросов в самом движке инференса (проверяйте актуальную документацию конкретной версии — эта возможность у разных движков разного уровня зрелости и может отличаться от релиза к релизу)Вы уже используете движок, который это поддерживает, и не хотите городить отдельный диспетчерЗависит от движка
Разные квоты по времени суток (batch-задачи запускать ночью, когда интерактивной нагрузки почти нет)Batch не привязан к конкретному времени выполненияНизкая, но негибко

На практике самый надёжный и предсказуемый вариант для смешанной нагрузки — держать входную очередь на стороне приложения (например, простая очередь на Redis с двумя списками — queue:high и queue:low) и лёгкий диспетчер, который всегда сначала выбирает задачи из очереди высокого приоритета, а к движку инференса (vLLM/SGLang) обращается уже с готовым, отсортированным потоком запросов. Так вы не зависите от того, поддерживает ли конкретная версия движка приоритеты «из коробки», и полностью контролируете политику сами.

Мониторинг: время ожидания в очереди — отдельная метрика

Здесь частая ошибка — мониторить только скорость самой генерации (токены в секунду) и не мониторить отдельно время, которое запрос провёл в очереди до того, как его вообще начали обрабатывать. Субъективное ощущение «эта LLM тормозит» у пользователя почти всегда складывается именно из времени ожидания в очереди, а не из скорости генерации токенов — модель может генерировать с абсолютно тем же темпом, что и вчера, но если очередь перед запросом выросла, пользователь всё равно почувствует и назовёт «тормозами» именно это.

Метрики, которые стоит собирать раздельно (у vLLM и аналогичных движков многие из них уже экспортируются в формате Prometheus через эндпоинт /metrics):

  • Time to first token (TTFT) — время от отправки запроса до первого токена ответа. Включает и ожидание в очереди, и первый forward pass.
  • Время ожидания в очереди отдельно (queue time) — если движок не отдаёт эту метрику напрямую, считайте её сами на уровне вашего API-шлюза: фиксируйте timestamp приёма запроса и timestamp начала фактической обработки движком.
  • Inter-token latency — скорость собственно генерации (это отдельно от очереди, и именно эту метрику часто путают с «общей скоростью»).
  • Глубина очереди (queue depth / число запросов, ожидающих слота в батче) — растущий тренд этой метрики — ранний сигнал, что пропускной способности не хватает при текущей нагрузке, до того как пользователи начнут жаловаться.
  • Percentiles, а не средние — p50/p95/p99 по времени ожидания. Среднее легко маскирует то, что 5% пользователей ждут в разы дольше остальных.
# пример алерта в Prometheus/Alertmanager на растущую очередь
- alert: LLMQueueGrowing
  expr: vllm_num_requests_waiting > 10
  for: 2m
  labels:
    severity: warning
  annotations:
    summary: "Очередь к LLM растёт дольше 2 минут — проверьте нагрузку и max-num-seqs"

Настройку полноценного дашборда для этих метрик разберём отдельно — общий подход к тому, как следить за нагрузкой на локальную модель, описан в статье как мониторить нагрузку локальной LLM. Если вам не хватает мощности одной видеокарты для интерактивной нагрузки нескольких пользователей одновременно, стоит также посмотреть в сторону сервера с более серьёзным GPU под общую нагрузку — например, GPU-сервер в Великобритании под несколько пользователей снимает вопрос очереди на уровне железа, а не только настройки движка.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Развернуть ИИ на сервере

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Батчинг решает проблему очереди полностью?

Нет, он существенно улучшает пропускную способность и справедливость распределения задержки, но не отменяет саму физику: если суммарная нагрузка стабильно превышает возможности одной видеокарты, очередь всё равно будет расти — батчинг просто отодвигает порог, при котором это становится заметно, и делает деградацию более плавной, а не резкой.

Какое значение max-num-seqs ставить по умолчанию?

Универсального числа нет — оно зависит от объёма VRAM, размера модели и типичной длины контекста ваших запросов. Начните с консервативного значения (например, 8-16 для одной потребительской видеокарты), смотрите на метрики задержки и глубины очереди под реальной нагрузкой и корректируйте — как и обещано в инструкции, точные цифры без вашего замера приводить не будем.

Можно ли обойтись без отдельного диспетчера приоритетов, если у меня всего 2-3 пользователя?

Да, при небольшом числе одновременных пользователей continuous batching в vLLM/SGLang сам по себе обычно снимает большую часть боли — отдельный приоритетный диспетчер оправдан, когда у вас реально смешанная нагрузка (интерактив + тяжёлый batch) или десятки параллельных клиентов.

Что делать, если очередь уже выросла и пользователи жалуются прямо сейчас?

Краткосрочно — снизьте max_tokens по умолчанию и включите более строгий rate limiting, чтобы срезать пиковую нагрузку; среднесрочно — проверьте, используете ли вы движок с continuous batching вообще, и рассмотрите более мощный GPU-сервер, если нагрузка выросла системно, а не разово.

Нужно ли мониторить очередь, если запросов мало и всё «и так быстро»?

Стоит начать сбор метрик заранее — глубина очереди и время ожидания растут нелинейно при приближении к пределу пропускной способности, и без истории метрик вы заметите проблему только тогда, когда пользователи уже начнут жаловаться, а не когда её ещё легко предотвратить.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →