Перейти к содержимому
6 мин чтения

Роутинг с учетом prefix cache в LLM-кластере

Роутинг с учетом prefix cache снижает повторный prefill: когда прогретый KV-кэш важнее свободного GPU и как внедрить это без очередей.

Роутинг с учетом prefix cache в LLM-кластере

Обычная балансировка LLM-кластера почти всегда смотрит не туда. Она видит свободную память, длину очереди, число активных последовательностей и загрузку GPU. Но она не видит уже выполненный prefill конкретного запроса. Поэтому она легко отправляет длинный документ на свободный, но холодный воркер, пока соседний воркер держит готовый KV-кэш этого документа.

При повторяющихся системных промптах, больших политиках, договорах, медицинских выписках и RAG-контекстах меньше вычислений часто важнее более свободной видеокарты. Это не аргумент за то, чтобы прибивать весь трафик к одному GPU. Это аргумент за маршрутизацию, которая сначала оценивает цену повторного prefill, а потом сравнивает ее с очередью и доступной памятью.

Свободный GPU не означает быстрый ответ

Свободный GPU хорош для холодного запроса, но он не получает никакого преимущества от того, что другой воркер уже обработал 30 000 токенов того же документа. Он должен снова прогнать эти токены через модель, создать KV-состояния всех слоев и только потом перейти к генерации ответа.

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

Именно здесь обычная балансировка ошибается. Она трактует два запроса как одинаково дорогие, если у них одинаковый размер входа. Для GPU они одинаковы только в холодном состоянии. После прогрева один запрос уже частично посчитан.

Это особенно заметно в трех типах нагрузки:

  • один длинный системный промпт для множества диалогов;
  • один документ, к которому разные пользователи задают разные вопросы;
  • набор шаблонов, где меняются только поля клиента, дата, язык или короткое задание в конце.

Если у вас каждое обращение несет уникальный контекст, роутинг по прогретому префиксу ничего не исправит. Не надо строить сложную систему ради красивого слова cache. Сначала докажите, что трафик действительно повторяет начало промпта.

Prefix cache совпадает по токенам, а не по смыслу

Prefix cache повторно использует KV-состояния для идентичной последовательности токенов в начале запроса. Он не понимает, что два абзаца говорят об одном и том же. Он не считает равными JSON-объекты с разным порядком полей. Он не догадывается, что пробел перед переносом строки не меняет смысл.

Документация vLLM описывает automatic prefix caching именно так: движок повторно использует KV-пары ранее обработанного префикса и пропускает его повторное вычисление. В проектной документации vLLM блоки связываются хэшами токенов самого блока и предшествующего префикса. Это правильная модель для эксплуатации: вам нужен не «похожий» вход, а одинаковая токенизированная история до точки расхождения.

Отсюда следует неприятный, но полезный вывод. В большинстве систем первым врагом кэш-хитов оказывается не планировщик, а прикладной код.

Например, эти две конструкции логически эквивалентны, но для кэша могут оказаться разными:

Системная инструкция\n
Документ: ...\n
Вопрос: Какой срок оплаты?
Системная инструкция\n
Время запроса: 2026-07-23T10:15:00Z\n
Документ: ...\n
Вопрос: Какой срок оплаты?

Во втором варианте вы вставили меняющееся поле перед документом. Все токены после времени сдвинулись относительно первого запроса, и длинный документ перестал быть общим префиксом. Команда обычно замечает проблему поздно: cache hit rate низкий, GPU заняты prefill, а шаблон «почти одинаковый».

Разделяйте две вещи, которые часто смешивают:

  1. Совпадение документа. Это полезно, но не гарантирует prefix hit, если перед документом есть изменяемые данные.
  2. Совпадение префикса. Это то, что действительно позволяет взять готовый KV-кэш.

Последствие простое: стабильные, длинные данные ставьте раньше изменяемых. Метаданные запроса, идентификатор сессии, дата, экспериментальная группа и пользовательский вопрос должны идти после раздела, который вы хотите повторно использовать.

Обычная балансировка разрушает локальность кэша

