Почему один символ обнуляет prompt caching в vLLM — разбор с кодом | AiManual
AiManual Logo Ai / Manual.
02 Июл 2026 Гайд

Один символ обнуляет prompt caching в vLLM: копаем в исходники

Глубокий разбор механизма prompt caching в vLLM: почему один пробел или перевод строки сбрасывает кэш, и как это обойти. С исходниками и примерами.

Вы потратили часы на оптимизацию, а vLLM плюет на кэш

Вы собрали идеальный системный промпт. Обкатали его сотни раз. vLLM исправно кэширует префикс... пока вы не добавили точку в конце. Или случайно не поставили два пробела вместо одного. Кэш обнуляется мгновенно. Вы теряете до 80% ускорения. И самое бесячее — кажется, что это баг. Но нет, это фича. Или баг? Давайте разберемся, заглянув в исходники vLLM. Спойлер: механизм prefix caching жесток и не прощает неточностей.

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

Анатомия кэша в vLLM: не просто хэш-таблица

vLLM использует механизм prefix caching (он же automatic prefix caching). Идея: если два запроса начинаются одинаково, мы переиспользуем KV-кэш общего префикса. Но как vLLM понимает, что префикс совпадает? Он строит хэш на основе последовательности токенов. Каждый токен — это целое число ID. Хэш рассчитывается не для всего промпта целиком, а для каждого блока (обычно 16 токенов). Блоки хранятся в PagedAttention с уникальным хэшем. Если хэш блока совпадает — кэш попадание.

Звучит логично. Но тут есть засада: хэш строится строго от input_ids. Токенизация не прощает различий в пробелах, пунктуации, регистре даже если семантика та же. Один символ — другой токен — другой хэш — кэш мимо.

Заглянем в исходники: как vLLM ищет совпадения

Откроем файл vllm/worker/model_runner.py (версия на июль 2026, но логика неизменна годами). Там есть метод _prepare_inputs, который вызывает _compute_prefix_cache_hash. Конкретно:

def _compute_prefix_cache_hash(self, input_tokens: List[int]) -> List[int]:
    block_size = self.scheduler_config.block_size  # обычно 16
    prefix_hashes = []
    for i in range(0, len(input_tokens), block_size):
        block_tokens = input_tokens[i:i+block_size]
        block_hash = hash(tuple(block_tokens))
        prefix_hashes.append(block_hash)
    return prefix_hashes

Обратите внимание: hash(tuple(block_tokens)). Токены — это просто числа. Если первый блок начинается с [1, 34, 567] вместо [1, 34, 568] — хэш блока другой. Весь первый блок пересчитывается заново, и все последующие тоже, потому что они зависят от предыдущих? Нет, vLLM не строит цепочку хэшей, каждый блок независим. Но проблема в том, что сдвиг токенов даже на одну позицию (/например, добавили пробел в начало/) меняет все блоки! Потому что первый блок теперь содержит на 1 токен больше (или меньше), границы блоков смещаются, и все последующие блоки получают другое содержимое.

Это ключевой момент: vLLM кэширует блоки по их абсолютной позиции в последовательности. Если вы вставили символ в середину префикса, все блоки начиная с того, в который попал новый токен, изменятся. Кэш для них не применится.

Почему один пробел в начале убивает всё?

Представьте: системный промпт занимает 100 токенов. Вы кэшировали его. Теперь вы добавляете в начало запроса один пробел (токен ID 220). vLLM видит новый первый токен. Первый блок (токены 0-15) теперь содержит этот пробел, а последние 15 токенов из старого первого блока сдвинулись во второй блок. Хэши всех блоков изменились. Кэш обнулён полностью.

Вот цитата из реального issue: «I changed one space in the system prompt and the cache hit rate dropped from 95% to 0%». Так и есть.

Не дайте себя обмануть: кэш работает, только если точная последовательность токенов идентична. Один лишний байт — и вы пересчитываете всё с нуля.

