上一课我们完成了 text → vectors 的转换。现在我们有一组向量,每个代表一个 token 的"意义"。但问题来了:
这些向量是独立的。"cat" 的 embedding 不知道 "sat" 的存在。
然而,语言的核心就是关系。"The cat sat on the mat because it was tired" — 这里的 "it" 指代什么?人类能轻松理解 "it" 指 "cat",但模型怎么做到?
答案就是 Self-Attention(自注意力)——让每个 token "看看"句子中的其他 token,决定该关注谁。
想象你在一个图书馆里。你有一个问题(query),每本书有一个标签(key)和内容(value)。你会怎么做?
Self-Attention 就是这个过程的数学实现。只不过"图书馆"是当前句子的所有 token。
在 Self-Attention 中,每个 token 的 embedding 向量会被变换成三个不同的向量:
| 向量 | 角色 | 类比 |
|---|---|---|
| Query (Q) | "我在找什么?" | 搜索关键词 |
| Key (K) | "我是什么?" | 书籍标签/索引 |
| Value (V) | "我能提供什么信息?" | 书籍内容 |
用代码来理解:
# 每个 token 的 embedding (e.g., 4096 维)
embedding = [0.12, -0.87, 0.34, ...]
# 通过三个不同的线性变换(三个权重矩阵),得到 Q, K, V
query = W_q @ embedding # "我想找什么"
key = W_k @ embedding # "我是什么"
value = W_v @ embedding # "我能提供什么"
这里的 W_q、W_k、W_v 是可学习的权重矩阵——它们在训练过程中被优化。你可以把它们想象成三个不同的"投影镜头",从不同角度观察同一个 embedding。
让我们用 "The cat sat on the mat" 这句话,看看 Self-Attention 如何工作。
# 6 个 token,每个变成 3 个向量
"The" → q₁, k₁, v₁
"cat" → q₂, k₂, v₂
"sat" → q₃, k₃, v₃
"on" → q₄, k₄, v₄
"the" → q₅, k₅, v₅
"mat" → q₆, k₆, v₆
对于每个 token,我们用它的 Query 去和所有 token 的 Key 做点积(dot product),得到"匹配分数":
# 以 "sat" (token 3) 为例
score(sat→The) = q₃ · k₁ = 0.1
score(sat→cat) = q₃ · k₂ = 2.8 ← 高分!"sat" 关注和 "cat" 的关系
score(sat→sat) = q₃ · k₃ = 0.5
score(sat→on) = q₃ · k₄ = 1.2
score(sat→the) = q₃ · k₅ = 0.0
score(sat→mat) = q₃ · k₆ = 1.5
点积越高,说明两个 token 越"相关"。这里 "sat" 和 "cat" 的匹配度最高——因为 "sat" 的 query 在寻找"谁在坐?",而 "cat" 的 key 正好标识了"我是坐的主体"。
原始分数不太好用——它们的大小没有固定范围。所以我们用 softmax 把分数转换成概率分布(总和为 1):
# Softmax 后的注意力权重
weight(sat→The) = 0.02
weight(sat→cat) = 0.65 ← 65% 的注意力在 "cat" 上
weight(sat→sat) = 0.04
weight(sat→on) = 0.08
weight(sat→the) = 0.01
weight(sat→mat) = 0.20
最后,用注意力权重对所有 token 的 Value 做加权求和,得到输出向量:
# "sat" 的新表示
output_sat = 0.02 × v₁("The")
+ 0.65 × v₂("cat") ← 主要来自 "cat"
+ 0.04 × v₃("sat")
+ 0.08 × v₄("on")
+ 0.01 × v₅("the")
+ 0.20 × v₆("mat")
# 结果:一个新的 4096 维向量,融合了上下文信息
# "sat" 现在"知道"了自己在描述 "cat" 的动作
这就是 Self-Attention 的核心:每个 token 的新表示,不再是孤立的 embedding,而是融合了整个上下文的"信息混合物"。
输入: 6 个 token 的 embedding [e₁, e₂, e₃, e₄, e₅, e₆]
│
▼
┌─────────────┐
│ 线性变换 │ 每个 e → (q, k, v)
└─────────────┘
│
▼
┌─────────────┐
│ Q·Kᵀ │ 计算所有 token 之间的匹配分数
│ (点积) │ 得到一个 6×6 的分数矩阵
└─────────────┘
│
▼
┌─────────────┐
│ Softmax │ 分数 → 注意力权重(概率分布)
└─────────────┘
│
▼
┌─────────────┐
│ × V │ 用权重对 Value 加权求和
│ (矩阵乘法) │
└─────────────┘
│
▼
输出: 6 个新的向量 [o₁, o₂, o₃, o₄, o₅, o₆]
每个都融合了全局上下文信息
注意:所有 token 的 Q、K、V 都来自同一个序列。模型不是在一个"外部记忆库"中搜索,而是在自己的输入中搜索关系。这就是"self"的含义——自己关注自己。
对比一下:如果你用搜索引擎查资料,那是"cross-attention"(查询和文档是两个不同的东西)。而 Self-Attention 是一句话里的词互相查看——"cat" 既可以是 searcher(用 Query 搜索),也可以是被搜索的目标(用 Key 被找到)。
一个 Self-Attention 层只能学到一种"关注模式"。但语言关系是多维的——语法关系、语义关系、指代关系……怎么办?
解决方案:Multi-Head Attention(多头注意力)。并行运行多组 Self-Attention,每组学习不同的关注模式:
# 例如 96 个 head(GPT-3 使用 96 个注意力头)
Head 1: 可能关注"语法主语"关系 (sat → cat)
Head 2: 可能关注"介词宾语"关系 (on → mat)
Head 3: 可能关注"指代"关系 (it → cat)
Head 4: 可能关注"邻近词"关系
...
Head 96: 可能关注某种我们尚未理解的模式
# 所有 head 的输出拼接起来,再做一次线性变换
output = W_o @ concat(head₁, head₂, ..., head₉₆)
GPT 系列模型(包括 Claude)有一个重要限制:token 只能关注它自己和前面的 token,不能看后面的。
为什么?因为 GPT 是一个自回归模型——它一个 token 一个 token 地生成文本。当它在生成第 5 个 token 时,第 6、7、8 个 token 还不存在。
# Causal masking(因果遮罩)的效果
# 行 = 当前 token,列 = 可以关注的 token
# ✅ = 可以看到,❌ = 被遮罩
The cat sat on the mat
The [ ✅ ❌ ❌ ❌ ❌ ❌ ]
cat [ ✅ ✅ ❌ ❌ ❌ ❌ ]
sat [ ✅ ✅ ✅ ❌ ❌ ❌ ]
on [ ✅ ✅ ✅ ✅ ❌ ❌ ]
the [ ✅ ✅ ✅ ✅ ✅ ❌ ]
mat [ ✅ ✅ ✅ ✅ ✅ ✅ ]
这就像一个下三角矩阵。实现方式很简单——在 softmax 之前,把"未来"位置的分数设为负无穷大(-∞),softmax 后这些位置的权重就变成了 0。
Self-Attention 需要计算每对 token 之间的关系。如果有 n 个 token,就需要计算 n² 个注意力分数。
| 上下文长度 | 注意力分数数量 | 相对计算量 |
|---|---|---|
| 1,024 | ~100 万 | 1× |
| 8,192 | ~6700 万 | 64× |
| 100,000 | ~100 亿 | ~10,000× |
| 200,000 | ~400 亿 | ~40,000× |
计算量随上下文长度平方增长。这就是为什么长上下文窗口如此昂贵——不仅是内存问题,更是计算问题。
把整个过程写成一个函数,用 Python 风格的伪代码:
def self_attention(embeddings):
"""
embeddings: shape (n_tokens, d_model)
例如 (6, 4096) 表示 6 个 token,每个 4096 维
"""
# Step 1: 计算 Q, K, V
Q = embeddings @ W_q # (6, 4096)
K = embeddings @ W_k # (6, 4096)
V = embeddings @ W_v # (6, 4096)
# Step 2: 计算注意力分数
scores = Q @ K.T # (6, 6) — 每对 token 的匹配度
scores = scores / sqrt(d_k) # 缩放,防止分数太大
# Step 2.5: 应用 causal mask (GPT 风格)
mask = lower_triangular(6)
scores = where(mask, scores, -infinity)
# Step 3: Softmax 归一化
weights = softmax(scores, axis=-1) # (6, 6) — 每行和为 1
# Step 4: 加权求和
output = weights @ V # (6, 4096)
return output
就这么简单!整个 Self-Attention 的核心逻辑,不到 10 行代码。当然,实际实现中还有 multi-head 拆分、bias、layer normalization 等细节,但核心思想就是这些。
现在我们可以把前两课串起来,看到完整的"输入处理"流程:
"The cat sat"
│
▼
┌────────────┐
│ Tokenizer │ "The cat sat" → ["The", " cat", " sat"]
└────────────┘
│
▼
┌────────────┐
│ Vocabulary │ ["The", " cat", " sat"] → [464, 3797, 1042]
│ Lookup │
└────────────┘
│
▼
┌────────────┐
│ Embedding │ [464, 3797, 1042] → [e₁, e₂, e₃] (3 个 4096 维向量)
└────────────┘
│
▼
┌────────────┐
│ Self- │ [e₁, e₂, e₃] → [o₁, o₂, o₃] (融合了上下文的向量)
│ Attention │ "cat" 现在知道 "sat" 的存在
└────────────┘
│
▼
??? → 如何从向量变回文字?(下一课!)
Q1. 在 Self-Attention 中,Query 和 Key 的点积(dot product)表示什么?
Q2. 为什么 GPT 需要 causal masking?
Q3. Multi-Head Attention 中,不同的 head 主要区别是什么?
Q4. Self-Attention 的计算复杂度是 O(n²),其中 n 是什么?
Self-Attention 是 LLM 最核心的概念。如果你还没看,强烈推荐:
现在我们有了"融合了上下文的向量表示"。但 LLM 最终要输出文字——怎么从向量变回 token?下一课我们来看 Output Layer(输出层):线性变换 + Softmax → 概率分布 → 下一个 token。还会涉及 temperature 和 top-p 这些你在 API 中常见的参数到底在干什么。