Transformer 的论文你多半翻过,但真正卡住的地方往往不是框架图,而是公式背后那些前置概念。比如你记住了 Attention 的公式是softmax(QK^T / sqrt(d_k))V,但 Q、K、V 到底从哪来,为什么除以sqrt(d_k),为什么还需要位置编码,这些如果不补,后面去看 transformer 源码、手撕 transformer,或者去追 vision transformer、swin transformer 的改进,都会卡在同一批问题上。
这篇文章就把 Transformer 的前置概念按顺序完整复习一遍:序列建模与 RNN、词嵌入、注意力机制、自注意力与 QKV、位置编码、残差连接、LayerNorm、前馈网络,以及训练阶段的 Softmax、交叉熵、Adam 和 warmup。最后用一套完整的前向流程把 Transformer 架构及其工作原理串起来。
与其说这是一篇教程,不如说是一份自查清单。适合论文能看懂大意的初学者、准备面试的算法岗候选人,以及被源码里的张量形状劝退的读者。文中所有代码示例都不依赖 GPU,普通 Python 环境就能跑,建议看到对应章节直接复制运行,效果比只看笔记好得多。
1. 前置概念复习目标速览
先把整份复习内容拆成一张表,后续章节按这个顺序展开:
| 章节 | 核心概念 | 在 Transformer 中的作用 | 复习重点 |
|---|---|---|---|
| 序列建模与 RNN | RNN / LSTM / CNN | 理解 Transformer 要替代的基线 | 循环过程、长距离依赖、并行瓶颈 |
| 词嵌入 | 分布式表示 / BPE | 文本如何变成可计算的向量 | token -> embedding -> (batch, seq_len, d_model) |
| 注意力机制 | 相关性加权求和 | Transformer 的基本操作单元 | query / key / value 的含义 |
| 自注意力与 QKV | 缩放点积注意力 / 多头 | Encoder 和 Decoder 的核心模块 | 线性投影产生 QKV、注意力权重 |
| 位置编码 | 正弦位置编码 / 可学习位置编码 | 给无顺序感知的自注意力补上顺序信息 | 公式、相对位置特性、外推能力 |
| 工程组件 | 残差连接 / LayerNorm / FFN | 保证深层网络训练稳定 | 编码层的前向流程 |
| 训练技巧 | Softmax / 交叉熵 / Adam / Warmup | 让 Transformer 真正训练起来 | 损失函数、优化器、学习率策略 |
这张表对应了很多人在学习 transformer 架构时最容易漏掉的部分。下面逐项展开。
2. 序列建模与 RNN:Transformer 想解决什么
2.1 序列建模的目标
自然语言、语音、时序信号都属于变长序列。序列建模要解决的核心问题是:给定一串输入x1, x2, ..., xt,模型需要学习它们之间的依赖关系。
举一个最简单的例子:句子“我昨天看到一只猫,它非常可爱”里的“它”指代的是“猫”,模型要正确生成或理解这个指代关系,就必须记住前文的信息,并且知道“它”与“猫”之间有关系。
Transformer 出现之前,处理这类问题的主力是 RNN 以及在它基础上改进的 LSTM、GRU。要理解 Transformer 的价值,必须先理解 RNN 的处理方式和瓶颈在哪。
2.2 RNN 的核心公式
RNN 的基本思想是引入一个隐状态h_t,把上一步的信息带到现在。前向计算可以写成:
h_t = tanh(x_t @ W_ih + h_{t-1} @ W_hh + b_h)其中x_t是当前时刻的输入,h_{t-1}是上一时刻的隐状态,W_ih和W_hh是两个可学习权重矩阵,tanh是激活函数。
用 Python 写一个最简版本:
import numpy as np np.random.seed(0) # 当前输入和上一步隐状态 x_t = np.array([0.2, 0.4, 0.6]) # 假设输入维度是 3 h_prev = np.zeros(4) # 假设隐状态维度是 4 # 随机初始化权重 W_ih = np.random.randn(3, 4) W_hh = np.random.randn(4, 4) b_h = np.zeros(4) h_t = np.tanh(x_t @ W_ih + h_prev @ W_hh + b_h) print(h_t)这套机制的本质是:把序列信息压缩进隐状态,然后用h_t作为当前时刻的“记忆”。如果输入很长,那么靠前时刻的信息要经过很多次非线性变换才能到达后面,很容易衰减或爆炸。
2.3 RNN 的瓶颈:长距离依赖与串行计算
RNN 有两个非常头疼的问题。
第一个是梯度消失和梯度爆炸。由于每个时间步都要乘以相同或相似的权重矩阵,误差信号在反向传播时会出现指数级衰减或放大。LSTM 和 GRU 用门控机制缓解了这个问题,但并没有完全解决,尤其是超过一定长度后,长距离依赖仍然难以稳定建模。
第二个问题是必须串行计算。h_t依赖h_{t-1},所以没办法像计算两个矩阵乘法那样一次把整段序列同时算完。对 GPU 来说,串行意味着利用率很低,训练速度慢。
CNN 可以并行计算,但感受野有限,要覆盖长距离依赖需要堆很多层或者用膨胀卷积。这引出了一个看起来很自然的问题:能不能设计一种结构,既能并行,又能直接建模全序列依赖?
2.4 为什么最后是 Transformer
Transformer 的答案是:自注意力。让每个 token 直接和序列里所有 token 计算相关性,再把信息聚合起来。这样任意两个位置之间只需要一次交互,不依赖中间传递,梯度路径变短,长距离依赖问题也得到缓解。
同时,自注意力计算本质上是矩阵乘法,整个序列可以一次算完,GPU 并行效率很高。后面所有关于 transformer 模型详解、手撕 transformer、以及 vision transformer、swin transformer 等改进,都建立在这个基础上。理解这一点,就理解了为什么最后是 Transformer 成为主流结构。
3. 词嵌入:文本怎么变成向量
3.1 从 One-Hot 到分布式表示
模型不能直接处理文字,第一步要把文本转成数字。最朴素的做法是 One-Hot:假设词表大小是V,每个词用一个长度V的向量表示,对应位置为 1,其余为 0。
One-Hot 有两个明显问题:维度巨大,训练和存储成本高;任意两个词向量都是正交的,语义相近的词之间没有距离关系。比如“猫”和“猫咪”在向量空间里的关系,和“猫”与“汽车”的关系没有任何区别。
更好的方案是分布式表示:把每个词映射到一个低维稠密向量,比如 128 维、512 维,让语义相近的词在向量空间中距离更近。
3.2 Word2Vec 与共现统计
Word2Vec 是学习分布式表示的经典方法,核心思想来自分布假说:一个词的含义由它经常出现的上下文决定。Word2Vec 有两个主要变体:
- CBOW:用上下文预测当前词。
- Skip-gram:用当前词预测上下文。
训练完成后,嵌入矩阵中每一行就是一个词的向量。后面出现的 GloVe 利用全局共现统计信息,FastText 引入子词信息,本质上都是为了让词向量表达更丰富。
3.3 Tokenization 与 BPE
现代 Transformer 模型很少直接对整词做嵌入,而是先做子词切分。常用的算法是 BPE(Byte Pair Encoding)。它把词表控制在一个合理范围内,同时能处理未登录词。
例如“transformer”可能被切成["transform", "er"],这样即使遇到没有见过的词,也能用已有子词拼出来。GPT 系列用的 BPE,BERT 用的 WordPiece,思路类似。
3.4 词嵌入在 Transformer 里的形状
在 Transformer 里,输入先转成 token id,再查嵌入表,得到形状为(batch_size, seq_len, d_model)的向量。其中d_model是模型隐藏维度,BERT base 是 768,也有的模型用 512 或 1024。
在 PyTorch 里,一个最简单的嵌入层长这样:
import torch from torch.nn import Embedding # 假设词表大小 10000,嵌入维度 512 embed = Embedding(num_embeddings=10000, embedding_dim=512) # token id 输入形状: (batch_size, seq_len) tokens = torch.tensor([[2, 15, 289, 44]]) embeddings = embed(tokens) print(embeddings.shape) # torch.Size([1, 4, 512])看到这个形状变化,后面的很多结构就都围绕同一个问题展开:如何在这个三维张量上做各种变换,同时保持每个 token 对应的位置不动。
4. 注意力机制:先理解“加权求和”
4.1 为什么要注意力
注意力机制最早在 seq2seq 机器翻译任务中被引入。当时的问题是:Encoder 把整个输入句子压缩成一个固定向量,Decoder 在生成每个词时都只能从这个固定向量里取信息,句子越长,信息丢失越严重。
注意力机制改变了这种做法:Decoder 在生成每个词时,都回去看一遍 Encoder 的每个输出,并且根据当前生成位置,动态决定应该重点看哪些词。这个动态决定的过程就是“注意力”。
4.2 注意力的通用公式
注意力可以非常简洁地概括为三步:
- 有一个 query 和一个 key 集合。
- 计算 query 与每个 key 的相关性,得到权重。
- 用权重对 value 做加权求和,得到输出。
写成公式就是:
Attention(query, key, value) = sum_i(weight_i * value_i)其中:
weight_i = softmax(score(query, key_i))这里的本质不是“注意什么神奇的东西”,而是对一组向量做加权平均,权重来自 query 与 key 的匹配程度。
4.3 加性注意力 vs 点积注意力
历史上常用的打分函数有两种:
- 加性注意力,常见于 Bahdanau 等人的工作,用一层全连接网络计算打分。
- 点积注意力,直接计算 query 与 key 的点积,更简单,矩阵运算更高效。
Transformer 使用的是缩放点积注意力。因为点积结果会随维度增大而变大,如果不做缩放,Softmax 的梯度会变得很小,所以除以sqrt(d_k)做归一化。
Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V4.4 一次最小规模的手算验证
为了看清这个过程,构造一个极小的例子。假设只有 2 个 token,d_k = 2:
import numpy as np # 两个 token 的 query 和 key Q = np.array([[0.5, 0.1], [0.2, 0.8]]) K = np.array([[0.3, 0.7], [0.6, 0.4]]) V = np.array([[1.0, 0.0], [0.0, 1.0]]) d_k = Q.shape[1] scores = Q @ K.T / np.sqrt(d_k) weights = np.exp(scores - scores.max(axis=-1, keepdims=True)) weights = weights / weights.sum(axis=-1, keepdims=True) output = weights @ V print("scores:", scores, sep="\n") print("weights:", weights, sep="\n") print("output:", output, sep="\n")运行之后会看到多行输出:scores 是两两 token 的相关性分数,weights 是归一化后的概率,output 是对 V 的加权求和结果。这个流程理解清楚后,自注意力就只是把它扩展到大矩阵上。
5. 自注意力与 QKV:手撕 Transformer 的重要前置
5.1 从注意力到自注意力
普通注意力里,query 来自 Decoder,key 和 value 来自 Encoder。自注意力则不同:query、key、value 都来自同一个输入序列。
这意味着每个 token 都去和序列里的所有 token 计算相关性,包括它自己。自注意力让每个位置能直接捕获全局信息,并且不依赖循环结构。
5.2 Q、K、V 从哪来
很多初学者卡在这里:注意力公式里写着 Q、K、V,但原始输入不是 QKV,它们是怎么产生的?
答案非常简单:用三个可学习的线性变换矩阵对输入做投影。
Q = X @ W_Q K = X @ W_K V = X @ W_V其中X的形状是(batch_size, seq_len, d_model),W_Q、W_K、W_V的形状是(d_model, d_k)或(d_model, d_v)。经过投影后,Q 和 K 的每一行仍然对应一个 token,但被映射到适合做相关性比较的空间里。
这也是为什么代码里经常能看到类似nn.Linear(d_model, d_k)的层。
5.3 缩放点积注意力公式
自注意力的计算过程可以写成:
QK^T -> scores scores / sqrt(d_k) -> scaled_scores softmax(scaled_scores) -> attention_weights attention_weights @ V -> output矩阵形状变化如下:
Q:(batch, seq_len, d_k)K:(batch, seq_len, d_k)V:(batch, seq_len, d_v)Q @ K^T:(batch, seq_len, seq_len)- 最后
attention_weights @ V:(batch, seq_len, d_v)
除以sqrt(d_k)的原因是:当d_k较大时,点积结果方差会变大,Softmax 会变得很尖锐,导致梯度极小。缩放之后,分布更平滑。
5.4 多头注意力
多头注意力就是把上面这个过程做多次,而不是只做一次。做法是:把d_model维度的 Q、K、V 拆成多组,每组独立计算注意力,最后拼回去,再经过一个线性层投影。
通俗地说,单头注意力只能捕获一种相关性模式,多头注意力可以让不同头分别关注不同的关系,比如一个头关注指代关系,另一个头关注语法结构。这也是 transformer 性能更强的重要原因。
5.5 NumPy 实现一个最小自注意力
下面给出一个不依赖深度学习框架的最小自注意力实现,重点在于看清形状变化:
import numpy as np def scaled_dot_product_attention(Q, K, V): d_k = Q.shape[-1] scores = Q @ K.transpose(-2, -1) / np.sqrt(d_k) weights = np.exp(scores - scores.max(axis=-1, keepdims=True)) weights = weights / weights.sum(axis=-1, keepdims=True) output = weights @ V return output, weights # 模拟一个 batch 为 1、序列长度 3、隐藏维度 8 的输入 np.random.seed(42) X = np.random.randn(1, 3, 8) d_model = 8 d_k = 8 W_Q = np.random.randn(d_model, d_k) W_K = np.random.randn(d_model, d_k) W_V = np.random.randn(d_model, d_k) Q = X @ W_Q K = X @ W_K V = X @ W_V print("Q:", Q.shape) # (1, 3, 8) print("K:", K.shape) # (1, 3, 8) print("V:", V.shape) # (1, 3, 8) output, weights = scaled_dot_product_attention(Q, K, V) print("output:", output.shape) # (1, 3, 8) print("weights:", weights.shape) # (1, 3, 3)手撕 Transformer 时,这个函数就是最核心的模块之一。理解了它,后面看编码层、解码层、BERT、GPT、ViT,都会顺很多。
6. 位置编码:让模型知道顺序
6.1 自注意力对顺序不敏感的问题
自注意力虽然强大,但它有一个天然缺陷:完全不关心 token 的顺序。
如果把“猫追老鼠”和“老鼠追猫”分别转成 token,再输入自注意力,模型在没有额外信息的情况下会得到完全一样的表示。因为在注意力计算里,序列是被当成一个集合来处理的。
语言中词序非常重要,所以必须给模型注入位置信息,这就是位置编码存在的意义。
6.2 正弦位置编码公式
原始 Transformer 使用正弦位置编码,公式如下:
PE(pos, 2i) = sin(pos / 10000^(2i / d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i / d_model))这里的pos是 token 在序列中的位置,i是嵌入维度的下标。也就是说,位置编码向量的偶数和奇数维度分别使用正弦和余弦函数。
用代码生成一组位置编码:
import numpy as np def sinusoidal_positional_encoding(max_len, d_model): pe = np.zeros((max_len, d_model)) for pos in range(max_len): for i in range(0, d_model, 2): angle = pos / np.power(10000, 2 * i / d_model) pe[pos, i] = np.sin(angle) if i + 1 < d_model: pe[pos, i + 1] = np.cos(angle) return pe pe = sinusoidal_positional_encoding(10, 512) print(pe.shape) # (10, 512)在实际 Transformer 里,位置编码会加到词嵌入上:
X_final = X_embedding + position_encoding这样每个 token 的向量里既包含语义信息,又包含位置信息。
6.3 为什么用正弦和余弦
选择正弦和余弦而不是直接给每个位置一个随机向量,主要是两个原因。
第一,相对位置可以方便地表示出来。对于固定的偏移量k,PE(pos + k)可以用PE(pos)的线性变换近似表达,这对模型学习相邻位置关系有帮助。
第二,不同维度使用不同的波长,可以让模型同时关注短距离和长距离的依赖关系。
不过需要说明的是,现在很多实际模型会直接使用可学习的位置嵌入,而不是固定的正弦编码。效果上看两者差别并不大,正弦编码的主要优势在于不需要训练额外参数,并且有一定外推能力。
6.4 可学习位置编码与 RoPE
可学习位置编码的做法是初始化一个和输入序列长度相同的位置嵌入表,然后随模型一起训练。GPT、BERT 早期版本就是这样的思路。
近年来的很多 transformer 架构进一步采用了旋转位置编码 RoPE。它把位置信息通过旋转矩阵加到 query 和 key 上,能够同时表达绝对位置和相对位置,在长文本和推理类任务里表现更好。看 transformer 源码时如果遇到rotate_half这类函数,通常就是在实现 RoPE。
7. 残差连接、LayerNorm 与前馈网络
7.1 残差连接
Transformer 每个子层后面都套了一层残差连接。做法很简单:
output = LayerNorm(x + sublayer(x))先对输入做自注意力或前馈网络变换,再把原始输入加回去,最后做 LayerNorm。
残差连接的意义是让梯度可以绕过当前子层直接回传,避免深层网络出现梯度消失。如果没有残差连接,几十层 Transformer 很难稳定训练。
7.2 LayerNorm 与 BatchNorm
Transformer 使用 LayerNorm 而不是 BatchNorm,这个细节很容易被忽略,但很重要。
BatchNorm 对一个 batch 的同一维度做归一化,依赖 batch 大小,如果 batch 很小,统计量会不稳定。而且处理变长序列时,不同样本 pad 的规则也会对统计量造成干扰。
LayerNorm 是对单个样本的所有特征维度做归一化,不依赖 batch 大小,更适合序列模型。公式如下:
LayerNorm(x) = (x - mean(x)) / sqrt(var(x) + eps) * gamma + beta其中gamma和beta是可学习参数。用 PyTorch 实现时:
import torch x = torch.randn(2, 5, 8) # (batch, seq_len, d_model) ln = torch.nn.LayerNorm(8) y = ln(x) print(y.shape) # torch.Size([2, 5, 8])这里的LayerNorm(8)指的是对最后一维做归一化,也就是对每个 token 的 8 维向量做归一化。
7.3 逐位置前馈网络
自注意力负责聚合序列信息,前馈网络负责对每个位置的信息做非线性变换。它的结构很简单,就是两层全连接加激活函数:
FFN(x) = max(0, x W1 + b1) W2 + b2原始 Transformer 用的是 ReLU,后续很多模型换成了 GELU。中间维度通常比d_model大很多,一般是 4 倍,比如d_model=512时,中间层是2048。
这个过程也被称为 Position-wise FFN,意思是同一个前馈网络对序列中的每个 token 独立计算,参数共享。
7.4 编码层完整前向流程
一个 Transformer Encoder Block 的前向流程可以写成下面的伪代码:
def encoder_block(x, self_attention, ffn, dropout): # 1. 自注意力子层 attn_out = self_attention(x, x, x) x = layer_norm(x + dropout(attn_out)) # 2. 前馈网络子层 ffn_out = ffn(x) x = layer_norm(x + dropout(ffn_out)) return x注意这里的顺序是 Post-LN,也就是先残差后 LayerNorm。现在很多主流实现也采用 Pre-LN,即先 LayerNorm 再进入子层,训练更稳定。两种方式对应不同的代码写法,读源码时需要注意区分。
维度变化可以用下面的表总结:
| 阶段 | 输入形状 | 输出形状 | 说明 |
|---|---|---|---|
| 词嵌入 | (batch, seq_len) | (batch, seq_len, d_model) | token id 查表 |
| 加位置编码 | (batch, seq_len, d_model) | (batch, seq_len, d_model) | 逐位置相加 |
| 多头自注意力 | (batch, seq_len, d_model) | (batch, seq_len, d_model) | 形状不变,信息交互 |
| 前馈网络 | (batch, seq_len, d_model) | (batch, seq_len, d_model) | 中间升维再降维 |
| 全连接输出 | (batch, seq_len, d_model) | (batch, seq_len, vocab_size) | 预测下一个 token 概率 |
这个表几乎就是 transformer 架构及其工作原理的主干。
8. 训练 Transformer 的前置概念
8.1 Softmax 与交叉熵
Transformer 在输出层会接一个 Softmax,把 logits 转成概率分布,然后和真实标签计算交叉熵损失。
Softmax 公式:
softmax(z)_i = exp(z_i) / sum_j exp(z_j)交叉熵公式:
Loss = -sum_i y_i * log(p_i)其中y_i是真实标签的概率分布,p_i是模型预测分布。语言模型通常一次只预测下一个 token,所以真实标签可以看成 one-hot。
在代码里,如果使用 PyTorch,CrossEntropyLoss会自己把 Softmax 和交叉熵合并计算,不需要手动加 Softmax。
8.2 Adam 优化器
Transformer 训练几乎所有场景都用 Adam 优化器。它结合了动量和自适应学习率,对大多数参数都能给出比较稳定的更新。
Transformer 参数量大,梯度尺度变化也很剧烈,Adam 的优势在于每个参数有独立的学习率调整,在训练早期更容易稳定。更常用的是 AdamW,在 Adam 基础上解耦了权重衰减。
8.3 Warmup 学习率预热
Warmup 是训练 Transformer 时经常用到的学习率策略。它的特点是在训练初期,学习率从很小的值线性上升到一个峰值,然后再按平方根衰减。
为什么要这样做?Transformer 层数深,初始化阶段的输出分布不太稳定。如果一开始就用较大学习率,容易造成训练震荡甚至发散。先用小学习率让模型逐步稳定,再进入正常学习区间,效果通常更好。
8.4 Dropout、标签平滑与梯度裁剪
Dropout 是 Transformer 里最常用的正则化手段,会加在自注意力的输出、残差连接和 FFN 中。很多开源实现里都能看到dropout=0.1或dropout=0.2这样的参数。
标签平滑是另一种常见的正则化手段。它把真实标签的 one-hot 分布部分概率分给其他类别,避免模型过于自信。原始 Transformer 训练时已经使用了 label smoothing。
梯度裁剪则用于处理梯度爆炸,尤其是训练较长序列时。方法很简单:当梯度的全局范数超过阈值时,等比缩放所有梯度。这个技巧在训练 RNN 时代就很常用,Transformer 训练中同样有效。
9. Transformer 架构串联与复习自查
9.1 Encoder 侧
Encoder 的输入是源序列,经过词嵌入和位置编码后,进入若干个相同的 Encoder Block。
每个 Block 内部只有两种子层:
- 多头自注意力。
- 逐位置前馈网络。
每个子层都带残差连接和 LayerNorm。最后的输出是对源序列的高层表示,每个位置都融合了全序列的信息。
9.2 Decoder 侧
Decoder 的结构要复杂一些,每个 Block 内部有三种子层:
- Masked 多头自注意力。
- 多头交叉注意力。
- 逐位置前馈网络。
Masked 自注意力保证生成时只能看到当前位置之前的 token,不能偷看未来。交叉注意力的 query 来自 Decoder,key 和 value 来自 Encoder 最后一层的输出,也就是让解码器去编码器的信息里检索相关内容。
9.3 Masked Self-Attention
Masked Self-Attention 的实现方式很简单:在计算注意力得分时,把未来位置的得分替换成负无穷大,这样 Softmax 之后这些位置的权重就是 0。
例如序列长度为 4,生成第 2 个位置时,位置 3 和位置 4 的未来信息都要被屏蔽。这个 mask 矩阵是上三角矩阵,对角线及以下为 0,以上为负无穷。
9.4 Cross-Attention
Cross-Attention 是连接 Encoder 和 Decoder 的桥梁。在机器翻译场景里,Encoder 负责理解源语言,Decoder 负责生成目标语言,Cross-Attention 让每个目标语言 token 去关注源语言中最相关的部分。
它和自注意力的唯一区别是 Q、K、V 的来源不同:
- Q 来自 Decoder 当前层的输出。
- K 和 V 来自 Encoder 最后一层的输出。
如果只看公式,Cross-Attention 和普通注意力没有任何区别。
9.5 输入到输出的形状流转
把整条链路串起来看:
源文本 -> token ids -> embedding -> 加位置编码 -> N 个 Encoder Block -> Encoder 输出 目标文本 -> token ids -> embedding -> 加位置编码 -> N 个 Decoder Block -> 输出层 -> Softmax -> 概率形状流转如下:
| 位置 | 形状 | 说明 |
|---|---|---|
| 输入 token | (batch, seq_len) | 离散 id |
| Embedding 后 | (batch, seq_len, d_model) | 密集向量 |
| Encoder 输出 | (batch, seq_len, d_model) | 语义表示 |
| Decoder 输入 | (batch, tgt_len, d_model) | 目标序列 |
| 最终 logits | ` |