上一篇《微调与对齐:SFT、RLHF、DPO 如何让模型“听话”》讲的是如何让模型更符合人的需求。本篇解决另一个问题:模型已经会回答了,怎样让它回答得更快,以及为什么上下文越长越吃显存。

先给结论:KV Cache 用显存换重复计算,量化用较低数值精度换存储和带宽;它们都不能让长上下文变得“免费”。

一、生成一个新 Token,为什么还要重算旧内容?

想象你在接龙写小说。每写一个新字,都把前面所有内容重新阅读、重新做笔记,显然很浪费。更合理的方式是:旧内容的笔记留着,新字来了只补一页,再查阅已有笔记。

自回归模型也类似:每轮根据已有 Token,预测下一个 Token。如果采用最直接的实现,每轮都把完整前缀重新送进模型:

第 1 轮:处理 [A B C]       → 预测 D
第 2 轮:处理 [A B C D]     → 预测 E
第 3 轮:处理 [A B C D E]   → 预测 F
          └─旧内容反复计算─┘

前面介绍过因果注意力:每个位置只能看自己和更早的位置。因此,在标准因果 Transformer 的推理中,追加新 Token 不会改变旧位置的表示,旧位置计算出的 Key、Value 也不需要更新。

这就是 KV Cache 能成立的关键。以下默认模型处于推理模式、位置编码处理一致,而且没有淘汰缓存等额外机制。

二、KV Cache:保存旧笔记,只计算新内容

2.1 到底缓存什么?

对某一层注意力,输入表示先投影成:

Q=XWQ,K=XWK,V=XWV Q=XW_Q,\qquad K=XW_K,\qquad V=XW_V Q=XWQ​,K=XWK​,V=XWV​

其中:

  • XXX:这一层的输入表示;
  • WQ,WK,WVW_Q,W_K,W_VWQ​,WK​,WV​:模型学到的投影矩阵;
  • QQQ:查询,表示当前位置要寻找什么;
  • K,VK,VK,V:键和值,供其他位置检索和读取。

每层分别保留历史位置的 K,VK,VK,V。新 Token 到来时,只计算它自己的 qt,kt,vtq_t,k_t,v_tqt​,kt​,vt​,然后追加缓存:

K1:t=[K1:t−1;kt],V1:t=[V1:t−1;vt] K_{1:t}=[K_{1:t-1};k_t],\qquad V_{1:t}=[V_{1:t-1};v_t] K1:t​=[K1:t−1​;kt​],V1:t​=[V1:t−1​;vt​]

ot=softmax⁡(qtK1:tTdh)V1:t o_t=\operatorname{softmax}\left(\frac{q_tK_{1:t}^{\mathsf T}}{\sqrt{d_h}}\right)V_{1:t} ot​=softmax(dh​​qt​K1:tT​​)V1:t​

这里,ttt 是当前位置,分号表示沿序列维拼接,dhd_hdh​ 是每个注意力头的维度,oto_tot​ 是当前头的输出。

为什么通常不缓存 Q? 因为预测新位置时只需要当前查询,旧查询对应的输出已经算完了;未来 Token 要读取的是旧位置的 K、V。

各层历史 K、V ──────────────┐
                           ↓
新 Token → 当前层 q、k、v → 追加 k、v → 当前 q 查询全部 K、V
                                                   ↓
                                             进入下一层

2.2 Prefill 与 Decode 是两种工作

阶段处理内容主要特点
Prefill,提示词预填充一次处理整个输入建立各层 KV Cache,得到预测首个输出 Token 的分布
Decode,逐步解码每轮处理一个新 Token追加缓存,用当前查询读取历史 K、V

对完整 Transformer 来说,缓存还避免了反复执行旧位置的前馈网络等计算。不过,它没有省掉新查询对历史 K、V 的访问,上下文越长,每个解码步通常仍然越贵。

三、KV Cache 到底吃多少显存?

把缓存看成一个表格:每层、每条请求、每个 Token、每个 KV 头,都要存一份 K 和一份 V。

在各层配置相同、每条序列长度相同的情况下:

MKV=2×L×B×S×HKV×dh×b M_{\mathrm{KV}}=2\times L\times B\times S\times H_{\mathrm{KV}}\times d_h\times b MKV​=2×L×B×S×HKV​×dh​×b

各符号含义如下:

