你大概率见过下面这个公式:Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V。市面上讲注意力机制的文章,十篇里八篇都是这个套路:贴公式、画一个注意力矩阵热力图、然后告诉你"这就是注意力"。公式没有错,但如果你真的动手写过一次模型推理,或者调过某个序列模型的长上下文性能,就会发现这套描述完全不解决实际问题。维度怎么对上?为什么要除以根号d_k而不是别的?mask为什么是加进去的而不是乘上去的?推理的时候代码里为什么莫名其妙多了一大堆K和V的缓存?
这篇不打算再复述一遍公式,而是把一次真实的注意力计算从头到尾拆开:从输入向量进入投影矩阵那一刻,到注意力分数生成,到掩码和Softmax,再到多头拼接落回主链路,最后到KV Cache是怎么在推理阶段把重复计算省掉的。全程我会用一个固定配置来讲数字:模型宽度d_model=512,8个注意力头,每头维度d_k=d_v=64。这样每一处的形状变化都能算得清清楚楚。适合两类人:一类是公式背得熟但代码里维度总是对不上的初学者,另一类是自己写推理引擎或者做长上下文优化、需要真正理解缓存和带宽瓶颈的工程师。
1. 从一条输入序列出发:QKV投影的维度是怎么一步步变形的
1.1 三个矩阵的分工:一次档案查找
先不急着看公式,先理解Q、K、V这三个角色到底是谁。
把注意力想象成你在一个档案室里找资料:V是柜子里真正的内容,K是贴在每份档案背面的检索标签,Q是你手里拿着的查询申请单。注意力的过程,就是拿你的查询单,去跟所有档案的检索标签做匹配,匹配分数高的档案内容会被更多地提取出来。这里的"匹配分数"就是Q和K的内积,最后加权提取的内容就是V的加权和。
这个类比能解释为什么Q、K、V用的是完全不同的投影矩阵。W_Q负责把输入变成"查询"形态,W_K负责变成"标签"形态,W_V负责变成"内容"形态。三者从同一个输入X出发,但各自进入不同的向量空间,匹配才有意义。如果Q和K投影完全一样,那就退化成只能比较输入自身的相似度,模型能表达的关系一下子就窄了很多。
1.2 从X到Q/K/V:维度变化的全过程
输入X的形状一般是[B, L, D],B是批次大小,L是序列长度,D是模型宽度。为了把数字算明白,假设B=1、L=1024、D=512。
三个投影矩阵的形状是[D, D]。这一步要特别注意:很多人以为每个头独立搞一个W_Q,但现代实现通常是把所有头拼在一起投影,再用reshape拆开。也就是说,W_Q的形状是[512, 512],X乘上去之后得到Q,形状还是[1024, 512]。然后把这512维拆成8组,每组64维,于是得到8个shape为[1024, 64]的头。K和V完全是同样的流程。
我把这个形状变化用代码写出来,方便对着看:
import numpy as np D, H, d_k = 512, 8, 64 x = np.random.randn(1024, D) w_q = np.random.randn(D, D) w_k = np.random.randn(D, D) w_v = np.random.randn(D, D) # 先整体投影,再拆头 q = x @ w_q # [1024, 512] k = x @ w_k # [1024, 512] v = x @ w_v # [1024, 512] # 拆成多头:最后一维切分成 H 段 n_head_dim = D // H # 64 q = q.reshape(1024, H, n_head_dim).transpose(0, 1) # [H, 1024, 64] k = k.reshape(1024, H, n_head_dim).transpose(0, 1) v = v.reshape(1024, H, n_head_dim).transpose(0, 1)reshape和transpose这一步看着不起眼,但它是多头并行计算的基石。reshape把512维切成8段,transpose把头的维度挪到最前面,让每个头内部的计算互不干扰。很多新手在这写错,最常见的错误是把reshape写成view,在不能连续内存的时候直接报错——这属于框架层面的坑,后文我还会提。
1.3 为什么每头维度通常是D/H
现代Transformer里,Q/K/V的单头维度几乎总是d_k = D/H。这不是巧合。如果每个头都有完整的D维空间,H个头的参数一下膨胀H倍,计算量根本扛不住。把D维切成H份,每个头在一个子空间工作,总参数反而和单头保持一致,这才能让多头在不增加计算量的前提下获得多视角。
实际落地时d_k常见取32、64、128,对应8头、12头、32头的配置。d_k并不是越大越好。维度越大,单次匹配的区分度越高,但矩阵QK^T的数值规模也会变大,后文说到缩放因子时会牵扯到这个问题。我的经验是,在模型带宽不够深的时候,盲目加头数不如降低d_k换更多头,关系建模通常更细腻;但如果头太多,每个头分到的信息太少,训练时反而容易不稳定,语感会变差。这个度需要在具体任务上试,不要迷信某个固定值。
2. 缩放点积:那个除以根号d_k的动作到底防住了什么
2.1 不缩放会怎样:一个方差实验
上一节算出了Q和K,现在计算它们的内积矩阵。对于每个头,Q的形状是[H, L, d_k],K的形状是[H, L, d_k],QK^T得到[H, L, L]的分数矩阵,每一个位置代表一个query和某个key的匹配程度。
问题来了:为什么不直接拿这个分数去做Softmax,非要额外除以一个sqrt(d_k)?
先假设Q和K里的每个分量都是独立的随机变量,均值0、方差1。那么在计算某个内积score = sum_i(q_i * k_i)时,每个乘积的期望方差是1,内积的方差就是d_k。也就是说,当d_k=64时,内积的标准差大概是8;当d_k=128时,标准差大概是11.3。维度越高,分数越分散。
高维分数下一步要进Softmax,而Softmax对尺度极其敏感。一个直观的例子:假设某个query跟其中一个key的匹配分数是10,跟其他key的匹配分数是0。不缩放且直接Softmax时,exp(10)大概22026,其他位置exp(0)=1,那几乎所有权重都被这个key拿走,结果接近one-hot。但如果这一行的分数整体都偏大,比如最大值50、其他值45,Softmax依然会给出接近one-hot的分布。
从梯度角度看更严重。Softmax饱和区梯度接近于0,如果分数分布太极端,模型更新会非常缓慢。整个训练过程会变成"一开始就拍死,后面基本不动"。
2.2 除以根号d_k的数学理由
除以sqrt(d_k)本质上是把内积分数的方差重新压回到1的量纲。我个人的理解方式是把内积当成一个"信号叠加"过程:d_k维的独立小信号叠加,信噪比天然会随维度上升。除以sqrt(d_k)就是给分数做一个尺度补偿,相当于把所有头的匹配分数统一到同一音量,再交给Softmax。
这个动作也解释了为什么维度越大越需要缩放。d_k=64时,除以8;d_k=128时,除以11.3。如果你在某个大模型里看到注意力分数的分布明显靠向0或1,第一反应可以去检查这个缩放因子有没有正确生效,尤其是自己手写推理代码时,这步非常容易漏。漏掉的后果倒不一定是直接报错,而是模型输出质量慢慢变差,且很难定位,因为数值上只是偏差了一点点。
2.3 新时代的替代方案:QK归一化与可学习缩放
不过现在"必须除以sqrt(d_k)"这个说法已经不是铁律了。近两年不少模型开始用QK归一化代替固定缩放:对Q和K各自做一次RMSNorm或LayerNorm,再做内积。这样内积的方差天然被控制住,不再依赖sqrt(d_k)这个手工设计。还有一些实现直接用一个可学习的缩放系数s,让模型自己学出合适的尺度。
我实际对比过两种配置:固定缩放实现简单,数值行为在FP16下稳定;QK归一化的训练初期更稳,模型对学习率的敏感度更低,但对推理框架的融合优化要求更高。如果你只是在已有框架里做小改进,优先保持固定缩放;如果你想从零实现一个新架构,QK归一化值得考虑。这个选择背后没有绝对优劣,核心是你要理解缩放因子起到的是"数值稳定器"作用,而不是什么魔法常数。丢掉它或者替换它,都要先想清楚方差问题谁来管。
3. 掩码与Softmax:分数的"秩序化"过程不能省
3.1 因果掩码为什么用加法而不是乘法
注意力分数矩阵QK^T算出来之后,文本生成模型通常要加一个因果掩码。所谓因果,就是第t个token只能看到前t-1个token和当前token,不能看到未来。
这里有一个极容易踩坑的细节:掩码是"加"进分数矩阵的,不是"乘"上去的。标准做法是生成一个上三角为非常小负数的矩阵,把未来位置的注意力分数加上一个小负数,让它们在Softmax之后变成0。具体来说:
# scores: [H, L, L] 因果掩码示意 mask = np.full((L, L), 0.0) for i in range(L): mask[i, i+1:] = -1e9 # 未来位置给一个很大的负值 scores = scores / np.sqrt(d_k) + mask为什么不能用一个0/1矩阵把未来位置的分数乘成0?因为Softmax分母会把所有位置都算进去。即使某个位置的分数被清零,exp(0)=1仍然会在分母里占一席之地。在L=1024时,几百个未来位置的"1"会被加进分母,相当于把每个有效位置的权重都稀释了几百倍。更麻烦的是,这种稀释还不是均匀的,序列越长稀释越严重,因果性实际上被破坏了。
而加法形式里,未来位置的分数是-1e9,exp(-1e9)在浮点下就是0,不进分子也不进分母,干干净净。这就是为什么所有标准实现都选择加掩码而不是乘掩码。
3.2 Softmax的数值实现:先减最大值不是可选项
点亮掩码之后的分数矩阵,每一行要被Softmax归一化,让该query对所有key的注意力权重和为1。
Softmax的朴素写法是exp(x_i)/sum_j exp(x_j)。这里有个数值陷阱:如果x_i比较大,exp(x_i)会溢出。尤其在FP16下,能表示的最大正数约65504,但你只要exceed这个值就会变成inf,后面的除法直接产出NaN。注意力分数即使经过缩放,极端情况下仍然可能到十几个甚至几十个,exp(10)是22026,exp(12)约162754,直接溢出。
所以实际实现里几乎都先减去该行的最大值再指数:softmax(x) = exp(x - max(x)) / sum(exp(x - max(x)))。平移不改变相对大小,但能把指数函数的输入压到非正区间,从根源上防止溢出。这就是我在自己写融合算子时反复确认过的点:如果发现训练或推理Loss出现NaN,先查这个减去最大值的逻辑有没有丢;很多手写版本在小模型上没问题,一到长序列或者大量头时就开始炸,多半就是这个原因。
3.3 从密集注意力到稀疏化:分数的另一种走法
Softmax之后的权重矩阵通常还是稠密的,每个query对所有历史position都有非零权重。这也是长序列计算量O(L^2)的来源:1K序列长度,每层每头要算约100万对分数;序列到8K,这个数涨到6400万。
模型领域这几年有不少"让注意力稀疏化"的尝试,比如局部窗口注意力(每个query只看附近一段窗口)、全局token(每隔几个位置放一个全局可attend的token)、或者基于学习的稀疏选择。我倾向于把这些理解为对"分数分配方式"的重新设计:既然大部分token之间的注意力本来就接近0,强算它们就是在浪费算力。但稀疏化会让推理系统的访存模式变得不规则,很多经典优化手段会失效。实操上,如果算力充足,密集注意力在长序列场景反而是最容易优化的;算力紧张时才值得上稀疏方案。这是工程取舍,不是理论选择。
4. 多头拼接与输出投影:注意力结果如何回到主链路
4.1 每个头到底在关注什么
Softmax权重矩阵乘上V,输出每个头的注意力结果,形状[H, L, d_v]。这一步就是"按权重提取内容":权重越大,V中对应token的信息在输出向量里占比越高。
多个头并行的意义在于,不同头可以把注意力分配到不同类型的关系上。8个头里,可能有几个头学会了关注"紧邻的上一个token",几个头学会了关注"远距离的指代关系",还有几个头学会了关注"句子结构句法依赖"。这当然不是人工设计的,而是训练数据里的统计规律被不同的子空间分别捕获了。
有一种常见的误解是头与头之间必须有明确分工。实际上经过训练后的头经常会存在冗余,很多头关注的模式高度重叠。做模型剪枝时,直接砍掉一部分头往往对效果影响不大,这在一些做模型压缩的团队里是常规操作。这说明多头设计更多是给模型提供了足够的"并行表达空间",而不是保证每个头一定学到独特信息。
4.2 Concat后再乘W_O:为什么不能省
每个头的输出形状是[H, L, d_v]。它们需要被拼回原来的模型宽度D。具体过程是:先把8个头的输出在最后一维拼起来,得到[L, 512];再乘一个输出投影矩阵W_O,形状[512, 512],得到最终的[L, 512]。
这里的W_O绝不是可有可无的。每个头在各自的子空间里完成加权提取,但这些信息需要被融合成统一的模型宽度表示,才能进入后面的前馈网络、残差连接。你可以这样理解:注意力的"内容提取"是每个头分头做的,但最终"说什么话"必须由模型统一决定,W_O就是那个负责统一口径的角色。
如果省略W_O,直接把H个头的输出拼接起来,模型宽度会变成H*d_v,然后后续所有模块都要适配这个宽度,参数规模会失控。而且头与头之间没有任何交叉交互,信息的组合表达能力会大打折扣。所以在任何标准Transformer结构里,W_O都是不可省略的。
4.3 工程上的等价优化:逐头累加
从计算等价的角度讲,先拼接再乘W_O可以改成"每个头先乘W_O的一个切片,再把结果累加"。这样做数学上完全等价,但真正实现推理时通常逐头累加更节省显存。此处的差异在长序列、小显存环境下会被放大:如果先拼出完整的[L, 512],再乘[512, 512],需要保留一个较大的中间张量;逐头做则可以一边算一边累加,峰值显存更低。
我在做推理优化时,经常会从这种细小的中间张量管理上扣显存。一个标准的Transformer层里有好几次这样的机会,一次省几个MB,40层累计下来就接近百MB级别,足够多塞一两千token的上下文窗口了。这提醒我们:理解形状变化不只是为了看懂公式,还是为了知道哪些中间结果可以"用完即弃"、哪些必须保留。
5. KV Cache:推理时被省掉的万亿次重复计算是怎么省出来的
5.1 自回归里谁在做重复劳动
终于说到标题里最关键的KV Cache。要理解它,先要看清推理阶段的自回归生成是什么样的。
当模型生成token时,是逐token生成的:当前token预测下一个token,再把新token拼到序列末尾,作为下一轮的输入。这个过程要注意:第t步的输入是前t个token,模型要预测第t+1个token。
如果不做任何缓存,在第t步,模型需要重新对前t个token做一遍完整的QKV投影和注意力计算。可是前t-1个token的K和V在第t-1步已经算过了,它们没有变化。同样的K、V向量被反复算了好几遍,而且这个重复量随序列增长线性膨胀:总计算量大概是1+2+...+T的规模,也就是O(T^2)。在一千token的序列里还好,到了万级token的长上下文,这个重复计算直接让推理慢到不可用。
KV Cache的思路非常简单粗暴:既然K和V算过一次就不变了,那我把它们缓存下来。新token到来时,只需计算它自己的K和V,和缓存拼在一起,然后只算新token的Q和所有历史K的分数。
5.2 KV Cache的显存账单:一张表算明白
缓存的代价是显存。K和V的数量级不是按token数简单线性增长,还要乘以层数和头数。拿我们固定配置来算:d_k=64,H=8,单token在单层的K=64*8=512个元素,V同样512个元素,总共1024个元素。如果用FP16存储,每个元素2字节,那单个token单层需要2048字节,也就是2KB。
我经常用下面这张表来估算实际开销:
| 层数 | 每Token单层开销 | 2048 Token上下文 | 8192 Token上下文 |
|---|---|---|---|
| 1层 | 2 KB | 4 MB | 16 MB |
| 12层 | 2 KB | 48 MB | 192 MB |
| 40层 | 2 KB | 160 MB | 640 MB |
| 80层 | 2 KB | 320 MB | 1280 MB |
这张表还没有把batch和多用户并发算进去。一旦同时跑32路请求,8192 token、40层模型的KV Cache就要20GB以上。这就是为什么大模型部署时,长上下文最稀缺的资源不是算力而是显存。很多团队为了省显存,把推理精度从FP16降到INT8做KV Cache量化,本质上就是在用极少的质量回退换大量的可用上下文。
5.3 为什么只缓存K和V,而不是Q或整个输出
这个问题很多人想过,但想清楚了才算真正理解缓存逻辑。
K和V被缓存是因为它们会被未来所有token的注意力计算复用。当第t+1步的新token到来时,它的Q要去跟所有历史K匹配,它的Softmax权重会作用到所有历史V上。所以K和V是真正的"公共资源"。
Q不需要缓存,因为第t+1步的新token只会用自己新算的Q,历史token的Q只用于计算它们当时的输出行,这些分数在生成完历史token后就已经没有价值了。注意力权重矩阵同样不需要缓存,因为每个query只需要自己那一行。解码阶段专门的状态叫"预填充",就是把整个prompt一次算完并填满KV Cache;之后每一步只做单token的增量计算。如果连权重矩阵也缓存,显存消耗会翻好几倍,但收益几乎为零。
另外注意,注意力机制的输出O也不需要留缓存。每个token的O会被送入后续的前馈网络、残差连接,最终生成该token位置的推理结果。一旦这一步结束,这个位置的O就不会再被未来token直接消费了。真正在层间传递的,是下一层的输入X,而X本身由残差计算得出,不需要你手动缓存历史位置的X——上一个token的X已经在生成它的那步算完了。
5.4 GQA与MQA:给缓存瘦身
KV Cache显存大头躲不掉,业界于是提出了共享K/V的变体。最彻底的是MQA:所有注意力头共享同一组K和V。这样K和V的缓存占用直接除以H,8个头变成只存一份,缓存立刻缩小8倍。代价是不同头之间没法拥有独立的检索视角,表达能力会有损失。
GQA是个折中方案:把H个头分成G组,组内共享一组K/V,不同组之间各自独立。常见配置如H=32、G=8,缓存缩小4倍,质量损失通常很小。很多新模型选择GQA,主要目的就是给KV Cache瘦身,换取更长上下文窗口。我在部署时遇到显存瓶颈,第一优先就是看模型支不支持GQA/缓存共享;只有不支持才去动量化。
这里有一个很容易忽略的点:KV Cache量化并不减少元素个数,只减少每个元素的字节数,所以GQA和量化是可以叠加的。两者都用上,缓存占用可能只有原来的1/8到1/16,代价是推理前需要做基准测试,看看量化后长上下文场景下的困惑度回退是否在可接受范围内。
5.5 缓存生命周期里的工程细节
KV Cache的工程实现远没有公式那么优雅。
预分配问题:如果每步增长时都重新分配内存,性能会非常差。常规做法是按最大序列长度预分配连续内存块,但这又会在请求长度远小于上限时白白占用显存。所以高性能推理框架普遍在朝"分页缓存"的思想走:把KV Cache切成固定大小的逻辑块,必要时才分配,序列完成后立即释放,跟操作系统管理内存换页是一个逻辑。
内存碎片问题:多个请求同时生成,每个请求的KV Cache都在变化,碎片会导致显存利用率下降。部分框架为此引入了按块复用的调度机制,减少碎片化的分配释放。
解码带宽问题:生成阶段每一步,新token的Q要跟缓存的全部K做内积,这意味着每一层都要遍历一遍该层KV Cache的所有元素。序列越长,访存量越大,而每一步的计算量其实很小。于是推理瓶颈从算力逐渐切换到内存带宽。这就是为什么长上下文的单token生成延迟很难靠加大batch解决——你得把整个KV Cache读一遍。
6. 一次完整计算的数字复盘:从形状到显存再到精度陷阱
6.1 维度走查表:从输入到输出的每一步形状
把前文所有形状变化收进一张表,一眼看清楚每个阶段发生了什么:
| 步骤 | 输入形状 | 操作 | 输出形状 |
|---|---|---|---|
| 输入X | [L, 512] | 投影到Q/K/V | [L, 512] 各一份 |
| 拆头 | [L, 512] | reshape成[L, H, d_k]再transpose | [H, L, 64] |
| 注意力分数 | [H, L, 64] × [H, 64, L] | QK^T | [H, L, L] |
| 缩放+掩码 | [H, L, L] | 除以sqrt(64),加上三角掩码 | [H, L, L] |
| Softmax | [H, L, L] | 逐行归一化 | [H, L, L] |
| 注意力输出 | [H, L, L] × [H, L, 64] | 权重×V | [H, L, 64] |
| 多头拼接 | [H, L, 64] | transpose+reshape | [L, 512] |
| 输出投影 | [L, 512] × [512, 512] | 乘W_O | [L, 512] |
这张表我建议所有初学者自己动手推一遍。始终记住一个核心原则:整个注意力机制只是把[L, D]的输入重新映射成[L, D]的输出,中间无论切多少个头,最终都要回到模型宽度D。谁把这一步搞错了,后面看任何代码都会糊涂。
6.2 精度与易错点:FP16、掩码值、长上下文
数值精度这件事,在纸上推公式时注意不到,跑起来全是坑。
第一个坑是掩码值的选取。早期很多实现写的是-1e9,这在FP32下没问题,但在FP16下,-1e9已经超出FP16表示范围,运行时可能被当成-inf。数学上结果是一样的,因为exp(-inf)=0,但某些框架在反传或特殊算子下会出现警告甚至NaN。更稳妥的做法是给一个FP16可表示的大负数,比如-3e4或者用框架提供的finfo.min。要是你发现损失曲线在某个节点突然变成NaN,把注意力掩码值列进排查清单。
第二个坑是Softmax的减最大值。我在前面提过,但这里要再强调一遍,因为这是手写算子时最容易偷懒的地方。很多教程代码里直接写np.exp(scores)然后归一化,小模型下确实能跑,因为分数范围不大。但换成长序列和更大模型后,分数的极端分布会让数值在FP16下溢出。不要贪这个省事,减去最大值几乎不增加成本,却能把数值稳定性牢牢锁住。
第三个坑是长上下文下的缓存精度。如果KV Cache是FP16,长序列下误差会随步数累积。我遇到的一个实际案例是:短文本生成质量正常,但一旦上下文超过2K token,输出开始出现重复或混乱,排查到最后发现是KV Cache量化精度不够,换成更高精度后症状消失。这类问题无法靠调prompt解决,只能做量化前后的质量对比实验。
6.3 动手实验建议:把数字打出来
如果只读不练,注意力永远停留在"公式我会背"的层面。我给你一个实验室建议:用极小的配置,比如D=32、H=4、L=8,手写一次前向计算,然后把每一步张量的shape打印出来,再打印几个关键点上的数值。
打印时重点看三个位置:Q最后一行和K全部行的分数分布,Softmax前和Softmax后的权重分布,以及输出向量和输入向量在同一位置上的差异。看完这几个数字,你才算真正见过"一次注意力计算长什么样"。然后可以加一个对比实验:在自回归生成模拟里,对比开不开KV Cache的耗时。序列长度拉到512就能看出明显差别,拉到1024时差距会大到让你怀疑是不是代码写错了——那恰恰说明缓存的意义所在。
另一个更进阶的练习是手动把多头改成GQA:让所有头共享同一份K/V,重新跑推理,观察显存占用和质量变化。这一步做下来,前面所有知识基本上都串起来了。
最后再分享一个实操心得:我不建议直接用某个框架的封装Attention跑到完,因为封装的接口会替你做太多决定,你反而看不到门道。自己手动实现一次,再把实现放到一个小任务上训练,感受数值和形状的变化,比读一百篇教程都有用。等到你亲手把一个完整流程从投影推到KV Cache,再回头看那些早就背熟的公式,它们就不再是纸面符号了。