写在前面
我一直想搞明白"大模型到底是怎么工作的"。结果一搜资料,铺天盖地都是 Transformer、Attention、QKV 这些词。硬着头皮去读那篇 Attention Is All You Need,说实话第一遍读下来是懵的。
后来花了几个周末,找了不少博客、视频、代码实现,才慢慢把整个拼图拼起来。这篇文章就是我自己的学习笔记,从我最开始困惑的地方写起,希望能帮到同样在自学的人。
一、为什么需要注意力机制
在 Transformer 之前,做序列任务的主流方案是 RNN 和 LSTM。它们在处理"一句话"这样的序列数据时,是一个词一个词按顺序读的:
我 -> 爱 -> 你
每一步的隐藏状态都由上一步决定,数据流是串行的。这就带来两个要命的问题:
- 长距离依赖难搞:信息一步步往后传,越远越容易丢失。比如句子"我在北京待了十年,但还是不习惯这里的__",要预测"这里"指的是"北京",中间隔了十几个词,RNN 往往就忘了。
- 没法并行训练:第 3 个词必须等第 2 个词算完,GPU 的优势发挥不出来。
那怎么办?研究者想到一个很自然的思路:让模型在处理某个词的时候,能直接看到句子中所有其他词,然后自己决定关注谁。
这就是 Attention。
QKV 怎么理解
注意力机制的核心公式长这样:
Attention(Q, K, V) = softmax(QK^T / √d_k) V
我第一次看到 Q、K、V 的时候在想:这搞什么飞机?后来看到一个类比觉得特别好——图书馆查书:
- Q(Query):你想查的书,好比"机器学习的入门书籍"
- K(Key):每本书的标签,好比"机器学习"、"Python"、"深度学习"
- V(Value):书的实际内容
你拿着 Q 去图书馆,挨个跟每本书的 K 比对相似度(点积),匹配度高的书就多看几眼(权重高),匹配度低的就略过。最后你对这个主题的理解(输出),是所有书内容的一个加权综合。
对应到文本中,如果句子是"它站在银行门口",那么"它"作为 Q,去匹配"银行"、"门口"、"站"这些 K。匹配度高说明它们关系密切,最终"它"的向量表示里就融入了"银行"的信息。
这里的关键是 Q、K、V 不是输入本身,而是输入经过三个不同的线性变换得到的:
# 这三个 W 是模型要学习的参数,把输入映射到不同的"语义空间"
# 类比一下:Q 是"我想找什么",K 是"我有什么",V 是"我实际的内容"
Q = X @ W_q
K = X @ W_k
V = X @ W_v
W_Q、W_K、W_V 是模型学习出来的参数,它们把输入映射到不同的"空间",方便做匹配。
graph LR
X["输入序列 X"] --> WQ["W_Q"] --> Q
X --> WK["W_K"] --> K
X --> WV["W_V"] --> V
Q --> Attn["Attention<br/>Q·K^T"]
K --> Attn
Attn --> Softmax["softmax<br/>归一化"]
V --> Weighted["加权求和"]
Softmax --> Weighted
Weighted --> Out["输出 Z"]
缩放因子 √d_k 是干嘛的
这是我当时看了半天才理解的点。
Q 和 K 做点积的时候,如果向量维度 d_k 很大,点积结果也会很大。比如 d_k=64,点积的方差大概是 64,那算出来的分数可能分布在几十到几百的范围。然后 softmax 对这么大的数值做归一化,结果就接近 one-hot 了——几乎所有的权重集中在一个位置上,其他位置几乎为零。
这会有什么后果?梯度几乎为零,模型学不动了,俗称注意力坍缩。
除以 √d_k 本质上就是控制方差,把数值范围拉回到合适的区间,让 softmax 能输出比较平滑的分布。从数学角度说就是做了一次方差归一化。
二、自注意力机制
到这里我们讲了通用的注意力机制。自注意力(Self-Attention)的特殊之处在于:Q、K、V 全部来自同一个输入序列。也就是说,序列里的每个元素"自己和自己玩",互相看对方。
拿句子"我在看书"来说:
- "我"作为 Q,去匹配"在"、"看"、"书"的 K
- 算出"我"和每个词的关联度
- 按这个关联度重新加权"我"的表示
结果就是"我"这个向量不再只是"我"这个词本身的意思,而是融合了它在这个句子中的上下文信息。
graph TD
subgraph "输入序列"
T1["我"]
T2["在"]
T3["看"]
T4["书"]
end
subgraph "自注意力层"
direction LR
Q1["Q(我)"] --> Dot["点积匹配"]
K2["K(在)"] --> Dot
K3["K(看)"] --> Dot
K4["K(书)"] --> Dot
Dot --> W["softmax → 权重"]
W --> Sum["加权求和"]
V1["V(我)"] --> Sum
V2["V(在)"] --> Sum
V3["V(看)"] --> Sum
V4["V(书)"] --> Sum
end
Sum --> Out["输出:上下文感知的向量"]
下面是假代码,帮理解实际计算长什么样:
import numpy as np
def self_attention(X, W_q, W_k, W_v):
# X: (seq_len, d_model) - 输入序列,每个词是一个 d_model 维的向量
Q = X @ W_q # (seq_len, d_k) - 每个词变成"我想找什么"
K = X @ W_k # (seq_len, d_k) - 每个词变成"我有什么标签"
V = X @ W_v # (seq_len, d_v) - 每个词变成"我的实际内容"
# 每个词跟所有词算相似度,除以 sqrt(d_k) 防止数值爆炸
scores = Q @ K.T / np.sqrt(d_k) # (seq_len, seq_len)
# softmax 把分数变成概率分布,和为 1
weights = softmax(scores, axis=-1)
# 按权重对所有词的内容加权求和,得到融合了上下文的新表示
output = weights @ V # (seq_len, d_v)
return output
当时看到这里我悟了一件事:自注意力其实就是动态加权平均。每个词的输出表示都是整个序列的加权和,而权重不是固定的,是动态算出来的。
三、多头注意力
一个自注意力头能学到一种关注模式,但句子中词之间的关系是多维的。比如:
"苹果很好吃"和"苹果发布了新手机"
两个"苹果"含义完全不同,前者需要关注"好吃",后者需要关注"发布了"。一个注意力头要同时捕捉这么多不同的关系,有点难为它了。
多头注意力(Multi-Head Attention)的思路很直接:用多组 QKV,每组学一种关系。
具体来说:
- 把输入分别投影到 h 组不同的 Q、K、V 空间
- 每组独立算一次注意力,得到 h 个不同的结果
- 把这 h 个结果拼起来,再投影回原始维度
graph TD
X["输入"] --> Linear["线性投影 × h 组"]
Linear --> H1["Head 1<br/>Q₁K₁V₁"]
Linear --> H2["Head 2<br/>Q₂K₂V₂"]
Linear --> Hn["... Head h<br/>QₕKₕVₕ"]
H1 --> A1["Attention₁"]
H2 --> A2["Attention₂"]
Hn --> An["Attentionₕ"]
A1 --> Concat["拼接 Concat"]
A2 --> Concat
An --> Concat
Concat --> WO["输出投影 W_O"]
WO --> Out["输出"]
每个头可以关注不同的东西。一些可视化研究发现:有的头关注语法关系(冠词+名词),有的头关注长距离依赖(句首主语 + 句末动词),还有的头专注位置关系。
这就好比让你同时问好几个专家同一个问题——有人关注细节,有人看整体,最后把大家的意见汇总,理解更全面。
参数量简析
多头注意力的参数量其实没有想象中那么多。因为每组 QKV 的投影是把 d_model 拆成 h 份,每份维度 d_k = d_model / h。总参数量和一整组 QKV(不分头)是一样的,只是做了拆分。
class MultiHeadAttention:
def __init__(self, d_model, num_heads):
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads # 每个头的维度 = 总维度 / 头数
# 四个线性层,每个都是 d_model -> d_model
# 注意:不是每个头一个线性层,而是整体投影后再拆分
self.W_q = Linear(d_model, d_model)
self.W_k = Linear(d_model, d_model)
self.W_v = Linear(d_model, d_model)
self.W_o = Linear(d_model, d_model)
def forward(self, X):
batch_size, seq_len, _ = X.shape
# 投影后拆成多个头,shape 变成 (batch, seq, num_heads, d_k)
Q = self.W_q(X).reshape(batch_size, seq_len, self.num_heads, self.d_k)
K = self.W_k(X).reshape(batch_size, seq_len, self.num_heads, self.d_k)
V = self.W_v(X).reshape(batch_size, seq_len, self.num_heads, self.d_k)
# 转置后所有头可以并行算,不用循环一个个来
# 这就是 Transformer 能充分利用 GPU 的关键
所有头是并行计算的,不是循环一个一个算。这也是 Transformer 能充分利用 GPU 的原因。
四、两种重要的注意力变体
掩码自注意力(因果注意力)
做文本生成的时候(比如 GPT 系列),模型要一个一个词地预测。预测第 t 个词时,只能看前 t-1 个词,不能偷看后面的内容。
实现方式是在注意力分数上加上一个上三角掩码(mask),把未来位置的分数设成负无穷。softmax 之后这些位置的权重就会变成零。
graph TD
subgraph "注意力分数矩阵"
M["我 在 看 书<br/>我[1 0 0 0]<br/>在[1 1 0 0]<br/>看[1 1 1 0]<br/>书[1 1 1 1]"]
end
1 表示能看到,0 表示被遮住。这就是为什么它叫"因果注意力"——只能基于过去预测未来,符合因果关系。
交叉注意力
在 Encoder-Decoder 架构中(比如翻译任务),除了每个模块自己的自注意力,还需要在 Decoder 里加一个跨模块的注意力,让 Decoder 能看到 Encoder 的输出。
它的 Q 来自 Decoder,K 和 V 来自 Encoder。这样 Decoder 在生成每个词的时候,可以去"查"源句子的哪些部分跟自己当前要生成的词最相关。
graph LR
subgraph "编码器 Encoder"
ENC["输出表示"]
end
subgraph "解码器 Decoder"
Q_dec["Q(来自解码器)"]
AttnCross["交叉注意力"]
K_enc["K(来自编码器)"]
V_enc["V(来自编码器)"]
end
ENC --> K_enc
ENC --> V_enc
Q_dec --> AttnCross
K_enc --> AttnCross
V_enc --> AttnCross
AttnCross --> Out["上下文向量 → 生成"]
五、整体架构
把上面所有的模块拼在一起,就是完整的 Transformer。
graph TD
subgraph "编码器 Encoder × N"
In["输入序列"] --> PE1["位置编码"]
PE1 --> SA1["自注意力"]
SA1 --> FFN1["前馈网络"]
FFN1 --> OutEnc["编码器输出"]
end
subgraph "解码器 Decoder × N"
Target["目标序列"] --> PE2["位置编码"]
PE2 --> MSA["掩码自注意力"]
MSA --> CA["交叉注意力"]
OutEnc --> CA
CA --> FFN2["前馈网络"]
FFN2 --> Linear["线性层"]
Linear --> Softmax["Softmax"]
Softmax --> Pred["预测下一个词"]
end
编码器把输入序列转成一组语义表示,解码器基于这组表示逐步生成输出。
但需要注意,现在的主流大语言模型(GPT、Llama、ChatGLM 等)基本都是 Decoder-only 架构,不再使用 Encoder。因为 Decoder-only 在文本生成上更高效,而且随着模型变大,纯 Decoder 也能学到很好的表示。
六、为什么 Transformer 能 Scale
这是我觉得最值得思考的一个问题。
在 Transformer 之前,最好的序列模型是 LSTM。但 LSTM 受限于循环结构,参数规模做到亿级就已经很吃力了。而 Transformer 能一路做到千亿、万亿参数,原因主要有这几个:
- 并行计算:自注意力是一次性处理整个序列,完全不依赖前一步的结果。这让 GPU 的矩阵运算能力可以充分发挥
- 信息直达:任意两个位置的词之间只隔了一层注意力计算,信息传递路径短,长距离依赖不再是问题
- 结构简单:没有复杂的门控单元(LSTM 的 forget gate、input gate 那一套),就是注意力加前馈网络反复堆叠,简洁的结构反而更利于规模化
随着参数增长,模型还涌现出很多小模型没有的能力——上下文学习、思维链推理等。这些现象到现在也没有完全被解释清楚,我觉得这也是这个领域最吸引人的地方。
七、还没完全搞懂的部分
写这篇文章的时候,我还有很多地方处于"好像懂了"到"真懂了"之间的状态:
- 层归一化的具体位置:Pre-Norm 和 Post-Norm 对训练的影响,实验上确实有差异,但我对背后的理论分析还没吃透
- KV-Cache 的实现细节:概念上知道是缓存之前的 K、V 避免重复计算,但实际工程里的内存管理、量化策略又是另一回事
- GQA / MLA:在 MHA 基础上的各种优化变体,原理大概能讲,但为什么某些方案效果更好需要看更多实验分析
这些东西等着慢慢啃吧。
留给自己的一个思考题
回过头来看,理解 Transformer 在我心里就是几个核心认知的转变:
- 从顺序到并行:RNN 的词序处理 → 一次性看全部
- 从固定权重到动态权重:注意力机制让模型自己决定关注什么
- 从单一视角到多个视角:多头注意力扩展了模型的理解维度
- 从结构限制到结构极简:简单的组件反复堆叠,反而能涌现复杂能力
不过有个问题我还没想明白:为什么简单的"注意力 + 前馈"堆叠起来,就能产生"理解"和"推理"的能力?这到底是量变引起质变,还是我们还没发现的某种机制在起作用?