2.1 自回归生成的困境与KV Cache的诞生 本节摘要:自回归生成天生带着重复劳动——每吐出一个新 token,注意力都要把前面所有历史 token 重新算一遍。KV Cache 把每个 token 的键、值矩阵在算出后钉在显存里,后续步骤直接取用,把单请求内注意力的计算量从随序列长度平方增长压到线性增长。这一节先用直觉讲清浪费在哪,再推导两种情形的复杂度,最后用一段不到二十行的脚本把乘加次数的差距摆到台面上。 一次生成里藏着多少重复劳动 先把镜头对准"模型怎么说话"。Transformer 不是一次性把整句话写完,而是一个字一个字往外蹦:给了提示词,它先预测第 1 个新 token;把这个 token 接在后面,再预测第 2 个;如此往复,直到吐出结束符。
本节摘要:自回归生成天生带着重复劳动——每吐出一个新 token,注意力都要把前面所有历史 token 重新算一遍。KV Cache 把每个 token 的键、值矩阵在算出后钉在显存里,后续步骤直接取用,把单请求内注意力的计算量从随序列长度平方增长压到线性增长。这一节先用直觉讲清浪费在哪,再推导两种情形的复杂度,最后用一段不到二十行的脚本把乘加次数的差距摆到台面上。
先把镜头对准"模型怎么说话"。Transformer 不是一次性把整句话写完,而是一个字一个字往外蹦:给了提示词,它先预测第 1 个新 token;把这个 token 接在后面,再预测第 2 个;如此往复,直到吐出结束符。这种"用已生成的去生成未生成的"方式叫自回归。
问题就出在这个"接在后面"。注意力机制要求第 t 个 token 在预测时,必须回头看第 1 到第 t-1 个 token 的全部信息——而且看的方式不是查个摘要,是把自己这一刻的查询向量,和前面每一个 token 的键、值向量做点积。键和值向量(也就是 K 和 V)是从原始隐状态里线性投影出来的,算一次就定了,后面不会再变。可如果一个朴素的实现每次预测都从头跑一遍完整前向,那么第 2 个 token 算了一遍前 1 个的 K、V,第 3 个 token 又把前 2 个的 K、V 重算一遍,第 t 个 token 把前 t-1 个全重算一遍。
把账算清楚:生成到第 100 个 token 时,第 1 个 token 的 K、V 已经被算过 99 次,第 50 个被算过 50 次。这些中间结果每一次都一模一样,却白白烧了 99 倍、50 倍的算力。第 1 章讲"重复 Prefill 是跨请求的浪费",这里是更隐蔽的"单请求内的重复"——它不来自两个请求撞车,而来自自回归机制本身的串行特性。
KV Cache 的念头朴素到近乎常识:既然 K、V 算出来就不变,那第一次算完别扔,存进显存,后面谁要谁取。就像工厂里每道工序产出的半成品先码进仓库,下一道工序直接领用,而不是每次都从原料重做。显存在这里扮演的正是"半成品仓库",它预付了存放成本,回收的是后续每一步省下的重算。
现在把"浪费"翻译成可比较的数字。我们只盯注意力里最重的一块:查询和键做打分、再用分权重平均值的那两步。设隐藏维度为 d,序列已经长到长度 t。
没有 KV Cache 的情形。 生成第 t 个 token 时,实现要对前 t 个 token 跑完整前向,光注意力这块就要为这 t 个 token 各自算 K、V(每个约 2·d² 次乘加),再让新查询和这 t 个键一一打分(约 t·d 次),再用分数对 t 个值加权(又约 t·d 次)。主导项是随 t 平方增长的那一块。把从第 1 个到第 n 个 token 全加起来,总和的量级是 1²+2²+…+n² ≈ n³/3。
有 KV Cache 的情形。 第 t 个 token 进来,只算它自己的 K、V(2·d² 次,和 t 无关),直接写进缓存;打分和加权只和已经躺在缓存里的 t 个键、值发生,约 2·t·d 次。注意这里乘加次数随 t 线性增长,不再平方。从第 1 加到第 n 个,总和量级是 1+2+…+n ≈ n²/2。
两式相除,差距大约是 (n³/3)/(n²/2) = 2n/3。也就是说,序列越长,有缓存相对无缓存的优势越夸张——生成 100 个 token,差距约 67 倍;生成 1000 个,差距约 667 倍。复杂度从"随序列长度平方"降到"随序列长度线性",这个降价不是挤牙膏,是量级上的搬迁。
顺便点破一个常见误解:KV Cache 省的不是"一次前向"的全部,而是"每步重算历史"的那部分。它不减少生成第一个 token 的成本,也不减少每层里把查询投影出来的固定开销,它减的是"历史被重复搬进流水线"的累加浪费。所以增益随已生成长度单调递增,越长的回答越划算。
光推导不够,跑一下更踏实。下面这段脚本做的事很单纯:给定生成长度 n 和隐藏维度 d,分别累加"无缓存"和"有缓存"两种情形下的注意力量级(用相对乘加次数代替绝对 FLOP,比例不变),最后打印差距倍数。它不算任何真实张量,只是把上面两个求和公式落成循环,所以不到二十行就能说清问题。
# 对比有无 KV Cache 时,生成 n 个 token 的注意力量级(相对乘加次数) n = 128 # 已生成的历史长度,也等于本次新生成的数量 d = 4096 # 模型隐藏维度,仅用于量级,不影响比例 # 无缓存:第 t 步对前 t 个 token 全量重算,注意力量级随 t 平方 no_cache = sum(t * t for t in range(1, n + 1)) # 有缓存:每步只算新 token 与已存 KV 的交互,注意力量级随 t 线性 with_cache = sum(t for t in range(1, n + 1)) print("无缓存累计注意力量级:", no_cache) print("有缓存累计注意力量级:", with_cache) print("差距倍数约:", round(no_cache / with_cache, 1))
上面脚本的运行输出(n=128,d 不参与求和故输出与 d 无关):
无缓存累计注意力量级: 708160 有缓存累计注意力量级: 8256 差距倍数约: 85.8
把 85.8 和公式给的 2n/3 = 85.3 对一下,基本吻合——偏差来自求和取整而非近似。结论很硬:同样生成 128 个 token,无缓存要把注意力量级堆到八十五倍之高。
脚本刻意只算相对量级,因为真实 FLOP 还含前馈层和各类投影,但那些项在"有无缓存"两种情形下的比值相同,不影响力量的差距方向。若想看绝对数字,把相对量级乘以每层 2·d²·n 这个常数系数即可,比例保持不动。这正说明 KV Cache 的增益是结构性、与模型宽度无关的。
背景:上面跑的是 128 个 token 的中等长度。工程里常遇到几千 token 的长文生成,比如代码补全一口气写一大段、或者 Agent 多轮后汇总结论。
操作:把脚本里的 n 依次改成 512、1024、2048,其余不动,重跑三次。
结果:n=512 时差距约 341 倍,n=1024 时约 683 倍,n=2048 时约 1365 倍。倍数严格按 2n/3 爬升,没有平台期。
解读:这印证了复杂度推导——无缓存的成本是立方增长、有缓存是平方增长,二者比值随 n 线性放大。换句话说,序列越长,不上 KV Cache 的代价越不可承受;长到几千 token 时,无缓存几乎等于把服务拖垮。
变式:如果把 d 从 4096 改成 8192,输出倍数不变,因为 d 在两项里等比出现、相除约掉。这说明 KV Cache 的增益和"模型多宽"无关,只和"生成多长"有关——它专治长序列,不治宽模型。

