1. 从共享状态这个词说起一个被忽视的工程直觉共享状态隔离问题——这八个字第一次出现在我脑子里是在调试一个多轮对话服务的时候。当时线上出现了一个非常诡异的现象同一个批次里的请求A 用户的回答里偶尔会混进 B 用户上一轮的上下文片段。日志里每个请求的输入都是干净的模型输出却串味了。排查了大半天最后定位到的根因是推理引擎里 KV cache 的复用逻辑在批量调度时把不同序列的缓存块错误地共享了。这件事让我意识到一个很朴素但经常被忽略的道理在大模型推理系统里共享和隔离是一对永远在拉扯的矛盾。共享是为了省显存、提吞吐隔离是为了保证每个请求的语义边界不被污染。而 KV cache恰恰是这对矛盾最集中的战场。这篇内容我想聊的不是某个具体框架的 API 怎么调而是围绕 KV cache、Transformer 注意力机制、批量调度这几个关键词把共享状态这件事从原理到实操拆开讲一遍。适合谁看如果你正在做 LLM 推理服务的部署、优化或者单纯想搞明白为什么我的显存明明够吞吐却上不去又或者你被多轮对话串上下文这类问题折磨过那这篇应该能给你一些可以直接抄作业的思路。我会尽量用生活化的类比把注意力机制和缓存复用的原理讲清楚也会给出可复现的排查路径和参数计算。先说结论性的判断KV cache 的本质是用显存换计算而批量调度的本质是用调度复杂度换硬件利用率两者叠加之后任何一处状态共享的边界没划清楚都会以看起来像模型抽风的形式暴露出来。理解了这一点后面所有的坑都好解释了。2. KV cache 到底缓存了什么把注意力机制拆到能算账的程度2.1 自注意力里那三个矩阵为什么只有两个需要缓存要讲清楚 KV cache得先把自注意力self-attention的计算过程摆出来。给定输入序列的隐藏状态经过三个线性变换得到 QueryQ、KeyK、ValueV三个矩阵。注意力的计算可以概括成一句话用 Q 去和所有 K 做点积算相似度归一化之后作为权重去加权求和所有的 V。用生活类比你去图书馆找资料Q 是你脑子里的问题K 是每本书的标签/索引V 是书里的实际内容。你拿问题去和每本书的标签比对Q·K比对得分高的书你就多读一点它的内容加权 V。关键点来了在自回归生成autoregressive decoding里每生成一个新 token这个新 token 的 Q、K、V 都要算但前面所有 token 的 K 和 V 是不变的——因为它们只依赖于前面的输入不依赖于当前生成到哪一步。而 Q 是当前这一步才产生的用完即弃。所以缓存什么缓存 K 和 V。这就是 KV cache 名字的由来。Q 不需要缓存因为每一步的 Q 只服务于当前这一步。这里有个很多人第一次接触时会卡住的点为什么不是缓存整个注意力输出因为注意力输出依赖于当前 Q而 Q 每步都变缓存了也没用。真正跨步复用的只有 K 和 V。2.2 显存账本KV cache 到底吃掉多少显存光说缓存 K 和 V太抽象我们算一笔账。KV cache 的显存占用公式大致是KV cache 显存 2 × batch_size × seq_len × num_layers × num_kv_heads × head_dim × dtype_bytes其中那个 2 就是 K 和 V 两份。我拿一个常见的配置举例假设模型有 32 层隐藏维度 4096注意力头数 32head_dim 128用 FP162 字节存储。如果 num_kv_heads 等于 num_heads标准多头注意力 MHA那么单个 token 的 KV cache 大小是2 × 1 × 1 × 32 × 32 × 128 × 2 字节 524288 字节 ≈ 0.5 MB也就是说每多一个 token就要多占 0.5 MB 显存。如果序列长度到 4096单个请求就是 2 GB。批量 8 个请求就是 16 GB——这还没算模型权重本身。这就是为什么长上下文场景下显存会爆炸。也正是这个账本催生了 MQAMulti-Query Attention和 GQAGrouped-Query Attention让多个 Q 头共享同一组 K/V 头把 num_kv_heads 从 32 降到 8 甚至 1显存直接砍到 1/4 或 1/32。代价是表达能力略有损失但实测在大多数任务上几乎无感。这个取舍非常值得尤其是你要做高并发服务的时候。提示如果你在选型阶段看到某个模型标称支持 128K 上下文先别急着高兴算一下它的 KV cache 在目标批量下要占多少显存。很多时候支持和能跑起来是两回事。2.3 预填充和解码两个阶段KV cache 的行为完全不同这是实操中必须分清楚的一件事。LLM 推理分两个阶段预填充prefill阶段一次性处理整个输入 prompt所有 token 并行计算KV cache 被填满。这个阶段是计算密集型的GPU 算力吃满。解码decode阶段每次只生成一个 token逐个往外吐。这个阶段是显存带宽密集型的因为每生成一个 token 都要把整个 KV cache 读一遍。为什么这个区分重要因为批量调度在两个阶段的策略是相反的。预填充阶段适合大 batch 并行把算力榨干解码阶段则受限于显存带宽batch 越大反而可能因为 KV cache 读取量线性增长而变慢。很多推理框架比如 vLLM 的 continuous batching就是靠把这两个阶段交错调度让 GPU 在等解码的时候去跑别的请求的预填充从而把利用率拉起来。我踩过的一个坑早期自己写了个简单的批量推理脚本把预填充和解码混在一个 batch 里同步跑结果 GPU 利用率只有 30% 出头。后来改成把请求按阶段分开调度同样的硬件吞吐翻了将近两倍。这个教训就是——别把两个性质完全不同的阶段当成一件事处理。3. 批量调度里的共享陷阱PagedAttention 与缓存块管理3.1 连续内存分配的浪费和操作系统分页的启发最朴素的 KV cache 实现是给每个请求预分配一块连续的显存大小按最大序列长度算。问题很明显一个只生成 50 个 token 的请求也占着 4096 长度的坑浪费率极高。实测下来这种方案的有效显存利用率经常只有 20%~40%。vLLM 提出的 PagedAttention 借鉴了操作系统的虚拟内存分页思想把 KV cache 切成固定大小的块block比如每块存 16 个 token 的 KV然后按需分配用一张块表block table记录逻辑位置到物理块的映射。这样不同请求的块可以非连续存放碎片被大幅消除显存利用率能拉到 90% 以上。这个设计的精妙之处在于它把共享这件事变得可控了。因为块是独立管理的你可以精确地决定哪些块共享、哪些块隔离。3.2 前缀共享省显存的好东西也是串味的源头PagedAttention 带来一个非常实用的能力前缀共享prefix sharing。如果多个请求有相同的前缀比如同一个 system prompt或者多轮对话里共享的历史它们的 KV cache 块可以指向同一份物理块不用重复存储。这在多轮对话场景下收益巨大。想象一个客服机器人system prompt 有 2000 个 token100 个并发请求如果不共享光 system prompt 的 KV cache 就要占 100 份共享之后只占 1 份。省下来的显存可以拿去提高并发。但——这就是我开头那个串味bug 的来源——共享的前提是内容完全相同且只读。一旦某个请求需要修改共享块里的内容比如它要继续往这个前缀后面追加 token就必须触发写时复制copy-on-write把共享块复制一份出来改成私有的再往上写。如果框架的写时复制逻辑有 bug或者块引用计数ref count管理出错就会出现我改了共享块结果影响了别的请求的情况。我遇到的那次串味根因就是引用计数在某个异常路径下没有正确递增导致一个块被提前回收又分配给了另一个请求。3.3 一个可复现的排查路径如果你怀疑自己遇到了类似的共享污染问题可以按这个链路排查先确认是不是真串味把出问题的请求的完整输入、输出、以及同批次其他请求的输入都打出来人工比对。有时候串味其实是 prompt 模板拼接错误不是缓存问题。关掉前缀共享再测大多数框架都有开关比如禁用 prefix caching。如果关掉之后问题消失基本可以锁定是共享逻辑的问题。降低并发到 1单请求单批次跑如果问题消失说明是批量调度引入的。打开块级别的调试日志看块的分配、释放、引用计数变化。重点看有没有块被释放后又被引用的情况。构造最小复现用两个前缀相同、但后续内容不同的请求反复跑看是否稳定复现。这套路径我在实际排查里用过不止一次核心思路就是逐层剥离变量先排除应用层再排除共享层最后定位到调度层。注意写时复制这个机制在共享前缀 各自续写的场景下是必须的。如果你自己实现缓存管理一定要把引用计数和写时复制当成一等公民来对待别图省事直接原地修改共享块。4. 注意力机制的变体它们如何影响缓存策略4.1 多头、多查询、分组查询缓存大小的三档选择前面提过 MHA、MQA、GQA 对 KV cache 大小的影响这里展开说一下它们对调度的影响。注意力类型KV 头数KV cache 相对大小表达能力适用场景MHA等于 Q 头数1x基准最强质量优先、显存充裕GQAQ 头数 / 分组数1/4 ~ 1/8接近 MHA主流选择平衡MQA11/32 左右略弱高并发、显存紧张从调度角度看KV cache 越小同样显存能塞下的并发请求越多批量调度的空间越大。所以现在主流开源模型基本都用 GQA这是一个工程上非常务实的折中。4.2 滑动窗口注意力与稀疏注意力把无限长变成有限长标准注意力是每个 token 看前面所有 tokenKV cache 随序列线性增长。滑动窗口注意力sliding window attention则规定每个 token 只看前面固定窗口内的 token超出窗口的 KV 就可以丢掉。这样 KV cache 大小被钉死在窗口长度上不随总序列增长。这个思路对长文本场景特别有用。代价是模型无法直接看到窗口外的信息需要靠多层堆叠来间接扩大感受野。Mistral 系列就是典型代表。还有一类是稀疏注意力比如只保留部分重要的 KV类似 H2O、StreamingLLM 那类思路通过淘汰不重要的 token 来控制缓存。这类方法在实测中效果参差取决于任务类型——对需要精确回忆远处细节的任务淘汰策略可能丢信息。4.3 视觉领域的空间注意力和 LLM 的注意力有什么不同热词里出现了高度方向、宽度方向注意力Swin Transformer视觉 Transformer这些我顺带说一下区别因为很多人会把它们混为一谈。NLP 里的自注意力是在序列维度上做的token 之间是一维排列。而视觉 TransformerViT把图像切成 patch也是拉成一维序列处理。但图像有天然的空间结构Swin Transformer 引入了窗口注意力把特征图划分成不重叠的窗口只在窗口内做注意力再通过窗口移位shifted window让信息跨窗口流动。这本质上和滑动窗口注意力是同一个思想——用局部性换计算量。对 KV cache 的启示是一样的限制注意力的范围就能限制缓存的大小。这个思路是跨领域通用的。5. 实操从零搭一个带缓存管理的批量推理小实验5.1 为什么建议自己动手写一遍简化版光看框架文档很多细节是体会不到的。我强烈建议你自己写一个极简版的 KV cache 管理 批量调度哪怕只支持固定长度、不支持动态分配。写完之后你对共享和隔离的理解会上一个台阶。下面给一个概念性的伪代码展示核心逻辑不是可直接运行的完整实现重点是结构class KVCacheBlock: def __init__(self, block_id, size): self.block_id block_id self.size size # 能存多少个 token 的 KV self.ref_count 0 # 被多少个序列引用 self.data None # 实际的 K/V 张量 class BlockManager: def __init__(self, num_blocks, block_size): self.free_blocks [KVCacheBlock(i, block_size) for i in range(num_blocks)] self.block_table {} # seq_id - [block_id, ...] def allocate(self, seq_id, num_tokens): # 按需分配块记录到 block_table ... def share_prefix(self, src_seq, dst_seq, prefix_len): # 让 dst_seq 的前 prefix_len 个 token 指向 src_seq 的块 # 关键ref_count 1 ... def append_token(self, seq_id, kv): # 如果当前块满了分配新块 # 如果当前块是共享的ref_count 1先 copy-on-write ...重点看append_token里的写时复制逻辑只要发现要写的块被多个序列引用就必须先复制。这一行如果漏了就是串味的根源。5.2 关键参数怎么定块大小、批量上限、显存水位几个实操中需要拍板的参数块大小block size常见是 16。太小则块表管理开销大太大则内部碎片多。16 是个经验值实测比较平衡。最大批量max batch size不要拍脑袋定用公式反推。可用显存 总显存 - 模型权重 - 激活值预留然后除以单请求平均 KV cache 大小。显存水位gpu_memory_utilizationvLLM 里默认 0.9意思是预留 10% 给临时分配。如果你发现偶发 OOM先把这个值降到 0.85 试试。我一般会先跑一个压测把并发从 1 逐步往上加记录吞吐和显存曲线找到吞吐开始下降或者显存逼近上限的拐点那个点附近的并发就是比较合适的配置。5.3 实测中的意外吞吐不升反降的几种情况批量调度的直觉是batch 越大吞吐越高但实测经常打脸。我遇到过几种吞吐不升反降的情况解码阶段 batch 过大显存带宽成为瓶颈每个 token 生成都要读巨量 KV cache延迟飙升。预填充和解码互相阻塞如果调度器没有做阶段交错大 batch 的预填充会把解码请求饿死导致首 token 延迟TTFT爆炸。块分配碎片化频繁分配释放小块导致块表膨胀管理开销吃掉收益。对应的解法分别是解码阶段控制 batch、启用 continuous batching、增大块大小减少管理频率。这些都不是文档里会明说的得自己压测才能发现。6. 那些容易被忽略的边界条件6.1 序列结束与块回收一个经典的悬空引用序列生成结束时它占用的块要回收。但如果这些块还被别的序列共享着ref_count 1就不能直接回收只能把 ref_count 减一。这个逻辑听起来简单但在异常路径下比如请求被取消、超时中断特别容易出错。我见过的一个真实 bug请求超时被中断中断处理里直接释放了所有块没有检查 ref_count结果把还在被其他请求共享的块给回收了后续分配出去就串味了。异常路径的块回收一定要和正常路径走同一套引用计数逻辑。6.2 多轮对话的历史管理什么时候该重新计算多轮对话里历史越长KV cache 越大。有些框架支持复用上一轮的 KV cache只计算新增的部分。这能省大量计算但前提是历史完全没变。如果中间做了任何修改比如重新排序、截断、插入系统消息缓存就失效了必须重新预填充。我建议在应用层维护一个缓存有效性标记任何对历史的修改都把这个标记置为无效避免用错缓存。6.3 采样参数与缓存的关系一个反直觉的点采样参数temperature、top_p 等不影响 KV cache 的内容因为缓存的是 K 和 V和采样无关。但有一个例外如果你用了 beam search每个 beam 都要维护自己的 KV cache显存占用会乘以 beam 数量。这个在配置 beam search 时一定要算进去否则很容易 OOM。7. 我在实际项目里沉淀下来的几条经验第一条也是最值钱的一条任何共享都必须有明确的只读或写时复制语义没有中间地带。共享块要么绝对只读要么在写之前先复制。我见过太多 bug 都是因为我以为它不会改。第二条把 KV cache 的显存账算清楚再上线。不要等 OOM 了才去算。公式前面给了套进去就行。尤其是长上下文 高并发 beam search 这三个因素叠加的时候显存需求是指数级增长的。第三条批量调度不是越大越好要分阶段看。预填充和解码的最优 batch 策略是相反的能用 continuous batching 就用别自己造轮子同步跑。第四条排查串味问题先关共享再降并发。这个二分法能快速把问题范围缩小到共享逻辑还是调度逻辑。最后分享一个我常用的自检小技巧在开发环境里给每个请求的 KV cache 块打上归属标签跑压测的时候定期扫描看有没有块被多个不同归属的序列同时引用却没有走写时复制。这个检查能提前发现大部分共享污染问题比等线上出事再排查划算得多。这个思路其实和共享状态隔离问题这八个字是一回事——共享是为了效率但隔离的边界必须清晰到可以被验证。
阅读完成 · 觉得有帮助?