
1. 为什么 KV Cache 是 Transformer 推理的命门第一次接触 Transformer 推理优化的人十有八九会先盯着模型参数量、量化精度、算子融合这些方向但真正在线上跑过大模型服务的人都知道KV Cache 才是决定推理吞吐和延迟的那条生命线。我最早做文本生成服务的时候单条请求响应时间长得离谱GPU 利用率却低得可怜排查了半天才发现问题出在自回归解码阶段——每生成一个 token模型都要把前面所有 token 重新算一遍注意力。这种重复计算在短序列上不明显一旦输出长度上到几百上千 token算力浪费直接指数级放大。KV Cache 要解决的就是这个问题。它的核心思路非常朴素自回归生成时已经计算过的 Key 和 Value 矩阵不会因为新 token 的到来而改变那就没必要重复算缓存起来复用即可。听起来简单但真正落地时会牵扯出一堆工程问题——显存怎么分配、缓存怎么管理、不同请求之间怎么共享、长序列怎么压缩。这篇文章我会从原理到实操把 KV Cache 这件事彻底讲透包括我踩过的坑和实际调优的参数。适合读这篇内容的人正在做 LLM 推理服务部署的工程师、想理解推理框架内部机制的算法同学、以及被显存和延迟折磨过的运维人员。哪怕你之前只调过 API看完也能明白为什么你的推理成本降不下来。2. KV Cache 的核心原理与设计取舍2.1 自回归解码到底在重复算什么先把注意力机制的计算过程拆开看。标准的自注意力里输入序列经过三个线性变换得到 Query、Key、Value 三个矩阵Q X · W_q K X · W_k V X · W_v Attention softmax(Q · K^T / sqrt(d)) · V在训练阶段整个序列是一次性喂进去的所有位置的 Q、K、V 同时算出来并行度拉满。但推理阶段不一样生成是逐 token 进行的。假设当前已经生成了 t 个 token现在要生成第 t1 个模型需要拿第 t1 个位置的 Query去和前面 t 个位置的 Key 做点积再对 t 个 Value 加权求和。问题就在这里前面 t 个位置的 K 和 V在之前的每一步里都已经算过了。如果不缓存每生成一个新 token 就要把整个前缀重新过一遍 W_k 和 W_v 的线性变换计算量随序列长度线性增长累积起来就是 O(n²) 的复杂度。缓存之后每步只需要算新 token 自己的 K、V然后追加到缓存里计算量降到 O(n)。我用一个具体数字说明差距。假设隐藏维度 4096序列长度 2048不做缓存的话生成第 2048 个 token 时光 K、V 的线性变换就要做 2048 次矩阵乘法做了缓存只需要做 1 次。这个差距在长文本生成场景下是几十倍的性能差异不是优化几个百分点的问题。2.2 KV Cache 的显存账本怎么算缓存不是免费的它吃的是显存。这部分账必须算清楚否则线上很容易 OOM。单个 token 的 KV Cache 占用公式是每 token 显存 2 × num_layers × num_kv_heads × head_dim × dtype_bytes其中 2 代表 K 和 V 两份num_kv_heads 在用了 GQA分组查询注意力的模型里会小于 num_heads。我拿一个常见的 7B 级别模型举例32 层32 个注意力头head_dim 128FP16 精度2 × 32 × 32 × 128 × 2 524288 字节 ≈ 0.5 MB / token也就是说一条 4096 token 的请求光 KV Cache 就要占 2 GB 显存。如果并发 16 条这样的请求就是 32 GB比模型权重本身还大。这就是为什么很多推理框架的瓶颈不在算力而在显存带宽和容量。注意很多人算显存只算模型权重忽略了 KV Cache结果上线后并发一上来就崩。做容量规划时KV Cache 必须单独列一项而且它随并发数和序列长度线性增长是最不可控的那部分。2.3 为什么是缓存 K 和 V 而不是 Q这个问题我被问过很多次。原因在于注意力计算的角色分工Q 是当前查询每个新 token 都有自己的 Q用完就丢没有复用价值而 K 和 V 是历史信息一旦算出来就固定不变后续每个新 token 都要去查它们。所以缓存的是被反复读取的那部分Q 不需要缓存。从另一个角度理解这就像查字典Q 是你要查的词每次都不一样K 是字典的索引V 是索引对应的释义它们建好之后就一直放在那里供查询。你不会每次查词都把字典重抄一遍KV Cache 就是这个道理。2.4 方案选型从朴素缓存到 PagedAttention最早的 KV Cache 实现很粗暴就是给每条请求预分配一块连续的显存按最大序列长度预留。这种做法有两个致命问题一是显存浪费严重实际生成了 100 token 却预留了 4096 的空间二是显存碎片化不同长度的请求交错分配后大块连续显存越来越难找。后来业界提出了PagedAttention的思路把 KV Cache 切成固定大小的 block比如 16 个 token 一块用类似操作系统虚拟内存分页的方式管理。逻辑上连续的序列物理上可以分散在不连续的 block 里通过一张 block table 做映射。这个设计带来三个好处显存利用率大幅提升、支持不同请求共享前缀 block、碎片问题基本消除。我在实际项目里对比过两种方案在混合长短请求的负载下分页方案的显存利用率能从 40% 左右提升到 85% 以上同样的卡能扛的并发数翻了一倍多。所以现在选推理框架是否支持 PagedAttention 或类似的分页 KV 管理是我第一个看的指标。3. 核心细节解析与实操要点3.1 缓存的数据布局影响性能的隐形杀手KV Cache 在显存里怎么摆直接决定了访存效率。常见的布局有两种一种是[batch, num_heads, seq_len, head_dim]另一种是[batch, seq_len, num_heads, head_dim]。前者把序列维度放在头维度后面后者把序列维度提前。为什么这个顺序重要因为注意力计算时需要按序列位置连续读取 K 和 V。如果序列维度在内存里是连续的读取就是顺序访问缓存命中率高如果序列维度被 head 维度隔开每次读一个位置都要跨一大段内存访存效率急剧下降。实测下来布局选错能让解码速度差 20% 到 30%。现在主流框架基本都采用[batch, seq_len, num_heads, head_dim]这种序列优先的布局配合分页管理每个 block 内部序列连续。你自己写推理代码时这一点务必注意别为了转置方便就随便选布局。3.2 预分配还是动态增长缓存空间的分配策略有两种路线。预分配是启动时按最大长度一次性留足优点是运行期没有分配开销延迟稳定缺点是浪费显存且最大长度设小了会截断设大了浪费。动态增长是按需扩展初始只分配一小块序列变长时再扩容。优点是省显存缺点是扩容时可能触发内存拷贝造成延迟毛刺而且频繁分配释放容易产生碎片。我的经验是在线服务用分页预分配 按需填充 block 的混合策略。启动时把显存池化切成固定大小的 block请求来了按需领 block用完归还。这样既没有运行期的大块分配开销又不浪费显存。纯动态增长只适合离线批处理或者显存极度紧张的场景。3.3 多请求并发下的缓存管理线上服务不可能一次只跑一条请求并发管理是重头戏。这里有几个关键决策点。第一是前缀共享。很多请求有相同的系统提示词这部分 KV Cache 完全可以共享不必每个请求都算一遍。实现上就是让多个请求的 block table 指向同一批物理 block配合引用计数最后一个请求释放时才真正回收。这个优化在系统提示词很长的场景下收益巨大我见过提示词占 80% 长度的业务开启前缀共享后首 token 延迟直接砍半。第二是抢占与换出。显存不够时得决定牺牲谁。常见策略有 FCFS先来先服务、优先级调度、以及把部分请求的 KV Cache 换出到主机内存。换出到内存虽然慢但比直接丢弃重算要划算因为重算的代价是重新跑一遍前缀。第三是批处理中的 padding 问题。同一批里不同请求长度不一如果按最长序列对齐短请求会浪费大量计算。分页方案天然解决了这个问题因为每个请求的 block 独立不需要对齐。3.4 精度选择FP16、BF16 还是 FP8KV Cache 的精度直接影响显存占用和数值稳定性。FP16 是默认选择占用 2 字节BF16 同样 2 字节但动态范围更大长序列下更不容易溢出FP8 只占 1 字节显存直接减半但对数值精度敏感需要配合缩放因子。我实测过 FP8 缓存在大多数生成任务上质量损失可以接受但在需要精确回忆长上下文的任务上偶尔会出现细节丢失。所以我的建议是显存充裕就用 BF16显存紧张且任务对精度不苛刻再用 FP8。别一上来就 FP8出了问题很难定位是精度还是逻辑的锅。提示切换缓存精度时一定要做端到端的质量回归测试不能只看困惑度指标。有些任务对缓存精度特别敏感比如代码生成里的变量名一致性。4. 实操过程与核心环节实现4.1 手写一个最小可用的 KV Cache理解原理最好的方式是自己实现一遍。下面这段伪代码展示了单层注意力的缓存逻辑用 Python 风格写重点是逻辑而非性能class CachedAttention: def __init__(self, dim): self.W_q Linear(dim, dim) self.W_k Linear(dim, dim) self.W_v Linear(dim, dim) self.cache_k None self.cache_v None def forward(self, x, is_prefill): q self.W_q(x) k self.W_k(x) v self.W_v(x) if is_prefill: # 预填充阶段整段序列一起算直接建立缓存 self.cache_k k self.cache_v v else: # 解码阶段只算新 token追加到缓存 self.cache_k concat(self.cache_k, k, dim1) self.cache_v concat(self.cache_v, v, dim1) # 用完整的 K、V 做注意力 attn softmax(q self.cache_k.transpose(-1, -2) / sqrt(dim)) return attn self.cache_v关键点在于区分prefill 阶段和decode 阶段。prefill 是处理用户输入的那一次前向序列长、并行度高、算力密集decode 是逐 token 生成序列短、访存密集。两者的性能特征完全不同优化手段也不一样。prefill 阶段要尽量做大 batch 提升算力利用率decode 阶段要尽量减少访存和 kernel 启动开销。4.2 分页缓存的 block 管理实现分页方案的核心是 block table 和物理 block 池。我用简化代码说明管理逻辑class BlockManager: def __init__(self, num_blocks, block_size): self.block_size block_size self.free_blocks list(range(num_blocks)) self.block_table {} # request_id - [block_ids] def allocate(self, request_id, num_tokens): needed ceil(num_tokens / self.block_size) blocks [self.free_blocks.pop() for _ in range(needed)] self.block_table[request_id] blocks return blocks def append_token(self, request_id): blocks self.block_table[request_id] last_block blocks[-1] # 判断最后一个 block 是否还有空位 if self.block_used[last_block] self.block_size: self.block_used[last_block] 1 else: new_block self.free_blocks.pop() blocks.append(new_block) self.block_used[new_block] 1 def free(self, request_id): for b in self.block_table.pop(request_id): self.free_blocks.append(b)block_size 的选择是个权衡太小则 block table 很长管理开销大太大则内部碎片多浪费显存。实践中 16 是常见默认值长序列场景可以调到 32 或 64。我试过 8 和 128 两个极端8 的管理开销明显128 的碎片浪费明显16 到 32 是比较舒服的区间。4.3 前缀共享的引用计数实现前缀共享需要引用计数来管理生命周期class SharedPrefixCache: def __init__(self): self.ref_count defaultdict(int) def share_prefix(self, src_request, dst_request, prefix_len): blocks self.block_table[src_request] shared blocks[:ceil(prefix_len / self.block_size)] self.block_table[dst_request] list(shared) for b in shared: self.ref_count[b] 1 def free(self, request_id): for b in self.block_table.pop(request_id): self.ref_count[b] - 1 if self.ref_count[b] 0: self.free_blocks.append(b)这里有个坑共享的 block 是只读的任何请求都不能往共享 block 里写新 token。所以当共享前缀的请求要生成新内容时必须做copy-on-write把最后一个共享 block 复制一份私有化再往里追加。这个逻辑如果漏了会导致多个请求互相污染缓存生成结果错乱而且这种 bug 很难复现因为取决于请求的时序。4.4 参数调优的实测记录我在一台 80G 显存的卡上做过一组对比实验模型是 13B 级别输入 512 token输出 512 token测不同配置下的吞吐配置缓存精度block_size最大并发吞吐(tokens/s)显存占用AFP16168124062 GBBFP16328131060 GBCFP81616218071 GBDFP83216226068 GB从数据能看出几个规律block_size 从 16 提到 32 有小幅提升因为管理开销降低FP8 让并发翻倍吞吐接近翻倍但显存占用反而更高是因为并发上去了。这组数据说明在显存受限的场景下缓存精度是比 block_size 更有效的杠杆。不过 FP8 的收益不是无条件的。如果任务本身输出很短比如分类或抽取缓存占用本来就小FP8 带来的显存节省意义不大反而增加了精度风险。所以调优前先搞清楚你的瓶颈是显存还是算力。5. 常见问题与排查技巧实录5.1 生成结果错乱或重复这是 KV Cache 最典型的问题表现是模型输出突然开始重复某句话或者答非所问。排查思路按这个顺序走先查位置编码。缓存复用时新 token 的位置编码必须接在已有序列后面如果位置索引算错注意力就会错位。尤其是用了 RoPE 这类相对位置编码的模型缓存的 K 在追加新 token 时需要应用正确的位置旋转漏了或错了都会出问题。再查缓存污染。前面提到的共享 block 被写入就是典型场景。检查你的 copy-on-write 逻辑是否覆盖了所有写入路径。最后查精度溢出。FP8 或 FP16 在超长序列下可能累积误差尝试切回 BF16 看是否复现。5.2 显存缓慢泄漏服务跑一段时间后显存越来越高最后 OOM。这种问题通常是 block 没有正确归还。排查方法是在 BlockManager 里加统计定期打印 free_blocks 的数量。如果请求结束后 free_blocks 没有回升说明有 block 泄漏。常见原因是异常路径没有释放。比如请求中途被取消、生成超时、或者解码出错这些分支如果没走 free 逻辑block 就永远占着。我的做法是用 try-finally 包住整个请求生命周期确保无论怎么退出都会释放。5.3 首 token 延迟高但后续很快这是 prefill 和 decode 性能特征不同导致的。首 token 延迟由 prefill 决定如果输入很长prefill 就是算力瓶颈。优化方向是 chunked prefill把长输入切成几块分批处理避免单次 prefill 占用太久阻塞其他请求。另一个原因是前缀没共享。如果所有请求都有相同的长系统提示词每次 prefill 都重算一遍就是浪费。开启前缀共享后第一个请求算完后续请求直接复用首 token 延迟能降一个数量级。5.4 并发上不去显存够但并发就是提不上去通常是调度策略的问题。检查你的批处理是否按最长序列对齐如果是短请求会被长请求拖累。分页方案下应该按 block 独立调度不同请求互不影响。还有一种情况是 block 池太小请求来了没 block 可用只能排队。这时候要么增大 block 池加显存要么提高 block 复用率开启前缀共享、及时释放。5.5 常见问题速查表现象可能原因排查方向输出重复/错乱位置编码错误、缓存污染检查 RoPE 应用、copy-on-write显存泄漏block 未释放统计 free_blocks、检查异常路径首 token 慢prefill 瓶颈、无前缀共享chunked prefill、开启共享并发低批对齐、block 池不足独立调度、扩容或提复用长序列质量下降缓存精度不足切 BF16、检查数值范围延迟毛刺动态扩容拷贝改预分配分页方案实操心得KV Cache 的 bug 有个共同特点——在小规模测试下不复现一上并发就出问题。所以测试时一定要模拟真实并发单请求跑通不代表没问题。我吃过这个亏单测全绿上线十分钟就炸。6. 进阶优化方向与个人经验6.1 缓存量化与压缩除了降精度还有更激进的压缩手段。比如对 K 做低秩近似因为 K 的秩往往远小于维度或者对 V 做分组量化不同通道用不同缩放因子。这些方法能进一步压缩显存但实现复杂度高且对质量的影响需要仔细评估。我的建议是先把分页和前缀共享做扎实这些基础优化的收益确定性更高压缩类手段留到确实需要时再上。6.2 滑动窗口与稀疏注意力对于超长上下文全量缓存不现实。滑动窗口只保留最近 N 个 token 的缓存老 token 丢弃显存恒定。代价是丢失远距离信息。稀疏注意力则是有选择地保留重要位置的缓存比如 attention sink 机制保留开头几个 token 作为锚点。这些方法在特定任务上有效但通用性不如全量缓存选型时要看你的业务是否容忍信息损失。6.3 我踩过的几个坑第一个坑是忽略了 prefill 和 decode 的 kernel 差异。我一开始用同一套 kernel 处理两个阶段结果 decode 阶段 kernel 启动开销占比过高性能很差。后来针对 decode 写了专门的融合 kernel把多个小算子合并速度提升明显。第二个坑是block_size 设成 1。当时想着这样最省显存结果 block table 长到爆炸管理开销比省下的显存还值钱。教训是任何参数都有合理区间极端值往往得不偿失。第三个坑是没做前缀共享就上线。系统提示词 2000 token每条请求都重算GPU 利用率虚高但吞吐上不去。加上共享后同样的硬件多扛了三倍流量。这个优化实现不难但收益巨大属于必做项。6.4 给不同阶段同学的建议如果你刚接触推理优化先把 KV Cache 的原理和显存账算明白这是所有后续优化的基础。如果你已经在做服务部署重点检查分页管理和前缀共享有没有做到位这两项是性价比最高的优化。如果你在自研推理框架那缓存的数据布局、kernel 融合、调度策略是拉开差距的地方值得投入精力深挖。KV Cache 这件事表面看是个缓存问题往深了挖是显存管理、访存优化、调度策略的综合工程。它没有银弹每个决策都要结合你的模型结构、硬件配置、业务负载来权衡。我到现在也不敢说吃透了每次遇到新的负载模式总能发现之前没考虑到的角落。这大概就是推理优化的魅力所在永远有下一个瓶颈等着你。