铺垫到这,得泼一点冷水,否则容易把 KV Cache 当成万灵药。它用显存换算力:每多缓存一个 token,就要在显存里给它留 2·层数·KV头数·头维度·精度的空间。短请求、低并发时这笔预付几乎可忽略;可一旦上下文长、并发高,缓存本身就会吃光显存——这正是 2.2 要摊开的账。
另一个常被忽略的点:KV Cache 要求严格的自回归因果顺序,且缓存和具体的 Batch 形状、调度策略耦合。一旦服务要做连续批处理(continuous batching),不同请求长度不一、进度不同,缓存的存和取就不再是简单的"先进后出",而需要一套显存管理机制来避免互相踩踏。PagedAttention 正是冲着这个麻烦去的,我们 2.3 再细说。
把 KV Cache 接进系统,还有一个变化值得记:它把解码阶段的主要成本从"算力"换成了"显存带宽"。没有缓存时,每生成一个 token 都要重算全部历史的 K、V,那是实打实的乘加运算,瓶颈在 GPU 的计算单元;有了缓存,K、V 现成躺在显存里,每步只需把这批向量从显存搬到片上再做点积,计算量小了,可要搬运的数据没少——甚至因为要读的历史越长,要搬的字节越多。
这意味着长上下文解码时,GPU 常常不是算不过来,而是在等数据从显存过来。这也解释了为什么"把 KV 做小"(2.5 的压缩)和"把 KV 摆得紧凑"(2.3 的页式)会直接转化为延迟收益:它们减少的不是运算,而是搬运。一个看似矛盾的现象由此可解——有的服务上了 KV Cache 后首 token 延迟没变(首 token 属于 Prefill,本就要完整算),但后续每 token 延迟大幅下降(解码变成了搬运而非重算)。
缓存越大,单请求占的显存越多,能同时解码的并发就越少,于是吞吐反而受"缓存挤占"制约。这又把话题交还给 2.2 的账本和 2.3 的分配——复用省了算力,却向显存借了债,债总要还。
本节先记住一句话收尾:KV Cache 不创造算力,它只是把已经付过的算力在显存里"存票再兑付",而这张票的兑现效率,取决于下一节要算的显存账本。