重点 · 先看论文里那张图
2017 年的经典结构:encoder 加 decoder
这是《Attention Is All You Need》的图 1,面试和教材里说「Transformer 的结构」,指的通常就是它。当时的任务是翻译:左边的 encoder 读原文,右边的 decoder 写译文。图从下往上看。
图比较宽,可以左右滑动。
论文的原图里,每个 Add & Norm 旁边还画了一条绕过子层的线,那就是残差连接。这里为了清楚没有画,写在了方框的说明里。
翻译一句话,数据怎么走
- 原文「我 爱 猫」从左下角进去,变成向量,加上位置编码。
- 经过 6 层 encoder。每层先做 self-attention(三个词互相看),再过一个 MLP。出来的是三个向量,每个词一个,已经带上了整句话的信息。encoder 只算这一次。
- decoder 从右下角开始。第一步的输入只有一个「开始」符号。
- 每层 decoder 做三件事:先在已经写出来的词之间做 self-attention,带 mask,只能看前面;再做 cross-attention,回头看 encoder 输出的那三个向量;最后过 MLP。6 层 decoder 看的都是 encoder 最后一层的输出。
- 最上面的 Linear 和 Softmax 给出下一个词的概率,从中选出「I」。论文里用的是 beam search,同时保留几个候选,不是每一步只取概率最大的。
- 把「I」接到 decoder 的输入后面,重复第 4、5 步,直到输出结束符号。
cross-attention 和 self-attention 的区别
公式完全一样,区别只在 Q、K、V 从哪来。self-attention 里三者来自同一串词。cross-attention 里,Q 来自 decoder 这一层里每个位置的向量(刚做完带 mask 的 self-attention),K 和 V 来自 encoder 的输出。意思是:decoder 拿着「我现在要写什么」去原文里找相关的词。这就是 2014 年那个 attention 的 Transformer 版本,下面「追问」的第 4 条有动图。
图里的每个零件,在下面哪一步讲
| 图里的零件 | 在十步里的位置 | 现在的 LLM 里 |
|---|
| Input / Output Embedding | 第 2 步 | 保留,只剩一张表 |
| Positional Encoding | 第 3 步 | 多数公开的模型换成了 RoPE |
| encoder 的 Multi-Head Attention | 第 4、5 步,不带 mask | 没有 encoder,这部分去掉了 |
| Masked Multi-Head Attention | 第 4、5、9 步 | 保留,整个模型只剩这一种 attention |
| decoder 中间的 Multi-Head Attention | 上面这一小节 | 没有 encoder 可看,去掉了 |
| Add & Norm | 第 6 步 | 保留,Norm 挪到了子层前面 |
| Feed Forward | 第 7 步 | 保留,有的模型换成 MoE |
| Linear 和 Softmax | 第 8 步 | 保留 |
把这张图的左半边和 decoder 中间那个 attention 去掉,剩下的就是现在大语言模型的骨架,零件的改动见上表。下面的十步按这个精简后的结构来讲,因为零件更少,而且是现在实际在用的形状。每一步讲的零件和这张图里的是同一个。
动手练习 · 约 5 分钟
用 NumPy 写一遍 self-attention
需要 Python 和 NumPy。把下面的代码存成 attention.py 再运行。核心只有四行。
import numpy as np
np.set_printoptions(precision=3, suppress=True)
tokens = ["小猫", "追", "老鼠", "它"]
# 每个词的 query、key、value 向量。这里是手写的 2 维向量,方便心算;
# 真实模型里它们由 embedding 乘以三个训练出来的矩阵得到。
Q = np.array([[1, 0], [1, 1], [0, 1], [2, 0.5]])
K = np.array([[2, 0], [0, 1], [1, 1], [0, 0.5]])
V = np.array([[1, 0], [0, 1], [0.5, 0.5], [0, 0]])
def softmax(x):
e = np.exp(x - x.max(axis=-1, keepdims=True))
return e / e.sum(axis=-1, keepdims=True)
def attention(Q, K, V, causal):
d = K.shape[-1]
scores = Q @ K.T / np.sqrt(d) # 第 1 步:每个 query 和每个 key 算相似度
if causal: # 第 2 步:只能看自己和前面的词
hidden = np.triu(np.ones_like(scores), k=1).astype(bool)
scores = np.where(hidden, -np.inf, scores)
weights = softmax(scores) # 第 3 步:每一行变成加起来等于 1 的权重
return weights, weights @ V # 第 4 步:按权重把 value 加起来
weights, output = attention(Q, K, V, causal=False)
print("不加 mask 的权重(每一行是一个词在看谁)")
print(weights)
print("「它」这一行:", dict(zip(tokens, weights[3].round(3).tolist())))
print("「它」的输出向量:", output[3])
weights, output = attention(Q, K, V, causal=True)
print("\n加上 causal mask 的权重")
print(weights)
print("每一行的和:", weights.sum(axis=1))
在 NumPy 1.26 上的实际输出:
不加 mask 的权重(每一行是一个词在看谁)
[[0.505 0.123 0.249 0.123]
[0.352 0.174 0.352 0.122]
[0.154 0.313 0.313 0.22 ]
[0.666 0.056 0.231 0.047]]
「它」这一行: {'小猫': 0.666, '追': 0.056, '老鼠': 0.231, '它': 0.047}
「它」的输出向量: [0.782 0.171]
加上 causal mask 的权重
[[1. 0. 0. 0. ]
[0.67 0.33 0. 0. ]
[0.198 0.401 0.401 0. ]
[0.666 0.056 0.231 0.047]]
每一行的和: [1. 1. 1. 1.]
- 这里的数字和上面「亲手算一次 attention」里的完全一样,用的是同一组向量。可以对着那张表一格一格核对。
- 加上 mask 之后,矩阵的右上角全是 0:每个词只能看自己和前面的词。第一行只剩自己,所以权重是 1。
- 「它」是最后一个词,它后面没有词,所以加不加 mask 这一行都一样。
- 自己改一改:把除以 np.sqrt(d) 去掉,看「它」这一行会不会更集中在「小猫」上;把 Q 里「它」的向量改成 [0.5, 2],看它转而去看谁。
速查表
考前十分钟过一遍
- N-gram
- 数数。只看前几个词,词之间没有相似度。
- Word2Vec
- 词变向量。一个词一个向量,不看上下文。
- RNN
- 顺序读,一个隐藏状态。梯度消失,不能并行。
- LSTM / GRU
- 加门和直通路,记得更远。还是不能并行。
- Seq2Seq + Attention
- encoder 读,decoder 写,attention 回头看原文。
- ResNet
- 输出 = 输入 + 修改量。深层网络能训练的前提。
- Transformer
- 只用 attention。并行,任意两个词一步直达,代价是计算量随长度的平方增长。
- self-attention
- softmax(QKᵀ / √d_k) · V。除以 √d_k 是为了不让 softmax 饱和、梯度消失。
- BERT
- encoder-only,双向,完形填空。理解和检索。
- GPT
- decoder-only,causal mask,预测下一个词。生成。
- 现代 LLM
- Pre-Norm、RMSNorm、RoPE、SwiGLU、GQA、MoE、KV cache。
- 参数量
- 约 12 × 层数 × d² + 词表 × d。
- Diffusion
- 逐步去噪。是生成方法,网络可以是 Transformer。
- Mamba
- 计算量和长度成正比,状态按输入内容决定记什么。常和 attention 混用。
留言
说说你的看法
有想法或问题都可以写在这里。留言会立刻显示;想收到回复通知再填邮箱。还没有留言,你可以写第一条。