尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
vLLM Prefix Caching 原理:如何用 Paged KV 消除重复计算
先说一个我遇到过的情况。某个线上推理服务所有请求都带着同一段两千多字的 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”其实它还能处理同一个请求内部的公共前缀。比如n2做并行采样或者 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 还是 immutableblock 在什么时候才有资格进缓存继续往源码里走。每个 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 都会在这里记录它的物理地址。对命中缓存的 blockslot_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 层面压根不一样。任何优化都必须先过这一关否则后面的缓存命中率和显存收益都会跑偏。
RELATED

相关推荐

ElevenLabs Brand Kit Creative版:品牌语音资产化实战指南

ElevenLabs Brand Kit Creative版:品牌语音资产化实战指南

给品牌做视频的时候,最头疼的往往不是画面,而是声音。画面可以用一套VI体系锁死,字体和配色放到规范手册里,配音却经常处于"今天约A老师、明天约B老师"的状态。我最近留意到ElevenLabs的Brand Kit上线了面向创意群体Cre…

📅 2026/10/20 20:21:01
个人AI助理的真正壁垒:通讯录关系链而非大模型

个人AI助理的真正壁垒:通讯录关系链而非大模型

1. 这个项目到底在解决什么问题前阵子有个朋友问我,做个人 AI 助理,是不是选个大模型套个壳就完事了。我说你如果这么想,做出来的东西跟市面上几百个 demo 不会有任何区别。我自己把个人 AI 助理这个方向拆了很久,最后得出的结论是…

📅 2026/10/20 20:21:01
Spring Boot多数据源切换实战:dynamic-datasource配置与踩坑记录

Spring Boot多数据源切换实战:dynamic-datasource配置与踩坑记录

做信息系统的开发久了,你会发现一个躲不开的事实:一个项目里面很难只用一个数据库。主从分离要读库写库分开,微服务拆开后订单数据和用户数据各管各的,稍微老一点的项目甚至要同时应对MySQL和PostgreSQL。每次遇到这种场景&#x…

📅 2026/10/20 20:21:01
MORE NEWS

更多资讯

📰

C#上位机串口调试助手源码解析与二次开发指南

简介:这套串口调试上位机源码用C#编写,源自一个恒温控制系统参赛项目,主要面向正在学习上位机开发、需要实现PC与单片机串口通信的工程师和高校学生,也适合作为自动化类课程设计或毕业设计的参考资料。程序实现了与下位机的串口通…

📰

TensorFlow入门指南:从张量原理到模型部署全流程

1. TensorFlow到底是什么:先聊清楚"张量"这件小事很多人在接触TensorFlow的第一天,就被"张量""计算图""自动微分"这些词唬住了。我当年也一样,装完环境之后对着import tensorflow的报错盯了半小时&a…

📰

AI论文工具实测:从文献到盲审的组合用法与避坑指南

实话说,作为一只被论文折磨了快三年的研究生,我是在导师把绪论改到第十一稿、批注里还写着“论证链条不够紧密”的时候,才开始认真研究AI论文工具的。那会儿我满脑子只有一个问题:到底有没有工具能把这种抽象批注,变成…

📰

AI账号被封?从风控原理到申诉恢复的合规使用指南

昨天打开客户端,一条冷冰冰的提示弹出来:账户已停用。当时心里咯噔一下——Claude 又封号了,这次轮到我。作为长期重度用户,我一直觉得自己是"最规矩的那批人",结果还是踩了雷。更难受的是,我手里…

📰

苹果回应OpenAI造硬件:“我们不慌”——但这场AI路线之争,才刚刚开始

151款APP集体“拉黑”豆包手机助手:当AI想替你点外卖,巨头们为何说不? 前言你的手机,到底是谁的?当豆包手机助手试图替你打开淘宝、点一份美团外卖时,微信、淘宝、美团们用151款应用的集体“拒绝”给出了答…

📰

块级镜像备份原理与实操:从Snapshot看硬盘数据备份的保命之道

前一阵帮一位朋友检修老电脑,开机咔咔响,系统盘读取速度慢得像蜗牛,我用工具一测,已经出现了不少坏道。当场做完整盘镜像之后,那块盘又苟延残喘了两周,数据一条没丢。这件事让我对"硬盘备份工具"…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