Round robin, least connections и выбор наименее загруженного GPU распределяют запросы равномерно. Для веб-серверов и коротких API-вызовов это разумно. Для LLM-инференса с длинным повторяемым контекстом равномерность может стать источником лишней работы.

Представьте четыре реплики одной модели. У вас есть 200 запросов к одному регламенту на 40 000 токенов. При простом round robin каждая реплика в какой-то момент прогреет этот документ, что уже лучше полного отсутствия кэша. Но дальше приходит второй документ такого же размера, затем третий, а память KV-кэша ограничена. Каждая реплика начинает хранить случайную смесь префиксов. Вытеснения учащаются, а вероятность попасть в нужный прогретый набор снижается.

При prefix-aware подходе роутер старается собрать запросы с одинаковым началом на ограниченном числе воркеров. Он создает локальность: конкретный документ и конкретный шаблон чаще остаются на тех GPU, где их уже вычислили. Это не означает вечную привязку. Это означает, что новый запрос получает предпочтение там, где он дешевле.

Работа Preble, посвященная распределенному планированию промптов, формулирует это полезнее многих маркетинговых описаний кэшей. Авторы рассматривают запрос с попаданием в prefix cache ближе к decode-нагрузке, а запрос с промахом ближе к prefill-нагрузке. Различие важнее, чем кажется. Prefill быстро занимает много вычислений на длинном входе. Decode дольше живет, но работает с уже созданными состояниями и по-другому нагружает память.

Если балансировщик не различает эти типы работы, он может одновременно:

  • послать холодный длинный запрос туда, где уже идет тяжелый prefill;
  • отправить cache hit на свободный GPU и превратить его в cache miss;
  • перегрузить один пул длинными документами, хотя в другом пуле есть прогретые совпадения;
  • вытеснить дорогой для повторного создания префикс ради случайного короткого трафика.

Обычный балансировщик не «плохой». У него просто нет сигнала о стоимости уже сделанной работы.

Прогретый префикс должен побеждать не всегда

Правило «всегда иди туда, где есть hit» быстро превращается в очередь перед одним GPU. Так ломаются первые реализации prefix-aware роутинга. Команда видит высокий cache hit rate, радуется, а пользователи ждут, потому что роутер слишком долго держит их за одним прогретым воркером.

Нужно сравнивать ожидаемую цену двух вариантов. Для каждого кандидата роутер оценивает:

ожидаемая задержка =
  ожидание в очереди
  + prefill для непокрытых токенов
  + влияние на decode активных запросов
  + риск нехватки KV-памяти

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

Практическое правило я обычно задаю так: прогретый воркер получает предпочтение, пока его прогнозируемое время до первого токена не хуже холодной альтернативы больше чем на заранее выбранный запас. Запас нужен потому, что оценка очереди никогда не бывает идеальной, а cache hit имеет реальную ценность для будущих запросов.

Вместо единственного «лучшего» воркера полезно хранить ранжированный список:

  1. Точный hit с приемлемой очередью.
  2. Частичный hit с приемлемой очередью.
  3. Свободный воркер, если прогретые кандидаты задерживают запрос слишком долго.
  4. Отдельный cold-пул, если рабочие реплики держат дорогие горячие префиксы.

Частичный hit важен. Если совпали первые 24 000 токенов из 30 000, это все еще серьезно меняет цену запроса. Но не подменяйте длину совпадения долей совпадения. Совпадение 2 000 из 2 100 токенов и 2 000 из 100 000 токенов имеют одинаковую абсолютную экономию prefill, хотя процент выглядит совершенно по-разному.

Стабильный шаблон промпта дает больше, чем хитрый роутер

Один endpoint для моделей
Через один OpenAI-совместимый endpoint AI Router маршрутизирует запросы к 500+ моделям от 68+ провайдеров.

Роутер не починит промпты, которые приложение случайно меняет на каждом вызове. Сначала приведите вход к форме, которую можно кэшировать.

Хороший порядок сообщения для задач «один документ, много вопросов» выглядит так:

