vLLM系统拆解-06-Prefix Caching:跨请求KV复用如何做到精确、高效且安全

这篇文章要搞清楚的,不是"vLLM 支持 prefix cache"这句功能描述,而是它为什么要这样设计:为什么按 block 做而不是按整段 prompt 做,为什么命中条件必须精确到 token 级,为什么只缓存 full block,block hash 里的 parent hash 和 extra keys 分别解决什么问题,以及 cache salt 这个看起来和性能无关的东西为什么必须存在。

这篇文章的前置知识是 vLLM系统拆解-05-KV Cache与PagedAttention:核心不是Attention公式,而是存放方式:Prefix Caching 建立在 block/page 化 KV Cache 之上,理解 block 化设计是读这篇文章的基础。


普通 KV Cache 的边界在哪里

vLLM系统拆解-05-KV Cache与PagedAttention:核心不是Attention公式,而是存放方式 讲的 KV Cache,解决的是单请求内部的重复计算问题:一个请求在 decode 阶段,每一步不需要重新算历史 token 的 K/V,因为之前的结果已经缓存下来了。

但如果来了另一个请求,它的前 128 个 token 和上一个请求的前 128 个 token 完全一样——普通 KV Cache 不会帮它复用那 128 个 token 的 K/V,它仍然要重新走一遍 prefill。

这就是普通 KV Cache 的边界:它只避免"一个请求重复算自己的历史",但不能自动避免"多个请求重复算同样的前缀"。

Prefix Caching 解决的正是这个问题:把已经算好的前缀 KV blocks 缓存起来,让后续有相同前缀的请求直接复用,跳过这部分 prefill。


哪些场景会产生大量跨请求前缀重复

三类场景覆盖了大多数在线服务的情况:

固定 system prompt。 企业服务通常会把角色定义、风格要求、安全规则、工具调用规范、输出格式模板这些内容固定在所有请求前面。这段内容可能很长,但每个请求都要重新 prefill 一遍。

RAG 固定模板。 问答系统在用户问题前通常会拼接 instruction、引用规则、输出格式、工具规则等模板内容。这些模板高度重复。

多轮对话公共前缀。 多轮对话里,前几轮的消息可能完全相同,只是后面追加了一点新内容。前面那一大段历史完全适合复用。

这几类场景的共同特征是:大量请求并不是从零开始的,它们有长度不等的共同前缀。Prefix Caching 的收益就来自这里。


为什么按 block 缓存,而不是按整段 prompt 缓存

这是 Prefix Caching 设计里最关键的一个决策,理解它需要从三个角度来看。

一、粒度一致性。 vLLM 的 KV Cache 本来就是 block/page 化管理的。如果 Prefix Caching 选择以整段 prompt 为单位做缓存,就需要在现有 block 管理体系之外再定义一套"大对象缓存",然后命中后还要把这个大对象重新拆回 block,才能接入后续计算。多一层转换就多一层复杂度。按 block 缓存,则和现有物理管理粒度天然一致。

二、部分命中是常态。 现实里两个请求往往是"前 10 个 block 相同,第 11 个 block 开始不同"。如果按整段 prompt 缓存,命中逻辑就很僵硬:要么全段一样,要么不命中。按 block 链缓存,则可以做到:前 10 个 block 命中,后续从第 11 个 block 继续 prefill。这才是前缀复用真正该有的形态。

三、生命周期管理更容易。 Block 粒度的缓存对象更适合做引用计数、LRU 驱逐、部分共享。一整段 prompt 对应的大对象,生命周期管理要复杂得多。


命中条件:严格 token 级一致,不是语义相似

Prefix Caching 最容易产生的误解是:同一个 system prompt 只要"看起来差不多"就能命中。这不对。

命中条件是:前缀 token 序列精确一致。

原因很直接:KV Cache 是模型对具体 token 序列计算出的中间状态。一个 token 不同,后续所有 attention 的结果就可能不同。所以判断能否复用,必须是精确一致,不能是模糊匹配或语义近似。

这也是为什么 Prefix Caching 最终要落到 block hash,而不是字符串近似判断。


Block Hash 设计:为什么需要 parent hash 和 extra keys

这部分是 Prefix Caching 实现里最容易被忽视的细节,也是让它真正"正确"的关键。

根据 vLLM 设计文档和 kv_cache_utils 的说明,每个 full block 的 hash 由以下内容计算得出:

1
2
3
4
5
block_hash = hash(
parent_block_hash, # 前一个 block 的 hash
curr_block_token_ids, # 当前 block 的 token 序列
extra_keys # LoRA ID、多模态输入 hash、cache salt 等
)

为什么需要 parent_block_hash?

考虑这个例子(block size = 4):

1
2
3
4
5
请求 A:[a b c d]  [e f g h]
block 0 block 1

请求 B:[x y z w] [e f g h]
block 0 block 1

两个请求的第二个 block token 完全相同,都是 [e f g h]。但它们的 prefix 完全不同,所以第二个 block 对应的 KV 也不同,不能复用。

如果只用当前块的 token 计算 hash,就会误判为可以复用。加入 parent_block_hash 后:

  • A 的 block 1 hash 依赖 A 的 block 0 hash(由 [a b c d] 决定)
  • B 的 block 1 hash 依赖 B 的 block 0 hash(由 [x y z w] 决定)

即使当前块 token 一样,最终 hash 也不同。这保证了前缀链级别的正确性

一个 block 的 hash,隐含了"从起点到当前块"为止的完整前缀信息。

为什么需要 extra_keys?

即使 token 序列完全一样,底层推理条件不同时 KV Cache 也不能共享:

  • 使用不同 LoRA adapter:不同 adapter 的 K/V projection 权重不同,对同样 token 序列计算出的 KV 也不同
  • 使用不同多模态输入:影响 attention 计算的上下文发生变化
  • 使用不同 cache salt:用于隔离不同信任域(后面专门讲)

把这些额外信息纳入 hash,确保命中条件不只是"token 相同",而是"同一前缀链、同一块 token、同一组影响推理语义的额外条件"。


为什么只缓存 Full Block

这是 Prefix Caching 里一个设计决策,背后有四个清晰的工程理由。

内容和边界都稳定。 一个还没填满的 block 仍然在接收新 token,它的内容还没有最终确定。既然内容会变,hash 就不稳定,缓存身份也不稳定。只有 full block 的内容固定、边界固定、hash 固定,才适合作为可复用缓存对象。

避免频繁元数据更新。 如果半满 block 也参与缓存,那每追加一个 token,hash 就变一次,之前的缓存身份要不要作废、旧 hash 如何驱逐、多个请求共享时如何处理"正在变动的尾块"——这些问题会让缓存管理复杂度大幅上升。Full block 策略把这个问题直接切掉:尾块在未满前只当作普通的 request-owned block,满了之后才"晋升"为可缓存 block。

与 block/page 化管理天然一致。 vLLM 本来就以 page 为物理管理单位,一个完整 page 才更像稳定的物理单元。

工程权衡的最优点。 理论上支持 partial block 缓存能多命中一点尾部前缀,但工程上实现更复杂、元数据更新更频繁、一致性更难维护,而收益并不大。牺牲一点理论最优命中率,换来实现简单、行为稳定、维护成本低——这是很典型的 infra 权衡逻辑。


Prefix Caching 的数据结构

Prefix Caching 实现在 KV Cache Manager 里(路径:vllm/v1/core/kv_cache_manager.py),初始化时建立以下几个核心组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
KV Cache Manager 内部结构:

┌─────────────────────────────────────────────────────┐
│ Block Pool │
│ 所有 KVCacheBlock 对象在启动时一次性预分配 │
│ 避免推理过程中频繁创建 Python 对象 │
├─────────────────────────────────────────────────────┤
│ Free Block Queue(双向链表) │
│ block 内部直接带链表指针,O(1) 移动中间元素 │
│ 用于 LRU 管理和空闲 block 回收 │
├─────────────────────────────────────────────────────┤
│ Cache Blocks Map │
│ block_hash → block_id │
│ 新请求按前缀逐块查询这张表 │
├─────────────────────────────────────────────────────┤
│ Request Blocks Map │
│ request_id → allocated block ids │
│ 每个请求当前持有哪些 block │
├─────────────────────────────────────────────────────┤
│ ref_cnt(每个 block 持有) │
│ 多个请求可共享同一批 immutable full block │
│ ref_cnt = 0 且不在活跃缓存中才可回收 │
└─────────────────────────────────────────────────────┘

Block Pool 的设计体现了高性能系统里常见的对象池思路:高频路径上的对象管理尽量静态化、池化,避免运行时频繁创建和销毁 Python 对象的 GC 压力。Free Block Queue 的双向链表则让 LRU 的"移动到尾部"操作为 O(1),不需要额外的包装容器。