符号含义
222K 和 V 两份数据
LLLTransformer 层数
BBB同时缓存的序列数
SSS每条序列已缓存的 Token 数
HKVH_{\mathrm{KV}}HKV​每层 KV 头数,不一定等于查询头数
dhd_hdh​每个头的维度
bbb每个缓存元素占用的字节数

例如,32 层、8 个 KV 头、头维度 128、缓存使用 FP16,每条请求已有 8192 个 Token:

MKV=2×32×1×8192×8×128×2=1 GiB M_{\mathrm{KV}} =2\times32\times1\times8192\times8\times128\times2 =1\ \mathrm{GiB} MKV​=2×32×1×8192×8×128×2=1 GiB

这里 1 GiB=2301\ \mathrm{GiB}=2^{30}1 GiB=230 字节。

改动理论 KV 数据量
上述配置,单条 8192 Token1 GiB
长度增加到 32768 Token4 GiB
4 条请求,每条 8192 Token4 GiB
KV 头数由 8 增加到 32,单条 8192 Token4 GiB

普通多头注意力通常每个查询头对应一个 KV 头;GQA 让多个查询头共享一组 K、V,因此可以减少缓存。

这只是 KV 数据本身,不是推理总显存。总显存还包括权重、临时张量、运行时工作区、缓存分配余量等。实际服务中,各请求长度不同,也要按各自长度累计估算。

四、长上下文为什么贵:平方与线性要分清

如果会议室里有 SSS 个人,每个人都要评估其他所有人的发言,就存在约 S2S^2S2 对关系。完整注意力的计算与此类似。

设序列长度为 SSS、查询头数为 HQH_QHQ​、每头维度为 dhd_hdh​。完整注意力的两次主要矩阵乘法,即 QKTQK^{\mathsf T}QKT 和注意力权重乘 VVV,计算量为:

O(S2HQdh) O(S^2H_Qd_h) O(S2HQ​dh​)

因果掩码只允许看左侧,仍不改变平方级的渐近复杂度。若其他条件相同,长度变成原来的 4 倍,这部分计算约变成 16 倍,并非整个模型耗时必然变成 16 倍。

使用 KV Cache 后,单个解码步只有一个新查询,面对 SSS 个历史位置,注意力计算变成:

O(SHQdh) O(SH_Qd_h) O(SHQ​dh​)

但若初始提示长度为 PPP,经过预填充后再执行 TTT 个增量解码步,累计注意力访问规模仍是:

∑t=1T(P+t)=TP+T(T+1)2 \sum_{t=1}^{T}(P+t)=TP+\frac{T(T+1)}{2} t=1∑T​(P+t)=TP+2T(T+1)​

所以单步线性,不代表生成很长一段文本的累计代价也是线性。

还要区分两种内存:

  • KV Cache:随缓存长度线性增长。
  • 显式注意力分数矩阵:形状包含 S×SS\times SS×S,内存随长度平方增长。

FlashAttention 一类实现通过分块等方法,避免在显存中完整保存这个平方矩阵并减少数据搬运,但没有把标准稠密注意力的算术复杂度改成线性。

五、量化:把精细刻度换成较粗刻度

量化像把精确到小数的测量值,改存成有限档位的整数编号:占用更小,但存在舍入误差。

一种简单的对称量化写法是:

q=clip⁡(round⁡(x/s),qmin⁡,qmax⁡),x^=sq q=\operatorname{clip}\left(\operatorname{round}(x/s),q_{\min},q_{\max}\right), \qquad \hat{x}=sq q=clip(round(x/s),qmin​,qmax​),x^=sq

其中,xxx 是原浮点数,s>0s>0s>0 是缩放系数,qqq 是量化整数,qmin⁡,qmax⁡q_{\min},q_{\max}qmin​,qmax​ 是整数范围,x^\hat{x}x^ 是近似恢复的浮点数。例如,x=0.73x=0.73x=0.73、s=0.1s=0.1s=0.1,量化后为 7,恢复后为 0.7。

格式每个值的理论存储相对 FP16 的原始数据量
FP162 字节100%
INT81 字节50%
INT40.5 字节,需要打包25%

实际还要保存缩放系数,有些方案还有零点,并常按通道或分组量化,因此实际占用高于理想值。

量化对象也要分清:

  • 权重量化:减小模型权重,可能降低权重读取带宽。
  • 激活量化:对计算中的中间表示量化。
  • KV Cache 量化:直接减小历史缓存,但需要专门支持,也可能影响精度。