Копаем глубже: блоки, аллокатор и хэш

За распределение блоков отвечает PrefixCachingBlockAllocator в vllm/core/block_manager.py. Там есть метод allocate_mutable_block и _hash_block. Вот как он считает хэш:

def _hash_block(self, block: Block) -> int:
    return hash(tuple(block.token_ids))

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

Теперь представьте, что ваш префикс — это сообщение системы + история диалога. Если вы добавили один символ в историю, все последующие блоки изменятся. А если вы используете чат-темплейт, то vLLM добавляет токены ролей (например <|im_start|>user). Даже лишний пробел в темплейте может сместить границы. Я сталкивался с ситуацией, когда обновление библиотеки tokenizers меняло поведение обрезания пробелов — и кэш переставал работать.

Как обходить эту дичь? Три рабочих способа

В теории это работает так: нужно гарантировать, что префикс идентичен на уровне токенов. На практике:

  1. Normalize input: перед отправкой в vLLM обрезайте пробелы и приводите к единому формату. Например, всегда убирайте trailing spaces, используйте один пробел между словами. Это может снизить количество «ложных» расхождений.
  2. Используйте отдельный вызов для системного промпта: разбейте запрос на системный префикс (вызов с бОльшим max_tokens без генерации) и затем донастройте. Но это костыль.
  3. Экспериментируйте с block_size: в vLLM можно менять размер блока (флаг --block-size). Крупные блоки (32 токена) дают более грубое разбиение — вероятность совпадения выше, но кэш менее эффективен. Мелкие (8) — точнее, но чаще промахи. Для длинных системных промптов ставьте 32.

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

Не верьте кэшу — профилируйте!

Лучший совет, который я могу дать: никогда не надейтесь, что кэш работает. Всегда проверяйте. vLLM возвращает метрики в логах: prefix_cache_hit_rate. Запустите серию тестовых запросов и смотрите этот показатель. Если он упал ниже 90% при повторяющемся префиксе — ищите расхождение.

Напишите скрипт, который сравнивает input_ids двух запросов: если они совпадают на s токенов, а потом расходятся — вы увидите, где именно ломается кэш. Вот простой пример:

from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-Instruct-v0.3")

prompt1 = "<|im_start|>system\nБудь краток.<|im_end|>\n<|im_start|>user\nПривет<|im_end|>"
prompt2 = "<|im_start|>system\nБудь краток.<|im_end|>\n<|im_start|>user\n Привет<|im_end|>"  # пробел перед Привет

ids1 = tokenizer.encode(prompt1)
ids2 = tokenizer.encode(prompt2)

diff_idx = next(i for i, (a,b) in enumerate(zip(ids1, ids2)) if a != b)
print(f"Различие на токене {diff_idx}: {ids1[diff_idx]} vs {ids2[diff_idx]}")

Так вы быстро поймёте, где собака зарыта. И, кстати, проблема с одним символом особенно болезненна для длинного контекста: там каждый потерянный блок обходится в миллисекунды. Если вы работаете с Apple Silicon и MLX, рекомендую прочитать эксперимент с KV-кэшем на MLX — там похожая проблема, но с другим решением.

И да, если у вас всё ещё болит голова от KV-кэша, почитайте KV-cache в долговременной памяти: почему всё ломается и как это починить — там объясняется, почему даже при идеальном prefix caching могут быть проблемы с вниманием.

Резюме: один символ — это диагноз

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

И последнее: если вы увидели, что после обновления vLLM кэш перестал работать — не паникуйте. Скорее всего, изменилась токенизация (особенно если вы используете кастомный чат-темплейт). Сравните input_ids до и после обновления, и вы найдёте того самого «одного символа».

🔮
Прогноз: к 2027 году сообщество vLLM скорее всего добавит опциональную нормализацию (trim, lower, regex) на уровне API. Но пока — мы заложники точности. Используйте мои советы, профилируйте, и не дайте одному символу испортить вам инференс.

Подписаться на канал