vLLM Automatic Prefix Caching 源码精读:KV cache 为什么能跨请求复用

从 Scheduler、KVCacheManager、BlockPool 和 block hash 链路读懂 vLLM APC:它怎样把前缀命中变成已计算 token 和可复用 KV blocks。

vLLM Automatic Prefix Caching 源码精读:KV cache 为什么能跨请求复用

这篇只挖一个点:vLLM 的 Automatic Prefix Caching,简称 APC。

我不想把它写成“打开 enable_prefix_caching=True 会更快”这种功能介绍。真正值得吃透的是:

为什么两个请求只是 prompt 前缀相同,第二个请求就能少算一段 Transformer?
vLLM 源码里到底是谁判断命中、谁保护命中的 block、谁决定剩下 token 继续算?

先给结论:

APC 不是字符串缓存,也不是把上一次回答缓存起来。

它缓存的是已经完成 prefill 的 KV cache block。
新请求进来后,Scheduler 先用 prompt token 生成一串 block hash,
再从 BlockPool 找最长连续命中的物理 KV block。
命中的部分会被计入 num_computed_tokens,
剩下的 token 才进入本轮 prefill 计算。

源码版本是 vLLM commit 752a3a504485790a2e8491cacbb35c137339ad34。主线文件是:

  • vllm/v1/core/sched/scheduler.py
  • vllm/v1/core/kv_cache_manager.py
  • vllm/v1/core/single_type_kv_cache_manager.py
  • vllm/v1/core/block_pool.py
  • vllm/v1/core/kv_cache_utils.py

1. 先把 APC 放回推理内部

大模型自回归推理可以粗分成两段:

prefill:
  一次性吃完整个 prompt,为 prompt 里的每个 token 算出每层 attention 的 K/V。

decode:
  每次生成一个新 token,复用历史 K/V,只为新 token 追加新的 K/V。

KV cache 省的是“重复算历史 token 的 K/V”。但普通 KV cache 只在同一个请求内部复用历史。APC 往前多走一步:

如果请求 B 的前缀和请求 A 一样,
而请求 A 已经把这段前缀的 KV 算出来并留在 GPU block 里,
请求 B 就不必重新 prefill 这段前缀。

所以 APC 改善的主要是 prefill 成本,尤其是长系统提示词、长 RAG 文档、多轮会话共享前缀、批量问同一份材料时的 TTFT。它不会让后续长回答的每个 decode token 免费,因为 decode 仍然要逐步读历史 KV、跑当前 token、采样下一个 token。

这也是 vLLM 官方 feature example 的说法:APC 复用此前 prompt 的 KV pairs,从而减少重复计算。官方 V1 guide 还特别提醒:需要 prompt logprobs 的请求会忽略 prefix cache,重新计算完整 prefill,因为只复用 KV 拿不到完整 prompt logprob 输出。

2. PagedAttention 是 APC 的地基

APC 能在 vLLM 里做得自然,是因为 vLLM 本来就把 KV cache 拆成 block 管。

PagedAttention 的核心思想是:

逻辑上,一个请求有连续的 token 序列;
物理上,这些 token 的 KV 可以存在不连续的 GPU memory block 里;
block table 负责把逻辑块映射到物理块。

这像操作系统里的页表。请求不是一上来预留最大长度的连续 KV 区域,而是随着 token 增长按块分配。这样做先解决碎片和过度预留问题,APC 又利用这个结构做跨请求共享:

如果两个请求的逻辑前缀块相同,
它们的 block table 可以指向同一个物理 KV block。

vLLM 早期 PagedAttention 博客 把 KV cache block 类比成虚拟内存里的页面;PagedAttention 论文 也强调了这种 block-level memory management 对吞吐和内存利用率的意义。

这句话是本文的第一性原理。没有 block table 和物理 block 的间接层,跨请求共享 KV cache 就会变得很笨重。

3. 一个小例子:为什么只能命中完整 block

假设 block size 是 4。第一个请求的 prompt 是:

A B C D E F G

它至少会形成:

Block 0: A B C D  -> full,可以缓存
Block 1: E F G _  -> partial,主路径不能当完整块缓存

第二个请求是:

A B C D E X Y

它和第一个请求共享前 5 个 token,但主路径的 APC 只能稳定命中第一个完整 block:

命中: A B C D
不直接命中: E

为什么?因为 vLLM 的 prefix cache key 是按 block hash 管理的。官方 Automatic Prefix Caching 设计文档 也明确说:只缓存 full blocks。源码里的 KVCacheManager.get_computed_blocks() docstring 同样强调 computed blocks 必须是 full。