权重用了 INT4,不代表 KV Cache 也用了 INT4,更不代表速度一定提高 4 倍。 一些实现仍使用浮点激活,读取压缩权重后再转换计算;实际收益取决于硬件、内核、批量大小、反量化开销,以及当前瓶颈是否在内存带宽上。

六、NumPy 实验:缓存前后少算了多少?

下面模拟一个单层、单头因果注意力:提示长度为 8,再逐步加入 3 个固定 Token,总共比较 4 次前向计算。两条路径使用相同输入,以便验证输出一致。

为聚焦缓存,省略位置编码、输出投影和前馈网络。统计单位为 MAC(一次乘加):矩阵 (m,k)(m,k)(m,k) 乘 (k,n)(k,n)(k,n) 记作 mknmknmkn 次 MAC;不统计 softmax、拼接和访存,因此不是速度测试。

import numpy as np

rng = np.random.default_rng(42)
prompt_len, steps, d = 8, 4, 16
x = rng.normal(size=(prompt_len + steps - 1, d))
wq, wk, wv = [rng.normal(size=(d, d)) / np.sqrt(d)
              for _ in range(3)]


def softmax(a):
    e = np.exp(a - a.max(axis=-1, keepdims=True))
    return e / e.sum(axis=-1, keepdims=True)


def full_forward(tokens):
    n = len(tokens)
    q, k, v = tokens @ wq, tokens @ wk, tokens @ wv
    scores = q @ k.T / np.sqrt(d)
    mask = np.triu(np.ones((n, n), dtype=bool), k=1)
    scores = np.where(mask, -np.inf, scores)
    out = softmax(scores) @ v
    macs = 3 * n * d * d + 2 * n * n * d
    return out, k, v, macs


# 不缓存:每轮重新处理完整前缀。
full_outputs = []
full_macs = 0
for n in range(prompt_len, prompt_len + steps):
    out, _, _, cost = full_forward(x[:n])
    full_outputs.append(out[-1])
    full_macs += cost

# 使用缓存:先预填充,之后每轮只处理一个新位置。
out, k_cache, v_cache, cached_macs = full_forward(x[:prompt_len])
cached_outputs = [out[-1]]

for pos in range(prompt_len, prompt_len + steps - 1):
    token = x[pos:pos + 1]
    q = token @ wq
    k_cache = np.concatenate((k_cache, token @ wk), axis=0)
    v_cache = np.concatenate((v_cache, token @ wv), axis=0)
    scores = q @ k_cache.T / np.sqrt(d)
    out = softmax(scores) @ v_cache
    cached_outputs.append(out[0])
    n = len(k_cache)
    cached_macs += 3 * d * d + 2 * n * d

same = np.allclose(full_outputs, cached_outputs, atol=1e-10, rtol=1e-10)
print(f"输出一致: {same}")
print(f"无缓存 MACs: {full_macs}")
print(f"有缓存 MACs: {cached_macs}")
print(f"计算量比值: {full_macs / cached_macs:.2f}")

运行结果:

输出一致: True
无缓存 MACs: 40896
有缓存 MACs: 11456
计算量比值: 3.57

这里有三个值得注意的地方:

  1. 缓存复用了相同的 K、V,不是近似算法;两条路径的输出在浮点误差范围内一致。
  2. 增量步骤只有最新位置的查询,缓存里没有未来位置,因此无需额外的三角掩码。
  3. 示例的完整路径先计算整个分数矩阵再加掩码,统计包含了被遮住的位置;它对应这份代码,不代表所有优化内核的计算方式。

代码用 concatenate 便于理解,但每轮会复制缓存;生产实现通常采用预分配或分页管理。因此,3.57 是这个实验的矩阵乘加量比值,不能直接当成真实推理加速比。

七、把三件事放在一起看

方法或现象主要影响不能忽略的代价
KV Cache避免反复计算旧位置缓存占显存,新查询仍需读取历史
INT8 / INT4 量化减少存储,可能降低带宽压力数值误差、转换开销、内核支持
更长上下文允许输入更多内容预填充更贵,缓存更大,解码访问更多历史

排查推理瓶颈时,可以先问:慢在预填充还是逐步解码?显存主要被权重占用,还是被并发请求的 KV Cache 占用?这比直接把所有参数都改成 INT4 更有针对性。

下一篇《RAG:让大模型先查资料再回答》,我们将讨论如何让模型利用外部资料回答问题,而不是只依赖参数中的知识。

如果这篇对你有帮助,欢迎点赞收藏,关注我获取更多实战干货。

Logo

更多推荐