2. Transformer 架构概览:编码器-解码器,多头注意力,位置编码
老实说,2017 年那篇 Attention Is All You Need 刚出来的时候,我第一反应是“又来一个新架构?”。但真正动手复现了一遍,才发现它把之前 RNN 那套串行处理的逻辑彻底颠覆了。这一节,我会带你拆开 Transformer,看看它到底怎么工作的。
2.1 故事的起点:为什么需要 Transformer?
在 Transformer 出来之前,做序列建模基本就是 RNN、LSTM、GRU 的天下。但有个问题我很头疼:序列太长,记忆就丢了。 就算用 LSTM 加门控机制,长距离依赖依然是个大坑。
更重要的是,RNN 的每一步必须等上一步算完。你想想看,训练一个 1000 步的句子,GPU 利用率再高,也得串行跑完。这在工业级训练场景下,简直是时间黑洞。
2.2 编码器-解码器结构
Transformer 的整体框架,说白了就是经典的 Encoder-Decoder 架构。左边编码器负责把输入序列变成一组上下文表示,右边解码器再从这个表示里一步步生成输出。
我个人习惯把编码器理解为 “阅读理解”,解码器理解为 “写作”。先读懂全文,再一句一句写。
2.2.1 编码器堆叠
编码器由 N 个相同的层堆叠而成。每个层包含两个子层:
- 多头自注意力子层 —— 让序列里的每个位置都能看到其他位置。
- 前馈神经网络子层 —— 简单说就是两个全连接,中间加个 ReLU。
每个子层后面都跟着残差连接和层归一化。这个结构我建议你记牢,因为几乎所有后续的变体(BERT、GPT)都沿用这个模板。
2.2.2 解码器结构
解码器也是 N 层,但每层多了个 交叉注意力子层。这三层按顺序是:
- 带掩码的多头自注意力 —— 只允许看到当前位置之前的信息。
- 交叉注意力 —— 查询来自解码器,键值来自编码器输出。
- 前馈神经网络。
2.3 多头注意力机制
很多人觉得多头注意力很玄乎。其实没那么复杂。我习惯这样拆解:
自注意力做了一件事——给序列里每个位置搞一个全局视野。一个词跟谁相关,就多关注谁。数学上就是三个矩阵:查询 Q、键 K、值 V。先算 Q 和 K 的相似度,再用 softmax 加权求和 V。
但单头注意力有个局限:它只能关注一种关系模式。比如“苹果”这个词,既可能是水果,也可能是公司。单头注意力只能抓住一种。多头注意力就聪明多了:
- 把 Q、K、V 投影到 h 个不同的子空间。
- 每个头独立算自注意力。
- 最后拼起来再投影回去。
我敢说,这种“一个模型学多种语义角色”的思路,是 Transformer 成功的关键之一。我记得第一次实现时,把 8 个头拆开可视化,发现有的头专注语法依赖,有的头专注语义相似,很有意思。
| 组件 | 核心作用 | 个人经验 |
|---|---|---|
| Q (Query) | 当前词想问什么 | 可以理解为“提问者” |
| K (Key) | 其他词有什么特征 | 可以理解为“标签” |
| V (Value) | 其他词的实际信息 | 可以理解为“内容” |
| 多头投影 | 不同子空间学习不同模式 | 我常用 8 或 16 个头 |
# 单头注意力的核心计算(伪代码)
def attention(Q, K, V):
scores = Q @ K.T / sqrt(d_k)
weights = softmax(scores, dim=-1)
output = weights @ V
return output
# 多头注意力:先拆分,再拼接
def multi_head_attention(Q, K, V, h):
# 投影到 h 个头
Qs = split(linear(Q, hidden_dim), h)
Ks = split(linear(K, hidden_dim), h)
Vs = split(linear(V, hidden_dim), h)
# 每个头独立计算
heads = [attention(q, k, v) for q, k, v in zip(Qs, Ks, Vs)]
# 拼接并投影回原维度
concat = concatenate(heads, axis=-1)
output = linear(concat, hidden_dim)
return output
2.4 位置编码:给模型装上“空间感”
Transformer 最大的“天生缺陷”是什么?没有位置信息。 你想啊,自注意力本身是置换等变的。你把“我打你”和“你打我”的词顺序打乱,它算出来的结果一模一样。这在自然语言里简直是灾难。
位置编码就是来解决这个问题的。最经典的做法是用不同频率的正余弦函数:
PE(pos, 2i) = sin(pos / 10000^(2i / d_model))
PE(pos, 2i+1) = cos(pos / 10000^(2i / d_model))
为什么要用正余弦?我个人理解是:它有规律,而且能外推。就算训练时只见过 512 长度的序列,推理时给了 1024 的词,也能算出合理的位置向量。
2.4.1 绝对位置 vs 相对位置
我刚开始做项目时,一直用的绝对位置编码。但在一个语义角色标注任务里,我发现模型对“长距离修饰关系”学得不好。后来查资料才知道,相对位置编码 在很多下游任务中效果更好。
| 类型 | 核心思想 | 典型代表 | 我的选择建议 |
|---|---|---|---|
| 绝对位置编码 | 每个位置一个固定向量 | 原版 Transformer | 简单任务够用 |
| 相对位置编码 | 关注词与词之间的偏移量 | Transformer-XL, T5 | 序列长度变化大的场景优先 |
| 可学习位置编码 | 让模型自己学位置向量 | BERT | 数据量大时效果好 |
2.5 整体架构图
说了这么多,我把整个 Transformer 架构画成一张 SVG 图。你看完应该能有个整体印象:
2.6 三个关键点,记住就算入门
- 并行计算:自注意力让所有位置同时计算,解决了 RNN 串行瓶颈。
- 多头机制:不同头学不同语义模式,比单头强很多。
- 位置编码:没有这个,Transformer 就是词袋模型。