当前源码里还有 BlockPool.cache_partial_block(),它能注册 partial prefix-cache entry。但它的本质不是复制一段半块 KV,而是给已有 block 增加一个细粒度边界的 lookup metadata。理解主链路时,先抓住“完整 block 是基本复用单位”,再把 partial entry 看成补洞优化。

4. 请求一创建,就开始准备 block hash

APC 的第一步不是调度器临时比较字符串,而是请求对象提前维护 block hash。

Request.update_block_hashes() 做的事很小:

如果存在 block_hasher,就为新出现的 full blocks 计算 hash,并追加到 request.block_hashes。

真正的 hash 逻辑在 hash_block_tokens()

block_hash = hash(parent_block_hash, current_block_token_ids, extra_keys)

这里有三个关键点。

第一,hash 带 parent。

同样的 E F G H 出现在不同前缀后面,不能复用成同一个语义位置。因为 Transformer attention 看到的不只是当前 block 的 token,还包括它前面的全部上下文。把 parent hash 放进 key,相当于把“前缀链”编码进当前块。

第二,hash 带当前 block token ids。

APC 不按原始字符串匹配,而是按 tokenizer 之后的 token id 匹配。chat template、special tokens、multimodal placeholder 都会影响 token 序列,所以“看起来一样的文本”不一定一定命中。

第三,hash 带 extra keys。

generate_block_hash_extra_keys() 会把 LoRA、多模态输入 hash、cache_salt、prompt embeddings 等因素加入 key。这个设计非常重要:KV cache 是模型内部状态,不同 LoRA adapter、不同图片 embedding、不同安全隔离域下,即使 token id 一样,也不应该随便共享。

cache_salt 只加在第一个 block 上,但因为后续 block 的 hash 都带 parent hash,所以 salt 会沿着整条前缀链传播。换句话说,它不是只隔离第一块,而是隔离整个 cache namespace。

这里要把安全边界说清楚:APC 的“复用”只发生在同一个 cache namespace 内。没有传 cache_salt 时,同一个 vLLM 实例里的兼容请求默认可以共享 prefix cache;传了不同 cache_salt 后,即使 token id 完全一样,block hash 链也不同,跨 salt 就不会命中。实际多租户服务里,应该按 tenant、user group 或安全域设置 salt;如果同一个租户内部想共享系统提示词和工具 schema,就让这些请求使用同一个 salt。这样不是禁止复用,而是把复用限定在你允许的边界内。

它防的也不是“缓存把别人的回答直接吐出来”这种数据泄露。prefix cache 里存的是内部 K/V 状态,只有当前请求真的提交了相同前缀,模型才会把它当作自己的历史状态继续算。更现实的风险是侧信道:攻击者如果能猜测某段敏感 prompt,并观察请求是否明显变快,就可能推断这个前缀是否曾在共享服务里出现过。cache_salt 通过改变 hash namespace,让不同安全域之间连“是否命中”都不可见。

5. Scheduler 先问:这个请求已经算过多少 token?

APC 真正进入运行时,是在 Scheduler.schedule()

vLLM V1 Scheduler 的注释很关键:它内部没有僵硬地区分 prefill phase 和 decode phase。它维护的是:

num_computed_tokens
num_tokens_with_spec

每一轮调度都让 num_computed_tokens 追赶 num_tokens_with_spec。这套统一表述同时覆盖:

chunked prefill
prefix caching
speculative decoding
未来的 jump decoding

对一个还没开始跑的新请求,调度器会先查本地 prefix cache:

new_computed_blocks, num_new_local_computed_tokens =
    kv_cache_manager.get_computed_blocks(request)

这一步的含义不是“返回一段文本”,而是:

请告诉我:这个请求的 prompt 前面有多少 token 的 KV 已经在 cache block 里了?
以及这些 token 对应哪些物理 KVCacheBlock?

然后调度器会把本地命中和外部 KV connector 命中相加,得到本请求当前可以视为 computed 的 token 数:

num_computed_tokens =
  num_new_local_computed_tokens
  + num_external_computed_tokens

剩下要进入本轮计算的 token 数才是:

num_new_tokens = request.num_tokens - num_computed_tokens

这就是 APC 的核心:把缓存命中转成调度器的已计算进度。

6. get_computed_blocks:全命中也要留最后一个 token 重算

KVCacheManager.get_computed_blocks() 做三件事:

1. 如果没开 caching,或者请求被标记为 skip_reading_prefix_cache,直接返回 0 命中。
2. 设置 max_cache_hit_length = request.num_tokens - 1。
3. 调 coordinator.find_longest_cache_hit(...) 找最长前缀命中。

第二点很容易漏。为什么全命中还要减 1?

因为生成下一个 token 需要最后一个 prompt token 位置的 logits。即使前面的 KV 都命中,vLLM 仍然需要重算最后一个 token 的 forward 来拿 logits。源码注释还说,因为 allocate_slots() 要求 num_computed_tokens 按 block size 对齐,这可能导致重算整个 block,而不只是最后一个 token。

所以不能把 APC 想成:

prompt 完全一样 -> prefill 变成 0

更准确是:

能复用尽量多的完整 KV blocks;
但为了拿到本次请求需要的 logits,边界附近仍可能重算。

这也是源码比概念文章更值得读的地方:它逼你看到“语义正确性”和“block 对齐”之间的工程边界。

7. find_longest_cache_hit:命中必须是连续前缀

具体到 full attention,FullAttentionManager.find_longest_cache_hit() 的逻辑很朴素:

for block_hash in request.block_hashes:
  如果 BlockPool 里能找到这个 block_hash 对应的 cached block:
    追加到 computed_blocks
  否则:
    break

注意最后的 break。APC 找的是最长连续前缀,不是任意相同子串。

假设 block hash 序列是:

B0 hit
B1 hit
B2 miss
B3 hit

最终只能复用:

B0, B1

因为 B3 即使碰巧存在,它也不是当前请求在 B2 之后连续计算出来的状态。Transformer 的每个位置都依赖前缀上下文,不能跳过中间缺口。

多 KV cache group 时还更严格。BlockPool.get_cached_block() 会对每个 group 查 hash + group_id。任何一个 group miss,这个 block 就不能作为共同前缀命中。官方 hybrid KV cache manager 文档 也把这一层抽象说清楚:不同 attention 类型由不同 SingleTypeKVCacheManager 处理,coordinator 负责组合它们。

还有两个源码细节:

EAGLE/MTP 开启时可能丢掉最后一个匹配 block,强制重算以拿 drafting head 需要的 hidden states。
alignment_tokens 会让返回命中长度对齐到某个边界。

这说明 APC 不是孤立优化,它必须服从 speculative decoding、hybrid attention、context parallelism 这些运行时约束。

8. allocate_slots:命中的 block 要先被保护起来

命中 block 之后,还不能直接跑模型。调度器接着调用 KVCacheManager.allocate_slots()

这一步负责把请求的逻辑进度落实到物理 block table:

computed prefix blocks
external computed tokens
new tokens to compute
lookahead tokens for spec decode
encoder tokens for cross attention
watermark / reserved blocks

源码里的流程可以压缩成四步:

1. 计算当前请求总共已经 computed 多少 token。
2. 释放 sliding window 等机制已经不需要看的旧 blocks。
3. 判断剩余空闲 blocks 是否够本轮分配,不够就返回 None,让 Scheduler 换请求或触发 preemption。
4. 把命中的 computed blocks 挂到请求上,再为待计算 token 分配新 blocks。

“把命中的 computed blocks 挂到请求上”这一步很关键。命中不是只记一个数字,它还要让这个请求的 block table 指向那些已经存在的物理 KV blocks。这样后续 attention kernel 才能按 block table 读到正确历史 K/V。

如果开启 caching 且不是延迟 KV transfer,allocate_slots() 最后还会调用 coordinator 去 cache blocks,缓存到:

total_computed_tokens + num_new_tokens

但它会被 request.num_tokens 截断,避免把 speculative decoding 里还没验证的 draft tokens 提前缓存。这个点也很工程:缓存只能缓存“已经确定属于序列”的 token,不能缓存可能被 reject 的草稿。

9. BlockPool:缓存表、引用计数和 LRU 队列

BlockPool 是 APC 最像操作系统内存管理的地方。

它维护几类状态:

blocks:
  所有 KVCacheBlock 对象,初始化时一次建好。

free_block_queue:
  空闲 block 队列,同时承担 LRU eviction order。

cached_block_hash_to_block:
  从 block hash + group id 到 KVCacheBlock 的映射。

req_to_blocks:
  从 request id 到该请求当前 block table 的映射。

BlockHashToBlockMap 的 docstring 有一个非常关键的设计选择:vLLM V1 当前不对重复 block 做去重。也就是说,如果一个新分配的 block 后来变成 full,并且内容和已有 cached block 一样,vLLM 不马上把它替换成旧 block。