ref_cnt 是共享 block 的关键机制:Prefix Caching 不是给每个请求复制一份 KV,而是让多个请求共同引用同一批 immutable full block。只有当没有请求再引用某个 block 时,它才能返回 free queue。


新请求到来时的完整处理流程

flowchart TD
    A["新请求进入 Scheduler"] --> B["get_computed_blocks()\n按 block 切分 prompt\n逐块计算 hash,查 Cache Blocks Map"]
    B --> C{"是否有从头开始的\n连续 block 命中?"}
    C -- "有命中" --> D["命中的 full blocks\n直接作为 computed prefix\n跳过这部分 prefill"]
    C -- "无命中" --> E["从头分配新 blocks"]
    D --> F["allocate_slots()\n只为未命中部分分配新 blocks"]
    E --> F
    F --> G["执行 prefill\n写入未命中的 block"]
    G --> H{"某个新 block 写满?"}
    H -- "是" --> I["计算稳定 hash\n写入 Cache Blocks Map\n等待下次命中"]
    H -- "否(尾块未满)" --> J["普通 request-owned block\n不进入 prefix cache"]
    I --> K["请求继续 decode"]
    J --> K
    K --> L["请求结束\n所有引用 block 的 ref_cnt--\nref_cnt=0 的 block 进入 LRU 候选"]

有一个细节值得注意:命中必须从前缀起点开始连续,不能跳着命中。因为 parent_block_hash 把前缀链编码进了每个 block 的 hash,中间断掉了就意味着后续的 hash 也全部不同,无法复用。


为什么 Prefix Caching 放在 KV Cache Manager 里

初看起来,Prefix Caching 可以单独做成一个"prompt cache service"。vLLM 没这么做,背后有清晰的理由。

缓存对象本身就是 KV blocks。 Prefix Caching 缓存的不是文本字符串、不是 logits,而是 KV block 本身。既然缓存对象就是 KV block,最自然的归属地就是管理 KV block 的模块。

block 的生命周期必须统一管理。 Prefix cache 命中后,请求引用了已有 block,这些 block 仍然要参与 ref_cnt 管理、free queue 管理、驱逐策略、request 映射。如果把 Prefix Caching 单独拆出去,就会有两套状态需要同步:一套在缓存模块,一套在 KV manager。两套状态之间的一致性维护是额外成本。

Scheduler 需要的接口可以统一。 Scheduler 不关心"缓存系统有多高级",它只需要知道:这个请求的前缀有哪些 block 已经计算好了,还需要分配多少新 block。把 Prefix Caching 放在 KV Cache Manager 里,这些问题通过同一个接口体系解决,scheduler 不需要感知两个模块。

这是一种典型的系统设计思路:让资源的所有权与生命周期管理尽量在同一层闭环,不要人为拆成多个需要相互同步的模块。


为什么用 Hash Lookup 而不是 Trie

看到"前缀复用",很容易联想到用 trie 来组织——它天然适合前缀共享和前缀检索。但 vLLM 当前设计以 block hash 和 Cache Blocks Map 为主,而不是显式的 trie。

物理管理单位是 block,不是 token。 如果用 trie,更自然的倾向是按 token 组织前缀树。但这和 KV Cache 的物理粒度(block)不一致,命中之后还要再从 token 节点映射到对应的 block,增加了一层转换。

Hash lookup 更直接,更符合缓存的使用模式。 Prefix Caching 的核心需求是:快速判断是否有可复用 block,命中后直接得到 block。这比复杂的前缀索引更像一个缓存系统,hash lookup 天然适合。

parent_block_hash 链已经隐含了前缀结构。 虽然没有显式 trie,但 parent_block_hash + current_tokens 的设计,本质上已经把"前缀链"编码进了 hash 关系里。它不是没有前缀结构,而是把前缀结构编码成了更轻量的 hash 链,不需要一棵显式树来维护。


LRU 驱逐:资源紧张时如何"优雅地忘记"

GPU 显存有限,缓存块不能无限保留。当 block 紧张时,系统要驱逐一些旧缓存块来给新请求腾出空间。

LRU(最近最少使用)是 vLLM 这里的驱逐策略,原因是它和 prefix cache 的使用模式非常匹配:

  • 热门前缀(比如被大量请求共享的 system prompt)会在短时间内被反复命中
  • 冷前缀长期不被访问,留着收益低

