Если зайдёт — на канале @fuckup_files разбираю похожие темы про LLM-инфру в более коротком формате.
Когда серверу прилетает 200 одинаковых system-prompt'ов и 200 разных вопросов поверх них, инфраструктура должна хотя бы раз понять, что 90% входа — это копия предыдущего реквеста, и не тратить compute на пересчёт уже посчитанного. Это и есть prefix caching: ключевой механизм, который превращает голый attention в production-ready serving.
В vLLM и SGLang prefix caching реализован принципиально по-разному: vLLM использует block-level хэширование, SGLang — token-level radix tree. На бумаге это разница в структуре данных. На practice — это 29% throughput на multi-turn workload и до 6.4× ускорения на prefix-heavy сценариях вроде RAG. Разберём, почему так получается, как они работают внутри, и как выбрать движок под свою нагрузку.
Что такое prefix caching и зачем он нужен
KV cache в transformer'ах хранит ключи и значения для каждого токена контекста, чтобы при декодировании следующего не пересчитывать всю историю. Обычно его инвалидируют между запросами. Prefix caching ломает это правило: если новый запрос начинается с того же префикса, что был у предыдущего, KV-блоки префикса можно переиспользовать как есть — это сокращает prefill (вычисление KV для всего входа) пропорционально длине общего префикса.
В реальной нагрузке общий префикс почти всегда есть:
- Multi-turn чат: каждый новый turn повторяет всю историю + system prompt
- RAG: одни и те же chunks могут попадать в окно к разным пользователям
- Tool/agent loops: длинный system prompt + tool definitions переиспользуется на каждом шаге
- Few-shot batch inference: одинаковая преамбула на тысячах запросов
Без prefix caching prefill этих токенов выполняется заново при каждом запросе. С ним — переиспользуется из кэша. Разница в TTFT (time to first token) и в общем throughput'е — на порядок.
vLLM: block-level хэширование
vLLM нарезает KV cache на фиксированные блоки (обычно по 16 токенов) и считает для каждого хэш на основе:
- хэша родительского блока в префиксе,
- токенов внутри текущего блока,
- доп. идентификаторов: LoRA ID, multimodal-хэшей,
cache_salt.
Когда приходит новый запрос, vLLM хэширует его блоки слева направо и для каждого ищет совпадение в cache_blocks (мапа hash -> block_id). Совпало — берём готовый блок, не считаем заново. Не совпало — выделяем новый.
Алгоритм хэширования настраивается:
# По умолчанию — sha256, безопасный, но медленнее
vllm serve meta-llama/Llama-3.1-70B-Instruct \
--enable-prefix-caching \
--prefix-caching-hash-algo xxhash
| Алгоритм | Скорость | Безопасность | Когда выбирать |
|---|---|---|---|
sha256 (default) | средняя | криптографическая | multi-tenant без особых требований |
xxhash | быстрая | non-crypto | single-tenant, throughput критичен |
sha256_cbor | средняя | crypto + reproducible | multi-tenant + аудит |
Eviction — LRU с двусвязным free queue: при OOM выкидываются давно не использованные блоки с головы очереди. Освобождённые добавляются в хвост в обратном порядке, чтобы менее «полезные» блоки эвиктились первыми.
Важный момент — кэшируются только полные блоки. Если контекст не кратен 16 токенам, последний "хвост" не попадёт в кэш и будет пересчитан заново при следующем запросе. Это не баг, а компромисс ради простоты адресации.
Для multi-tenant сетапов есть ещё одна тонкость: hash-коллизия теоретически позволяет одному пользователю получить кусок чужого KV-cache. Защита — cache_salt в payload'е реквеста, который подмешивается в хэш первого блока:
{
"messages": [{"role": "user", "content": "..."}],
"cache_salt": "user_42"
}
Реквесты с разными salt физически не могут хитнуть один блок, что закрывает timing side-channel attack.
SGLang: RadixAttention поверх radix tree
SGLang хранит KV cache в radix tree — древовидной структуре, где рёбра помечены последовательностями токенов произвольной длины (в отличие от обычного trie с одним токеном на ребро). Ключ — последовательность токенов, значение — соответствующий KV-тензор. Префикс матчится естественным обходом дерева от корня вниз.
Преимущество перед block-level подходом: tree автоматически находит частичные совпадения на уровне отдельных токенов. Если у предыдущего запроса был префикс [A, B, C, D], а у нового — [A, B, C, X], SGLang переиспользует [A, B, C] без необходимости попадать в block boundary. vLLM в той же ситуации либо переиспользует блок целиком (если границы совпали), либо пересчитает всё с нуля.
Eviction в RadixAttention — рекурсивный LRU по листьям. Когда свободной памяти нет, удаляется лист с самой старой last_used_at меткой; если родитель остался без потомков и не используется — удаляется и он, и так вверх по дереву. Это сохраняет «горячие» куски графа максимально долго.
Поверх RadixAttention SGLang включает cache-aware scheduling: когда в очереди стоит несколько запросов, scheduler приоритизирует те, у которых самый длинный matched prefix (DFS-order по дереву). Это аппроксимирует оптимальный порядок выполнения и максимизирует cache hit rate. У vLLM scheduler — обычная FIFO-очередь без учёта кэша.
Запускается RadixAttention в SGLang без отдельных флагов — он включён по умолчанию:
python -m sglang.launch_server \
--model-path meta-llama/Llama-3.1-70B-Instruct \
--tp 2 \
--mem-fraction-static 0.85
Тонкая настройка идёт через --mem-fraction-static (доля GPU-памяти под KV cache) и --schedule-policy (по умолчанию lpm — longest prefix match, что и есть cache-aware DFS).
Архитектурно структура SGLang выглядит так:
[root]
/ | \
["You are"] ["Hi"] ["Translate"]
/ | \
["a helpful AI"] [...] ["from English"]
/ \ |
[...] [...] ["to Russian"]
Каждое ребро — последовательность токенов; каждый узел — указатель на блоки KV cache в paged memory. При новом запросе scheduler проходит по дереву максимально глубоко, насколько совпадает префикс, и стартует генерацию с нужного места.
Числа: где разница реально видна
Бенчмарки на H100 (independent: Spheron, PremAI, Clarifai по сравнительному обзору morphllm):
| Workload | SGLang | vLLM | Преимущество SGLang |
|---|---|---|---|
| General throughput | ~16,200 tok/s | ~12,500 tok/s | +29% |
| Prefix-heavy (RAG, multi-turn) | — | — | до 6.4× |
| DeepSeek V3 (с MTP) | — | — | 3.1× |
| Уникальные промпты, нет overlap | паритет | паритет | без разницы |
Конкретный замер от Runpod на 2×H100 SXM, DeepSeek-R1-Distill-Llama-70B, контекст 7k токенов:
| SGLang | vLLM | |
|---|---|---|
| С прогретым кэшем | 35.0 tok/s (4.29s) | 32.8 tok/s (4.57s) |
| Холодный (без кэша) | 29.5 tok/s (5.09s) | 28.6 tok/s (5.25s) |
| Прирост от кэша | ~20% | ~10% |
Важный наблюдаемый эффект: сам кэш у SGLang эффективнее почти вдвое по приросту. На холодном старте движки идут вровень — основное преимущество SGLang проявляется именно в способности агрессивно переиспользовать частичные совпадения.
Что ломает кэш на практике
Несколько вещей резко сбрасывают эффективность prefix caching, и это одинаково больно для обоих движков:
- Динамический контент в начале промпта. Текущая дата/время, ID пользователя, любая переменная в первых токенах системного промпта моментально обнуляет hit rate. Все динамические значения переносить в конец промпта или в отдельную секцию после общего префикса.
- Перестановка инструкций. Если в одном запросе
[system, tools, examples], а в другом[system, examples, tools]— это с точки зрения кэша разные префиксы. Жёстко фиксируй порядок секций. - LoRA-адаптеры. vLLM включает LoRA ID в хэш блока, поэтому адаптер на запрос — отдельный namespace кэша. На пересечении LoRA-моделей кэш не переиспользуется.
- Multimodal-входы. Хэши изображений тоже идут в block hash; даже одно и то же фото после повторной перекодировки даст другой хэш и промахнётся мимо кэша.
- Tokenizer warm-up. На первых запросах после рестарта серва кэш холодный — реальные числа throughput'а имеет смысл снимать только после прогрева хотя бы 5–10 минут.
Когда что выбирать
def pick_inference_engine(workload):
if workload.has_unique_prompts and not workload.shared_system_prompt:
return "vllm" # cache не помогает, выбираем по hardware/maturity
if workload.is_multi_turn or workload.is_rag:
return "sglang" # radix tree побеждает на partial overlap
if workload.target_hw in ("tpu", "trainium", "inferentia", "intel_gpu"):
return "vllm" # SGLang только NVIDIA + AMD ROCm
if workload.requires_mtp_models: # DeepSeek V3 и др. с Multi-Token Prediction
return "sglang" # vLLM пока без MTP
return "vllm" # шире экосистема, больше контрибьюторов
vLLM остаётся правильным выбором, когда:
- нагрузка batch'евая с уникальными промптами (overlap минимален),
- нужен TPU/Trainium/Gaudi/Intel,
- критична совместимость со зрелой экосистемой и большим community.
SGLang выигрывает, когда:
- много multi-turn/RAG-нагрузки с длинными общими префиксами,
- модель использует Multi-Token Prediction (DeepSeek V3, Llama-MTP),
- железо — H100/H200/B200/MI300X.
Заключение
Разница между prefix caching в vLLM и SGLang — не «у одного лучше, у другого хуже». Это разница инженерных компромиссов: vLLM предпочёл простоту адресации (фиксированные блоки + хэш) и поэтому работает на любом железе и интегрируется со всем подряд. SGLang выбрал более сложную структуру (radix tree + cache-aware scheduler) и за счёт этого выигрывает там, где у vLLM формат блоков становится узким горлышком — на partial overlap.
Что делать дальше: если у тебя сейчас крутится vLLM на multi-turn / RAG-нагрузке с длинными system prompts, имеет смысл прогнать тот же workload через SGLang и замерить разницу — обычно она измеряется десятками процентов throughput'а на тех же GPU. Только не забудь, что бенчить надо на реальных распределениях запросов, а не на синтетических уникальных промптах: на них преимущества RadixAttention не видны.
Это лишь один кусок большой темы про LLM-serving. На канале @fuckup_files разбираю смежное — конфиги vLLM/Ray под разные нагрузки, истории отладки multi-GPU, бенчмарки. Если хотя бы одна эта статья сэкономила тебе пару часов — подписаться займёт пять секунд.