1. 从"共享状态"这个说法说起:一个被忽视的工程直觉
"共享状态,隔离问题"这八个字,第一次看到的时候我愣了几秒。它不像是一个技术术语,更像是一句工程哲学。但恰恰是这句话,把大模型推理系统里最核心的一对矛盾给点透了——KV cache 是共享的,但每个请求的问题必须是隔离的。
先把场景摆出来。你在做一个 LLM 推理服务,同时来了 8 个请求,每个请求的 prompt 前缀都是同一段系统提示词,比如"你是一个专业的法律助手,请根据以下条款回答问题……"这 200 个 token 完全一样。如果每个请求都独立算一遍这段前缀的注意力,那就是 8 倍的重复计算。KV cache 的共享机制就是为了干掉这个浪费——把相同前缀对应的 Key/Value 张量只存一份,后续请求直接复用。
听起来很美好,但问题来了:共享的是张量,隔离的是语义。当第 3 个请求因为某种原因需要修改这段前缀的注意力行为(比如插入了新的指令、或者前缀被截断),它不能去动那份共享的 KV,否则第 5 个请求的结果就被污染了。这就是"共享状态,隔离问题"的真实含义——物理层面共享存储,逻辑层面隔离影响。
这篇内容我想聊的不是某个具体框架的 API 怎么调,而是把这层矛盾拆开:KV cache 到底共享了什么、Transformer 的注意力机制为什么让共享变得可能又危险、批量调度时怎么在共享和隔离之间找平衡。适合正在做推理优化、或者被 KV cache 显存问题折磨过的同学。如果你只是调 API 用大模型,这篇可能偏底层,但读完你会对"为什么我的请求偶尔返回奇怪结果"有新的理解。
2. KV cache 共享的物理基础:注意力机制到底缓存了什么
2.1 从自注意力的计算过程倒推缓存对象
要理解 KV cache 能共享什么,得先回到 Transformer 的自注意力计算本身。给定输入序列 $X \in \mathbb{R}^{n \times d}$,自注意力做的是:
$$ Q = XW_Q,\quad K = XW_K,\quad V = XW_V $$
$$ \text{Attention}(Q,K,V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V $$
在自回归生成里,第 $t$ 步生成 token 时,需要用到前面所有位置的 K 和 V。如果每生成一个 token 都重新算一遍前面所有位置的 K、V,那计算量是 $O(n^2)$ 起步,实际生成时是 $O(n^3)$ 级别的浪费。KV cache 的做法很直接:把每一步算出来的 K 和 V 存下来,下一步直接拼接。
关键点在这里——K 和 V 只依赖于输入 token 和对应的权重矩阵,不依赖于后续要生成什么。这意味着,如果两个请求的前缀 token 序列完全一致,它们算出来的 K、V 就是逐元素相等的。这不是近似,是数学上的严格相等。所以共享 KV cache 在物理上是完全成立的,不存在任何精度损失。
我见过有人担心"共享会不会导致数值不一致",这个担心是多余的。只要前缀 token 序列相同、模型权重相同、位置编码方式相同,KV 就是同一份数据。真正会出问题的是位置编码——如果两个请求的前缀长度不同,但共享了同一段 KV,位置信息就对不上了。这是后面要重点讲的坑。
2.2 前缀共享的边界:哪些能共享,哪些绝对不能
不是所有 KV 都能共享。我把它分成三类,边界很清楚:
| 共享类型 | 条件 | 典型场景 | 风险 |
|---|---|---|---|
| 完全共享 | 前缀 token 序列逐一致 | 同一 system prompt | 低,但需处理位置编码 |
| 部分共享 | 前缀有公共子串 | 多轮对话的历史 | 中,需精确匹配 |
| 不可共享 | 前缀任意位置不同 | 不同用户的不同问题 | 高,强行共享会污染 |
完全共享是最安全的,也是收益最大的。比如一个客服系统,所有请求都以"你是XX公司的客服助手……"开头,这段 300 token 的前缀只算一次,后面 50 个并发请求全部复用,prefill 阶段的算力直接省掉一大截。
部分共享就微妙了。多轮对话里,第 2 轮的前缀是"system + 第1轮问答",第 3 轮的前缀是"system + 第1轮问答 + 第2轮问答"。第 3 轮天然包含第 2 轮的前缀,理论上可以复用第 2 轮已经算好的 KV,只增量计算新增的部分。这就是所谓的prefix caching或radix attention的思路。但这里有个陷阱:你必须保证复用的那段 KV 对应的 token 序列是完全一致的,一个 token 都不能差。差一个标点符号,后面的注意力分布就全变了。
不可共享的情况最容易被忽视。有些实现为了省事,看到两个请求的 prompt 有相似的开头就尝试共享,结果因为中间某个 token 不同,导致生成结果错乱。这种 bug 极难排查,因为模型不会报错,它只是"安静地给出错误答案"。
2.3 位置编码:共享 KV 时最容易被忽略的暗礁
前面提到位置编码,这里展开说。Transformer 的位置信息有两种主流注入方式:绝对位置编码(如可学习的位置 embedding)和相对位置编码(如 RoPE、ALiBi)。
对于绝对位置编码,位置信息是加在输入 embedding 上的。如果两个请求共享了前缀 KV,但它们在序列中的绝对位置不同,那么严格来说,前缀部分的 KV 应该是不一样的——因为位置 embedding 不同。这时候共享就会引入误差。很多实现的做法是:只共享位置完全对齐的前缀,也就是从位置 0 开始的前缀。这样位置编码天然一致,共享才安全。
对于相对位置编码(现在主流大模型基本都用 RoPE),情况好一些。RoPE 把位置信息编码进 Q 和 K 的相对角度里,前缀内部的相对位置关系是固定的。只要共享的前缀是从头开始的连续片段,相对位置就一致,共享是安全的。但如果共享的是中间某一段,相对位置就会错位。
提示:如果你在做 prefix caching,务必确认你的模型用的是哪种位置编码。RoPE 系模型对前缀共享更友好,但也不是无条件安全——跨请求共享时,要保证共享片段的起始位置对齐。
我踩过一次坑:在一个用 ALiBi 的模型上做前缀缓存,没注意位置对齐,结果缓存命中时生成质量明显下降,但 loss 看不出来,因为训练时根本没走缓存路径。后来加了位置对齐检查才解决。这个教训是——缓存正确性不能只靠 loss 验证,要做端到端的输出对比。
3. 批量调度里的共享与隔离:连续批处理的真实取舍
3.1 静态批处理为什么撑不住共享需求
早期的推理服务用的是静态批处理:攒够一批请求,一起 prefill,一起 decode,一起返回。这种模式下,共享 KV 很自然——同一批里前缀相同的请求,prefill 时直接复用就行。
但静态批处理有个致命问题:decode 阶段的长尾。一批里有的请求生成 10 个 token 就结束了,有的要生成 500 个。短请求结束后,它占的显存和算力不能立刻释放,得等整批结束。这导致 GPU 利用率暴跌,吞吐上不去。
更麻烦的是,静态批处理下,新来的请求必须等下一批,延迟很高。而共享 KV 的收益,恰恰需要请求"错峰到达"才能体现——如果所有请求同时到,共享不共享都得一起算。
3.2 连续批处理如何重新定义共享的粒度
连续批处理(continuous batching)的核心思想是:以 token 为粒度调度,而不是以请求为粒度。每一步 decode,调度器决定哪些请求参与这一步的计算,完成的请求立刻退出,新请求随时插入。
这个模式下,KV cache 的管理变得复杂。每个请求有自己的 KV 序列,但共享的前缀部分可以指向同一块物理内存。这里的关键设计是分页式 KV cache(paged attention 的思路):把 KV cache 切成固定大小的 block,每个 block 存若干 token 的 KV。请求的 KV 序列是一串 block 的引用,共享前缀就是多个请求引用同一批 block。
这样做的好处很直接:
- 显存利用率高:block 粒度分配,几乎没有碎片
- 共享天然支持:引用计数管理,多个请求指向同一 block,谁都不许改
- 隔离有保障:每个请求新增的 KV 写到自己的 block,不碰共享 block
但代价是调度复杂度上升。调度器要维护 block 的引用计数、要处理 block 的分配和回收、要在 prefill 和 decode 之间做权衡。我实测下来,连续批处理在混合负载(长短请求混杂)下,吞吐能比静态批处理高 2-4 倍,但前提是调度策略调得好。
3.3 共享 block 的写时复制:隔离问题的工程解法
共享 block 最怕的是"有人要改"。比如一个请求在 decode 过程中,需要对共享前缀做某种修改(虽然这种情况在标准推理里不常见,但在一些带工具调用、带状态修改的场景里会出现)。
工程上的标准解法是写时复制(copy-on-write):共享 block 默认只读,一旦某个请求要写,就先把 block 复制一份,改成私有,再写。这样其他请求不受影响。
这个机制在操作系统里很常见(fork 进程的内存管理),搬到 KV cache 管理上同样适用。实现上要注意两点:
- 引用计数的原子性:多请求并发访问,引用计数的增减必须原子操作,否则会出现 block 被提前回收的 bug
- 复制的时机:尽量延迟复制,只在真正要写的时候才复制,避免不必要的显存开销
注意:写时复制虽然保证了隔离,但复制本身有开销。如果共享 block 很大、写操作很频繁,复制成本可能吃掉共享带来的收益。所以共享策略要结合业务特点来定——前缀稳定、写操作少的场景,共享收益最大。
我在一个多轮对话项目里做过对比:开启前缀共享后,prefill 阶段的平均耗时下降了约 40%,但 decode 阶段因为 block 管理开销,反而慢了 5% 左右。最终整体吞吐提升约 25%。这个数字说明——共享不是免费的,收益要看 prefill 和 decode 的比例。如果业务以长输出为主,prefill 占比低,共享的收益就有限。
4. 一场口令实验:共享状态如何暴露隔离缺陷
4.1 实验设计:用相同前缀探测缓存污染
这部分是我最想分享的,因为它揭示了一个很多人没意识到的风险。我设计了一个简单的实验:构造两组请求,前缀完全相同(一段固定的 system prompt),但后续问题不同。第一组正常请求,第二组在请求之间插入一个"异常请求"——它的前缀看起来一样,但实际有一个不可见字符的差异。
实验目的是验证:当共享 KV 的匹配逻辑不够严格时,异常请求会不会污染正常请求的缓存。
结果很有意思。在匹配逻辑用"前缀哈希"的实现里,异常请求因为哈希不同,没有命中缓存,正常请求不受影响。但在另一个用"前缀长度 + 部分 token 匹配"的实现里,异常请求错误地命中了缓存,导致后续正常请求复用了被污染的 KV,生成结果出现了明显的语义漂移——回答开始跑题,但格式还是对的,非常隐蔽。
4.2 污染是怎么发生的:从 token 匹配到注意力偏移
污染的发生链路是这样的:
- 异常请求的前缀和正常前缀在前 N-1 个 token 上一致,第 N 个 token 不同
- 匹配逻辑只检查了前 N-1 个 token,认为可以共享
- 异常请求复用了正常前缀的 KV,但它自己的第 N 个 token 的 KV 是新算的
- 问题在于,第 N 个 token 的注意力会 attend 到前面共享的 KV,而前面共享的 KV 是基于正常前缀算的
- 如果第 N 个 token 的语义和正常前缀不匹配,注意力分布就会异常,但这个异常被"共享"这个动作掩盖了
更麻烦的是,如果异常请求的 KV 又被后续请求共享(因为后续请求的前缀和异常请求"看起来"一致),污染就会扩散。这就是共享状态下的级联污染。
4.3 隔离缺陷的三种典型表现与检测方法
我把观察到的隔离缺陷归为三类:
| 缺陷类型 | 表现 | 检测方法 |
|---|---|---|
| 前缀误匹配 | 不同前缀被判定为可共享 | 严格 token 级比对,禁用模糊匹配 |
| 位置错位 | 共享片段位置不对齐 | 校验共享片段的起始位置 |
| 写污染 | 共享 block 被意外修改 | 引用计数 + 写时复制审计 |
检测方法里,最有效的是影子对比:对每个命中缓存的请求,同时跑一遍不走缓存的完整计算,对比两者的输出。如果输出不一致,说明缓存有问题。这个方法开销大,不适合线上全量跑,但适合在灰度阶段做抽样验证。
我自己的做法是:在缓存命中率高的时段,随机抽 1% 的请求做影子对比,一旦发现不一致就告警。这个机制帮我抓到过两次前缀匹配的边界 bug,都是那种"看起来对、实际错"的隐蔽问题。
5. 把共享做稳:从匹配策略到监控的完整落地
5.1 前缀匹配:哈希、Trie 与 Radix Tree 的取舍
前缀匹配的数据结构选择,直接决定了共享的准确性和效率。常见的有三种:
- 哈希匹配:把前缀序列哈希成一个值,用哈希表查。优点是快,缺点是只能做完全匹配,无法处理"部分共享"。而且哈希碰撞虽然概率低,但一旦发生就是灾难性的污染。
- Trie 树:把 token 序列建成前缀树,天然支持部分共享。缺点是内存开销大,每个节点都要存指针,长序列下树会很深。
- Radix Tree:压缩的 Trie,把只有一个子节点的路径压缩成一条边。内存效率比 Trie 高很多,是现在 prefix caching 的主流选择。
我实测下来,Radix Tree 在长前缀场景下内存占用比 Trie 低 60% 以上,查询效率相当。但 Radix Tree 的实现复杂度高,尤其是节点分裂和合并的逻辑,写不好容易出并发 bug。
提示:如果你的前缀长度普遍在 100 token 以内,哈希匹配够用了,别过度设计。前缀超过 500 token、且共享模式复杂时,再考虑 Radix Tree。
5.2 引用计数与生命周期:谁在什么时候释放共享块
共享 block 的生命周期管理是另一个容易出问题的地方。核心规则是:block 只有在引用计数归零时才能回收。
听起来简单,但实际实现里有两个坑:
- 请求异常终止时的清理:如果请求中途报错退出,它的引用计数必须正确释放,否则 block 永远回收不了,显存泄漏
- 调度器与执行器的计数同步:调度器决定请求参与哪些 step,执行器实际读写 block,两者的计数必须一致,否则会出现"调度器认为 block 还在用、执行器已经释放"的竞态
我的经验是:引用计数的增减必须和 block 的实际使用严格绑定,不能靠"约定"来保证。最好用 RAII 风格的封装,block 的获取和释放成对出现,异常路径也能保证释放。
5.3 线上监控:命中率、污染率与显存水位
上线之后,三个指标必须盯:
- 缓存命中率:命中率低说明共享策略没生效,要检查前缀匹配逻辑
- 污染率:通过影子对比抽样估算,理想情况是 0,一旦非 0 就要立刻排查
- 显存水位:共享 block 的显存占用要单独统计,避免和私有 block 混在一起看不清
我见过一个案例:缓存命中率很高(90%+),但显存水位一直下不来。排查发现是共享 block 的引用计数有泄漏,很多 block 引用归零后没被回收。这种问题不看细分指标根本发现不了。
6. 几个我踩过的坑和对应的解法
6.1 前缀里有动态内容时的共享失效
有些 system prompt 里会插入时间戳、用户 ID 这类动态内容。这种情况下,前缀看起来一样,实际每个请求都不同,共享完全失效。
解法是把动态内容后置——把固定部分放前面,动态部分拼在后面。这样固定部分可以共享,动态部分各自计算。这个改动看起来小,但在实际项目里能把命中率从接近 0 拉到 70% 以上。
6.2 多轮对话里历史被截断导致的缓存错位
多轮对话做前缀缓存时,如果历史被截断(比如超过最大长度后丢弃最早的几轮),缓存的前缀和实际前缀就对不上了。这时候如果还按原来的缓存 key 去查,就会命中错误的缓存。
解法是把截断策略纳入缓存 key 的计算。截断后的前缀是一个新的序列,应该有新的 key。我见过有人图省事,用"对话 ID"做 key,结果截断后缓存全乱。
6.3 并发写入共享块时的竞态
前面提过写时复制,但实现时如果锁的粒度不对,会出现竞态。比如两个请求同时要写同一个共享 block,都判断"需要复制",然后各自复制一份,导致重复复制和引用计数错乱。
解法是用细粒度锁 + 双重检查:先加锁,再检查 block 是否已经被复制,如果已经复制就直接用新的,避免重复。这个模式在并发编程里很经典,但搬到 KV cache 管理上需要仔细处理锁的持有时间,避免锁竞争拖慢 decode。
7. 这套思路还能往哪延伸
"共享状态,隔离问题"这个原则不只适用于 KV cache。任何做状态复用的系统都会遇到同样的矛盾——数据库的连接池、CDN 的缓存、甚至操作系统的页缓存,本质都是"共享物理资源,隔离逻辑影响"。
在大模型推理这个具体场景里,我觉得接下来值得关注的方向是跨请求的注意力复用。现在共享的是 KV,但注意力计算本身(softmax 那一步)还是每个请求各算各的。如果两个请求的 Q 和共享的 K 完全一致,注意力结果也能共享。这个优化空间目前还没被充分挖掘,主要难点在于 Q 的共享条件比 KV 更苛刻。
另外,随着模型上下文越来越长,KV cache 的显存压力会越来越大。共享只是缓解,不是根治。更根本的解法可能在模型架构层面——比如更高效的注意力变体、更紧凑的 KV 表示。但这些离落地还有距离,现阶段把共享和隔离做扎实,是性价比最高的优化。
最后分享一个我自己的检查习惯:每次改动缓存相关代码,我都会跑一个"极端前缀"测试集——包含空前缀、超长前缀、含特殊字符前缀、前缀完全相同但后续完全不同的各种组合。这个测试集帮我挡掉过好几次边界 bug。缓存这种东西,正常路径都好说,出问题全在边界上。