vLLM内核探秘-第10章 前缀缓存:零开销的加速

《vLLM 内核探秘》完整目录

第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 的影响(注意力机制的因果性)。

graph LR B0["Block 0
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 本身是一个 NamedTuplekv_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
flowchart TD R["新请求到达"] --> H["计算哈希链\nhash_request_tokens()"] H --> LP{"需要 prompt\nlogprobs?"} LP -->|是| Skip["跳过缓存\n需要重算每个 token"] LP -->|否| Find["find_longest_cache_hit\n在 BlockPool 中查找"] Find --> Hit{"命中了\n几个块?"} Hit -->|"0 块"| Full["完整预填充\n所有 token"] Hit -->|"K 块"| Partial["部分预填充\n只算第 K+1 块起"] Hit -->|"全部"| Decode["直接进入解码\n(极少发生)"] style Find fill:#3b82f6,color:#fff,stroke:none style Partial fill:#10b981,color:#fff,stroke:none

如果前 3 个块命中缓存,第 4 个块未命中,那么请求只需要从第 4 个块开始计算------前 3 个块的 KV Cache 直接复用,省去了 48 个 Token 的预填充计算。

10.3 零开销的秘密

V0 的前缀缓存有明显的性能开销------大约 5-10% 的吞吐量下降,即使在 0% 缓存命中率的情况下。这是因为 V0 在每步调度时都要计算哈希、查找缓存、更新引用计数,这些 Python 操作在高 QPS 下成为瓶颈。

V1 通过几个优化将开销降到了 < 1% ,使得前缀缓存可以默认启用

优化一:极简的块对象KVCacheBlockkv_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_blocknext_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 = 。这意味着同样的 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 策略,但有一个细节:链尾块优先驱逐

graph LR B0["Block 0
系统提示开头
高复用率"] --> 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 类中。当调度器需要为新请求分配块但空闲块已耗尽时,会触发驱逐:

  1. 从空闲链表的头部取出 LRU 块
  2. 如果该块有缓存哈希(被标记为可缓存),从哈希表中删除对应条目
  3. 重置块的状态(清除哈希、引用计数归零)
  4. 将块分配给新请求

关键的性能考量:驱逐操作是 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
相关推荐
杨艺韬5 小时前
Harness Engineering-第4章 上下文工程:比 Prompt Engineering 更重要的事
agent
杨艺韬5 小时前
vLLM内核探秘-第9章 采样与输出处理
agent
杨艺韬5 小时前
Harness Engineering-前言
agent
杨艺韬5 小时前
Harness Engineering-第2章 Agent 架构模式全景
agent
杨艺韬5 小时前
Harness Engineering-第3章 Agent Loop:心跳与决策循环
agent
杨艺韬5 小时前
Harness Engineering-第20章 成本控制与性能优化
agent
杨艺韬5 小时前
Harness Engineering-第10章 Few-shot、CoT 与动态提示策略
agent
杨艺韬5 小时前
Harness Engineering-第13章 多轮对话与会话状态机
agent
杨艺韬5 小时前
Harness Engineering-第16章 多 Agent 协调模式
agent