从算法到人工智能 · 第 25 课:Attention 与 Transformer
上一课我们学了 CNN 和 RNN:CNN 看图像,RNN 读序列。但它们各有硬伤:
- RNN 不能并行:必须一个词一个词地读,第 100 个词要等前面 99 个词都算完。句子一长就慢,而且距离远的词之间的信息传着传着就"忘"了。
- CNN 视野有限:卷积核只有 3×3、5×5 那么大,要叠很多层才能看到全局。
2017 年,一篇论文《Attention Is All You Need》提出了 Transformer,一次性解决了这两个问题:既能并行,又能让任意两个词之间直接"看到"对方。GPT、BERT、Claude、Gemini……今天几乎所有大语言模型,都是 Transformer 的后代。
这一课,我们搞懂它的心脏——注意力机制(Attention)。
一、先看 RNN 的病根:信息要"慢慢传"
翻译这句话:"The cat sat on the mat because it was tired"。
这里的 it 指谁?是 cat 还是 mat?人一看就知道是 cat。但 RNN 要一个字一个字读,读到 it 时,它只带着"前一个时刻的记忆",中间隔了好几个词,cat 的信息可能已经被冲淡了。
RNN 的问题:词与词之间的关系,要通过"隐藏状态"一步步传递,传得越远越模糊。
Attention 的答案:别慢慢传了,让每个词直接"看"所有其他词,自己决定该关注谁。
读到 it 时,直接算 it 和句子里每一个词的关联度,发现和 cat 关联最高,就把注意力集中到 cat 上。一步到位,不用层层传递。
二、注意力机制的直觉:三个向量 Q、K、V
注意力机制可以浓缩成一句话:
"拿一个问题 Q,去和每个候选 K 比对打分,按分数加权,把对应的 V 汇总起来。"
对应到语言模型,每个词都会生成三个向量:
| 向量 | 全称 | 通俗理解 |
|---|---|---|
| Q(Query) | 查询 | "我在找什么?" —— 当前词想找的信息 |
| K(Key) | 键 | "我是什么?" —— 每个词能提供什么 |
| V(Value) | 值 | "我的内容" —— 每个词实际的信息 |
过程三步:
- 打分:拿当前词的 Q,和所有词的 K 做点积(内积),分数越高越"相关"。
- 归一化:softmax 把分数变成权重(每行和为 1)。
- 加权求和:用权重把所有词的 V 加权平均,得到当前词的"新表示"。
类比:你在图书馆找书(Q = 你想找什么),每本书有个标签(K),书的内容是 V。你拿 Q 去比对每个标签,给每本书打个"相关度",然后按相关度"综合"读几本书——这就是 Attention。
三、为什么叫"缩放点积注意力"
第 1 步的"打分"用的是 点积:Q · K。但点积值会随向量维度增大而变大,导致 softmax 的梯度变小、训练困难。所以除以一个因子 √d_k(d_k 是 K 的维度)来"缩放"。
于是完整的注意力公式:
Attention(Q, K, V) = softmax( Q·Kᵀ / √d_k ) · V这就是论文里最核心的一行公式。看懂它,你就看懂了 Transformer 的心脏。
四、从零手写注意力机制
不调库,纯 NumPy 实现上面那行公式:
import numpy as np
def softmax(x, axis=-1):
x = x - np.max(x, axis=axis, keepdims=True) # 减去最大值,防溢出
e = np.exp(x)
return e / np.sum(e, axis=axis, keepdims=True)
# 三个"词",每个用 4 维向量表示(embedding)
X = np.array([
[1.0, 0.0, 1.0, 0.0], # 词 0
[0.0, 1.0, 0.0, 1.0], # 词 1
[1.0, 1.0, 1.0, 1.0], # 词 2
])
d_k = 4
np.random.seed(0)
Wq = np.random.randn(4, d_k) * 0.5 # 把 X 映射成 Q
Wk = np.random.randn(4, d_k) * 0.5 # 把 X 映射成 K
Wv = np.random.randn(4, d_k) * 0.5 # 把 X 映射成 V
# 1. 生成 Q、K、V
Q = X @ Wq
K = X @ Wk
V = X @ Wv
# 2. 打分 + 缩放
scores = Q @ K.T / np.sqrt(d_k)
# 3. softmax 归一化成权重
attn = softmax(scores, axis=-1)
# 4. 加权求和
out = attn @ V
print("注意力权重(每行是一个词对三个词的关注度):")
print(attn.round(3))
print("每行之和(应该都是 1):", attn.sum(axis=1).round(4))
print("输出形状:", out.shape)跑起来你会看到:attn 是一个 3×3 的矩阵,每一行代表一个词对三个词的关注权重,每行加起来正好是 1。这就是"每个词都在有选择地关注其他词"。
五、词向量从哪来:Embedding 与 Word2Vec
第四节代码里,每个"词"已经是一个 4 维向量(X 数组),注意力直接拿它算 Q、K、V。但这个向量是哪来的? 这是理解一切 LLM 的关键一环,必须补上。
为什么不能"数着"表示词
最朴素的表示是 one-hot:假设词典有 10000 个词,第 i 个词就是一个"长度 10000、只有第 i 位是 1"的向量。但 one-hot 有两个致命问题:
- 维度爆炸:一个词就占 1 万维,词典一大就没法用。
- 没有语义:"猫"和"狗"的 one-hot 向量点积是 0(完全无关),但它们明明语义相近。
分布式表示:让"语义相近"的词,向量也相近
解法是把每个词映射到一个低维稠密向量(比如 300 维),并且让语义相近的词,向量在空间里也靠得近。这个向量就叫词向量(word embedding)。
经典例子:
向量("国王") - 向量("男人") + 向量("女人") ≈ 向量("女王")向量不只是"编号",它编码了语义关系——这是 one-hot 永远做不到的。
Word2Vec:把"学词向量"变成自监督任务
词向量是怎么学出来的?关键洞察是分布假说:一个词的含义,由它周围的词决定。
Word2Vec(2013)据此设计了两个自监督任务:
- CBOW:用上下文词预测中心词("___ sat _ the mat" 猜中间的 "on")。
- Skip-gram:用中心词预测它的上下文(给 "cat",猜周围会出现 "the""sat")。
训练方法和第 22 课一模一样:喂一堆(词 → 它的邻居)样本,用梯度下降调词向量,让"语义相近的词"向量靠近。不需要人工标注——答案就藏在文本里。
# 概念示意(真 Word2Vec 需要大语料 + 负采样)
import numpy as np
def cosine(a, b):
return a @ b / (np.linalg.norm(a) * np.linalg.norm(b))
# 假设已经学到(真实值是训练出来的):
cat = np.array([0.9, 0.1, 0.2, 0.8])
dog = np.array([0.8, 0.2, 0.1, 0.7]) # 和 cat 相近
apple = np.array([0.1, 0.9, 0.8, 0.1]) # 和 cat 无关
print("cat·dog 相似度:", round(cosine(cat, dog), 3)) # 高(≈0.99)
print("cat·apple 相似度:", round(cosine(cat, apple), 3)) # 低(≈0.4)体会:向量方向接近 = 语义接近。这就是"词向量让语义可计算"的含义。
现代 LLM:embedding 是"第一层"
今天的大模型不再单独跑 Word2Vec,而是把词向量直接作为网络的第一层参数,随整个模型一起端到端训练。分词器把文字变成 token id,第一层 Embedding 把每个 id 查表成一个向量,后面再接 Transformer。
import torch
import torch.nn as nn
# 词典 10000 个 token,每个映射成 300 维向量
embedding = nn.Embedding(num_embeddings=10000, embedding_dim=300)
ids = torch.tensor([12, 456, 7890, 12, 3, 9999, 42, 7])
vecs = embedding(ids)
print(vecs.shape) # torch.Size([8, 300])记住:词向量(embedding)把离散的文字变成连续的、带语义的向量空间,让"语义相近的词"天然靠拢——这是 Transformer 能"理解"语言的前提。第四节那个 4 维的 X,本质上就是一张极简版的 embedding 表。
六、多头注意力:多派几个"专家"去查
单头注意力只有一套 Q/K/V,关注的角度单一。多头注意力(Multi-Head Attention) 就是并行跑多套,让不同的"头"关注不同的关系:
- 有的头关注"语法"(哪个是主语)
- 有的头关注"指代"(it 指谁)
- 有的头关注"语义"(词义相近)
各头算完再把结果拼接起来。这就是为什么 Transformer 能同时捕捉多种关系。
七、位置编码:Transformer 怎么"知道"顺序
注意力机制本身是无序的——它把句子当成"一袋子词"。但"我爱你"和"你爱我"意思完全不同,顺序不能丢。
Transformer 的解法:在输入向量上加一个"位置编码(Positional Encoding)",让每个位置带上独一无二的"位置信号"。用的是正弦/余弦函数(不同频率):
import numpy as np
def pos_encoding(seq_len, d_model):
pe = np.zeros((seq_len, d_model))
for pos in range(seq_len):
for i in range(0, d_model, 2):
pe[pos, i] = np.sin(pos / 10000 ** (i / d_model))
pe[pos, i+1] = np.cos(pos / 10000 ** (i / d_model))
return pe
pe = pos_encoding(5, 8) # 5 个位置,8 维
print(pe.round(3))要点:每个位置编码成一个固定向量,直接加到词向量上。这样即使注意力"无序",位置信息也已经嵌进输入里了。
八、Transformer 整体结构
把前面拼起来,就是一个 Transformer 的 Encoder 层:
输入 (词向量 + 位置编码)
↓
多头自注意力 ←── 词与词直接互相看
↓
残差连接 + 层归一化
↓
前馈网络(全连接)
↓
残差连接 + 层归一化
↓
输出(每个词的新表示)三个关键设计:
| 设计 | 作用 |
|---|---|
| 多头自注意力 | 词与词直接、并行地互相看 |
| 残差连接 | 让梯度能顺畅传回,训练更深 |
| 层归一化 | 稳定训练 |
把这样的层叠 N 层,就是 Transformer。"注意力 + 前馈 + 残差 + 归一化"这个模块重复堆叠,就是今天的 GPT 们。
九、复杂度与要点
| 概念 | 说明 |
|---|---|
| 自注意力 | O(n² · d),n 是序列长度。瓶颈在 n²(长序列代价高) |
| 并行性 | 所有词同时算,不依赖前一个 → 可以并行(对比 RNN 的 O(n) 顺序) |
| 多头 | 多个 Q/K/V 并行,关注不同关系 |
| 位置编码 | 正弦/余弦,给无序的注意力注入顺序信息 |
十、动手时间 🎯
实验 1:观察注意力权重
跑第四节代码,然后手动改 X 里某个词向量,看 attn 权重怎么变。你能让"词 0"把大部分注意力集中到"词 2"上吗?
实验 2:验证 softmax 的性质
# 任给一组分数,验证 softmax 输出:① 都是正数 ② 加起来等于 1
s = np.array([2.0, 1.0, 0.1, 3.0])
def softmax(x):
x = x - np.max(x)
e = np.exp(x)
return e / e.sum()
print(softmax(s).round(4))实验 3(挑战):给注意力加"掩码"(mask)
把某些位置的分数设成 -inf,再走 softmax,看它们权重是不是变成 0。这就是 GPT 生成时"只能看到前面的词"的实现原理——因果掩码(causal mask)。
十一、小结
- 注意力 = Q 查 K、按分数加权取 V:
softmax(Q·Kᵀ/√d_k)·V,让每个词直接、有选择地关注其他词。 - Transformer 解决 RNN 两大病:能并行(不依赖前一个)、能直接看任意远的词(不用层层传递)。
- Transformer = 多头自注意力 + 前馈 + 残差 + 归一化,这个模块重复堆叠,就是今天的 GPT、BERT、Claude。