先说一个我遇到过的情况。某个线上推理服务,所有请求都带着同一段两千多字的 system prompt,并发一上来,GPU 利用率看着很高,但真正对用户有用的计算没多少——大部分算力在一遍遍重复计算同一段内容的 K 和 V。很长一段时间里,团队都把这当作“正常成本”接受下来了。直到我翻 vLLM 源码时才发现,这种浪费是可以通过 Prefix Caching 消掉的,而它真正的地基,恰恰是这个系列前面讲过的 Paged KV。这篇文章是这个系列的第四篇,前面已经分别聊过 PagedAttention 的显存组织方式、BlockSpaceManager 的分配逻辑、以及调度器怎么管理 waiting/running 序列。这一篇我们把注意力放在一个问题上:相同的前缀,为什么不必再算一遍?我会先从注意力计算的因果性说起,再讲 vLLM 如何用 block 哈希把“前缀复用”变成工程现实,最后带大家走一遍命中缓存时的完整调度路径。如果你想搞清楚--enable-prefix-caching到底打开了什么,或者正在为多轮对话场景中的显存和首 token 延迟发愁,这篇应该能给你答案。1. 前缀重复计算:最容易被误判为“刚需”的成本1.1 一个反直觉的结论:Attention 让前缀计算变成了纯粹的重复劳动大模型推理中,凡是 decode-only 的 Transformer 架构,都逃不掉一个事实:第 i 个 token 的注意力计算只会看到第 1 到第 i 个 token,这是由 causal mask 决定的。这意味着什么?意味着在模型参数固定、推理模式确定的情况下,给定同样的前缀 token 序列,算出来的第 i 个 token 的 Key 和 Value 向量是完全确定、没有任何随机性的。听上去像废话,但背后有个容易被忽略的推论:如果两个请求的前 100 个 token 完全一样,那么这 100 个 token 对应的 K、V 就应该一模一样。既然一模一样,那就没有理由为第二个请求重新算一遍。我们平时说“算力浪费”,很多时候是在说矩阵乘法太慢、显存不够,但前缀场景下的浪费更隐蔽:它做的是精确的、可预期的、每次都在复现的重复计算。从成本上看这种浪费不可小觑。一个 batch 里塞了 16 个请求,如果每个请求都有 1500 token 的公共前缀,那等于这个 batch 里每算一个 token,都有 15 份计算在重复别人已经算过的东西。TTFT(首 token 延迟)被拖长,batch 能容纳的有效请求数量变小,GPU 的 FLOPs 利用率看着高,有效产出却很低。1.2 为什么这种浪费长期被容忍这个问题在系统里长期存在,主要原因是“显式缓存 KV”这件事在工程上不好做。KV Cache 是推理过程中动态增长的中间状态,每个请求的前缀长度不一样,前缀之间的相似关系也五花八门。想缓存它,你得解决几个问题:缓存的单位是什么?怎么判断两个前缀是否相同?缓存的 KV 放在哪块显存?谁来负责回收?这几个问题不解决,缓存逻辑做出来只会比重算更慢。vLLM 给出的答案,不是设计一个复杂的“前缀树 + KV 复制”系统,而是先通过 Paged KV 把 KV Cache 变成可寻址的 block,然后在这个基础上,用哈希来识别重复前缀,用引用计数来共享物理 block。这两个机制加在一起,才是 Prefix Caching 的完整形态。我强烈建议不要跳过第三篇直接看这篇,因为 Prefix Caching 里的几乎每个关键操作,都是在 Paged KV 的 block 语义上展开的。2. 因果注意力下的确定性:前缀缓存成立的数学前提2.1 从 Transformer 前向过程看“为什么一定相同”我们简化看一个 token 在 Transformer 中的前向路径。输入 tokenx_i经过 embedding 之后得到向量,然后经过若干层 self-attention 和 FFN。Self-attention 那一层,x_i的 Query 由自身决定,而 Key、Value 虽然也由自身产生,但注意力汇聚时只用到1..i位置的 K、V。因果 mask 锁死了信息流的方向,所以 position i 的输出是x_1..x_i的确定性函数,不依赖任何未来 token。这样递推下去,前 i 个 token 完全相同,则每一层每个位置上的中间激活都相同,K、V 当然也相同。这个性质是 Prefix Caching 的立论根基。它非常朴素,但你一旦意识到它成立,后面所有工程机制都只是在回答一个操作性问题:怎么让这些必然相同的 K、V 只算一次?从数值角度补一句:这个“必然相同”成立的前提是模型处于 eval 模式、没有 dropout 随机性,并且模型权重固定。vLLM 默认就是推理模式,权重在一次加载后也不会变,所以这个前提在日常使用中是自动满足的。凡是做 LoRA 动态切换、或者热更新权重的场景,前缀缓存能不能跨权重版本复用,答案是否定的——权重一变,同样的前缀算出来的 K、V 就变了,缓存必须失效,这一点要心里有数。2.2 相同 token 序列,但不一定是“文本相同”这里有个容易被忽略的点:前缀缓存比较的是token IDs,不是文本。两个请求在文本层面看着都是“你好,请帮我总结一下”,但如果 prompt 里夹杂了不同的特殊 token、换了apply_chat_template的版本、或者在同一个位置用了不同的空格数量,tokenize 出来的 IDs 序列可能就不一致,前缀缓存直接不命中。我在实际项目里就踩过一次这类坑:两边都说是同样的 system prompt,结果有一方在前面的问题里多了一个endoftext之类的隐式 token,整个前缀从第一个 block 开始就全错位了,命中率长期为零,看起来就像缓存没生效。排查办法很简单,把两个请求的 token IDs 打出来对比前 16 个;但一开始压根没想到这个层面。2.3 不止跨请求:同一条请求内部的多个采样序列也能共享很多人以为 Prefix Caching 只是“跨请求复用公共 system prompt”,其实它还能处理同一个请求内部的公共前缀。比如n=2做并行采样,或者 beam search 时从同一个分叉点延伸出的多条序列,它们的公共前缀部分是天然共享的。这个能力在源码里表现为 BlockSpaceManager 的fork语义:一条 sequence 在某个 block 处派生出一个兄弟序列,兄弟序列的物理 block 引用计数加一,而不是重新分配、重新计算。后面第五部分走读调度路径时会看到,其实“派生”和“前缀命中”在底层用的是同一套 block 共享机制。3. Paged KV 的 block 化管理:Prefix Caching 的真正地基3.1 回顾:KV Cache 为什么非要用 block 不可现在的模型推理框架里,KV Cache 一般是一大块连续显存按最大长度预分配的。但不同请求的实际长度差异很大,如果每个请求都按最大长度预留,显存碎片和浪费会很严重。vLLM 的 PagedAttention 把 KV Cache 切成固定大小的 block,默认一个 block 放 16 个 token 的 K、V,物理上分散存放,逻辑上通过 block table 串起来。序列变长时按需分配新 block,序列结束时 block 回到池子里复用。这个机制和操作系统里的分页思想几乎一一对应:逻辑上连续 KV Cache 可以映射到任意物理位置,block 越小,碎片越小,但管理开销越大。vLLM 默认 block_size = 16,就是在这两者之间取的平衡点。3.2 block 天然就是“缓存复用”的最小语义单位讲清楚一个关键点:没有 block 化,前缀缓存是没法做精细的。假如 KV Cache 是整段连续分配的,想复用一段公共前缀,要么整段复制过来(开销大),要么把“这段缓存属于谁”的账本做得极其复杂。而 block 化之后,每个 block 是一个独立的、可寻址、可引用、可释放的实体。前缀长 100 个 token,就是 6.25 个 block,其中一个 block 相同、其余不同,也没关系,我们只在 block 粒度上决定哪些共享、哪些新建。引用计数机制在这个粒度上工作得很好。每个物理 block 有一个ref_count,当多个序列的逻辑 block 指向同一个物理 block 时,引用计数就是几。序列结束时逐个free,引用计数减到 0 时物理 block 才真正归还到池子里。这个机制在第三篇讲fork的时候已经出现过,Prefix Caching 只是把它从“同一个请求内多序列共享”扩展成了“跨请求共享”。3.3 为什么缓存单位不能是 token从数学上,按 token 粒度缓存也能做,但工程上成本太高。每个 token 都要在公共池里查一次是否已有相同内容,KV Cache 的查找、引用、释放粒度都会细到单个 token,调度器的 bookkeeping 开销会压过收益。block 粒度相当于把 16 次查找合并成一次,代价是缓存对齐的颗粒度变粗——前缀必须是 16 的整数倍 block 才能完整命中。这个取舍没有绝对对错,但 vLLM 选择了 block 粒度,所以你在设计 prompt 时也要往这个粒度靠。后面第六部分我会专门讲怎么“凑 block”。4. 哈希哈希还是哈希:vLLM 怎么识别“这个 block 我见过”4.1 哈希比较的常见误区:这不仅是“内容查重”要判断两个 block 内容是否相同,最简单的办法是对 block 内的 token IDs 算一个哈希值,然后把这个哈希当成 key,去一个全局字典里查。听起来很像内容寻址存储,但源码实现里有个容易看漏的细节:vLLM 算的不是单个 block 的哈希,而是“从开头到当前 block 的完整前缀”的哈希,并且在 key 里额外拼上了前一个 block 的哈希。为什么必须这么做?因为 KV 的计算不仅依赖 token 内容,还依赖 token 的位置。位置一变化,RoPE 旋转角度就变化,同一个 block 的 K、V 就不一样了。如果只按 single block 内容算哈希,两个请求在完全不同的位置出现了相同的一段 token,哈希会相同,但 KV 实际不同,复用就错了。把前序 block 哈希串成一条链,相当于保证了“哈希命中的同时,位置信息也完全一致”。所以严格说,这个机制应该叫 Prefix Caching,而不是 Block Caching——它缓存的是以每个 block 结尾的前缀,而不是孤立的 block。4.2 mutable 还是 immutable:block 在什么时候才有资格进缓存继续往源码里走。每个 block 在 vLLM 里有两种状态:mutable(可变、可追加 token)和 immutable(不可变、内容固定)。一个刚分配出来、正在写 token 的 block 是 mutable 的;只有被写满 16 个 token、内容不再变化之后,它才会被提升为 immutable。这里就有推动整个缓存机制的关键函数:promote_last_accessed_block_to_immutable。当一个 mutable block 写满后,计算它的前缀哈希,然后去全局缓存表_cached_blocks里查:如果表中没有这个哈希,说明当前 block 是第一个产生该前缀的 block,于是把它注册到表里,状态变为 immutable;如果表里已经有相同哈希,说明这块内容以前算过,那当前这个新 block 就没有保留价值了,把它的物理 block 释放掉,让逻辑 block 直接指向缓存表里的那个物理 block。这个过程在源码里看起来像“检测到重复就退货”,实际操作中它会同时处理引用计数和物理 block 池,所以比口头描述多一些簿记工作,但核心逻辑就是这么简单。我没有贴完整源码,但建议你看vllm/core/block/prefix_caching_block.py里PrefixCachingBlockAllocator的这两个方法:allocate_immutable_block和promote_last_accessed_block_to_immutable。前者是“已知这块内容以前算过,直接引用”,后者是“刚写满发现重复,现场换个引用”。把这两个方法看懂,Prefix Caching 的核心就读懂一大半了。4.3 计算哈希的数据结构:HashableSequence哈希计算的数据结构也要看一眼。vLLM 在vllm/sequence.py里维护了一个HashableSequence之类的结构,它把一个 block 内的 token IDs 转成 tuple,再拼上额外的上下文哈希,整体传给学生内置的hash()。这里有个工程思考:为什么不用密码学哈希?因为这里只需要进程内比较,不要求跨进程可复现,更不需要防碰撞攻击。Python 内置哈希已经足够快,碰撞概率在实际负载下可以忽略。vLLM 也特意把哈希限制在进程内有效——这决定了默认形态下,多副本部署时不同进程之间不能共享前缀缓存,这是一个需要你在架构层面记住的边界。4.4 额外哈希:防止“看起来一样但路径不同”的错误复用源码里计算哈希时除了 token IDs,还会拼上extra_hashes。这个设计初看很反直觉:token IDs 完全一样,KV 就该一样,为什么要额外区分?原因在于某些场景下,相同 token 序列可能来自不同的“上下文路径”,比如 speculative decoding 的推测序列与真实序列、beam search 中不同父节点的分支。为了避免特定场景下的误复用,vLLM 把这些上下文信息也混进哈希。这里不展开细节,但你排查“为什么哈希没命中”时,可以把 extra hash 作为一个排查方向,不要只看 token IDs。4.5 一个更直观的类比可以用打印来做类比。假设你打印一份文件,排版引擎知道:“如果前 16 个字和之前某份文件的前 16 个字一样,那么这一页不用重新排,直接调用旧版印刷模板。” 系统为每 16 个字生成一个指纹,并维护“指纹 -> 印刷母版”的目录。来了新文件,也只按 16 个字往后切,每切一刀就先查目录。查到了,这一页直接复刷;查不到,排一版新的并登记进目录。这就是 vLLM 里发生的事情。每 16 个 token 算一个指纹,指纹匹配 = 不用重算,直接复用物理 KV block。5. 源码走读:调度器里的一次前缀命中完整旅程5.1 请求进场:can_allocate的初筛当一个新 prompt 请求被调度器选中时,第一件事是给它的 KV Cache 规划物理 block。调度器会调用 BlockSpaceManager 的能力评估can_allocate(seq, num_tokens),看显存池里能不能装下这批 block。但注意,这里“装下”和普通请求不同:如果请求带有一长串可命中缓存的前缀,这部分不需要新的物理 block,只需要复用已经存在的物理 block,并让引用计数加一。所以 Prefix Caching 真正节省的是新增物理 block 的数量,这个结论在观察显存水位时很有用——不是所有序列都进来就吃大量显存。5.2 定位公共前缀:_allocate_common的“浅拷贝”式接续进了分配阶段后,关键逻辑在CommonPrefixBlockManager(旧版本里是BlockSpaceManagerV1/V2的对应分支)的 allocate 流程里。它分成两步:先分配公共前缀部分,再分配新增长部分。公共前缀分配的核心是遍历序列已有的哈希链:从第 0 个 block 开始,逐个比较新请求将要生成的 token 序列与前缀缓存中记录的 block 哈希。每次命中,就把对应的物理 block 引用一次,并把当前逻辑位置往后推进。直到某个位置的哈希不再命中,或者公共前缀已经覆盖完当前请求的需求,才停下。这个过程很像“浅拷贝”:不复制 KV 内容,只是让新序列的逻辑 block 指向缓存里的物理 block。等公共前缀部分接好之后,剩下的 token 才走常规分配路径,按需分配新的 mutable block,逐个写入 token。有一点值得强调:vLLM 不会因为某个请求的开头前 3 个 block 命中了缓存,就假定后面 100 个 block 都能命中。哈希链的匹配顺序是严格从前往后的,中间断了,后面即使有和缓存里一模一样的 block 也不会去复用。这是正确性设计,不是保守——之前说过,位置信息在哈希链里是隐含的,跳过中间 block 会破坏位置对应关系。5.3 slot_mapping 生成:复用 block 如何落到模型执行层分配完成后,模型执行层拿到的是逻辑到物理的映射,也就是slot_mapping。每一个 KV Cache slot 都会在这里记录它的物理地址。对命中缓存的 block,slot_mapping 直接指向共享物理 block 对应的地址;对新建 block,则指向新分配的物理地址。真正执行 PagedAttention kernel 时,这些映射会被传给 GPU。kernel 对每个 token 做 attention 计算时,只需要按照当前的因果掩码去读取“当前位置之前”的 K、V,而共享前缀 block 对多个序列来说都是只读的。所以共享并不会造成数据写冲突:新生成的 token 只会写入当前序列自己的新 block,不会去改写共享的前缀 block。这里还有个容易忽略的细节:attention 计算时每个序列能看到的“上下文长度”不同,所以 kernel 还要拿到一个 context length 的列表,用于告诉 GPU 哪些复用的 slot 是有效的。源码里这些信息会打包进attn_metadata,在每次前向时传给模型。5.4 推理完成:缓存写入、引用计数与逐出一个请求跑完之后,它的 sequence 对象被销毁或清理。此时复用过的缓存 block 引用计数会减一,但不会立即释放,因为它们仍躺在全局缓存字典里,等待下一个相同前缀的请求来命中。真正决定它们去留的是显存压力。当显存不足以分配新 block 时,BlockAllocator 会触发逐出机制,从缓存字典里挑一些 block 清理掉,释放显存给新请求。vLLM 的逐出器不是简单的一刀切,而是区分“普通 pool 的 block”和“前缀缓存 block”:被逐出的缓存 block 会从_cached_blocks字典里移除,之后再有相同前缀的请求进来,只能老老实实重算。这种设计保证了缓存不会无限膨胀,但也意味着命中率是动态的——显存越紧张,缓存越容易被打出去,命中率越低,形成一种很微妙的反馈回路。6. 上线前必须先知道的边界与调优经验6.1 命中条件的边界感:对齐、token 一致、进程内前缀缓存不是“打开开关就万事大吉”,它的命中需要满足几个硬条件:前缀长度至少是 16 的整数倍。这是 block 粒度的直接后果。如果你的公共前缀是 20 个 token,那么只有前 16 个 token 能命中缓存,剩下 4 个仍要重算;实际优化效果会打折扣。token IDs 完全一致,文本层面看起来“一样”不算数,前面说过apply_chat_template版本变化、隐藏空格、额外分隔符都会破坏命中。多副本部署时,缓存不跨进程共享。同一台 GPU 上如果起了多个 vLLM 进程,或者做了多实例水平扩展,各个进程的前缀缓存彼此独立。这是默认实现决定的,分布式前缀缓存属于额外能力,不在这篇讨论范围内。6.2 显存账本:缓存本身也要占地方有人会以为前缀缓存是“白赚”的显存优化,其实不是。缓存 block 仍然占据显存,只是它的“复用性”更好。同样的显存总量下,启用前缀缓存后能承载的有效序列可能变多,但如果负载中公共前缀很少,缓存 block 就会变成纯粹的显存占用,甚至可能因为过度逐出导致 hit rate 反而下降。我通常建议看两个指标:prefix cache hit rate 和平均每个请求重算的前缀 token 数。如果命中率很低,先别怀疑实现,去检查前缀长度是否对齐、token 是否一致、显存是否频繁触发逐出。在这三个方向里找出路,比调 block_size 更有效。6.3 三条可落地的调优方向结合源码逻辑,我总结三条实际调优路径:第一条:把公共内容放在 prompt 最前面。因为缓存只认前缀,公共 system prompt 如果被塞到中间,哪怕内容相同也无法命中。这是 prompt 结构设计层面最立竿见影的一招。第二条:让公共部分尽量按 16 token 对齐。block_size 默认是 16,如果你能控制公共提示的长度落在 16 的整数倍,比如 128、256、512 token,那么整段公共前缀都能完整命中缓存,不会出现“差几个 token 导致最后一块部分命中”的尴尬。实际操作中完全对齐很难,但尽量对齐到 16 的倍数附近,效果确实不一样。第三条:对多轮对话做“历史前缀折叠”。这类场景下,每一轮新请求的 prompt 通常由历史消息拼接而成,历史越长,公共前缀占比越高。如果聊天模板生成的 token 序列在每轮之间是稳定追加的,那么新请求自然能命中大量历史前缀。反过来,如果模板会在每次请求时重新渲染并引入时间戳、随机 ID 之类的不稳定 token,命中率就会断崖式下跌。检查聊天模板里有没有不稳定字段,往往是收益最大的优化动作。6.4 关于数据安全的一个说明前缀缓存机制本身是否会泄露不同用户之间的数据?理论上不会。两个 block 的哈希完全相同,前提两边 token IDs 完全一致;你不可能通过构造 prompt 去“试探”别人的私有内容,因为你必须先拥有完全相同的 token 序列才能命中。所以它在多租户场景下没有引入额外的隐私风险,这一点可以放心,不用在架构评审时过度担心。7. 写在最后:一次调优经验最后分享一个我自己的体会。刚开始接触 Prefix Caching 时,我也天真地以为它就是把 system prompt 缓存起来,打开开关就完事。真正读完源码才发现,决定命中率高低的往往不是“缓存开关”,而是 prompt 的对齐方式和 token 稳定性。后来我在一个模拟项目里做了一次改造:把公共 system prompt 固定为 512 token 的稳定模板,并把动态内容统一追加在末尾,确保前 512 个 token 完全不变;同时把聊天模板里会变化的字段从渲染结果中挪走。效果很直接:前缀命中率从不到 30% 涨到 85% 以上,首 token 延迟平均降了大约两成。如果你也在调这个功能,我的建议是先做一次“token 序列画像”,把线上请求的前 64 个 token 拉出来看一遍,你会发现很多自以为相同的请求,在 token 层面压根不一样。任何优化都必须先过这一关,否则后面的缓存命中率和显存收益都会跑偏。
先说一个我遇到过的情况。某个线上推理服务,所有请求都带着同一段两千多字的 system prompt,并发一上来,GPU 利用率看着很高,但真正对用户有用的计算没多少——大部分算力在一遍遍重复计算同一段内容的 K 和 V。很长一段时间里,团队都把这当作“正常成本”接受下来了。直到我翻 vLLM 源码时才发现,这种浪费是可以通过 Prefix Caching 消掉的,而它真正的地基,恰恰是这个系列前面讲过的 Paged KV。这篇文章是这个系列的第四篇,前面已经分别聊过 PagedAttention 的显存组织方式、BlockSpaceManager 的分配逻辑、以及调度器怎么管理 waiting/running 序列。这一篇我们把注意力放在一个问题上:相同的前缀,为什么不必再算一遍?我会先从注意力计算的因果性说起,再讲 vLLM 如何用 block 哈希把“前缀复用”变成工程现实,最后带大家走一遍命中缓存时的完整调度路径。如果你想搞清楚--enable-prefix-caching到底打开了什么,或者正在为多轮对话场景中的显存和首 token 延迟发愁,这篇应该能给你答案。1. 前缀重复计算:最容易被误判为“刚需”的成本1.1 一个反直觉的结论:Attention 让前缀计算变成了纯粹的重复劳动大模型推理中,凡是 decode-only 的 Transformer 架构,都逃不掉一个事实:第 i 个 token 的注意力计算只会看到第 1 到第 i 个 token,这是由 causal mask 决定的。这意味着什么?意味着在模型参数固定、推理模式确定的情况下,给定同样的前缀 token 序列,算出来的第 i 个 token 的 Key 和 Value 向量是完全确定、没有任何随机性的。听上去像废话,但背后有个容易被忽略的推论:如果两个请求的前 100 个 token 完全一样,那么这 100 个 token 对应的 K、V 就应该一模一样。既然一模一样,那就没有理由为第二个请求重新算一遍。我们平时说“算力浪费”,很多时候是在说矩阵乘法太慢、显存不够,但前缀场景下的浪费更隐蔽:它做的是精确的、可预期的、每次都在复现的重复计算。从成本上看这种浪费不可小觑。一个 batch 里塞了 16 个请求,如果每个请求都有 1500 token 的公共前缀,那等于这个 batch 里每算一个 token,都有 15 份计算在重复别人已经算过的东西。TTFT(首 token 延迟)被拖长,batch 能容纳的有效请求数量变小,GPU 的 FLOPs 利用率看着高,有效产出却很低。1.2 为什么这种浪费长期被容忍这个问题在系统里长期存在,主要原因是“显式缓存 KV”这件事在工程上不好做。KV Cache 是推理过程中动态增长的中间状态,每个请求的前缀长度不一样,前缀之间的相似关系也五花八门。想缓存它,你得解决几个问题:缓存的单位是什么?怎么判断两个前缀是否相同?缓存的 KV 放在哪块显存?谁来负责回收?这几个问题不解决,缓存逻辑做出来只会比重算更慢。vLLM 给出的答案,不是设计一个复杂的“前缀树 + KV 复制”系统,而是先通过 Paged KV 把 KV Cache 变成可寻址的 block,然后在这个基础上,用哈希来识别重复前缀,用引用计数来共享物理 block。这两个机制加在一起,才是 Prefix Caching 的完整形态。我强烈建议不要跳过第三篇直接看这篇,因为 Prefix Caching 里的几乎每个关键操作,都是在 Paged KV 的 block 语义上展开的。2. 因果注意力下的确定性:前缀缓存成立的数学前提2.1 从 Transformer 前向过程看“为什么一定相同”我们简化看一个 token 在 Transformer 中的前向路径。输入 tokenx_i经过 embedding 之后得到向量,然后经过若干层 self-attention 和 FFN。Self-attention 那一层,x_i的 Query 由自身决定,而 Key、Value 虽然也由自身产生,但注意力汇聚时只用到1..i位置的 K、V。因果 mask 锁死了信息流的方向,所以 position i 的输出是x_1..x_i的确定性函数,不依赖任何未来 token。这样递推下去,前 i 个 token 完全相同,则每一层每个位置上的中间激活都相同,K、V 当然也相同。这个性质是 Prefix Caching 的立论根基。它非常朴素,但你一旦意识到它成立,后面所有工程机制都只是在回答一个操作性问题:怎么让这些必然相同的 K、V 只算一次?从数值角度补一句:这个“必然相同”成立的前提是模型处于 eval 模式、没有 dropout 随机性,并且模型权重固定。vLLM 默认就是推理模式,权重在一次加载后也不会变,所以这个前提在日常使用中是自动满足的。凡是做 LoRA 动态切换、或者热更新权重的场景,前缀缓存能不能跨权重版本复用,答案是否定的——权重一变,同样的前缀算出来的 K、V 就变了,缓存必须失效,这一点要心里有数。2.2 相同 token 序列,但不一定是“文本相同”这里有个容易被忽略的点:前缀缓存比较的是token IDs,不是文本。两个请求在文本层面看着都是“你好,请帮我总结一下”,但如果 prompt 里夹杂了不同的特殊 token、换了apply_chat_template的版本、或者在同一个位置用了不同的空格数量,tokenize 出来的 IDs 序列可能就不一致,前缀缓存直接不命中。我在实际项目里就踩过一次这类坑:两边都说是同样的 system prompt,结果有一方在前面的问题里多了一个endoftext之类的隐式 token,整个前缀从第一个 block 开始就全错位了,命中率长期为零,看起来就像缓存没生效。排查办法很简单,把两个请求的 token IDs 打出来对比前 16 个;但一开始压根没想到这个层面。2.3 不止跨请求:同一条请求内部的多个采样序列也能共享很多人以为 Prefix Caching 只是“跨请求复用公共 system prompt”,其实它还能处理同一个请求内部的公共前缀。比如n=2做并行采样,或者 beam search 时从同一个分叉点延伸出的多条序列,它们的公共前缀部分是天然共享的。这个能力在源码里表现为 BlockSpaceManager 的fork语义:一条 sequence 在某个 block 处派生出一个兄弟序列,兄弟序列的物理 block 引用计数加一,而不是重新分配、重新计算。后面第五部分走读调度路径时会看到,其实“派生”和“前缀命中”在底层用的是同一套 block 共享机制。3. Paged KV 的 block 化管理:Prefix Caching 的真正地基3.1 回顾:KV Cache 为什么非要用 block 不可现在的模型推理框架里,KV Cache 一般是一大块连续显存按最大长度预分配的。但不同请求的实际长度差异很大,如果每个请求都按最大长度预留,显存碎片和浪费会很严重。vLLM 的 PagedAttention 把 KV Cache 切成固定大小的 block,默认一个 block 放 16 个 token 的 K、V,物理上分散存放,逻辑上通过 block table 串起来。序列变长时按需分配新 block,序列结束时 block 回到池子里复用。这个机制和操作系统里的分页思想几乎一一对应:逻辑上连续 KV Cache 可以映射到任意物理位置,block 越小,碎片越小,但管理开销越大。vLLM 默认 block_size = 16,就是在这两者之间取的平衡点。3.2 block 天然就是“缓存复用”的最小语义单位讲清楚一个关键点:没有 block 化,前缀缓存是没法做精细的。假如 KV Cache 是整段连续分配的,想复用一段公共前缀,要么整段复制过来(开销大),要么把“这段缓存属于谁”的账本做得极其复杂。而 block 化之后,每个 block 是一个独立的、可寻址、可引用、可释放的实体。前缀长 100 个 token,就是 6.25 个 block,其中一个 block 相同、其余不同,也没关系,我们只在 block 粒度上决定哪些共享、哪些新建。引用计数机制在这个粒度上工作得很好。每个物理 block 有一个ref_count,当多个序列的逻辑 block 指向同一个物理 block 时,引用计数就是几。序列结束时逐个free,引用计数减到 0 时物理 block 才真正归还到池子里。这个机制在第三篇讲fork的时候已经出现过,Prefix Caching 只是把它从“同一个请求内多序列共享”扩展成了“跨请求共享”。3.3 为什么缓存单位不能是 token从数学上,按 token 粒度缓存也能做,但工程上成本太高。每个 token 都要在公共池里查一次是否已有相同内容,KV Cache 的查找、引用、释放粒度都会细到单个 token,调度器的 bookkeeping 开销会压过收益。block 粒度相当于把 16 次查找合并成一次,代价是缓存对齐的颗粒度变粗——前缀必须是 16 的整数倍 block 才能完整命中。这个取舍没有绝对对错,但 vLLM 选择了 block 粒度,所以你在设计 prompt 时也要往这个粒度靠。后面第六部分我会专门讲怎么“凑 block”。4. 哈希哈希还是哈希:vLLM 怎么识别“这个 block 我见过”4.1 哈希比较的常见误区:这不仅是“内容查重”要判断两个 block 内容是否相同,最简单的办法是对 block 内的 token IDs 算一个哈希值,然后把这个哈希当成 key,去一个全局字典里查。听起来很像内容寻址存储,但源码实现里有个容易看漏的细节:vLLM 算的不是单个 block 的哈希,而是“从开头到当前 block 的完整前缀”的哈希,并且在 key 里额外拼上了前一个 block 的哈希。为什么必须这么做?因为 KV 的计算不仅依赖 token 内容,还依赖 token 的位置。位置一变化,RoPE 旋转角度就变化,同一个 block 的 K、V 就不一样了。如果只按 single block 内容算哈希,两个请求在完全不同的位置出现了相同的一段 token,哈希会相同,但 KV 实际不同,复用就错了。把前序 block 哈希串成一条链,相当于保证了“哈希命中的同时,位置信息也完全一致”。所以严格说,这个机制应该叫 Prefix Caching,而不是 Block Caching——它缓存的是以每个 block 结尾的前缀,而不是孤立的 block。4.2 mutable 还是 immutable:block 在什么时候才有资格进缓存继续往源码里走。每个 block 在 vLLM 里有两种状态:mutable(可变、可追加 token)和 immutable(不可变、内容固定)。一个刚分配出来、正在写 token 的 block 是 mutable 的;只有被写满 16 个 token、内容不再变化之后,它才会被提升为 immutable。这里就有推动整个缓存机制的关键函数:promote_last_accessed_block_to_immutable。当一个 mutable block 写满后,计算它的前缀哈希,然后去全局缓存表_cached_blocks里查:如果表中没有这个哈希,说明当前 block 是第一个产生该前缀的 block,于是把它注册到表里,状态变为 immutable;如果表里已经有相同哈希,说明这块内容以前算过,那当前这个新 block 就没有保留价值了,把它的物理 block 释放掉,让逻辑 block 直接指向缓存表里的那个物理 block。这个过程在源码里看起来像“检测到重复就退货”,实际操作中它会同时处理引用计数和物理 block 池,所以比口头描述多一些簿记工作,但核心逻辑就是这么简单。我没有贴完整源码,但建议你看vllm/core/block/prefix_caching_block.py里PrefixCachingBlockAllocator的这两个方法:allocate_immutable_block和promote_last_accessed_block_to_immutable。前者是“已知这块内容以前算过,直接引用”,后者是“刚写满发现重复,现场换个引用”。把这两个方法看懂,Prefix Caching 的核心就读懂一大半了。4.3 计算哈希的数据结构:HashableSequence哈希计算的数据结构也要看一眼。vLLM 在vllm/sequence.py里维护了一个HashableSequence之类的结构,它把一个 block 内的 token IDs 转成 tuple,再拼上额外的上下文哈希,整体传给学生内置的hash()。这里有个工程思考:为什么不用密码学哈希?因为这里只需要进程内比较,不要求跨进程可复现,更不需要防碰撞攻击。Python 内置哈希已经足够快,碰撞概率在实际负载下可以忽略。vLLM 也特意把哈希限制在进程内有效——这决定了默认形态下,多副本部署时不同进程之间不能共享前缀缓存,这是一个需要你在架构层面记住的边界。4.4 额外哈希:防止“看起来一样但路径不同”的错误复用源码里计算哈希时除了 token IDs,还会拼上extra_hashes。这个设计初看很反直觉:token IDs 完全一样,KV 就该一样,为什么要额外区分?原因在于某些场景下,相同 token 序列可能来自不同的“上下文路径”,比如 speculative decoding 的推测序列与真实序列、beam search 中不同父节点的分支。为了避免特定场景下的误复用,vLLM 把这些上下文信息也混进哈希。这里不展开细节,但你排查“为什么哈希没命中”时,可以把 extra hash 作为一个排查方向,不要只看 token IDs。4.5 一个更直观的类比可以用打印来做类比。假设你打印一份文件,排版引擎知道:“如果前 16 个字和之前某份文件的前 16 个字一样,那么这一页不用重新排,直接调用旧版印刷模板。” 系统为每 16 个字生成一个指纹,并维护“指纹 -> 印刷母版”的目录。来了新文件,也只按 16 个字往后切,每切一刀就先查目录。查到了,这一页直接复刷;查不到,排一版新的并登记进目录。这就是 vLLM 里发生的事情。每 16 个 token 算一个指纹,指纹匹配 = 不用重算,直接复用物理 KV block。5. 源码走读:调度器里的一次前缀命中完整旅程5.1 请求进场:can_allocate的初筛当一个新 prompt 请求被调度器选中时,第一件事是给它的 KV Cache 规划物理 block。调度器会调用 BlockSpaceManager 的能力评估can_allocate(seq, num_tokens),看显存池里能不能装下这批 block。但注意,这里“装下”和普通请求不同:如果请求带有一长串可命中缓存的前缀,这部分不需要新的物理 block,只需要复用已经存在的物理 block,并让引用计数加一。所以 Prefix Caching 真正节省的是新增物理 block 的数量,这个结论在观察显存水位时很有用——不是所有序列都进来就吃大量显存。5.2 定位公共前缀:_allocate_common的“浅拷贝”式接续进了分配阶段后,关键逻辑在CommonPrefixBlockManager(旧版本里是BlockSpaceManagerV1/V2的对应分支)的 allocate 流程里。它分成两步:先分配公共前缀部分,再分配新增长部分。公共前缀分配的核心是遍历序列已有的哈希链:从第 0 个 block 开始,逐个比较新请求将要生成的 token 序列与前缀缓存中记录的 block 哈希。每次命中,就把对应的物理 block 引用一次,并把当前逻辑位置往后推进。直到某个位置的哈希不再命中,或者公共前缀已经覆盖完当前请求的需求,才停下。这个过程很像“浅拷贝”:不复制 KV 内容,只是让新序列的逻辑 block 指向缓存里的物理 block。等公共前缀部分接好之后,剩下的 token 才走常规分配路径,按需分配新的 mutable block,逐个写入 token。有一点值得强调:vLLM 不会因为某个请求的开头前 3 个 block 命中了缓存,就假定后面 100 个 block 都能命中。哈希链的匹配顺序是严格从前往后的,中间断了,后面即使有和缓存里一模一样的 block 也不会去复用。这是正确性设计,不是保守——之前说过,位置信息在哈希链里是隐含的,跳过中间 block 会破坏位置对应关系。5.3 slot_mapping 生成:复用 block 如何落到模型执行层分配完成后,模型执行层拿到的是逻辑到物理的映射,也就是slot_mapping。每一个 KV Cache slot 都会在这里记录它的物理地址。对命中缓存的 block,slot_mapping 直接指向共享物理 block 对应的地址;对新建 block,则指向新分配的物理地址。真正执行 PagedAttention kernel 时,这些映射会被传给 GPU。kernel 对每个 token 做 attention 计算时,只需要按照当前的因果掩码去读取“当前位置之前”的 K、V,而共享前缀 block 对多个序列来说都是只读的。所以共享并不会造成数据写冲突:新生成的 token 只会写入当前序列自己的新 block,不会去改写共享的前缀 block。这里还有个容易忽略的细节:attention 计算时每个序列能看到的“上下文长度”不同,所以 kernel 还要拿到一个 context length 的列表,用于告诉 GPU 哪些复用的 slot 是有效的。源码里这些信息会打包进attn_metadata,在每次前向时传给模型。5.4 推理完成:缓存写入、引用计数与逐出一个请求跑完之后,它的 sequence 对象被销毁或清理。此时复用过的缓存 block 引用计数会减一,但不会立即释放,因为它们仍躺在全局缓存字典里,等待下一个相同前缀的请求来命中。真正决定它们去留的是显存压力。当显存不足以分配新 block 时,BlockAllocator 会触发逐出机制,从缓存字典里挑一些 block 清理掉,释放显存给新请求。vLLM 的逐出器不是简单的一刀切,而是区分“普通 pool 的 block”和“前缀缓存 block”:被逐出的缓存 block 会从_cached_blocks字典里移除,之后再有相同前缀的请求进来,只能老老实实重算。这种设计保证了缓存不会无限膨胀,但也意味着命中率是动态的——显存越紧张,缓存越容易被打出去,命中率越低,形成一种很微妙的反馈回路。6. 上线前必须先知道的边界与调优经验6.1 命中条件的边界感:对齐、token 一致、进程内前缀缓存不是“打开开关就万事大吉”,它的命中需要满足几个硬条件:前缀长度至少是 16 的整数倍。这是 block 粒度的直接后果。如果你的公共前缀是 20 个 token,那么只有前 16 个 token 能命中缓存,剩下 4 个仍要重算;实际优化效果会打折扣。token IDs 完全一致,文本层面看起来“一样”不算数,前面说过apply_chat_template版本变化、隐藏空格、额外分隔符都会破坏命中。多副本部署时,缓存不跨进程共享。同一台 GPU 上如果起了多个 vLLM 进程,或者做了多实例水平扩展,各个进程的前缀缓存彼此独立。这是默认实现决定的,分布式前缀缓存属于额外能力,不在这篇讨论范围内。6.2 显存账本:缓存本身也要占地方有人会以为前缀缓存是“白赚”的显存优化,其实不是。缓存 block 仍然占据显存,只是它的“复用性”更好。同样的显存总量下,启用前缀缓存后能承载的有效序列可能变多,但如果负载中公共前缀很少,缓存 block 就会变成纯粹的显存占用,甚至可能因为过度逐出导致 hit rate 反而下降。我通常建议看两个指标:prefix cache hit rate 和平均每个请求重算的前缀 token 数。如果命中率很低,先别怀疑实现,去检查前缀长度是否对齐、token 是否一致、显存是否频繁触发逐出。在这三个方向里找出路,比调 block_size 更有效。6.3 三条可落地的调优方向结合源码逻辑,我总结三条实际调优路径:第一条:把公共内容放在 prompt 最前面。因为缓存只认前缀,公共 system prompt 如果被塞到中间,哪怕内容相同也无法命中。这是 prompt 结构设计层面最立竿见影的一招。第二条:让公共部分尽量按 16 token 对齐。block_size 默认是 16,如果你能控制公共提示的长度落在 16 的整数倍,比如 128、256、512 token,那么整段公共前缀都能完整命中缓存,不会出现“差几个 token 导致最后一块部分命中”的尴尬。实际操作中完全对齐很难,但尽量对齐到 16 的倍数附近,效果确实不一样。第三条:对多轮对话做“历史前缀折叠”。这类场景下,每一轮新请求的 prompt 通常由历史消息拼接而成,历史越长,公共前缀占比越高。如果聊天模板生成的 token 序列在每轮之间是稳定追加的,那么新请求自然能命中大量历史前缀。反过来,如果模板会在每次请求时重新渲染并引入时间戳、随机 ID 之类的不稳定 token,命中率就会断崖式下跌。检查聊天模板里有没有不稳定字段,往往是收益最大的优化动作。6.4 关于数据安全的一个说明前缀缓存机制本身是否会泄露不同用户之间的数据?理论上不会。两个 block 的哈希完全相同,前提两边 token IDs 完全一致;你不可能通过构造 prompt 去“试探”别人的私有内容,因为你必须先拥有完全相同的 token 序列才能命中。所以它在多租户场景下没有引入额外的隐私风险,这一点可以放心,不用在架构评审时过度担心。7. 写在最后:一次调优经验最后分享一个我自己的体会。刚开始接触 Prefix Caching 时,我也天真地以为它就是把 system prompt 缓存起来,打开开关就完事。真正读完源码才发现,决定命中率高低的往往不是“缓存开关”,而是 prompt 的对齐方式和 token 稳定性。后来我在一个模拟项目里做了一次改造:把公共 system prompt 固定为 512 token 的稳定模板,并把动态内容统一追加在末尾,确保前 512 个 token 完全不变;同时把聊天模板里会变化的字段从渲染结果中挪走。效果很直接:前缀命中率从不到 30% 涨到 85% 以上,首 token 延迟平均降了大约两成。如果你也在调这个功能,我的建议是先做一次“token 序列画像”,把线上请求的前 64 个 token 拉出来看一遍,你会发现很多自以为相同的请求,在 token 层面压根不一样。任何优化都必须先过这一关,否则后面的缓存命中率和显存收益都会跑偏。