2. Transformer 架构概览:编码器-解码器,多头注意力,位置编码

老实说,2017 年那篇 Attention Is All You Need 刚出来的时候,我第一反应是“又来一个新架构?”。但真正动手复现了一遍,才发现它把之前 RNN 那套串行处理的逻辑彻底颠覆了。这一节,我会带你拆开 Transformer,看看它到底怎么工作的。

2.1 故事的起点:为什么需要 Transformer?

在 Transformer 出来之前,做序列建模基本就是 RNN、LSTM、GRU 的天下。但有个问题我很头疼:序列太长,记忆就丢了。 就算用 LSTM 加门控机制,长距离依赖依然是个大坑。

更重要的是,RNN 的每一步必须等上一步算完。你想想看,训练一个 1000 步的句子,GPU 利用率再高,也得串行跑完。这在工业级训练场景下,简直是时间黑洞。

我曾经踩过的坑: 在一批中文长文本摘要任务里,用双向 LSTM 叠了 4 层,batch size 稍微大一点,训练一个 epoch 就得小半天。而且梯度消失的问题在层数一多就暴露无遗。后来切到 Transformer,同样的数据规模,训练时间直接砍掉三分之二。

2.2 编码器-解码器结构

Transformer 的整体框架,说白了就是经典的 Encoder-Decoder 架构。左边编码器负责把输入序列变成一组上下文表示,右边解码器再从这个表示里一步步生成输出。

我个人习惯把编码器理解为 “阅读理解”,解码器理解为 “写作”。先读懂全文,再一句一句写。

2.2.1 编码器堆叠

编码器由 N 个相同的层堆叠而成。每个层包含两个子层:

  • 多头自注意力子层 —— 让序列里的每个位置都能看到其他位置。
  • 前馈神经网络子层 —— 简单说就是两个全连接,中间加个 ReLU。

每个子层后面都跟着残差连接和层归一化。这个结构我建议你记牢,因为几乎所有后续的变体(BERT、GPT)都沿用这个模板。

2.2.2 解码器结构

解码器也是 N 层,但每层多了个 交叉注意力子层。这三层按顺序是:

  1. 带掩码的多头自注意力 —— 只允许看到当前位置之前的信息。
  2. 交叉注意力 —— 查询来自解码器,键值来自编码器输出。
  3. 前馈神经网络。
带掩码的自注意力,很多人第一次看容易懵。我建议你这样记:训练时不让看到未来词。否则模型就学会“抄答案”了,推理时根本没法用。

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
嗯,这里要注意:d_k 是每个头的维度。如果你用 512 维和 8 个头,每个头就是 64 维。这个缩放因子 sqrt(d_k) 很关键,防止 scores 过大导致 softmax 梯度消失。

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 数据量大时效果好
我曾经犯的一个错:在微调 BERT 时,直接用了原版的 sin/cos 位置编码。结果下游任务 F1 掉了两个点。后来换成 BERT 原生的可学习位置编码,效果才回来。所以,预训练模型尽量保持它原始的位置编码方式,不要随便替换。

2.5 整体架构图

说了这么多,我把整个 Transformer 架构画成一张 SVG 图。你看完应该能有个整体印象:

Transformer 架构概览 输入序列 位置编码 编码器 (N×) 多头自注意力 前馈神经网络 解码器 (N×) 带掩码的多头自注意力 交叉注意力(K,V 来自编码器) K,V 前馈神经网络 输出序列 编码器子层 解码器子层 位置编码

2.6 三个关键点,记住就算入门

  1. 并行计算:自注意力让所有位置同时计算,解决了 RNN 串行瓶颈。
  2. 多头机制:不同头学不同语义模式,比单头强很多。
  3. 位置编码:没有这个,Transformer 就是词袋模型。
我个人习惯在一开始接触新架构时,先画图再写代码。把上面的 SVG 图理解清楚,再去看 attention is all you need 原文,会轻松很多。