{
  "model": "chosen-model",
  "messages": [
    {
      "role": "system",
      "content": "Ты отвечаешь только по приложенному документу. Если факта нет, так и скажи."
    },
    {
      "role": "user",
      "content": "<document id=contract-184>...полный нормализованный текст документа...</document>\n\nВопрос: Какой срок оплаты?"
    }
  ]
}

Пользовательский вопрос находится после документа. Идентификатор документа стабилен. Сам текст проходит одну и ту же нормализацию. Системная инструкция не содержит текущую дату, случайный trace ID или имя пользователя.

Плохой порядок чаще возникает из-за удобства разработчика:

{
  "role": "user",
  "content": "Пользователь=84721; запрос=af1e; время=...\nВопрос: Какой срок оплаты?\nДокумент: ..."
}

Здесь меняющиеся данные стоят до длинного контекста. Для человека это безобидная строка. Для prefix cache это граница, после которой общий документ уже не считается общим.

Нормализация должна быть детерминированной. Зафиксируйте:

  • формат переносов строк;
  • порядок ключей в сериализованном JSON;
  • правила удаления лишних пробелов;
  • версию шаблона системного промпта;
  • порядок фрагментов RAG-контекста.

Последний пункт часто вызывает спор. Разработчики хотят сортировать найденные куски только по релевантности, а затем удивляются, почему одинаковый запрос не дает hit. Если релевантность двух фрагментов близка, используйте стабильный tie-breaker, например идентификатор источника и позицию в нем. Небольшая жертва случайной вариативностью выдачи часто окупается повторным использованием длинного префикса.

Не вставляйте динамические поля в системный промпт ради трассировки. Передавайте trace ID в заголовках, метаданных шлюза или отдельном журнале. Токены промпта слишком дорогой слой для служебного шума.

Длинный документ и RAG-контекст требуют разных правил

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

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

Для RAG я предпочитаю разделить контекст на два уровня:

  1. Стабильный слой: системная инструкция, политика ответа, справочник терминов, неизменяемая база правил, профиль инструмента.
  2. Переменный слой: найденные фрагменты, вопрос, история конкретного пользователя, свежие данные.

Стабильный слой ставьте первым и пытайтесь удерживать прогретым. Переменный слой не надо насильно подгонять под cache hit. Это особенно верно для поиска по большой коллекции, где состав документов меняется почти в каждом запросе.

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

Не путайте кэширование KV и кэширование готового ответа. Готовый ответ можно вернуть только на полностью идентичный запрос при подходящей политике свежести. KV-cache позволяет по-новому спросить о том же документе. Это разные слои, разные риски и разные ключи.

Ключ маршрутизации должен отражать совместимость, а не только текст

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

Минимальный ключ кандидата я бы описал так:

cache_namespace =
  model_revision
  + tokenizer_revision
  + adapter_id
  + prompt_template_version
  + normalized_prefix_hash

normalized_prefix_hash надо строить не обязательно по всему запросу. Для длинного документа он может покрывать стабильный системный текст и документ до места, где начинается вопрос. Но маршрутизатор должен знать, что движок действительно умеет использовать именно такой префикс и на какой границе блоков он хранит KV-кэш.

Нельзя считать хэш приложения доказательством cache hit. Это только индекс для поиска вероятного кандидата. После выбора воркера сам inference engine подтверждает фактическое совпадение токенов и сообщает длину найденного префикса, если его метрики это позволяют.

Полезный журнал маршрутизации выглядит так:

{
  "request_id": "req_8f2c",
  "model": "chosen-model",
  "prefix_key": "pfx_4b19",
  "selected_worker": "gpu-03",
  "candidate_workers": ["gpu-03", "gpu-01"],
  "predicted_cached_tokens": 24576,
  "actual_cached_tokens": 24320,
  "queue_wait_ms": 38,
  "prefill_tokens": 912,
  "decision": "prefer-warm-worker"
}