原因是:

block table 走 append-only 设计;
已经分配给请求的 block id 不随便改。

这会产生短期重复缓存,但避免了运行中改 block table 带来的复杂性。重复会在请求释放后逐步消失。

cache_full_blocks() 做的是把 full block 的 hash 元数据写入 block,并插入 cached_block_hash_to_block。如果启用了 KV cache events,它还会发出 BlockStored 事件,里面带 parent hash、token ids、LoRA、extra keys、group id 等信息。

free_blocks() 则体现了 LRU 思路:

ref_cnt 减到 0 的 block 才能进 free queue。
没有 hash 的 block 永远不会被 APC 命中,优先放到更容易被分配的位置。
有 hash 的 block 保留在缓存表里,进入可淘汰队列。

真正分配新 block 时,get_new_blocks() 会从 free queue 头部弹出。如果弹出的 block 仍然带 cached hash,就调用 _maybe_evict_cached_block() 清掉 hash 元数据,并从缓存表移除。这样才保证这个物理 block 被新请求复用后,不会再被其它请求误认为旧前缀。

这一整套机制可以总结成:

free 不等于 evict。

free:
  当前没有请求引用这个 block 了。

cached:
  这个 block 的 KV 仍然可能被未来请求复用。

evict:
  这个物理 block 要重新分配给别的内容,旧 hash 必须失效。

10. 为什么 prompt logprobs 会跳过 APC

源码里 SamplingParams.__post_init__() 有个细节:

如果 prompt_logprobs 不为空,
skip_reading_prefix_cache 默认设为 True。

KVCacheManager.get_computed_blocks() 看到这个标记后会直接返回 0 命中。

原因不难理解。APC 复用的是 K/V,不是每个 prompt token 的 logits 或 logprobs。你要返回 prompt logprobs,就需要完整地重新跑 prompt 计算,拿到每个位置的概率信息。只读缓存的 KV block 不能凭空恢复这些 logprob 输出。

这给我们一个很实用的判断:

APC 优化的是“继续算下去所需的历史状态”。
它不是所有中间产物的万能缓存。

11. 什么时候 APC 最有价值,什么时候没感觉

APC 最有价值的场景:

长 system prompt
长 RAG context
同一份文档上连续问多个问题
多轮对话里复用稳定历史
agent/tool 流程里反复带同一段 instruction 和 schema

这些场景的共同点是:

prefill 长,且前缀重复率高。

APC 没什么感觉的场景:

prompt 很短
每次请求前缀都不同
主要耗时在超长 decode
要求 prompt_logprobs
chat template 或 tokenizer 细节导致 token 序列不一致
LoRA / multimodal / cache_salt 不同导致 extra keys 不一致

尤其要注意最后一点。很多人会说“明明文本前缀一样,为什么没命中?”源码视角下要反问:

token id 一样吗?
parent hash 链一样吗?
extra keys 一样吗?
是否按完整 block 对齐?
请求有没有要求跳过 prefix cache?

这才是排查 APC 的正确层次。

12. 精髓:APC 是调度器的“已计算进度注入”

读完这条链路,我会这样记 vLLM APC:

Request 负责产生 block hash 链。
Scheduler 负责在 admission 前查询 prefix cache。
KVCacheManager 负责把 cache hit 翻译成 computed blocks 和 computed token 数。
SingleTypeKVCacheManager 负责不同 attention/cache 类型的最长命中策略。
BlockPool 负责 hash -> physical block 映射、ref_cnt、free queue 和 eviction。
ModelRunner 只看到已经安排好的 block table 和本轮要计算的 token。

所以 APC 的本质不是:

发现字符串一样,所以少走一点代码。

而是:

把“这段前缀的 KV 状态已经存在”这个事实,
注入到 Scheduler 的 num_computed_tokens 和 block table 里,
让本轮执行只计算还没有 KV 状态的 token。

这就是 vLLM 这类 serving engine 和普通模型 wrapper 的差别。它优化的不是某个 Python 函数调用,而是请求、调度、KV block、attention kernel 之间的运行时状态。

如果只背“APC 复用前缀”,你学到的是功能名。

如果能顺着源码说清楚:

block hash 怎么构造
最长前缀命中怎么找
命中 block 怎么 touch 并挂进 request
新 block 怎么分配
free 和 evict 为什么不是一回事
哪些请求为什么必须跳过 APC

你就真正抓住了 vLLM 作为推理系统的味道。

参考