Head of line эффект ломает latency даже при свободных GPU
Head of line эффект в LLM-очереди: как связать длину промпта с TTFT, выявить блокировку и разделить интерактивные и фоновые запросы.

Длинный запрос редко выглядит опасным в тестовом стенде. Он просто медленнее отвечает сам. В продакшене он способен задержать десятки коротких запросов, которые пришли после него, хотя GPU не простаивает и средняя задержка почти не изменилась. Это и есть head of line эффект: работа в начале очереди определяет ожидание работы за ней.
В LLM-сервисе проблема особенно неприятна из-за prefill. Модель должна обработать весь входной контекст до первого выходного токена. Запрос на несколько страниц договора, историю агента с инструментами и большой JSON может занять вычисления заметно дольше короткого вопроса пользователя. Если все запросы входят в один поток по принципу FIFO, короткий запрос ждёт не свою работу, а чужой контекст.
Ни autoscaling, ни высокий средний throughput сами по себе не исправляют эту картину. Сначала нужно увидеть форму нагрузки, затем отделить классы работы и только после этого подбирать параметры планировщика.
Head of line эффект начинается до первого токена
Head of line эффект в LLM-инференсе чаще всего проявляется как ухудшение TTFT у коротких запросов, вставших за длинным prefill. TTFT, time to first token, измеряет путь от принятия запроса до первого переданного токена. В него входят ожидание в очереди, время на обработку входа, постановка в батч и небольшая часть сетевого пути.
Не путайте эту метрику с полной длительностью генерации. Пользователь интерактивного чата обычно терпит длинный ответ, если видит начало через приемлемое время. Напротив, API, который отдаёт ответ целиком после обработки документа, может обходиться без строгого TTFT, но требует предсказуемого срока завершения. Одной цифрой latency эти режимы не описать.
У запроса есть две разные фазы:
- Prefill прогоняет входные токены через модель и создаёт KV cache. Вход обрабатывается параллельно, поэтому эта фаза обычно хорошо загружает вычисления GPU.
- Decode добавляет выходные токены по одному. Здесь важнее размер активного батча, доступная память KV cache и время между токенами, TPOT.
Авторы DistServe прямо разделяют эти SLO: TTFT относится к prefill, TPOT к decode. Их работа важна не тем, что каждому сервису срочно нужны раздельные GPU, а тем, что она ломает вредную привычку считать оба режима одной нагрузкой. Совместное выполнение prefill и decode создаёт взаимные помехи, а один общий лимит параллелизма заставляет выбирать, какой из двух показателей вы будете портить.
Очередь может быть внешней, внутри API-шлюза или уже внутри движка инференса. Пользователь не видит разницы. Он видит, что короткий вопрос «какой статус платежа?» иногда начинает отвечать через несколько секунд, потому что перед ним пришла задача «извлеки реквизиты из 180 страниц сканов».
Средняя длина промпта скрывает виновника
Среднее число входных токенов почти бесполезно для управления очередью. Оно отвечает на удобный для отчёта вопрос, но не отвечает на вопрос планировщика: как долго конкретный запрос будет удерживать prefill-бюджет перед тем, как пропустить следующий класс работы.
Посчитайте распределение input_tokens по каждому endpoint, tenant и типу задачи. Вам нужны как минимум p50, p90, p95, p99 и максимум за выбранное окно. Затем наложите на те же бакеты TTFT. Обычно становится видно одно из двух:
- Короткие запросы сами по себе быстры, но их p95 TTFT растёт синхронно с появлением длинных входов.
- Длинные запросы занимают небольшой процент трафика, но потребляют несоразмерную долю prefill-времени.
Вторая ситуация часто возникает после «безобидного» изменения продукта. Команда добавляет в агент весь чат, результаты поиска, несколько документов и схему инструментов. С точки зрения приложения это один запрос. Для inference-сервера это уже другой класс работы.
Не используйте размер HTTP body как замену длины промпта. Base64-вложения, пробелы, структура JSON и особенности токенизатора делают байты плохим предиктором. Классифицируйте запрос после токенизации или оценивайте токены до отправки тем же токенизатором, который соответствует модели. Если один маршрут обслуживает разные модели, храните оценку отдельно для каждой модельной семьи.
Полезна таблица, которую можно строить в аналитическом хранилище по минутным окнам:
класс запросы p50 input p95 input p95 queue p95 TTFT отмены
interactive 12 400 420 1 100 180 мс 720 мс 3,1%
document 380 18 600 47 900 4 900 мс 8 200 мс 0,4%
agent 1 900 3 100 12 400 1 600 мс 3 100 мс 8,7%
Цифры в такой форме важнее общего p95 API latency. Они показывают, что происходит с разными типами работы. Если interactive и document сидят в одной очереди, первая строка часто портится не собственной нагрузкой.
Есть ещё один неприятный случай: агентские запросы. Их вход может быть умеренным при первом вызове, но после нескольких tool calls история и результаты инструментов раздувают контекст. Если вы логируете только endpoint, то увидите «нестабильный чат». Если логируете номер хода, размер истории и размер tool output, увидите предсказуемую причину.
Пустой GPU не означает свободную очередь
Высокая загрузка GPU не доказывает, что сервис работает хорошо. GPU может быть занят длинным prefill, пока срочные запросы копятся снаружи. Низкая загрузка тоже не оправдывает простую FIFO-очередь: возможно, движок ограничен KV cache, малым числом последовательностей или частыми вытеснениями, а мониторинг смотрит только на compute utilization.
Для расследования head of line эффекта добавьте в трассу запроса пять меток времени:
{
"request_id": "r_7f31",
"class": "interactive_short",
"input_tokens": 684,
"max_output_tokens": 320,
"accepted_at": "2026-07-23T10:14:01.184Z",
"prefill_started_at": "2026-07-23T10:14:02.016Z",
"first_token_at": "2026-07-23T10:14:02.441Z",
"completed_at": "2026-07-23T10:14:05.920Z",
"finish_reason": "stop"
}
Из этих полей получаются три числа, которые нельзя смешивать:
queue_ms = prefill_started_at - accepted_at;prefill_to_first_token_ms = first_token_at - prefill_started_at;generation_ms = completed_at - first_token_at.
Если растёт queue_ms, ищите правила admission, порядок очереди, конкурирующие классы и нехватку capacity. Если растёт второй показатель при стабильной очереди, проверяйте длину входа, модель, контекстное окно, prefix cache и параметры батчинга. Если проблема в третьем, смотрите decode, лимит выхода, sampling и давление на KV cache.
Это различие регулярно размывают. Команда видит высокий TTFT и начинает менять модель на более быструю, хотя короткий запрос вообще не попадал в prefill две секунды. Или наоборот, добавляет воркеры, хотя decode уже забит длинными ответами и новый prefill лишь усугубляет вытеснение памяти.
Continuous batching нужен, но он не является обещанием справедливости. Документация Hugging Face описывает его как перепланирование батча на каждом шаге генерации, чтобы освобождённые места сразу принимали новые запросы. Это увеличивает использование GPU, но политика допуска и размер работы, которую один запрос может внести за итерацию, всё ещё определяют хвост задержки.
Один большой prefill блокирует систему вполне обычным образом
Представьте один inference-пул и FIFO-очередь. В 09:00:00 в него приходит задача суммаризации документа на 42 000 входных токенов. Через 80 миллисекунд приходит шесть коротких запросов по 300-800 токенов: чат оператора, проверка адреса, поиск по внутренней базе, ответ пользователю.
Планировщик запускает большую задачу первой. Пока он обрабатывает её вход, короткие запросы не получают первый токен. Если движок смешивает prefill и decode, он может продолжать выдавать токены уже активным генерациям, но новые короткие запросы всё равно ждут точки, в которой их допустят в расписание. При высоком потоке таких документов очередь быстро перестаёт восстанавливаться.
Хуже всего, когда продукт ограничил только max_output_tokens, но не ограничил вход. Команда уверена, что ответы короткие и нагрузка под контролем. Затем клиент отправляет экспорт CRM, длинную переписку или неочищенный HTML. В ответ модель должна сгенерировать 100 токенов, но сначала ей надо прочитать десятки тысяч.
Проверка на этот сбой проста. Возьмите временной ряд p95 TTFT короткого класса и отметьте на нём запросы, где input_tokens выше вашего p99 для интерактивного трафика. Если пики TTFT появляются в те же окна, FIFO создаёт head of line эффект. Не требуйте идеальной корреляции: на итог влияют активный decode, доступная память и параллельность. Но если короткие запросы страдают в минуты прихода длинных входов, это уже достаточное основание менять маршрут.
Проблема не решается простым правилом «самые короткие первыми». Такое правило может бесконечно отодвигать документы при постоянном чатовом трафике. Оно также поощряет клиентов занижать оценку длины. Нужна явная политика классов, возраст запроса в очереди и ограниченный бюджет для фоновой работы.
Разделяйте очереди по обещанию пользователю
Хорошая схема очередей исходит из пользовательского ожидания, а не из названия endpoint. Чат, синхронная проверка формы и агент, работающий в интерфейсе оператора, требуют быстрого первого токена. Ночная классификация, суммаризация архива и обработка очереди документов терпят задержку, если система честно сообщает статус задачи.
Для большинства команд хватает трёх классов:
interactive_short: короткий контекст, потоковый ответ, строгий TTFT;interactive_long: агент или аналитический запрос с большим входом, но пользователь ждёт в интерфейсе;batch_long: документы и массовые операции без требования к мгновенному началу.
Не делайте класс по имени модели. Одна и та же модель может обслуживать чат и пакетную обработку, а у двух моделей с разными скоростями может быть одинаковое пользовательское обещание. Маршрут должен учитывать оценку входных токенов, допустимый выход, streaming, приоритет tenant и deadline.
Пример правил на уровне шлюза выглядит так:
classes:
interactive_short:
when:
streaming: true
input_tokens_lte: 4000
max_output_tokens_lte: 1200
ttft_target_ms: 1200
concurrency_share: 0.60
interactive_long:
when:
streaming: true
input_tokens_lte: 24000
ttft_target_ms: 5000
concurrency_share: 0.25
batch_long:
when:
streaming: false
admission: rate_limited
concurrency_share: 0.15
max_wait_ms: 180000
Это не конфигурация конкретного движка. Это контракт, который ваш gateway должен превратить в отдельные очереди, пулы или лимиты допуска. Самая частая ошибка здесь, дать background-классу 15% «всегда». Ночью это оставляет capacity пустым, а днём может всё равно давать документам слишком много. Используйте заимствование свободной мощности: batch берёт незанятый ресурс, но освобождает его при появлении интерактивной очереди.
Справедливость между клиентами добавляется отдельно. Один tenant с тысячей длинных документов не должен заполнять весь batch_long и лишать других даже фоновой обработки. Подойдут лимит одновременных запросов на ключ, token budget на интервал и weighted fair queue. Лимит запросов без учёта токенов плохо защищает от одного гигантского prompt.
AI Router может быть точкой такой политики для команд, которым нужен единый OpenAI-совместимый endpoint: классификация до маршрутизации полезнее, чем попытка лечить все задержки внутри одного model server. Отдельные ключевые rate limits и аудит запросов помогают проверить, какой класс и какой клиент создают хвост, но правила классов всё равно должна задать сама команда.
Chunked prefill сокращает блокировку, но имеет цену
Chunked prefill режет длинный вход на части и даёт планировщику возможность вставлять между ними другую работу. Вместо обработки 32 000 токенов одним длинным куском движок может обработать несколько фрагментов, параллельно поддерживая decode уже начатых запросов и допуская новые короткие prefill.
В документации vLLM это описано прямо: chunked prefill позволяет обрабатывать большие prefill меньшими частями и смешивать их с decode; запрос, который не помещается в лимит batched tokens, может быть автоматически разделён. Это полезная защита от грубой блокировки, но не повод ставить очень маленький chunk без измерений.
Маленький фрагмент даёт планировщику больше точек для переключения. Одновременно он повышает число решений расписания и может ухудшить эффективность выполнения. Большой фрагмент даёт хорошую вычислительную плотность, но снова делает короткие запросы заложниками длинной работы. Sarathi-Serve формулирует тот же компромисс: их chunked prefill создаёт расписание, в котором новые запросы добавляются без остановки активных decode, но размер фрагмента остаётся параметром компромисса между latency и throughput.
Проверяйте изменение chunk size не по среднему throughput, а по таблице SLO:
| Измерение | Что должно улучшиться | Что может ухудшиться |
|---|---|---|
| p95 TTFT короткого класса | ожидание за длинными prefill | почти ничего, если capacity достаточна |
| p99 TTFT короткого класса | редкие крупные блокировки | при слишком мелком chunk растут накладные расходы |
| TPOT активных стримов | decode меньше ждёт prefill | слишком агрессивный admission может переполнить KV cache |
| throughput batch-класса | не обязан расти | иногда немного падает ради интерактивного SLO |
Не делайте вывод по одному прогону с постоянным числом запросов. Воспроизведите реальную смесь: короткие стриминговые запросы, несколько длинных документов, распределение max_output_tokens, отмены клиентом и burst-приход. Иначе вы оптимизируете красивый синтетический батч, в котором head of line эффекта просто нет.
Раздельные пулы нужны, когда SLO конфликтуют постоянно
Если chunked prefill и классовая очередь не удерживают TTFT, разделите prefill и decode или хотя бы отделите интерактивный пул от пакетного. Это дороже в эксплуатации, зато конфликт становится явным: вы выделяете capacity под быстрый старт ответа и capacity под долгую обработку входа, вместо того чтобы надеяться на одну настройку батча.
DistServe предлагает полную дезагрегацию prefill и decode по разным GPU именно для устранения их взаимного влияния. Это сильное архитектурное решение, а не настройка, которую стоит копировать без причины. Оно добавляет передачу состояния, отдельное масштабирование и больше точек отказа.
Сначала достаточно менее радикальной схемы:
- Выделите интерактивной очереди собственный лимит concurrency и token budget.
- Запускайте batch только на свободной ёмкости или на отдельной реплике.
- Поставьте жёсткий предел входных токенов для синхронного API.
- Переведите документы выше предела в асинхронную задачу со статусом и результатом по готовности.
- Отменяйте pending-запросы, если клиент отключился или истёк deadline.
Отдельный пул оправдан, если вы регулярно видите конфликт не один раз в неделю, а как свойство рабочего трафика: интерактивный p95 нарушает цель именно в периоды batch-нагрузки, а batch невозможно переносить по времени. Для банка, телеком-оператора или сервиса с большим потоком документов это обычная ситуация, а не признак плохого кода.
Ограничение контекста часто полезнее ещё одной GPU
Самый дешёвый способ убрать большую часть head of line эффекта, не принимать в интерактивный путь бесконтрольный контекст. Многие приложения отправляют в модель намного больше, чем нужно для текущего ответа: полную переписку, все результаты поиска, дублирующиеся инструкции, таблицы без отбора и ответы инструментов целиком.
Сделайте входной бюджет частью API-контракта. Например, интерфейсный агент получает до 12 000 входных токенов. Если retrieval вернул больше, ранжировщик обязан выбрать фрагменты. Если tool вернул большой документ, агент получает краткую выжимку или ссылочный идентификатор для следующего целевого запроса, а не сырой текст целиком. Если задача действительно требует прочитать файл, она меняет класс на interactive_long или batch_long.
Prefix caching помогает только при реальном совпадении начала контекста. Он хорош для общей системной инструкции, неизменного набора политик или одинакового шаблона. Он не компенсирует то, что каждый запрос несёт новую длинную историю и уникальные результаты поиска. Не записывайте сокращение TTFT от cache hit как доказательство, что очередь здорова: cache miss в час пик вернёт старую проблему.
Также ограничивайте max_output_tokens. Длинный вывод в первую очередь давит на decode и KV cache, но из-за него дольше живут активные последовательности, сокращается место для новых prefill и растёт очередь. Входной и выходной бюджеты должны работать вместе.
Измеряйте хорошую пропускную способность, а не максимум запросов
Максимальное число запросов в секунду мало говорит о качестве LLM API, если половина интерактивных запросов начинает отвечать позже вашей цели. Полезнее считать goodput: число запросов, которые завершили нужную работу в своём SLO. Для чата это обычно TTFT плюс приемлемый TPOT. Для document-класса это срок готовности, корректность результата и отсутствие бесконечных ретраев.
Заведите простое правило тревоги: уведомляйте не только при росте общей очереди, но и когда p95 queue_ms короткого класса превышает его бюджет при наличии длинных pending-запросов. Вторая тревога должна срабатывать при росте input_tokens p99. Она часто предупреждает о регрессии после релиза агента раньше, чем пользователи успеют пожаловаться.
После этого тестируйте изменения по одному. Сначала разделите классы, затем включите chunked prefill, затем настройте лимиты входа и лишь потом добавляйте отдельные реплики. Если изменить всё одновременно, вы не узнаете, что удержало SLO, а что просто подняло счёт за GPU.
Очередь не обязана быть одинаково справедливой ко всем запросам. Она должна быть честной к обещанию, которое вы дали пользователю. Длинный документ заслуживает обработки, но не права задерживать короткий вопрос оператора только потому, что попал в систему на долю секунды раньше.
Часто задаваемые вопросы
Почему средний TTFT выглядит нормально, а пользователи всё равно жалуются на задержку?
Потому что среднее скрывает хвост распределения. Длинные промпты могут быть редкими, но один такой запрос занимает prefill достаточно долго, чтобы испортить TTFT у множества коротких запросов, пришедших следом. Смотрите p95 и p99 TTFT отдельно по диапазонам входных токенов.
Можно ли классифицировать запросы по числу символов?
Нет. При очередях модели и GPU видят токены после токенизации, а не символы и не байты. Два текста одинакового размера в символах могут дать разное число токенов, особенно при нескольких языках, коде, таблицах и JSON.
Чем TTFT отличается от времени между токенами?
Это разные метрики. TTFT включает ожидание в очереди, prefill и передачу первого фрагмента ответа, а TPOT описывает интервал между следующими токенами. Длинный prefill обычно бьёт по TTFT, а перегруженная decode-группа часто ухудшает TPOT.
Сколько очередей нужно для LLM API?
Начните с двух классов: интерактивные запросы с коротким входом и фоновая обработка с большим входом. Затем выделяйте отдельный класс только если у него другой SLO или он стабильно создаёт хвост задержек. Слишком много классов быстро превращают планировщик в набор исключений.
Достаточно ли включить chunked prefill, чтобы убрать head of line эффект?
Не обязательно. Chunked prefill уменьшает время, в течение которого один большой prompt удерживает вычисления, но не отменяет конкуренцию за KV cache, decode и общую пропускную способность. При разном приоритете и разных SLO отдельные очереди обычно дают более понятное управление.
Что делать первым, если длинные запросы тормозят чат?
Сначала ограничьте максимальный размер входа, задайте бюджет генерации и вынесите пакетные задачи из интерактивного пути. Потом измерьте TTFT по классам, долю отмен и вытеснения KV cache. Добавление GPU без этих границ часто просто делает проблему дороже.
Может ли continuous batching всё равно оставить блокировку в очереди?
Да, если API принимает только один плоский поток запросов и не различает цели работы. Даже continuous batching не знает, что срочный запрос оператора контактного центра важнее ночной суммаризации архива, пока вы не передадите это различие через отдельные пулы, ключи или очередь перед inference.
Какие LLM запросы стоит считать фоновыми?
Обычно это batch-задача: индексация, извлечение полей из документов, массовая классификация или подготовка датасета. Ей можно дать отдельную очередь, меньший приоритет и более долгий допустимый срок выполнения. Не маскируйте такую нагрузку под чат только потому, что оба пути вызывают одну модель.
Помогает ли отмена запроса против head of line эффекта?
Отмена полезна, когда пользователь закрыл экран, изменил вопрос или результат потерял смысл. Но отмена не вернёт уже потраченное prefill-время и может оставить короткий всплеск нагрузки до ближайшей точки планирования. Клиент должен закрывать поток, а сервер должен уметь снять запрос из pending и decode состояния.
Какие поля нужны в трассировке LLM-запроса?
Собирайте время постановки в очередь, время начала prefill, время первого токена, длину входа, лимит выхода, класс и причину завершения. Без этих полей вы увидите только медленный HTTP-запрос и начнёте лечить сеть, хотя очередь давно показывает виновника.