Free Block Queue 的双向链表设计正是为此服务的——每次命中时把对应 block 移到链表尾部(O(1) 操作),驱逐时从链表头部取。这不需要额外的包装容器,也不需要维护一个独立的 priority queue。


Cache Salt 和安全性:Prefix Caching 不只是性能问题

这是很多人学推理系统时会跳过、但在真实多租户部署中不得不面对的问题。

风险在哪里?

在共享推理环境里(比如公有云上的多租户服务),如果没有隔离机制:

  • 用户 A 先发了一个带特定 system prompt 的请求,该 prompt 的 KV blocks 被缓存
  • 用户 B 猜测 A 可能用了这个 prompt,发一个相同前缀的请求
  • 如果命中缓存,B 的 TTFT 会明显低于未命中时
  • B 通过观察响应延迟,就能推断出某个前缀是否之前被别人使用过

这是一种时序侧信道攻击,可能造成隐私泄露。

cache_salt 的作用:

官方设计文档说明,cache_salt 会参与第一个 block 的 hash 计算。这意味着:

  • 即便 token 序列完全相同
  • 只要 salt 不同,hash 就不同
  • cache 就不会跨 salt 共享

通过给不同用户或不同信任域分配不同的 salt,可以把 prefix cache 的复用限制在"同一信任组"内部,而不是对所有用户开放。

这个设计的聪明之处在于:它不是把 prefix cache 整个禁掉,而是加了一层轻量、可控的隔离机制,在性能和安全之间找到了平衡点。


与 Scheduler 和 PagedAttention 的关系

与 Scheduler 的关系:Prefix Caching 直接影响 Scheduler 每轮的工作量。KV Cache Manager 告诉 Scheduler 某个请求有哪些 block 已经计算好了,Scheduler 据此决定:这个请求本轮只需要多少 token budget(跳过已命中的部分)、哪些 blocks 还需要 prefill、剩余 budget 是否还够给其他请求。Prefix Caching 减少的不只是单请求的 prefill 成本,而是整个调度面上的 prefill 压力,让更多 budget 留给 decode 和新请求。

与 PagedAttention 的关系:两者解决的是不同层次的问题。PagedAttention 解决的是"KV Cache 如何以 block 形式存储,以及 attention kernel 如何在 paged layout 上高效运行"——这是底层数据组织与访问机制。Prefix Caching 解决的是"哪些 block 已经算过,是否可以跨请求复用"——这是跨请求的资源复用机制。

1
2
3
PagedAttention → 提供 block 化 KV 的底座

Prefix Caching → 利用这个底座把一部分 blocks 变成跨请求可复用资源

没有 block 化 KV,Prefix Caching 当然也能做,但管理会更笨重。有了 PagedAttention,按 block 复用就变得非常自然。


收益、边界和常见误区

真实收益:减少重复 prefill 计算量;降低 TTFT(跳过前缀计算,更快进入真正的生成阶段);提升系统吞吐(节省的 token budget 可以分给更多请求)。收益的大小取决于前缀重复率——对使用长固定 system prompt、RAG 模板或多轮对话的服务效果最显著。

边界与限制

场景 收益情况
前缀各不相同(随机请求) 命中率低,有元数据维护成本但收益有限
尾块未满(最后一个不完整 block) 不进入 prefix cache,跨请求无法命中
Block 池紧张 缓存 block 会被 LRU 驱逐,命中率下降
多租户环境 若无 cache salt 隔离,存在时序侧信道风险

几个常见误区需要澄清:

  • Prefix Cache 缓存的是 KV blocks,不是模型的回答结果
  • 它是跨请求复用,与普通 KV Cache 的单请求内部复用不是同一件事
  • 命中需要严格 token 级一致,语义相似不算命中
  • 是否缓存 partial block 是工程权衡问题,不是命中率越高越好
  • Cache salt 的存在说明这个功能既是性能机制,也是安全机制

结语

Prefix Caching 的设计体现了 vLLM 的一个一贯思路:把看起来是"高级 feature"的东西,工程化地落在已有基础设施上,而不是另起炉灶。它没有单独发明一套缓存系统,而是沿用 block/page 化 KV 的粒度;它没有维护两套状态,而是把缓存逻辑收归 KV Cache Manager;它没有为了命中率而引入 partial block 缓存的复杂性,而是选择 full block 策略获得简单稳定的实现。

它的局限也很清晰:前缀重复率低时收益有限;缓存 block 本身占显存需要驱逐策略;多租户场景需要正确配置 cache salt,否则引入安全风险。


参考资料


系列导航