Здесь важна пара predicted_cached_tokens и actual_cached_tokens. Если они постоянно расходятся, вы нашли не проблему GPU, а проблему идентификации входа. Причиной бывает скрытая вставка времени, изменение шаблона, другой токенизатор на части парка или нормализатор, который работает по-разному в двух сервисах.

Для multi-tenant среды добавьте tenant или policy namespace в ключ. Нельзя направлять запрос только ради совпадения текста, если правила изоляции запрещают совместное использование конкретного кэша. Даже когда KV-блоки технически отделены, решение о допустимости маршрута должно приниматься до оптимизации задержки.

Роутеру нужен холодный путь и защита от голодания

Маскируйте PII в потоке
AI Router маскирует PII в запросах с документами, контекстом и пользовательскими данными.

Горячий префикс легко монополизирует планировщик. Если один популярный договор или системный промпт создает непрерывный поток, роутер будет снова и снова выбирать один и тот же GPU. В какой-то момент туда придет запрос с другим важным документом и будет ждать слишком долго, хотя свободные ресурсы есть в другом месте.

Нужны ограничения, которые не дают cache locality превратиться в несправедливость.

Во-первых, задайте максимальное время ожидания для warm preference. После этого срока запрос идет по обычному правилу, даже если потеряет hit. Это не компромисс «против кэша». Это защита пользовательской задержки, которая всегда важнее красивого среднего hit rate.

Во-вторых, выделите квоту на холодные запросы. Она может быть отдельным пулом реплик или долей слотов на каждом воркере. Смысл один: уникальный запрос должен получить шанс начать prefill, а не стоять за бесконечной серией популярных префиксов.

В-третьих, учитывайте длину генерации. Короткий вопрос с огромным документом хорошо выигрывает от прогретого префикса. Длинная генерация может надолго занять decode-слоты, и отправлять на нее весь горячий трафик опасно. Роутер должен видеть хотя бы приближенный лимит output tokens и применять разные пороги для коротких ответов и развернутых генераций.

Наконец, не делайте affinity бессрочной. Префикс может быть вытеснен, модель может перезапуститься, воркер может потерять память после обновления. Храните запись о предполагаемом прогреве с временем жизни и снижайте ее при промахах. Роутер, который уверен в несуществующем кэше, хуже честного round robin: он направляет трафик в очередь без экономии вычислений.

Метрики должны показывать сэкономленный prefill

Cache hit rate сам по себе легко вводит в заблуждение. Если вы считаете каждый hit одинаково, попадание на 64 токена выглядит так же хорошо, как попадание на 30 000 токенов. Для стоимости и задержки это разные события.

Смотрите минимум на пять рядов метрик:

  • cached input tokens и uncached input tokens по модели и пулу;
  • время до первого токена отдельно для warm hit, partial hit и cold miss;
  • среднее ожидание в очереди по причине выбора маршрута;
  • вытеснения KV-кэша и возраст вытесненных префиксов;
  • долю запросов, ушедших с warm-кандидата из-за порога ожидания.

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

Полезна и простая бухгалтерская оценка. Для каждого запроса сохраняйте число токенов, которые были бы обработаны при холодном запуске, и число токенов, реально отправленных в prefill. Разница не равна точному числу GPU-секунд, но она показывает направление и позволяет заметить регресс после изменения шаблона.

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

Внедряйте роутинг на одном измеримом классе трафика

Сохраните привычный API-формат
Приложение можно перенести с OpenAI или OpenRouter заменой base_url, сохранив привычный формат запросов.

Начинать с «всех промптов компании» не нужно. Выберите один поток, где повторяемость очевидна: анализ одного документа многими сотрудниками, помощник с большой неизменяемой инструкцией или сервис, который обрабатывает типовые анкеты.

Сделайте внедрение в таком порядке:

  1. Снимите несколько дней журналов и посчитайте длины общих токеновых префиксов, а не только текстовые совпадения.
  2. Зафиксируйте шаблон и вынесите меняющиеся поля после кэшируемого блока.
  3. Включите prefix cache на ограниченном пуле и соберите фактические cached tokens от inference engine.
  4. Добавьте warm preference с жестким пределом ожидания и холодным запасным маршрутом.
  5. Сравните warm, partial и cold запросы по времени до первого токена, очереди и вытеснениям.

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

