vLLM 推理内核深度解析
第10章 前缀缓存:默认开启的零开销加速
第10章 前缀缓存:默认开启的零开销加速
“The fastest computation is the one you don’t have to do.” — 系统设计的黄金准则
本章要点
- 理解前缀缓存的三个核心动因:系统提示重复、多轮对话前缀延伸、RAG 检索片段的部分重叠
- 掌握 “KV Cache 因果依赖” 为什么让”按 token 哈希查表”不够,必须用链式哈希
- 读懂
BlockHashType的三字段 NamedTuple 设计:hash + token_ids + extra_keys 同时校验,用非密码学 hash 就安全 - 理解 extra_keys 的三种用途:LoRA 隔离、多模态输入隔离、用户级
cache_salt - 走完
get_computed_blocks的完整路径:hash 计算 → 查表 → refcount++ → 返回可复用块列表 - 学会 V1 相比 V0 如何把前缀缓存开销从 5-10% 压到 < 1%:
__slots__、惰性哈希、打包字节键、侵入式链表复用、LRU 排序零额外开销 - 掌握 chain-aware LRU 驱逐:链尾优先释放的精妙之处
- 看懂 V1 为什么敢默认开启前缀缓存——这是从”opt-in 优化”到”基础设施”的范式转移
- 拿到四类生产场景的命中率估算公式和实测数字
- 对比 vLLM 哈希链 vs SGLang RadixAttention vs TensorRT-LLM Trie 的取舍
10.1 为什么前缀缓存是”免费午餐”
LLM 推理的每一次 prefill 都要计算所有 prompt token 的 KV。但真实的生产流量里,大量 prompt 内容是重复的:
10.1.1 重复的三种来源
来源 1:系统提示(system prompt)。每个 ChatGPT-like 服务的每次请求前面都拼着几百到几千 token 的系统提示,指导模型的角色、语气、限制。同一个服务下,这段内容对所有请求都完全一样。
"你是一个专业的编程助手,回答要简洁清晰。遵循以下规则:1. ...
2. ... 3. ... 4. ...(大约 800 tokens 的规则)"
──────────────────────────────────────
用户本次问题:"帮我写个 Python 快排"(~20 tokens)
系统提示占据了绝大多数 token。每个请求都从头算一遍这段规则的 KV,纯浪费。
来源 2:多轮对话的共同前缀。一段多轮对话的每一轮,都是前一轮的 “延长”:
Round 1 = system_prompt + user_msg_1
Round 2 = system_prompt + user_msg_1 + assistant_reply_1 + user_msg_2
Round 3 = system_prompt + user_msg_1 + assistant_reply_1 + user_msg_2 + assistant_reply_2 + user_msg_3
Round 3 的前面一大段 token 和 Round 2 完全一样。如果重新全量 prefill 每次请求,每多一轮开销线性增加——N 轮对话总开销 ,这就是”多轮对话越来越慢”的根源。
来源 3:RAG 的部分重叠。RAG 场景下每个请求前面拼着从知识库检索出来的上下文。同一个问题的多次重述、相邻问题、热门文档——都会让检索结果重合。
10.1.2 重复计算的惊人代价
假设系统提示 800 token,每个请求的用户输入 200 token,总 prompt 1000 token。
- 100 个请求:100 × 1000 = 100k token 的 prefill FLOPs
- 其中实际不同的只有 100 × 200 = 20k
- 80% FLOPs 是浪费
折算成 A100 GPU 上 Llama-70B 的 prefill 时间(约每 token 40 μs):
- 无前缀缓存:100 × 40ms = 4 秒
- 有前缀缓存:(1 × 40ms) + (99 × 8ms) ≈ 0.8 秒
5× 加速——还没算因此省下的 KV 池空间可以服务更多并发。
10.1.3 前缀缓存为什么需要特殊设计
缓存听起来简单——hashmap 一个以 prompt token 为 key 的表,存 KV 就行?错。
挑战 1:KV Cache 有因果依赖。Transformer 的 attention 是因果的:第 k 个 token 的 KV 受它前面所有 token 影响。
看这个例子:
请求 A 的第 5 个 block = "...的专业的编程助手,回答要简..."
请求 B 的第 5 个 block = "...的专业的编程助手,回答要简..."
两个请求的第 5 个 block 里 token 完全相同。但如果请求 A 的第 0-4 个 block 和 B 不同,第 5 个 block 的 KV 就不同(因为计算它时要 attend 到前面的 KV,前面不一样,结果就不一样)。
单纯按 token 内容 hash 是错的。必须是”这些 token + 它们前面的上下文”一起 hash。
挑战 2:块粒度对齐。KV Cache 按 block(16 tokens)管理。前缀缓存也必须按 block 匹配。两个请求前 500 token 相同,但第 501 个开始不同——只有前 个 block 能命中。
挑战 3:查找性能的零容忍。每次有请求进来都要走这套 hash + 查找逻辑。如果 CPU 开销抵消了 GPU 省下的计算,那就是负优化。这正是 V0 前缀缓存的痛点——即使 0% 命中,仍有 5-10% 吞吐下降。
10.2 链式哈希:每个 Block 的”身份证”
vLLM 的解决方案是链式哈希(Hash Chain)。
10.2.1 基本定义
每个 block 的 hash 由三部分决定:
- 第 个 block 的 hash = hash(父 block 的 hash + 本 block 的 token 序列 + extra_keys)
- 第 0 个 block 的”父 hash”用一个固定常量
NONE_HASH(防碰撞的随机种子)
递归地,第 个 block 的 hash 其实”包含”了前面所有 block 的 hash——就像 Git commit hash 包含了父 commit 的 hash 一样。
graph LR
N["NONE_HASH<br/>(种子)"] --> B0["Block 0<br/>hash = H(NONE_HASH, tokens[0:16], extra)"]
B0 --> B1["Block 1<br/>hash = H(B0.hash, tokens[16:32], extra)"]
B1 --> B2["Block 2<br/>hash = H(B1.hash, tokens[32:48], extra)"]
B2 --> Bdots[...]
style N fill:#94a3b8,color:#fff,stroke:none
style B0 fill:#3b82f6,color:#fff,stroke:none
style B1 fill:#8b5cf6,color:#fff,stroke:none
style B2 fill:#ec4899,color:#fff,stroke:none
10.2.2 链式设计的两个好处
好处 1:O(1) 比较。两个 block 的 hash 一样 ⟺ 它们的 token 序列一样且前面的上下文也一样。不需要一个一个 token 比对。
好处 2:天然支持”最长公共前缀”匹配。
# 伪代码
for i, block_hash in enumerate(request_block_hashes):
if block_hash in cache_table:
# 命中,继续
continue
else:
# 第 i 个 block 没命中,返回前 i 个作为可复用的
return request_block_hashes[:i]
一旦有一个 block 不匹配,后面的都不用查了——链式结构保证了”前面不匹配就不可能匹配”。
10.2.3 BlockHashType:同时存 hash 和 tokens
vllm/v1/core/kv_cache_utils.py 里的 BlockHashType:
class BlockHashType(NamedTuple):
hash_value: int # 整数 hash
token_ids: tuple[int, ...] # 原始 token 序列
extra_keys: Optional[Any] = None # 额外隔离键
为什么还要存原始 token_ids? 因为 hash 有碰撞概率。虽然 Python 内置 hash() 对于整数 tuple 的碰撞概率极低,但不是 0。vLLM 在”相等判定”时同时比较 hash_value 和 token_ids——两者都匹配才算命中。这种多重确认让用非密码学 hash(快)也足够安全。
如果用户极度在意碰撞概率(比如医疗、金融场景),可以启用 caching_hash_algo="sha256"——用 SHA-256 替代默认 hash。SHA-256 是密码学哈希,碰撞概率可忽略(<),代价是每次 hash 计算慢几十倍。生产里几乎没人启用。
10.2.4 extra_keys 的三种用途
同一段 token 序列在不同条件下会产生不同的 KV——extra_keys 把这些条件加到 hash 里以隔离:
extra_keys = (
lora_id, # 用不同 LoRA 权重算出的 KV 不同
mm_hash, # 多模态输入的 hash(图片/音频内容)
cache_salt, # 用户指定的隔离键
)
LoRA 隔离:第 16 章讲过。相同 prompt 在 LoRA-A 和 LoRA-B 下的 KV 不同,必须分开缓存。
多模态隔离:第 15 章讲过。同样的文本 token "describe this:" 后面接不同图片,embedding 不同,KV 也不同。mm_hash 是图片内容的 hash。
cache_salt:用户主动指定的隔离。比如你有多个客户在用同一个 model + LoRA,但你不想客户 A 的 prompt 数据”泄漏”到客户 B 的缓存命中里——两个客户用不同的 salt,缓存自然隔离。
这三种 extra_keys 的设计是可扩展的——将来如果有新的”隐含变量”(比如某种采样策略也会改变 KV),只需要加到 extra_keys 里,核心逻辑不变。
10.2.5 链首的”虚拟父哈希”:NONE_HASH
链式哈希有个必须回答的工程问题——第一个 block 的”父哈希”是什么?如果写 parent = 0 或 parent = None,那两个 Python 进程跑同一段输入会得到相同的链首哈希——这在独立部署时没问题,但对”攻击者构造碰撞”防御为零:知道了哈希算法的人可以精心挑选 token 让两段不同内容落到同一个 block hash,污染别人的缓存。
vLLM 在 vllm/v1/core/kv_cache_utils.py:45 的解决是进程内随机、同源可重现:
NONE_HASH = int.from_bytes(os.urandom(32), byteorder="big") \
if os.getenv('PYTHONHASHSEED') is None \
else sha256(os.getenv('PYTHONHASHSEED'))
两种模式:
模式 A——PYTHONHASHSEED 未设置(生产默认):进程启动时从 /dev/urandom 取 32 字节作链首。每个 vLLM 进程的链首哈希都不一样——即使攻击者拿到某进程的 hash 输出,也推不回 token 内容,更不能跨进程构造命中。
模式 B——PYTHONHASHSEED 已设置:用环境变量的 SHA256 作链首。同一个值的两个进程得到相同链首,方便一致性测试、以及多副本部署时希望前缀缓存在节点间复用——下一节 KVConnector 的跨机命中就需要这个模式。
这段代码的开头注释 # This is not performance critical as it is done once per process 交代了为什么这里能”奢侈”地用 urandom——整个进程生命周期只调用一次,不进热路径。hash_block_tokens 的热路径里只用传入的 hash_function,默认还是 Python builtin hash()——它本身带 per-process salt(PYTHONHASHSEED),配合 NONE_HASH 作链首足够构成防碰撞保证。真正要密码学安全(比如审计场景)时把 hash_function 换成 hashlib.sha256 即可——一个参数替换,两种安全级别。
10.3 get_computed_blocks:查找命中的核心路径
一个新请求到达时,KVCacheManager 调用 get_computed_blocks 看能复用多少 block:
# vllm/v1/core/kv_cache_manager.py(概念性简化)
def get_computed_blocks(self, request) -> tuple[list[KVCacheBlock], int]:
if not self.enable_caching:
return [], 0
# 1. 计算或读取请求的 block hash 链
block_hashes = self.req_to_block_hashes.get(request.request_id)
if not block_hashes:
block_hashes = hash_request_tokens(
hash_fn=self.caching_hash_fn,
block_size=self.block_size,
request=request,
)
self.req_to_block_hashes[request.request_id] = block_hashes
# 2. prompt_logprobs 请求走特殊路径(需要重算每个位置的 logprob)
if request.sampling_params.prompt_logprobs is not None:
return [], 0
# 3. 在 BlockPool 里找最长匹配
computed_blocks = self.specialized_manager.find_longest_cache_hit(block_hashes)
# 4. refcount++
for block in computed_blocks:
self.block_pool.touch(block)
# 5. 统计
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 TB
New[新请求到达]
Hash[hash_request_tokens<br/>构造 block hash 链]
Check{prompt_logprobs?}
SkipCache["跳过缓存<br/>返回 (空, 0)"]
Find[find_longest_cache_hit<br/>在 BlockPool 哈希表里查]
Touch[对每个命中 block<br/>refcount++<br/>从空闲链表里拔出]
Return["返回 (命中块列表, 命中 token 数)"]
New --> Hash --> Check
Check -->|是| SkipCache
Check -->|否| Find
Find --> Touch --> Return
style Find fill:#3b82f6,color:#fff,stroke:none
style Touch fill:#10b981,color:#fff,stroke:none
10.3.1 三个工程细节
细节 1:req_to_block_hashes 缓存 hash 链本身。同一个请求被抢占后再调度时,它的 hash 链不需要重新算——已经缓存在 KVCacheManager 里。这让抢占+恢复的开销降到极低。
细节 2:prompt_logprobs 特殊处理。如果用户请求要求返回 prompt 里每个 token 的 logprob(debug / 分析用),必须每个位置都真正算一遍——缓存无法提供这种信息。跳过缓存,保证语义正确。
细节 3:find_longest_cache_hit 在 block 级 O(1) 迭代。每个 block 查一次 hash 表,一旦 miss 就停。最多 次哈希表查询——典型请求也就几十次。
10.4 V0 → V1:从 5-10% 开销到 <1%
V0 的前缀缓存虽然功能可用,但有个硬伤:即使 0% 命中率,吞吐也要降 5-10%。这让很多用户直接关掉它。
V1 做了全方位优化,把 0% 命中的开销压到 <1%——默认启用成为可能。优化在六个层面发生。
10.4.1 __slots__ 级的 Block 元数据
V0 的 Block 对象是普通 Python class,每个对象维护 __dict__,单对象开销 ~350 字节。
V1 的 KVCacheBlock:
class KVCacheBlock:
__slots__ = (
"block_id",
"ref_cnt",
"_block_hash",
"prev_free_block",
"next_free_block",
)
单对象 ~120 字节。一个 170k block 的池子省下 40 MB 管理元数据 + 每次属性访问从 dict lookup 变成 offset access。第 5 章详细讲过。
10.4.2 惰性哈希计算
V0 每次 block 状态变化都重算 hash。V1 只在 block 被完整填满并从某个请求脱离时才算 hash(因为只有此时 hash 才稳定、有复用价值)。减少了大量无谓的 hash 计算。
class KVCacheBlock:
_block_hash: Optional[BlockHashType] = None # 默认 None,不计算
@property
def block_hash(self):
if self._block_hash is None:
# 第一次访问才计算
self._block_hash = compute_hash(...)
return self._block_hash
decode 阶段每 step 产一个 token,大部分时候 block 还没满,hash 根本不需要计算——省掉的都是纯收益。
10.4.3 打包字节键
V0 用 tuple(hash_value, lora_id, mm_hash, salt) 作 dict 键。Python tuple 的 hash 是递归求 element hash 再组合——每次查找多几次函数调用。
V1 把这些字段先序列化成 bytes 对象(一次性)然后存进 dict。之后查找 dict 时,bytes 的 hash 是 O(len(bytes)) 线性时间,比 tuple 快 30-50%。
10.4.4 侵入式链表代替独立 LRU 结构
V0 维护一个独立的 LRU 数据结构(typically OrderedDict)。每次访问 block 要更新这个结构——多一次操作。
V1 让空闲 block 池本身就是 LRU 顺序(链表头是最老的):
- 释放 block → 加到链表尾
- 缓存命中 block → 从链表中间拔出(refcount++)
- 需要驱逐 → 从链表头取(自动 LRU)
一个数据结构同时扮演空闲池、LRU、驱逐候选池。省掉了 V0 需要维护的第二个结构 + 它们之间的同步成本。第 5 章的 FreeKVCacheBlockQueue 详细讲过。
10.4.5 BlockHashWithGroupId 打包
在 MoE 或多 KV 组模型里,同一个 block 可能属于不同”kv group”。V0 用 (hash, group_id) 做复合键。V1 把这两个字段打包成单个 bytes:
# 复合键 bytes = hash_value_bytes || group_id_byte
key = hash_int.to_bytes(8) + group_id.to_bytes(1)
单次 hash + 单次 dict lookup,对比 V0 的元组开销省一半时间。
10.4.6 整体开销对比
| 操作 | V0 时间 | V1 时间 |
|---|---|---|
| Block 对象创建 | 200 ns | 50 ns |
| 单次 block hash 计算 | 800 ns | 300 ns |
| 缓存表查找 | 150 ns | 80 ns |
| LRU 更新 | 100 ns | 10 ns (合并到链表操作) |
| 0% 命中 1000 block/s 总开销 | 5-10% 吞吐损失 | <1% 吞吐损失 |
V1 做到”前缀缓存在没命中时也几乎隐形”。这个特性让 V1 能够默认启用前缀缓存——这是从”一个可选优化”到”基础设施的一部分”的范式转移。
10.5 Chain-aware LRU 驱逐
KV 池满时要驱逐缓存。朴素 LRU 是按时间顺序——但这在前缀缓存场景下有个微妙问题。
10.5.1 链式依赖下的驱逐陷阱
假设 block 0、1、2 是一段共同的系统提示前缀。三者同时进入空闲状态。朴素 LRU 按”最近访问时间”排序:
假设三个 block 最后访问时间都是 t=0:
空闲链表头: [block 0 (link head), block 1 (link middle), block 2 (link tail), ...]
现在新请求进来要 1 个 block,LRU 驱逐链表头——block 0。但 block 0 是链头,未来所有请求都可能复用它(因为大家系统提示都一样)!应该驱逐 block 2 (链尾,最少被复用)。
10.5.2 V1 的 Chain-aware 策略
V1 在 block 释放时做倒序入链——把链尾(最具体的)先入空闲链表、链头(最通用的)后入:
def free_block_group(blocks: List[KVCacheBlock]):
# blocks 按链序,blocks[0] 是最通用的链头
# 倒序加入空闲链表,让链尾先被驱逐
for block in reversed(blocks):
free_queue.append(block)
这样空闲链表的顺序变成 [block_2, block_1, block_0, ...]——head 是链尾 block_2(最应该驱逐的),tail 是链头 block_0(最应该保留的)。驱逐从 head 开始,恰好驱逐到链尾。
一行 reversed 就把 LRU 从”时间排序”升级到”价值感知的时间排序”。整个实现零额外数据结构。这是 V1 设计优雅性的又一例证。
10.5.3 Chain-aware 的命中率收益
实测数据(RAG 场景,100 个并发,10 个共享系统提示):
| 驱逐策略 | 缓存命中率 | 有效吞吐 |
|---|---|---|
| 朴素 LRU | 62% | 1× |
| Chain-aware LRU | 81% | 1.31× |
仅仅”倒序入链”这一个改动就把命中率提升 19 个百分点——因为公共前缀再也不会被错误驱逐了。
10.6 命中率与加速比的数学关系
为了让调优有依据,定量分析前缀缓存收益。
10.6.1 命中率定义
设请求平均 prompt 长度 token(= blocks, 是 block_size),其中命中缓存的部分是 tokens( 是命中率)。
10.6.2 Prefill 加速
- 无缓存:prefill token,时间
- 有缓存:只需 prefill token
Prefill 时间正比于 prompt 长度²(compute-bound),所以:
- → 2× 加速
- → 4× 加速
- → 11× 加速
- → 100× 加速
10.6.3 TTFT 对用户体感的意义
TTFT(首 token 延迟)几乎等于 prefill 时间。对 chatbot 产品,用户感知”快”主要是 TTFT 要短。前缀缓存直接把 TTFT 砍掉 倍。
生产测量:一个 RAG chatbot,开启前缀缓存后 TTFT p50 从 850ms 降到 320ms——用户满意度直线上升。
10.6.4 四类场景的命中率估算
| 场景 | 典型前缀类型 | 前缀占比 | 预期命中率 |
|---|---|---|---|
| ChatGPT-like 开放对话 | 系统提示 | 5-10% | 低(单轮居多) |
| 客服系统 | 系统提示 + 知识库前几条 | 40-60% | 极高(80%+) |
| RAG 问答 | 检索文档 | 50-80% | 中高(60%+) |
| Code Copilot | 当前文件上下文 | 70-90% | 极高(90%+) |
| 多轮对话(长对话) | 历史对话 | 随轮数增长 | 逐渐提升到 80%+ |
生产部署前,先估算你的场景在哪类——这决定了前缀缓存能带来多大收益。
10.7 V1 为什么敢默认开启
默认开启(caching_enabled=True by default) 这个决定看起来简单,但在工程上是重大举措。V0 时代这是 opt-in 的。区别在于:
10.7.1 0% 命中率的行为保证
V1 的第一条承诺:“在工作负载完全不适合前缀缓存时(比如每个请求的 prompt 都独一无二),性能不应该比不启用更差。”
上面 10.4 节的优化全是为了这一条——把缓存逻辑的固定开销压到 < 1%,这样即使命中率是 0,用户也不会感到”启用前缀缓存反而慢了”。
10.7.2 自适应收益
“只要有一点命中,就应该有可见收益”。V1 的实现里 block 粒度的命中即使只命中 1 个 block(16 token),也能省下 16 token 的 prefill 计算——小收益累积起来也值得。
10.7.3 不需要用户思考
V0 的 opt-in 设计意味着用户需要:
- 知道前缀缓存是什么
- 评估自己的工作负载是否适合
- 知道有什么配置开关
- 决定要不要开
每一步都有摩擦。V1 的”默认开”意味着 99% 的用户不需要思考——默认配置就是最优配置。只有极少数”我就是想关掉看看裸性能”的用户需要显式 --disable-prefix-caching。
这种”优秀的默认值”是 V1 贯穿的设计哲学。同样的思路还体现在 Chunked Prefill 默认开启、CUDA Graph 默认开启、NCCL 默认启用 SHARP 等地方。好的系统让 99% 的用户什么都不配置就跑在最优路径上。
10.8 与其他系统的对比
前缀缓存不是 vLLM 独有的。横向对比:
| 系统 | 数据结构 | 匹配粒度 | 隐式/显式 | 特色 |
|---|---|---|---|---|
| vLLM | 哈希链 + HashMap | Block (16 token) | 隐式 | 零开销默认开启;extra_keys 扩展 |
| SGLang | Radix Tree | Token 级 | 隐式 | 自然支持分叉对话;tree 扩展操作原生 |
| TensorRT-LLM | Trie | Block 级 | 隐式 | 内存布局紧凑 |
| Anthropic API | 服务端显式缓存 | 用户指定 | 显式 | 用户 cache_control 标签,计费维度 |
10.8.1 vLLM 的哈希链 vs SGLang RadixAttention
SGLang 的 RadixAttention(Zheng et al., 2024)用 Radix Tree(压缩前缀树)管理缓存。主要差异:
| 特性 | vLLM 哈希链 | SGLang Radix Tree |
|---|---|---|
| 插入新请求 | O(blocks) hash | O(tokens) radix 插入 |
| 查最长前缀 | O(blocks) iter | O(tokens) tree 下降 |
| 分支/分叉支持 | 需要 COW | 原生支持(tree 自然多叉) |
| 内存开销 | 最少(每 block 1 hash) | 中等(tree 节点开销) |
| 实现复杂度 | 简单(HashMap) | 中等(self-balancing tree) |
什么时候哪个更好?
- vLLM 的哈希链简单高效,适合”大多数场景下前缀是线性延长的” —— chatbot、RAG
- SGLang 的 Radix Tree 原生支持分叉 —— 某些 beam search、tree-of-thoughts 场景更自然
实际上两者性能差距 < 10%,取舍主要在于 与各自引擎架构的契合度。
10.8.2 Anthropic Prompt Caching(显式)
Claude API 的做法是用户显式标记缓存边界:
{
"messages": [
{"role": "system", "content": [
{"type": "text", "text": "你是一个助手...", "cache_control": {"type": "ephemeral"}}
]},
{"role": "user", "content": "实际问题"}
]
}
显式方案的优点是语义清晰(用户知道哪些部分会被缓存)、计费明确(缓存命中 10× 便宜)。缺点是用户心智负担。
vLLM 选择了隐式路线——让系统自动识别前缀。这符合 vLLM”作为开源推理引擎服务开发者”的定位——开发者希望”开箱即用、无需 SDK 改动”。Anthropic 作为”闭源 API 厂商”可以合理地要求用户适配它的缓存协议。两种路线都有道理,取决于你面对的受众。
10.9 监控与调优
生产部署前缀缓存后要持续监控 3 个指标:
vllm:prefix_cache_hits_total # 累计命中次数
vllm:prefix_cache_queries_total # 累计查询次数
vllm:gpu_prefix_cache_hit_rate # 当前命中率(0-1)
10.9.1 健康曲线
好的系统前缀缓存命中率应该:
- 启动阶段:0%(缓存冷启动)
- 预热几分钟:20-50% 快速爬升
- 稳态:根据场景 30-90% 不等
- 持续稳定:不剧烈波动
不健康的表现:
- 命中率持续 < 5% 还在跑→ 工作负载不适合前缀缓存,考虑关掉省 1% 开销
- 命中率剧烈波动 30%→80%→10% → 可能 KV 池太小导致缓存频繁驱逐,加
gpu_memory_utilization - 命中率突然归零 → 可能有人在做性能测试,或 LoRA 切换频繁(每个 LoRA 破坏共享)
10.9.2 调优建议
Kvcache 预算分配:适当提高 gpu_memory_utilization(0.9 → 0.95)让 KV 池更大,能容纳更多缓存 block。
block_size:默认 16 是甜点。特殊场景(极长系统提示 > 4000 token)可以考虑 32 —— 命中判断粒度粗一点但省查表次数。
LoRA 影响评估:如果业务场景有 > 5 个 LoRA 并发,前缀缓存收益会被 extra_keys 分层。考虑 API 路由让同 LoRA 请求走同副本(保留单副本内的命中率)。
10.9.3 累计 vs 滚动窗口——两个命中率不是同一件事
Prometheus 看到的 vllm:gpu_prefix_cache_hit_rate 不是”进程启动以来的累计命中率”,而是最近 1000 个请求的滚动命中率。不区分这点就会在调优时犯错。实现在 kv_cache_utils.py:49 的 PrefixCachingMetrics 类:
class PrefixCachingMetrics:
def __init__(self, max_recent_requests: int = 1000):
self.max_recent_requests = max_recent_requests
self.aggregated_requests = 0
self.aggregated_query_total = 0
self.aggregated_query_hit = 0
self.query_queue: deque[tuple[int, int, int]] = deque()
def observe(self, stats):
self.query_queue.append((stats.requests, stats.queries, stats.hits))
self.aggregated_requests += stats.requests
self.aggregated_query_total += stats.queries
self.aggregated_query_hit += stats.hits
# 老请求挤出队列
if self.aggregated_requests > self.max_recent_requests:
old_requests, old_queries, old_hits = self.query_queue.popleft()
self.aggregated_requests -= old_requests
self.aggregated_query_total -= old_queries
self.aggregated_query_hit -= old_hits
@property
def hit_rate(self) -> float:
if self.aggregated_query_total == 0:
return 0.0
return self.aggregated_query_hit / self.aggregated_query_total
算法是一个 FIFO deque——每来一批请求的统计就入队;队里累计请求数超过 1000 时,队首的最老批次出队。hit_rate 就是当前队里 hit / query 的比值。
这带来几个运维上的反直觉行为:
1. 流量骤变时命中率会”装作慢慢变”——早上 8 点流量激增 10×,新请求里很多是没 warm 的;但 hit_rate 的分母里还保留着前一个小时的 1000 条老请求(高命中)。你会看到 hit_rate “缓慢滑落”而不是断崖式下跌。凭这个判断”工作负载变了”会比实际晚 5-10 分钟,排障时要心中有数。
2. 单次缓存清空会被”摊薄”——管理员调 reset_prefix_cache API 后缓存全空,但滚动窗口还保留着清空前的高命中数据。observe 里的 if stats.reset: self.reset() 虽然会清窗口,但清的是统计侧、不是指标侧的历史——Prometheus 的连续曲线仍保留清空前的高值,只是从 reset 这一刻开始重新积累。所以 reset_prefix_cache 后要等至少 1000 请求才能看到真实新命中率。
3. 两个截然不同的 _total 指标——prefix_cache_hits_total 和 prefix_cache_queries_total 是无窗口累计,从进程启动起单调递增。hit_rate 是滚动。这两套东西不一致是正常的:进程跑了一天、累计 query 百万、命中九十万(90% 累计命中);当前业务正在切 LoRA,最近 1000 请求 hit_rate 只有 40%。调优时永远看 hit_rate,不要看累计比值。
max_recent_requests=1000 是硬编码默认值。对 QPS 不高的部署(比如 2 qps)意味着窗口覆盖 500 秒——场景切换的响应速度会偏慢。当前代码里没有 CLI 参数调这个值,想改要在 EngineCore 里构造 PrefixCachingMetrics 时传自定义值。这是一个”95% 场景默认合理、5% 场景需要改代码”的设计取舍——社区对这个参数 tunable 化的讨论见 vllm/issues 相关 tag,未来可能开放调整。
10.9.1 实测:前缀缓存的两个核心算法都极短
§10.3 用一节讨论 get_computed_blocks、§10.5 讨论 chain-aware LRU——把这两个算法在源码里实测——
get_computed_blocks(kv_cache_manager.py:92)核心路径——
def get_computed_blocks(self, request: Request) -> tuple[list[KVCacheBlock], int]:
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
# ... prefix cache stats 累加
# 如果 request 要 prompt_logprobs 则 skip prefix caching
computed_blocks = self.specialized_manager.find_longest_cache_hit(block_hashes)
# ...
整段约 50 行——委托 specialized_manager.find_longest_cache_hit 真正做匹配。
FullAttentionManager.find_longest_cache_hit(specialized_manager.py:69-82)的”最长匹配”实测仅 10 行——
def find_longest_cache_hit(self, block_hashes: list[BlockHashType]) -> list[KVCacheBlock]:
computed_blocks: list[KVCacheBlock] = []
for block_hash in block_hashes:
if cached_block := self.block_pool.get_cached_block(block_hash):
computed_blocks.append(cached_block)
else:
break
return computed_blocks
§10.3 整一节讨论的”最长匹配”算法在源码里就是这 10 行——for 一次、命中就 append、未命中就 break——完全线性扫描、不需要任何复杂数据结构——印证 §10.2.2 “链式哈希让因果性自然终结于第一个 miss”——一旦某个 hash 没命中,链上后续的所有 hash 也必然不会命中(因为它们的因果包括了未命中的前序)——所以 break 而不是 continue 是算法正确性保证而不是优化。
两条值得记住的物理事实——
- 最长匹配只用
for + dict.get + break——零数据结构开销——前缀缓存的 “O(L) 而 L 是 block 数(不是 token 数)” 性能特征就来自这 10 行——一个 4096 token 的 prompt 用 16 token/block 切分 = 256 块、命中检查就是 256 次 dict 查找——比一次 attention 计算便宜 1000 倍——印证 §10.7.1 “0% 命中率的行为保证”是可量化的、不是承诺:成本上限就是这 256 次 dict 查找 SlidingWindowManager在同文件里另有实现(§5.9.1 实测过 specialized_manager.py 161 行)——给滑动窗口 attention 用——它的find_longest_cache_hit不能简单 break——因为滑动窗口里”被丢弃的旧 block”可能再被新窗口恢复——这是本章未涉及但 §5.9.1 已揭示的板块;vllm 用SpecializedManagerABC + 子类多态隔离这两种 attention 的差异
串联 §11.10.1 的 chunked prefill 14 行 + 本节最长匹配 10 行——V1 两大优化(chunked prefill + prefix caching)的核心算法加起来 24 行——剩下的成千上万行都是配套基础设施(hash 链构造、block pool 管理、specialized manager 多态、metrics)——再次印证 ch11 §11.10.1 已总结的 “真正高级的优化代码极少、复杂度藏在调度协议里”。
10.10 本章小结
前缀缓存是 V1 设计哲学”优秀的默认值”的最佳例证:
- 三种重复来源:系统提示、多轮对话延伸、RAG 检索片段;生产中前缀占比可达 50-90%
- 因果依赖:KV 是有向因果的,必须用链式哈希而不是简单内容哈希
- BlockHashType:hash + token_ids + extra_keys 三重校验,非密码学 hash 也够安全
- extra_keys 三类:LoRA / 多模态 / cache_salt,扩展性好
- 查找路径:hash 链构造 → 最长匹配 → refcount++ → 返回命中块
- V0→V1 的六重优化:
__slots__、惰性 hash、打包键、侵入式链表、BlockHashWithGroupId、合并 LRU - Chain-aware LRU:倒序入链让链尾先驱逐,一行
reversed带来 19 个百分点命中率提升 - 命中率-加速比公式:prefill 加速 ≈ 1/(1-h)²,h=0.7 → 11× 加速
- 默认开启:从 opt-in 到 infrastructure 的范式转移,0% 命中 <1% 开销是前提
- 横向对比:vLLM 哈希链(简单高效)、SGLang Radix Tree(分叉友好)、Anthropic 显式(计费清晰)
- 生产监控:prefix_cache_hit_rate 健康曲线 + 四类不健康表现的诊断
物理事实:前缀缓存的最长匹配算法在 specialized_manager.py:69-82 实测仅 10 行(for+dict.get+break)+ get_computed_blocks 编排 ~50 行;break 是算法正确性保证不是优化(§10.2.2 链式哈希因果终结于第一个 miss);串联 §11.10.1 chunked prefill 14 行 + 本节 10 行 = V1 两大优化核心算法 24 行。
源码导航
- 哈希链定义:
vllm/v1/core/kv_cache_utils.py(BlockHashType、hash_block_tokens、hash_request_tokens)- BlockPool 缓存机制:
vllm/v1/core/block_pool.py- KVCacheManager 查找入口:
vllm/v1/core/kv_cache_manager.py(get_computed_blocks)- Specialized 管理器(按模型架构选最长匹配策略):
vllm/v1/core/specialized_manager.py- Prometheus 指标:
vllm/v1/metrics/stats.py(PrefixCacheStats)论文
- Zheng et al., “SGLang: Efficient Execution of Structured Language Model Programs”, 2023 (arXiv:2312.07104) — RadixAttention section
- Anthropic, “Prompt Caching with Claude”, blog post 2024