《vLLM 内核探秘》完整目录
- 前言
- 第1章 架构总览
- 第2章 EngineCore:引擎的心脏
- 第3章 调度器:Token 的交通指挥
- 第4章 PagedAttention:虚拟内存的启示
- 第5章 KV Cache 管理:寸土寸金的显存
- 第6章 Worker 与 Executor:GPU 军团
- 第7章 模型加载与权重管理
- 第8章 前向计算与 CUDA Graph
- 第9章 采样与输出处理
- 第10章 前缀缓存:零开销的加速(当前)
- 第11章 分块预填充与混合批处理
- 第12章 投机解码:以小博大
- 第13章 量化引擎:精度与速度的平衡
- 第14章 张量并行与流水线并行
- 第15章 多模态推理
- 第16章 LoRA 适配器热切换
- 第17章 API 服务器与生产部署
- 第18章 设计模式与架构哲学
第10章 前缀缓存:零开销的加速
"The fastest computation is the one you don't have to do."
:::tip 本章要点
- 理解前缀缓存的动机:为什么同一个系统提示不应该重复计算
- 掌握哈希链(Hash Chain)的设计:如何唯一标识一个 KV Cache 块
- 深入 BlockHash 的构造过程:父块哈希 + 当前 Token + 额外键
- 理解零开销的实现原理:为什么 V1 能默认启用前缀缓存
- 认识缓存命中率对吞吐量的定量影响 :::
10.1 重复的代价
在典型的 LLM 服务场景中,大量请求共享相同的前缀:
- 系统提示(System Prompt)------所有请求都有相同的系统提示,可能长达数百甚至数千 Token
- Few-shot 示例------RAG 应用中检索到的上下文片段经常重复
- 多轮对话------同一个会话的前几轮对话是所有后续请求的公共前缀
没有前缀缓存时,每个请求都要独立计算这些重复部分的 KV Cache。100 个请求各有 500 Token 的系统提示,就要重复计算 100 × 500 = 50,000 Token 的注意力------其中 49,500 Token 是浪费的。
前缀缓存的思想很简单:如果一个 KV Cache 块的内容已经被计算过了,直接复用,不要重新计算。
这个思想并不新颖------它本质上就是缓存(Cache)。但在 LLM 推理中实现这个思想面临独特的挑战:
挑战一:KV Cache 的因果依赖。 不同于普通的键值缓存,KV Cache 中每个位置的值都依赖于它前面所有 Token 的值。这意味着你不能只比较当前块的 Token 是否相同------还必须确保前面所有块也完全相同。这就是为什么需要"哈希链"而不是简单的"按 Token 查表"。
挑战二:块粒度的匹配。 KV Cache 是按块(Block)管理的(第 4-5 章),前缀缓存也必须以块为粒度。一个块通常是 16 个 Token。如果两个请求的共同前缀是 25 个 Token,那只有前 1 个块(16 Token)可以被缓存命中,剩下的 9 个 Token 必须重新计算。
挑战三:性能开销的零容忍。 缓存查找本身有计算开销(哈希计算、字典查找、引用计数管理)。在高 QPS 场景下,如果缓存查找的 CPU 开销抵消了 GPU 计算的节省,那缓存就失去了意义。这是 V0 前缀缓存的主要问题------5-10% 的吞吐量下降导致很多用户选择关闭它。
10.2 哈希链:块的身份证
前缀缓存的核心问题是如何识别两个块是否包含相同的 KV Cache。
vLLM 使用**哈希链(Hash Chain)**为每个块生成唯一标识。一个块的哈希不仅取决于它自身包含的 Token,还取决于它前面所有块的内容------因为 KV Cache 的值受到前面所有 Token 的影响(注意力机制的因果性)。
hash(∅, tokens[0:16])"] --> B1["Block 1
hash(B0.hash, tokens[16:32])"] B1 --> B2["Block 2
hash(B1.hash, tokens[32:48])"] style B0 fill:#3b82f6,color:#fff,stroke:none style B1 fill:#8b5cf6,color:#fff,stroke:none style B2 fill:#ec4899,color:#fff,stroke:none
BlockHash 的构造公式定义在 vllm/v1/core/kv_cache_utils.py:392:
python
# vllm/v1/core/kv_cache_utils.py:392-420
def hash_block_tokens(
hash_function: Callable,
parent_block_hash: Optional[int],
curr_block_token_ids: Sequence[int],
extra_keys: Optional[tuple[Any, ...]] = None) -> BlockHashType:
"""Computes a hash value corresponding to the contents of a block
and the contents of the preceding block(s)."""
if not parent_block_hash:
parent_block_hash = NONE_HASH # 随机种子,防碰撞
curr_block_token_ids_tuple = tuple(curr_block_token_ids)
return BlockHashType(
hash_function(
(parent_block_hash, curr_block_token_ids_tuple, extra_keys)),
curr_block_token_ids_tuple, extra_keys)
BlockHashType 本身是一个 NamedTuple(kv_cache_utils.py:21),不仅包含哈希值,还保留了原始 token IDs 和 extra_keys 用于碰撞检测:
python
# vllm/v1/core/kv_cache_utils.py:21-32
class BlockHashType(NamedTuple):
hash_value: int # 哈希值
token_ids: tuple[int, ...] # 原始 Token IDs(碰撞检测)
extra_keys: Optional[Any] = None # 额外键(LoRA/多模态/salt)
这个设计的精妙之处:即使使用 Python 内置 hash()(非密码学安全),通过同时比较 hash_value 和 token_ids,碰撞概率也极低。而 v0.8.5 还支持 SHA256 模式(caching_hash_algo="sha256"),碰撞概率可忽略不计。
这个设计的核心洞察是链式哈希:每个块的哈希值不仅取决于自身的 Token,还"继承"了前面所有块的哈希。这意味着:
- 两个请求即使第 3 个块的 Token 完全相同,如果它们的第 1 或第 2 个块不同,第 3 个块的哈希也不同------因为 KV Cache 的值受到因果注意力的影响,前面的 Token 不同意味着 KV Cache 的值也不同。
- 哈希链的比较是 O(1) 的:只需要比较哈希值,不需要逐 Token 比较前面所有块的内容。
这和 Git 的 commit hash 有异曲同工之处------每个 commit 的 hash 包含了父 commit 的 hash,形成一条不可篡改的链。在这里,每个块的 hash 包含了父块的 hash,确保了因果一致性。
为什么需要 extra_keys? 因为相同的 Token 序列在不同条件下可能产生不同的 KV Cache:
- 不同的 LoRA 适配器会改变注意力计算的权重
- 不同的多模态输入(图片 embedding)会影响前面的 Token 表示
- 用户可以通过 cache salt 强制隔离不同会话的缓存
缓存查找与命中
当一个新请求到达时,KV Cache Manager 按块构造哈希链,依次在缓存中查找:
python
# vllm/v1/core/kv_cache_manager.py:93-155 (简化)
def get_computed_blocks(self, request):
if not self.enable_caching:
return [], 0 # 未启用前缀缓存
# 计算请求的哈希链(可能已缓存)
block_hashes = self.req_to_block_hashes[request.request_id]
if not block_hashes:
block_hashes = hash_request_tokens(
self.caching_hash_fn, self.block_size, request)
self.req_to_block_hashes[request.request_id] = block_hashes
# 特殊处理:prompt_logprobs 请求跳过缓存
# 因为需要重新计算每个 token 的 logprob
if request.sampling_params.prompt_logprobs is not None:
return [], 0
# 查找最长缓存命中
computed_blocks = (
self.specialized_manager.find_longest_cache_hit(block_hashes))
# 统计命中率
if self.log_stats:
self.prefix_cache_stats.queries += len(block_hashes)
self.prefix_cache_stats.hits += len(computed_blocks)
num_computed_tokens = len(computed_blocks) * self.block_size
return computed_blocks, num_computed_tokens
如果前 3 个块命中缓存,第 4 个块未命中,那么请求只需要从第 4 个块开始计算------前 3 个块的 KV Cache 直接复用,省去了 48 个 Token 的预填充计算。
10.3 零开销的秘密
V0 的前缀缓存有明显的性能开销------大约 5-10% 的吞吐量下降,即使在 0% 缓存命中率的情况下。这是因为 V0 在每步调度时都要计算哈希、查找缓存、更新引用计数,这些 Python 操作在高 QPS 下成为瓶颈。
V1 通过几个优化将开销降到了 < 1% ,使得前缀缓存可以默认启用:
优化一:极简的块对象 。KVCacheBlock(kv_cache_utils.py:112)的设计极其精简------只有 5 个字段:
python
# vllm/v1/core/kv_cache_utils.py:112-145
class KVCacheBlock:
"""KV-cache block metadata."""
block_id: int # 块 ID
ref_cnt: int = 0 # 引用计数
_block_hash: Optional[BlockHashType] = None # 哈希(惰性计算)
prev_free_block: Optional["KVCacheBlock"] = None # 双向链表-前驱
next_free_block: Optional["KVCacheBlock"] = None # 双向链表-后继
注意 prev_free_block 和 next_free_block------它们构成了一个侵入式双向链表 (Intrusive Doubly Linked List)。与传统的 collections.deque 不同,侵入式链表不需要额外的包装节点,O(1) 删除任意节点。管理十万级别的块时,这个差异很明显。
优化二:BlockHashWithGroupId 打包 。将块哈希和 KV Cache 组 ID 打包为单个 bytes 对象作为字典键,避免了元组键的哈希开销。
优化三:惰性哈希计算。块的哈希只在需要时才计算------如果一个块从未被释放(一直在使用中),就不需要计算哈希。
优化四:空闲链表复用。前缀缓存的 LRU 驱逐直接复用 BlockPool 的空闲链表,不需要额外的数据结构。
V0 vs V1 的开销对比
| 操作 | V0 实现 | V0 开销 | V1 实现 | V1 开销 |
|---|---|---|---|---|
| 块对象创建 | __dict__ 字典 |
~200ns/对象 | dataclass (类似 __slots__) |
~50ns/对象 |
| 缓存键查找 | 元组作为 dict key | ~150ns/查找 | 打包 bytes 作为 key | ~80ns/查找 |
| 哈希计算 | 每步都算 | 每步 O(N) | 惰性计算 | 仅释放时 O(1) |
| LRU 驱逐 | 独立的 LRU 数据结构 | 额外内存 | 复用空闲链表 | 零额外内存 |
| 总开销(0% 命中) | 5-10% 吞吐下降 | < 1% 吞吐下降 |
V1 的核心设计哲学是:在没有缓存命中时,前缀缓存的存在应该几乎"隐形"。 只有当缓存命中时,才应该产生可见的效果(吞吐提升)。这种"零惩罚"的设计让 V1 可以在默认配置中启用前缀缓存------用户不需要做任何配置就能享受缓存的好处,而在最坏情况下也不会受到性能惩罚。
这与数据库中"自适应查询优化"的理念一致:优化器应该在检测到可优化的模式时自动应用优化,而不是要求用户手动配置。
基准测试结果:在 0% 缓存命中率时,V1 的前缀缓存只带来 < 1% 的吞吐量下降。而在有缓存命中的场景(如共享系统提示),吞吐量提升可达 2-5 倍。
10.4 缓存命中率的定量影响
前缀缓存的收益取决于工作负载中前缀共享度。让我们用具体数字感受:
场景:客服系统
- 系统提示:800 Token(所有请求共享)
- 用户输入:平均 200 Token(各不相同)
- 总预填充:1000 Token/请求
没有前缀缓存:每个请求预填充 1000 Token。 有前缀缓存:第一个请求预填充 1000 Token,后续请求只预填充 200 Token(800 Token 的系统提示命中缓存)。
加速比 :1000/200 = 5×。这意味着同样的 GPU 资源可以服务 5 倍的预填充请求,或者每个请求的首 Token 延迟降低到原来的 1/5。
场景:RAG 应用
- 检索到的上下文:2000 Token(部分请求可能命中相同的文档片段)
- 假设 30% 的请求共享至少一个文档片段
- 共享部分平均 500 Token
平均加速:1 + 0.3 × 500/2200 ≈ 1.07×------只有 7%。RAG 场景的缓存命中率较低,因为检索结果的组合空间很大。
前缀缓存的最佳场景是:大量请求共享长前缀 (系统提示、few-shot 示例)。最差场景是:每个请求的输入都完全不同。
10.5 缓存驱逐
显存有限,不可能缓存所有历史块。当空闲块耗尽时,需要驱逐一些缓存块:
驱逐遵循 LRU 策略,但有一个细节:链尾块优先驱逐。
系统提示开头
高复用率"] --> B1["Block 1
系统提示结尾
中复用率"] B1 --> B2["Block 2
用户特定输入
低复用率"] style B0 fill:#10b981,color:#fff,stroke:none style B1 fill:#f59e0b,color:#fff,stroke:none style B2 fill:#ef4444,color:#fff,stroke:none
同一时间戳释放的块中,链越长的块(更靠近尾部的块)被驱逐的优先级越高。原因是:
- 链头的块包含公共前缀(如系统提示),被未来请求复用的概率最高
- 链尾的块包含个性化内容(如用户特定输入),被复用的概率最低
这个策略最大化了缓存的命中率。
驱逐的工程细节
驱逐实现在 BlockPool 类中。当调度器需要为新请求分配块但空闲块已耗尽时,会触发驱逐:
- 从空闲链表的头部取出 LRU 块
- 如果该块有缓存哈希(被标记为可缓存),从哈希表中删除对应条目
- 重置块的状态(清除哈希、引用计数归零)
- 将块分配给新请求
关键的性能考量:驱逐操作是 O(1) 的------链表头部弹出 + 哈希表删除。不需要遍历整个缓存来找到最不常用的块。这得益于侵入式链表的设计:块在被释放时就被插入到链表尾部,链表头部自然就是最早被释放的(即最不常用的)块。
10.6 与其他系统的对比
前缀缓存不是 vLLM 的专利。让我们看看其他推理系统如何处理类似问题:
Anthropic 的 Prompt Caching :在 API 层面提供,用户通过 cache_control 标记可缓存的前缀。这是显式缓存 ------用户需要主动标记哪些内容应该被缓存。vLLM 的前缀缓存是隐式缓存------系统自动检测和复用重复前缀。
TensorRT-LLM 的 KV Cache Reuse:也支持前缀缓存,但实现细节不同。TensorRT-LLM 使用基于 Trie(前缀树)的数据结构来管理缓存查找,而 vLLM 使用哈希链。Trie 的优势是查找时间与前缀长度无关(O(1) per node),劣势是内存占用较大。哈希链的优势是实现简单、内存高效,劣势是每次查找都需要顺序扫描哈希链。
SGLang 的 RadixAttention:使用基数树(Radix Tree)来管理前缀缓存。RadixTree 可以高效地找到最长公共前缀,而且支持动态的前缀拆分和合并。SGLang 的方法在多轮对话场景中特别有效------因为对话的每一轮都是前一轮的扩展,RadixTree 可以自然地表达这种"增量扩展"的关系。
| 系统 | 缓存数据结构 | 显式/隐式 | 内存开销 | 查找复杂度 |
|---|---|---|---|---|
| vLLM | 哈希链 + HashMap | 隐式 | 低 | O(前缀块数) |
| TensorRT-LLM | Trie | 隐式 | 中 | O(1)/node |
| SGLang | Radix Tree | 隐式 | 中 | O(前缀长度) |
| Anthropic API | 服务端缓存 | 显式 | N/A | N/A |
10.7 实践建议
何时前缀缓存收益最大:
- 所有请求共享长系统提示(> 500 Token)
- 多轮对话(每轮是前一轮的扩展)
- Few-shot 示例(多个请求共享相同的示例)
何时收益有限:
- 每个请求的输入完全不同(如独立的文档摘要任务)
- 前缀很短(< 1 个块 = 16 Token)
- 请求量很低(缓存还没被第二个请求命中就被驱逐了)
调优建议:
- 在 V1 中默认启用,无需手动配置
- 通过 Prometheus 指标
vllm:prefix_cache_hit_rate监控命中率 - 如果命中率持续 < 5%,说明工作负载不适合前缀缓存
- 增大 GPU 显存分配给 KV Cache 的比例(
gpu_memory_utilization)可以增加可缓存的块数
10.8 本章小结
前缀缓存是 vLLM 的"免费午餐":
- 动机------共享前缀的请求大量重复计算 KV Cache
- 哈希链------每个块的身份由 (父块哈希 + 当前 Token + 额外键) 唯一确定
- 零开销实现 ------
__slots__、打包键、惰性哈希、链表复用,V1 默认启用 - LRU 驱逐------链尾优先驱逐,保护高复用率的公共前缀块
- 收益------0% 命中时 < 1% 开销,有命中时 2-5 倍吞吐提升
源码导航
- BlockPool(含缓存逻辑):
vllm/v1/core/block_pool.py- BlockHash/KVCacheBlock:
vllm/v1/core/kv_cache_utils.py- KVCacheManager.get_computed_blocks:
vllm/v1/core/kv_cache_manager.py