AI Router может быть удобной точкой для такого эксперимента, потому что сохраняет OpenAI-совместимый формат запросов, а логика выбора пула остается за API-шлюзом. Но сам по себе шлюз не создаст повторяемость: ее создает дисциплина в том, как приложение собирает контекст.

Меньше вычислений важнее свободной памяти только при доказанном повторении

Prefix-aware роутинг не отменяет балансировку по загрузке. Он добавляет в нее стоимость уже выполненного prefill. В кластере без повторяющихся префиксов это лишняя сложность. В кластере, который снова и снова читает один и тот же длинный контекст, игнорировать прогретый KV-кэш значит сознательно платить за одну работу несколько раз.

Начните не с выбора модного планировщика, а с одного вопроса к своим логам: какие первые 10 000 токенов пользователи отправляют повторно? Если ответом окажутся системные инструкции, документы, политики или постоянные RAG-блоки, у вас уже есть предмет для маршрутизации. Остается перестать разбрасывать его по холодным GPU.

Часто задаваемые вопросы

Что такое prefix cache в LLM-инференсе?

Prefix cache хранит уже вычисленные KV-состояния для одинакового начала входного контекста. Следующий запрос может пропустить prefill для совпавшей части, но только если модель, токенизатор, параметры и сами токены префикса совместимы.

Нужен ли prefix-aware роутинг для всех LLM-запросов?

Не всегда. Для коротких запросов выигрыш от попадания в кэш часто меньше цены лишней маршрутизации, ожидания в очереди или перегруженного воркера. Это имеет смысл там, где длинный общий контекст повторяется достаточно часто.

Почему свободный GPU иногда хуже прогретого?

Потому что свободный GPU может быть холодным: ему придется заново обработать документ и системный промпт. Занятый воркер с готовым KV-кэшем иногда быстрее вернет первый токен, даже если его загрузка выше.

Работает ли prefix cache для семантически похожих промптов?

Нет. Большинство движков сопоставляют токены, а не смысл текста. Два промпта с одинаковой инструкцией, но разным пробелом, временем в шапке или порядком JSON-полей уже могут потерять совпадение.

Какие метрики нужны для prefix-aware роутинга?

Нужны как минимум идентификатор модели, версия токенизатора, хэш префикса, длина совпадения, состояние кэша и текущая очередь воркера. Без наблюдаемого hit ratio команда обычно принимает решения по загрузке GPU и не видит, сколько prefill она повторяет.

Как внедрить prefix-aware роутинг без риска?

Сначала закрепите единый шаблон системного промпта и детерминированную сериализацию документов. Затем включите кэш в одном пуле, добавьте журнал маршрутизации и сравните задержки холодных и прогретых запросов на реальном трафике.

Можно ли кэшировать длинные документы для RAG?

Да, если один и тот же документ лежит первым в сообщении и несколько пользователей задают к нему разные вопросы. Если вы режете документ на куски, меняете их порядок или добавляете пользовательские данные перед ним, совпадение резко падает.

Может ли prefix cache ухудшить работу кластера?

Да. Длинный общий префикс занимает память KV-кэша, поэтому движок может вытеснить его, когда приходит другой трафик. Политика вытеснения должна учитывать частоту повторного использования, размер префикса и цену его повторного prefill.

Чем prefix-aware роутинг отличается от балансировки по загрузке?

Обычная балансировка отвечает на вопрос, где сейчас меньше работы. Prefix-aware роутинг отвечает на другой вопрос: где уже выполнена часть именно этой работы. В продакшене нужен гибрид, а не замена одного правила другим.

Можно ли применить этот подход через OpenAI-совместимый API?

Используйте его как слой маршрутизации перед OpenAI-совместимым API, а не как изменение клиентского промпта. В AI Router команды могут сохранить SDK и формат запросов, меняя только базовый endpoint, а правила выбора пула и наблюдаемость строить на стороне шлюза.