Трендовые github проекты в нашем телеграм канале. Подпишись → Одинаковый RPS, разный p95: маршрутизация запросов с учётом KV-cache
На стенде Red Hat с OpenShift 4.20, четырьмя GPU NVIDIA L4 и моделью семейства Qwen сравнили два способа раздавать один и тот же чат-трафик по репликам. Нагрузка: 11 параллельных диалогов по 10 ходов, документы двух типов, случайные паузы между ходами. Результат:
| Метрика | Round-robin | Prefix-aware routing |
|---|---|---|
| TTFT p95, мс | 745 | 272 |
| TTFT p99, мс | 841 | 674 |
| Попадания в KV-cache | ~62% | ~90% |
| Запросов в секунду | 0,52 | 0,49 |
Пропускная способность почти совпадает: разница в 6% укладывается в разброс на выборке чуть больше сотни запросов. Время до первого токена на p95 при этом отличается в 2,7 раза. Модель, GPU и нагрузка одинаковые, поменялся только алгоритм выбора пода.
Откуда берётся разница
Классический backend не хранит состояние между запросами, реплики взаимозаменяемы, и равномерного распределения достаточно. В LLM-инференсе это допущение ломается в двух местах.
Во-первых, стоимость запросов несопоставима. Промпт на 200 токенов и промпт на 20 тысяч токенов приходят в один endpoint, а вычислений требуют на два порядка разных. Счётчик RPS считает их одинаковыми.
Во-вторых, у запроса есть состояние, и оно живёт в конкретном поде. Посчитанный префикс остаётся в KV-cache той реплики, которая его обработала. Следующий запрос с тем же началом, попавший в соседний под, заставит GPU пересчитать префикс заново. Для балансировщика такой запрос выглядит обслуженным, в метриках RPS его тоже не отличить, но GPU дважды сделал одну и ту же работу.
Общие префиксы встречаются часто: системный промпт, один и тот же документ в RAG, типовая инструкция агента. Десятки клиентов присылают запросы с одинаковой «головой», а round-robin разбрасывает их по всем репликам.
С ростом числа реплик разрыв увеличивается. Команда llm-d на восьми подах с двумя H100 каждый получила TTFT 0,542 с при prefix-aware routing против 92,551 с при случайном распределении (базовую линию снимали на перегруженном стенде, где очередь росла быстрее обработки). Vertex AI в Google Cloud на продовом трафике сообщает о снижении TTFT более чем на 35% и росте попаданий в префиксный кеш с 35 до 70%. В Яндексе на трафике AI Studio round-robin давал 5–10% попаданий, липкие сессии — около 30%, cache-aware router — до 54%.
Почему липкие сессии не спасают
Привязка пользователя к поду сохраняет кеш внутри одного диалога. Общий системный промпт на десятки тысяч токенов при этом приходит от множества разных пользователей, и каждый под, к которому они привязаны, считает его отдельно.
InferencePool и Endpoint Picker
В Kubernetes для этого появилось расширение Gateway API для инференса. Оно вводит отдельную цель маршрутизации — InferencePool, объединяющий реплики одной модели. В этой схеме он занимает место обычного Service. API стабилизирован в версии inference.networking.k8s.io/v1.
Реплику выбирает отдельный компонент — Endpoint Picker (EPP). Шлюз передаёт ему сведения о запросе и получает в ответ адрес пода. Решение строится на метриках, которые обязан отдавать model server по спецификации протокола:
- число запросов в очереди;
- число выполняющихся запросов;
- заполненность KV-cache.
RPS на входе описывает темп обращений ко всему пулу. Эти три метрики описывают незавершённую работу и давление на память в конкретном поде, поэтому по ним можно принимать решение о маршруте.
Поверх них работает префиксный скорер: он оценивает, какая часть начала запроса уже посчитана в каждом поде. По умолчанию он смотрит на первые 16 тысяч символов, это около 4 тысяч токенов английского текста. Окно ограничивает только сравнение: если префикс совпал, vLLM переиспользует его целиком, поэтому системный промпт на 40 тысяч токенов тоже отработает из кеша. Для русского текста токен короче в символах, и те же 16 тысяч символов покрывают больше токенов, чем указано в англоязычной документации.
Data plane для этой схемы поддерживают Istio начиная с 1.27, NGINX Gateway Fabric и agentgateway. API InferencePool и Endpoint Picker выпускаются в разных релизных циклах, совместимость версий проверяйте отдельно.
Перегрев пода с популярным префиксом
Строгая аффинность к кешу создаёт новую проблему. Если все пользователи одновременно работают с одним популярным документом, самый полный префикс окажется в одном поде, и туда пойдёт почти весь трафик, пока остальные реплики простаивают.
Google в Vertex AI решал это перевесом скореров: соотношение весов поменяли с 3:3:2 на 3:5:2, увеличив вес глубины очереди. После этого планировщик обходит перегруженный под, даже если в нём лежит подходящий префикс.
Бывают нагрузки, где аффинность проигрывает. Работа CacheRoute (август 2026) описывает два сценария на моделях 32B: в одном кеш-аффинная маршрутизация снижает ёмкость до 0,50–0,67 от уровня плоского балансировщика, в другом выигрыша нет вовсе. Стратегию предлагают выбирать по теневому прогону.
Что маршрутизация по префиксу сама по себе не решает:
- Справедливость между арендаторами. Число запросов не отражает время, которое запрос занимает GPU. У Red Hat для этого отдельная надстройка с полосами приоритета.
- Накладные расходы EPP. Компонент участвует в каждом запросе, а универсальной оценки его задержки нет — меряйте на своём трафике.
- Перекос между репликами. Datadog относит перегрев пода с популярным префиксом к штатным сценариям отказа. Алерт на неравномерность трафика внутри пула стоит настроить до включения маршрутизации.
Когда это окупается
Решение зависит от трёх параметров.
- Доля повторяющихся префиксов в токенах. Если это единицы процентов, экономия теряется на фоне остального расчёта. При десятках процентов, как 54% на агентском трафике Яндекса, маршрутизатору есть что переиспользовать.
- Длина общего префикса. Системный промпт на сотню токенов на время prefill почти не влияет. Префикс в тысячи токенов — влияет заметно.
- Число параллельных диалогов на реплику. На стенде Red Hat их было меньше трёх, и это примерно нижняя граница эффекта. При одном активном диалоге на реплику повторный запрос и так попадёт в нужный под.
На потоке коротких независимых запросов без общего начала новый слой добавит только компонент и задержку на выбор пода.
Что попробовать до отдельного маршрутизатора
- Настройки движка. В vLLM chunked prefill включён по умолчанию; проверьте, что его никто не отключил, и подберите размер батча в токенах под свой профиль. По оценкам, одна эта настройка даёт до ~50% прироста пропускной способности по токенам без новых компонентов.
- Хеш префикса от клиента. Если клиент ваш, он может считать хеш общего префикса и класть его в заголовок. Обычный балансировщик раскидает запросы по этому заголовку через консистентное хеширование.
- Роутер из vLLM production stack. Учитывает и совпадение префикса, и загрузку, работает внутри одного стека, Gateway API с расширением не нужен.
Порядок внедрения
Начните с четырёх замеров на текущей схеме:
- Снимите с движка очередь, число выполняющихся запросов и заполненность KV-cache. Очередь при неполном кеше означает, что реплики могут удерживать больше префиксов, а запросы при этом ждут GPU.
- Посчитайте долю попаданий в префиксный кеш по токенам. Подсчёт по запросам занижает эффект: один промах на префиксе в десятки тысяч токенов дороже множества коротких запросов.
- Настройте теневое воспроизведение реального трафика и сравните текущую раскладку с prefix-aware routing. Переносить в прод стоит, если на одинаковом профиле улучшается p99, растёт ёмкость или и то и другое.
- Отключите Endpoint Picker в тестовом контуре и посмотрите, что сделает шлюз: вернёт ошибку или продолжит раздавать запросы по пулу без подсказки.
Дальше — пул и EPP с весами по умолчанию, повторный замер на том же профиле, затем подбор весов скореров. HPA и канареечные выкатки меняйте последними, иначе не будет понятно, какое изменение дало эффект.
После перехода на InferencePool пересмотрите обе эти вещи. HPA по числу запросов видит темп обращений ко всему пулу, тогда как EPP выбирает под по его состоянию; целевые метрики автомасштабирования лучше привязать к длине очереди и заполненности KV-cache. Канарейка с весами между двумя Service больше не работает, потому что у шлюза одна цель маршрута: версии разделяются на уровне реплик и правил выбора внутри пула.
Ёмкость KV-cache равна видеопамяти реплики минус веса модели. От остатка зависит, сколько префиксов под удержит и как быстро начнёт вытеснять старые, поэтому при планировании GPU считайте сразу два числа: память под модель и память, которая останется под кеш.
Что держать на дашборде
Те же сигналы, по которым EPP выбирает реплику:
- длина очереди по каждой реплике;
- заполненность KV-cache;
- доля попаданий в префиксный кеш (её обычно приходится собирать из метрик кеша отдельно, первые две движок отдаёт из коробки).
Если на дашборде только RPS, рост задержки выглядит как нехватка GPU, и разговор сводится к покупке карт. Часть этих GPU-часов на деле уходит на повторный расчёт префиксов, которые уже лежат в кеше соседней реплики.