Transformer架构详解
什么是 Transformer?
2017 年,Google 的八位研究员发表了一篇题为 "Attention Is All You Need" 的论文,提出了一种全新的神经网络架构——Transformer。它彻底抛弃了 RNN/LSTM 的循环结构,完全基于 Attention 机制来建模序列数据。
这个看似激进的设计,后来证明是革命性的:GPT(Generative Pre-trained Transformer)、BERT、Llama、Claude……几乎所有现代大语言模型都建立在 Transformer 架构之上。可以说,不理解 Transformer,就无法真正理解今天的 AI。
一句话概括:Transformer 的核心思想是——让序列中的每个位置直接"关注"(attend to)所有其他位置,用加权求和的方式聚合全局信息,而不是通过循环逐步传递。这就像开会时每个人可以直接听到所有人的发言,而不是一个接一个传话。
架构全景图
INPUT → [Input Embedding + Positional Encoding]
│
ENCODER (×N layers):
├─ Multi-Head Self-Attention → Add & Norm
└─ Feed-Forward Network → Add & Norm
│
DECODER (×N layers):
├─ Masked Multi-Head Self-Attention → Add & Norm
├─ Cross-Attention (with Encoder output) → Add & Norm
└─ Feed-Forward Network → Add & Norm
│
OUTPUT → Linear + Softmax
Encoder 负责"理解输入"(把源语言编码成上下文表示),Decoder 负责"生成输出"(自回归地逐 token 生成目标语言)。但在 GPT 这类纯 Decoder 架构中,Encoder 被整个拿掉,Decoder 既做理解又做生成——这也是为什么 GPT 能"续写"。
完整架构:Encoder + Decoder
Encoder(编码器)
Encoder 由 N 层(原论文 N=6)完全相同的层堆叠而成。每层包含:
Encoder Layer (每层):
1. Multi-Head Self-Attention(能看到整个输入序列)
2. Add & Norm (残差 + 层归一化)
3. Feed-Forward Network
4. Add & Norm
输入:源语言 token 序列
输出:每个位置的上下文感知表示("理解了整个句子后对每个词的重新描述")
Decoder(解码器)
Decoder 也由 N 层堆叠,但每层比 Encoder 多了一个 Cross-Attention 子层:
Decoder Layer (每层):
1. Masked Multi-Head Self-Attention(只能看到已生成的 token)
2. Add & Norm
3. Cross-Attention — Q 来自 Decoder,K/V 来自 Encoder
4. Add & Norm
5. Feed-Forward Network
6. Add & Norm
Masked Self-Attention 是 Decoder 的关键设计:通过一个上三角矩阵(causal mask),确保生成 tokeni 时只能看到 token0 到 tokeni,看不到后面的 token。这模拟了自回归生成的过程——你写下一个词时只能基于已经写下的内容。
def create_causal_mask(seq_len):
# ═══════════════════════════════════════════════════════════
# 生成上三角 mask 矩阵,确保 token i 只能 attend token 0~i
# 对角线及以下 = 0(允许 attend),对角线以上 = -inf(禁止 attend)
# ═══════════════════════════════════════════════════════════
mask = np.triu(np.ones((seq_len, seq_len)), k=1) * (-np.inf)
print("Causal Mask (0=允许, -inf=禁止):")
print(mask)
return mask
mask = create_causal_mask(5)
# 输出:
# 0. -inf -inf -inf -inf → token 0 只能看到自己
# 0. 0. -inf -inf -inf → token 1 能看到 token 0,1
# 0. 0. 0. -inf -inf → token 2 能看到 token 0,1,2
# 0. 0. 0. 0. -inf → token 3 能看到 token 0,1,2,3
# 0. 0. 0. 0. 0. → token 4 能看到全部(0~4)
Cross-Attention:连接 Encoder 和 Decoder
Cross-Attention 是 Encoder-Decoder 之间的桥梁:
Q 来自 Decoder 当前层的输出 — "翻译到这个位置时,需要从源句子里找什么信息?"
K, V 来自 Encoder 的最终输出 — "源句子的每个词都提供了什么信息?"
结果:Decoder 每生成一个目标词,都会"回看"源句子中与当前生成最相关的部分。
这是翻译任务的核心——"the cat" 生成时 decoder 的注意力会集中在源句子的 "the cat" 上。
GPT 系列:只留 Decoder
GPT = Generative Pre-trained Transformer。它砍掉了整个 Encoder,只用 Decoder 部分(更准确地说是 Decoder 去掉 Cross-Attention),因为 GPT 的任务不是"翻译"而是"续写"——给定前缀,预测下一个 token。没有源语言需要"cross-attend"到。
🏗 GPT Decoder-Only 架构
原始 Transformer 的 Decoder 有三个主要组件:
1. Masked Self-Attention → GPT 保留(只需看到上文)
2. Cross-Attention → GPT 删除(不需要源语言信息)
3. Feed-Forward Network → GPT 保留
砍掉 Cross-Attention 后的 Decoder 就是 GPT 的核心结构。这也是为什么 GPT 能做一切任务——它的"输入"和"输出"都是同一套 token 序列,任何任务都可以被重新表述为"给定前缀,续写后缀"。
Self-Attention
什么是Self-Attention
假设你读这句话:"The cat sat on the mat because it was tired."
当人类读到 "it" 时,大脑自动把 "it" 和前面的 "cat" 关联起来——你知道 "it" 指的是猫,不是垫子。这就是 Attention 的本质:在处理某个词时,知道应该"看"句子中的哪些其他词,以及各看多少。
Transformer 的 Self-Attention 把这种直觉数学化了:
Attention(Q, K, V) = softmax( QK<sup>T</sup> / √d<sub>k</sub> ) · V
Q、K、V 都来自同一个输入序列(Self = 自己对自己)。序列中的每个位置既是查询者(Q),又是被查询者(K),还是信息提供者(V)。这让模型能够捕捉到序列内部的依赖关系——哪些词之间有关系、关系有多强。
与之对应的是 Cross-Attention:Q 来自 Decoder("我想生成什么"),K 和 V 来自 Encoder 的输出("源语言说了什么")。这在翻译任务中至关重要——生成每个目标词时都要"回看"源语言的对应部分。
为什么除以 √dₖ
假设 dₖ=64,Q 和 K 的各分量是独立标准正态分布 N(0,1)。那么 Q·KT 的结果是 64 个独立正态变量的和,方差为 64,即 Q·K ∝ N(0, 64)。如果不做缩放,内积值会很大,经过 softmax 后会变得非常尖锐(梯度接近 0),导致训练困难。
除以 √64 = 8 后,方差被拉回 1,softmax 输出变得平滑,梯度流动正常。
import numpy as np
# ═══════════════════════════════════════════════════════════════
# 模拟:不缩放 vs 缩放对 softmax 的影响
# ═══════════════════════════════════════════════════════════════
d_k = 64 # K 的维度
Q = np.random.randn(1, d_k) # 模拟一个 Query 向量,标准正态分布
K = np.random.randn(10, d_k) # 模拟 10 个 token 的 Key 向量
# 不缩放:Q·K^T 的方差 ≈ d_k = 64,值很大
scores_raw = Q @ K.T # 矩阵乘法,结果是一个 1×10 的行向量
print(f"未缩放 — 方差: {scores_raw.var():.1f}") # 输出 ~60-70
def softmax(x, axis=-1):
# softmax 公式: exp(x_i) / Σ exp(x_j),把任意实数向量转为概率分布
e_x = np.exp(x - np.max(x, axis=axis, keepdims=True)) # 减 max 防溢出
return e_x / np.sum(e_x, axis=axis, keepdims=True)
attn_raw = softmax(scores_raw) # 不缩放直接 softmax — 几乎变成 one-hot
print(f"未缩放 — attention 分布: {np.round(attn_raw, 3)}")
# 输出类似: [0. 0. 0.001 0.999 0. 0. 0. 0. 0. 0.] — 极度集中
# 缩放:除以 √d_k 后方差 ≈ 1,分布平滑
scores_scaled = scores_raw / np.sqrt(d_k) # 方差被标准化到 ~1
print(f"缩放后 — 方差: {scores_scaled.var():.1f}") # 输出 ~0.8-1.2
attn_scaled = softmax(scores_scaled)
print(f"缩放后 — attention 分布: {np.round(attn_scaled, 3)}")
# 输出类似: [0.12 0.08 0.15 0.18 0.09 0.06 0.11 0.07 0.06 0.08] — 平滑合理
Self-Attention 的完整计算流程
import numpy as np
def self_attention(X, d_k=64):
# ═════════════════════════════════════════════════════════
# Self-Attention 的完整计算流程(未加可学习权重)
# 输入 X: (seq_len, d_model) — 一句话中所有 token 的向量
# ═════════════════════════════════════════════════════════
seq_len, d_model = X.shape
# 在真实 Transformer 中,Q/K/V 是通过三个线性变换得到的:
# Q = X @ W_Q, K = X @ W_K, V = X @ W_V
# 这里为了演示核心逻辑,直接用 X 本身作为 Q/K/V (d_k = d_model)
Q = X.copy() # Query: "我在找什么"
K = X.copy() # Key: "我是什么标签"
V = X.copy() # Value: "我携带什么信息"
# Step 1: 计算注意力分数矩阵 (seq_len, seq_len)
# 矩阵中的 (i,j) 位置表示:token_i 应该"关注" token_j 多少
scores = Q @ K.T # Q 的每一行与 K 的每一行做内积
# Step 2: 缩放 — 防止大维度导致 softmax 梯度过小
scores = scores / np.sqrt(d_k) # 方差归一化到 ~1
# Step 3: softmax 归一化 — 把每行的分数转为"权重分布"
# 每一行独立 softmax,确保该 token 对全体的关注权重之和 = 1
attn_weights = np.exp(scores - scores.max(axis=1, keepdims=True))
attn_weights /= attn_weights.sum(axis=1, keepdims=True)
# Step 4: 加权求和 — 用注意力权重对 V 做加权平均
# 结果:(seq_len, d_model),每个 token 的新表示 = 所有 token 的 V 的加权和
output = attn_weights @ V
return output, attn_weights
# ═════════════════════════════════════════════════════════
# 演示:一句话中 "it" 如何通过 Attention 找到 "cat"
# ═════════════════════════════════════════════════════════
np.random.seed(42)
tokens = ["The", "cat", "sat", "it", "was", "tired"] # 6 个 token
# 每个 token 用 8 维向量表示(实际通常是 512~4096 维)
embeddings = np.random.randn(len(tokens), 8)
output, weights = self_attention(embeddings, d_k=8)
print("注意力权重矩阵 (行=查询者, 列=被关注者):")
print(" " + " ".join(f"{t:>6}" for t in tokens))
for i, t in enumerate(tokens):
print(f"{t:>6}: " + " ".join(f"{w:.3f}" for w in weights[i]))
# 每一行都是该 token 对全体的关注分布
# "it" 那行如果权重最大的位置在 "cat" 列 → attention 起作用了!
Multi-Head Attention
为什么需要多头?
单头 Attention 只有一种"关注模式"——就像你只能用一种视角看问题。但在语言中,一个词可能同时需要关注多个方面:
例子:"The cat chased the mouse because it was hungry."
"it" 需要关注 "cat"(语法上的指代关系 —— 谁饿了?)
同时 "chased" 需要关注 "because"(语义上的因果关系 —— 为什么追?)
如果只有一个 Attention 头,它必须在这些不同的关注需求之间"妥协"。
Multi-Head Attention 的解决方案:把 Q、K、V 分别通过 h 个不同的线性变换(WQ, WK, WV),投影到 h 个不同的"子空间",在每个子空间里独立做 Attention,最后把所有头的结果拼接起来再投影一次。
头 1 — 语法头
可能学会关注"主谓一致"关系。"it" ↔ "cat"(单数匹配)
头 2 — 语义头
可能学会关注"因果关系"。"chased" ↔ "because"(原因)
头 3 — 位置头
可能学会关注"相邻词"。"sat" ↔ "on"(动词+介词搭配)
头 4 — 共指头
可能学会关注"代词指代"。"it" ↔ "mouse"(another candidate)
Multi-Head Attention 的完整实现
import numpy as np
class MultiHeadAttention:
def __init__(self, d_model=512, num_heads=8):
# ═══════════════════════════════════════════════════════
# d_model: 模型总维度(原论文 512)
# num_heads: 注意力头数(原论文 8)
# d_k = d_model // num_heads 每个头处理的维度 (=64)
# ═══════════════════════════════════════════════════════
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads # 512/8 = 64,每个头只看 64 维
assert d_model % num_heads == 0, "d_model 必须能被 num_heads 整除"
# 初始化可学习的投影矩阵(随机初始化模拟)
# 实际训练中这些矩阵通过反向传播不断更新
scale = np.sqrt(2.0 / d_model) # Xavier 初始化的缩放因子
self.W_Q = np.random.randn(d_model, d_model) * scale # Q 的投影矩阵
self.W_K = np.random.randn(d_model, d_model) * scale # K 的投影矩阵
self.W_V = np.random.randn(d_model, d_model) * scale # V 的投影矩阵
self.W_O = np.random.randn(d_model, d_model) * scale # 输出投影矩阵
def _split_heads(self, x):
# ═══════════════════════════════════════════════════════
# 输入 x: (batch, seq_len, d_model) → (batch, seq_len, 512)
# 输出: (batch, num_heads, seq_len, d_k) → (batch, 8, seq_len, 64)
# 把 512 维切成 8 个 64 维的"头",每个头独立做 Attention
# ═══════════════════════════════════════════════════════
batch, seq_len, _ = x.shape
x = x.reshape(batch, seq_len, self.num_heads, self.d_k) # 掰成 8 份
return x.transpose(0, 2, 1, 3) # (batch, heads, seq, d_k)
def _combine_heads(self, x):
# 反向操作:把 8 个头的输出拼回 512 维
x = x.transpose(0, 2, 1, 3) # (batch, seq, heads, d_k)
batch, seq_len, _, _ = x.shape
return x.reshape(batch, seq_len, self.d_model) # (batch, seq, 512)
def forward(self, x, mask=None):
# ═══════════════════════════════════════════════════════
# Self-Attention 模式: Q, K, V 都来自同一个 x
# Cross-Attention 模式: Q 来自 decoder, K/V 来自 encoder
# ═══════════════════════════════════════════════════════
# Step 1: 线性投影 — 把输入投影到查询/键/值空间
Q = x @ self.W_Q # (batch, seq, d_model) → 每个 token 从"要查什么"角度编码
K = x @ self.W_K # 每个 token 从"我是什么标签"角度编码
V = x @ self.W_V # 每个 token 从"我携带什么信息"角度编码
# Step 2: 拆分成多个头
Q = self._split_heads(Q) # (batch, 8, seq, 64)
K = self._split_heads(K)
V = self._split_heads(V)
# Step 3: 在每个头内独立计算 Scaled Dot-Product Attention
# Q @ K^T: (batch, 8, seq, seq) — 每个头算一个注意力矩阵
scores = Q @ K.transpose(0, 1, 3, 2) / np.sqrt(self.d_k)
# causal mask: 上三角填充 -inf,确保 token 只看得到"过去"
if mask is not None:
scores = scores + mask # -inf 位置 softmax 后变 0
# softmax 得到每个头内的注意力权重分布
scores = scores - scores.max(axis=-1, keepdims=True) # 数值稳定
attn_weights = np.exp(scores)
attn_weights /= attn_weights.sum(axis=-1, keepdims=True)
# Step 4: 加权求和 — 用注意力权重对 V 做加权
context = attn_weights @ V # (batch, 8, seq, 64)
# Step 5: 拼接所有头 + 输出投影
context = self._combine_heads(context) # (batch, seq, 512)
output = context @ self.W_O # 输出投影,整合多头信息
return output, attn_weights
# ═══════════════════════════════════════════════════════════
# 演示 Multi-Head Attention 的参数量
# ═══════════════════════════════════════════════════════════
mha = MultiHeadAttention(d_model=512, num_heads=8)
total_params = 0
for name, param in [("W_Q", mha.W_Q), ("W_K", mha.W_K),
("W_V", mha.W_V), ("W_O", mha.W_O)]:
n = param.size
total_params += n
print(f"{name}: {param.shape} = {n:,} 参数")
print(f"\nMulti-Head Attention 总参数量: {total_params:,}")
# 4 × (512 × 512) = 1,048,576 — 约 100 万参数,仅占整个 Transformer 的一小部分
# 如果 8 个头各用独立的 W_Q(512×64 每个头),参数量一样:8×4×(512×64)
Multi-Head 的参数量与效率
| 方案 | 参数量 | 计算量 | 表达能力 |
|---|---|---|---|
| 单头 (d=512) | 3×512²=786K | 低 | 单一视角 |
| 8 头 (dₖ=64) | 3×512²+512²=1,048K | 中(多一个小 W_O) | 8 个独立视角 |
| 16 头 (dₖ=32) | 3×512²+512²=1,048K | 与 8 头相同 | 16 个更细粒度的视角 |
关键:无论多少个头,总参数量基本相同(都是 4 个 d_model × d_model 矩阵)。多头的关键不是"更多参数",而是"多种投影"——把同一个 512 维向量切分成 8 个 64 维子空间,让模型同时从 8 个角度做 Attention。
位置编码 — Positional Encoding
Attention 的盲点:没有顺序感
仔细看 Self-Attention 的计算:Q·K 的运算是对称的——"A attend to B"和"B attend to A"的计算方式完全相同。这意味着:Self-Attention 本身无法区分 token 的先后顺序。给模型输入 "狗咬人" 和 "人咬狗",如果不加位置信息,Attention 看到的"狗"和"人"之间的关系完全一样。
RNN 天生有时序——隐藏状态一步步传递。Transformer 没有循环,所以必须显式注入位置信息。
Sinusoidal Positional Encoding(原论文)
原论文使用了正弦/余弦函数来生成位置编码,而非可学习的 Embedding。公式如下:
PE(pos, 2i+1) = cos( pos / 100002i/dmodel )
其中 pos 是 token 位置(0, 1, 2...),i 是维度索引(0 到 dmodel/2-1)。
直觉理解:每个维度是一个不同频率的正弦波。低频维度(i 小)变化慢,可以区分"远距离的前后关系";高频维度(i 大)变化快,可以区分"相邻 token 的精确位置"。这些不同频率的波叠加在一起,给每个位置生成了一个独一无二的"指纹"。
代码实现:
import numpy as np
import matplotlib.pyplot as plt
def get_sinusoidal_positional_encoding(seq_len, d_model):
# ═══════════════════════════════════════════════════════════
# seq_len: 序列长度(最大支持的 token 数)
# d_model: 模型维度(必须是偶数,因为 sin/cos 成对出现)
# ═══════════════════════════════════════════════════════════
PE = np.zeros((seq_len, d_model)) # 初始化矩阵 (seq_len, 512)
# pos: 每个 token 的位置编号 (0, 1, 2, ..., seq_len-1)
# 用 reshape 变成列向量,方便广播运算
pos = np.arange(seq_len, dtype=np.float32).reshape(-1, 1)
# i: 维度索引,只取偶数位置 (0, 2, 4, ...),因为 sin/cos 成对
i = np.arange(0, d_model, 2, dtype=np.float32)
# 频率分母:10000^(2i/d_model),i 越大频率越高
# 当 i=0: 分母 = 10000^0 = 1 → 最低频率
# 当 i=254: 分母 = 10000^(508/512) ≈ 10000 → 最高频率
denominator = np.power(10000, i / d_model) # (d_model/2,)
# 所有位置 ÷ 频率 → 每个位置在不同频率上的相位
angle = pos / denominator # (seq_len, d_model/2)
# 偶数维度填 sin,奇数维度填 cos
PE[:, 0::2] = np.sin(angle) # 维度 0,2,4,... 填充 sin 值
PE[:, 1::2] = np.cos(angle) # 维度 1,3,5,... 填充 cos 值
return PE
# ═══════════════════════════════════════════════════════════
# 可视化位置编码:横轴=维度,纵轴=位置,颜色=编码值
# ═══════════════════════════════════════════════════════════
seq_len, d_model = 60, 128
pe = get_sinusoidal_positional_encoding(seq_len, d_model)
print(f"位置编码矩阵: {pe.shape}") # (60, 128)
print(f"位置 0 的前 8 维: {pe[0, :8]}") # 第 0 个 token 的位置编码
print(f"位置 5 的前 8 维: {pe[5, :8]}") # 第 5 个 token 的位置编码 — 不同位置编码不同
# 验证线性性质:PE(pos+offset) 可以通过 PE(pos) 线性变换得到
# 这是 Sinusoidal 编码的强大之处 — 模型可以学会"相对位置"
k = 3 # 偏移量
dot1 = pe[0] @ pe[k] # PE(0) 和 PE(3) 的内积
dot2 = pe[5] @ pe[5+k] # PE(5) 和 PE(8) 的内积
print(f"\n位置(0,3)内积: {dot1:.2f} | 位置(5,8)内积: {dot2:.2f}")
print(f"两个内积之差: {abs(dot1-dot2):.6f} — 几乎为 0!")
# 这证明:偏移相同距离的两个位置,其编码的内积相同
# 模型可以通过这个性质学会"相对距离"而非"绝对位置"
Sinusoidal vs Learned Positional Encoding
| 特性 | Sinusoidal(原论文) | Learned(GPT 常用) |
|---|---|---|
| 参数 | 0(完全由公式生成) | seq_len × d_model 个可学习参数 |
| 外推能力 | ✅ 天然支持任意长度 | ❌ 无法超出训练时的 seq_len |
| 相对位置 | ✅ 内积编码相对距离 | ❌ 需要模型自己学 |
| 效果 | 基础任务够用 | 通常略好(有可学习参数) |
RoPE — 旋转位置编码(现代模型标配)
位置编码演化史
| 方案 | 提出时间 / 论文 | 核心方式 | 代表模型 | 主要缺陷 |
|---|---|---|---|---|
| Sinusoidal(正弦) | Vaswani et al., 2017 Attention Is All You Need |
用 sin/cos 函数生成固定编码,加到 Embedding 上 | 原始 Transformer | 外推能力差(训 512 推到 1024 效果骤降) |
| Learned(可学习) | Devlin et al., 2019 BERT |
将位置当作可训练的 Embedding 参数 | BERT、GPT-1/2 | 不支持超过训练长度的序列 |
| Relative(相对) | Shaw et al., 2018 Dai et al., 2019 |
在 Attention 计算中注入相对距离偏差 | Transformer-XL | 计算开销大、实现复杂 |
| RoPE ★ | Su et al., 2021 RoFormer |
用旋转矩阵编码绝对位置,点积自然蕴含相对位置 | LLaMA、Qwen、ChatGLM Mistral、DeepSeek |
长序列需插值扩展 |
| ALiBi | Press et al., 2022 | 在 Attention 分数上加一个随距离线性衰减的偏置 | BLOOM | 表达能力弱于 RoPE |
RoPE 为什么胜出:RoPE 是目前几乎所有主流开源 LLM 的标配(LLaMA 1/2/3、Qwen 1/2、ChatGLM 2/3、Mistral、DeepSeek、Yi 等无一例外)。它兼具绝对位置编码的简洁和相对位置编码的外推能力——这是它取代其他所有方案的核心原因。
RoPE 核心思想:用旋转给向量"打位置标签"
RoPE = 用位置 m 决定一个旋转角度 θ = m · ω ,
将第 m 个 token 的 Query 和 Key 向量各自旋转 θ 度。
两个向量的点积结果只依赖它们的相对位置差 m − n。
类比理解:时钟指针
想象每个 token 向量是一根指针。位置编码就是把这根指针旋转一个角度——位置 0 的指针不转,位置 1 的转 30°,位置 2 的转 60°……
当你计算两个指针之间的夹角时,这个夹角只取决于它们的角度差(即位置差),而不是各自的绝对角度。这就是 RoPE 能天然编码相对位置的根本原因。
数学推导二维旋转矩阵
1、在二维平面上,将一个向量 (x, y) 逆时针旋转 θ 角度的变换矩阵是:
R(θ) = [ cos θ −sin θ ]
[ sin θ cos θ ]
将向量 q = (q₀, q₁) 旋转 θ 后:
R(θ) · q = [ q₀·cos θ − q₁·sin θ , q₀·sin θ + q₁·cos θ ]
关键性质:旋转矩阵是正交矩阵,满足 R(θ₁) · R(θ₂) = R(θ₁ + θ₂)。这意味着"先转 θ₁ 再转 θ₂"等价于"一次转 θ₁+θ₂"——这个可加性是 RoPE 最精妙的地方。
2、用"位置 m"来决定旋转角度
设第 m 个 token 的旋转角为 m · θ,其中 θ 是基础角频率。则:
qm = R(m·θ) · q kn = R(n·θ) · k
现在计算注意力分数——即旋转后的 Query 与旋转后的 Key 的点积:
qmT kn = (R(m·θ)q)T (R(n·θ)k)
= qT R(m·θ)T R(n·θ) k
= qT R(−m·θ) R(n·θ) k ← R 正交,转置 = 逆 = R(−θ)
= qT R( (n−m)·θ ) k ← R(θ₁)·R(θ₂) = R(θ₁+θ₂)
= qT R( (n−m)·θ ) k
🎯 奇迹发生了!最终结果只依赖 (n−m),即 Key 位置减去 Query 位置——这就是 相对位置差。
RoPE 用绝对位置编码(旋转各自的向量),天然得到了相对位置的效果。
数学推导高维旋转矩阵
1、问题:d 维向量不能整体旋转
上面推导只适用于 2 维。真实的 Transformer 中 Q/K 向量维度 d = 64 或 128。RoPE 的策略是——将 d 维向量拆成 d/2 个"2 维对",每对独立旋转。
[ 0 R(m·θ₁) ... 0 ]
[ ... ... ⋱ ... ]
[ 0 0 ... R(m·θd/2−1) ]
这是一个 分块对角矩阵(block-diagonal)。每一块是一个 2×2 的旋转矩阵,对应向量中的一个"维度对"(dimension pair)。
2 、各维度对的旋转角频率如何设定
不同维度对使用不同的旋转速度(频率),这是 RoPE 的关键设计:
| 参数 | 含义 | 典型值 |
|---|---|---|
base |
基础频率(theta_base) | 10000(原始 RoPE)、500000(LLaMA 3)、1000000(Qwen2) |
d |
每个注意力头的维度 | 64(LLaMA-7B)、128(LLaMA-70B) |
i |
维度对的索引 | 0 ~ d/2−1 |
θᵢ |
第 i 对的旋转角频率 | 从 base⁻⁰ = 1.0 到 base⁻² ≈ 0.0001,几何递减 |
直观理解:
- 低维对(i 小)→ θ 大 → 旋转快 → 捕捉近距离的位置差异
- 高维对(i 大)→ θ 小 → 旋转慢 → 捕捉远距离的位置关系
这类似于傅里叶变换中低频分量捕捉全局结构、高频分量捕捉局部细节的原理。
base=10000, d=128 时各维度的 θ 值:
i=0: θ₀ = 10000^(−0/128) = 1.0000 ← 转最快,位置 1 转 57°
i=1: θ₁ = 10000^(−2/128) ≈ 0.8659
i=2: θ₂ = 10000^(−4/128) ≈ 0.7499
...
i=30: θ₃₀ = 10000^(−60/128) ≈ 0.0133
i=60: θ₆₀ = 10000^(−120/128) ≈ 0.00018 ← 转极慢
i=63: θ₆₃ = 10000^(−126/128) ≈ 0.00011
3、数学推导第三步:复数视角下的优雅表达
3.1 旋转的复数表示
二维旋转有一个极其优雅的复数形式。将向量 q = (q₀, q₁) 写成复数 q₀ + i·q₁,旋转 θ 等价于乘以 eiθ = cos θ + i·sin θ:
右边展开后实部和虚部分别恰好等于旋转矩阵乘法的两个分量。所以对于高维向量,RoPE 等价于——把每对相邻维度看作一个复数,乘以 ei·m·θᵢ。
3.2 RoPE 的复数形式定义(论文原版)
其中:
- xm 是位置 m 的 d 维向量,按相邻维度配对解释为 d/2 个复数
- Θ = (θ₀, θ₁, ..., θd/2−1),其中 θᵢ = base−2i/d
- ⊙ 表示逐元素复数乘法
- ei m Θ = (cos mθ₀ + i sin mθ₀, cos mθ₁ + i sin mθ₁, ...)
3.3 复数形式下的相对位置推导
在复数视角下,注意力分数(点积)的实部为:
= Re[ (q ⊙ ei m Θ)∗ · (k ⊙ ei n Θ) ]
= Re[ q̄ · k · ei (n−m) Θ ]
和二维版本完全一致的结论:结果只依赖 n−m(相对位置差),与具体的绝对位置 m 和 n 无关。
4、RoPE 的核心性质:相对位置自然编码
性质一:点积只依赖相对位置
从上面的推导可知:
qmT kn = g(q, k, n−m)
这意味着——无论两个 token 在序列中的绝对位置是多少,只要它们的相对距离 (n−m) 相同,它们之间的注意力权重就应该相同。这是自然语言中"邻近的词语相关性更强"这一直觉的数学实现。
性质二:随距离增大,注意力自然衰减
由于不同维度的旋转角度不同,当 (n−m) 很大时,各个维度对的旋转累积到不同方向,向量在高维空间中的"对齐度"自然下降。这种远程衰减(Long-term Decay)不需要任何显式设计——完全由旋转矩阵的数学性质自动产生。
性质三:训练长度可外推(有限度)
由于 RoPE 编码的是相对位置,原则上训练时只见过位置 0-2048 的模型,在推理时可以理解 2048-4096 的相对位置。不过纯 RoPE 的外推能力有限——通常训 2K 推到 3K 还行,推到 10K 就需要插值扩展(见后文:RoPE 的扩展:突破训练长度限制)。
实现细节
一、三步走流程
二、关键优化:不要真的做矩阵乘法
虽然理论上"分块对角旋转矩阵",但实际代码中不需要构造 d×d 的矩阵。绝大多数元素是 0,构造稠密矩阵再做乘法会浪费 d² 的计算和存储。
实际做法是逐元素旋转——利用旋转矩阵在每对 (2i, 2i+1) 维度上只有 4 个非零元素的特点:
# 伪代码:对位置 m 的向量 x 施加 RoPE
x_rotated = x.clone()
for i in range(d // 2):
cos_val = cos_table[m, i] # cos(m * θᵢ)
sin_val = sin_table[m, i] # sin(m * θᵢ)
x_rotated[2*i] = x[2*i] * cos_val - x[2*i+1] * sin_val
x_rotated[2*i+1] = x[2*i] * sin_val + x[2*i+1] * cos_val
时间复杂度:O(d),仅与向量维度线性相关——比 O(d²) 的稠密矩阵乘法快得多。
三、完整 PyTorch 实现
import torch
import torch.nn as nn
import math
class RotaryEmbedding(nn.Module):
"""RoPE 旋转位置编码"""
def __init__(self, dim: int, max_seq_len: int = 2048, base: float = 10000.0):
"""
Args:
dim: 每个注意力头的维度(d_k),必须是偶数
max_seq_len: 最大序列长度
base: 基础频率 theta_base
"""
super().__init__()
self.dim = dim
self.max_seq_len = max_seq_len
self.base = base
# 1. 计算每个维度对的频率 θᵢ = base^(−2i/d)
# i 取 0, 1, 2, ..., d/2−1
inv_freq = 1.0 / (
base ** (torch.arange(0, dim, 2).float() / dim)
) # shape: [d/2]
# 2. 计算所有位置 m 与所有频率的外积
# freqs[m, i] = m * θᵢ
t = torch.arange(max_seq_len).float() # [max_seq_len]
freqs = torch.outer(t, inv_freq) # [max_seq_len, d/2]
# 3. 预计算 cos 和 sin 表
self.register_buffer("cos_cached", freqs.cos()) # [max_seq_len, d/2]
self.register_buffer("sin_cached", freqs.sin())
def forward(self, x: torch.Tensor, seq_len: int):
"""返回对应位置的 cos 和 sin"""
return (
self.cos_cached[:seq_len, :], # [seq_len, d/2]
self.sin_cached[:seq_len, :],
)
def rotate_half(x: torch.Tensor):
"""
将输入的相邻维度两两分组并执行 [x0, x1] → [−x1, x0]
这是旋转公式中"另一半"的快捷计算。
输入: [x₀, x₁, x₂, x₃, ...]
输出: [−x₁, x₀, −x₃, x₂, ...]
"""
x1 = x[..., : x.shape[-1] // 2] # 前半
x2 = x[..., x.shape[-1] // 2 :] # 后半
return torch.cat((-x2, x1), dim=-1)
def apply_rotary_pos_emb(
q: torch.Tensor,
k: torch.Tensor,
cos: torch.Tensor,
sin: torch.Tensor,
):
"""
对 Query 和 Key 施加 RoPE 旋转。
Args:
q: Query tensor, shape [batch, num_heads, seq_len, d_k]
k: Key tensor, shape [batch, num_heads, seq_len, d_k]
cos: cos 表, shape [seq_len, d_k/2] — 重复到 d_k 维
sin: sin 表, shape [seq_len, d_k/2]
Returns:
q_rotated, k_rotated
"""
# cos/sin 表是 [seq_len, d/2],
# 用 repeat_interleave 将每项重复一次 → [seq_len, d]
cos = cos.repeat_interleave(2, dim=-1) # [seq, d]
sin = sin.repeat_interleave(2, dim=-1) # [seq, d]
# 核心旋转公式:
# x_rotated = x * cos + rotate_half(x) * sin
#
# 推导(对每对维度):
# x₀' = x₀·cos θ − x₁·sin θ ← 第 1 项
# x₁' = x₀·sin θ + x₁·cos θ ← 第 2 项
#
# rotate_half 把 [x₀, x₁] 变成 [−x₁, x₀],
# 所以 x*cos + rotate_half(x)*sin =
# [x₀·cos − x₁·sin, x₁·cos + x₀·sin] ✓
q_embed = (q * cos) + (rotate_half(q) * sin)
k_embed = (k * cos) + (rotate_half(k) * sin)
return q_embed, k_embed
# ===== 使用示例 =====
batch, n_heads, seq_len, d_k = 2, 32, 512, 128
rope = RotaryEmbedding(dim=d_k, max_seq_len=2048, base=10000.0)
cos, sin = rope(None, seq_len) # 取前 seq_len 个位置
q = torch.randn(batch, n_heads, seq_len, d_k)
k = torch.randn(batch, n_heads, seq_len, d_k)
q_rope, k_rope = apply_rotary_pos_emb(q, k, cos, sin)
# q_rope 和 k_rope 已经带上了位置信息
代码核心技巧解析
- repeat_interleave:cos/sin 表是 [seq, d/2],因为每对维度 (2i, 2i+1) 共享同一个 θᵢ,所以每个值重复一次变成 [seq, d]。
- rotate_half:巧妙地将 [x₀, x₁, x₂, x₃, ...] 变成 [−x₁, x₀, −x₃, x₂, ...],配合向量化的乘法和加法,一行完成所有维度对的旋转。
- register_buffer:cos/sin 表不是可训练参数(不需要梯度),用 buffer 存储确保随模型保存/加载。
与其他位置编码方案深度对比
| 特性 | Sinusoidal | Learned | ALiBi | RoPE ★ |
|---|---|---|---|---|
| 编码方式 | sin/cos 固定函数 | 可训练参数 | 偏置加到 Attention | 旋转 Q/K 向量 |
| 相对位置 | ❌ 绝对位置 | ❌ 绝对位置 | ✅ 线性距离偏置 | ✅ 旋转角度差 |
| 外推能力 | 差(512→1024 就崩) | 无(训多长用多长) | 较好 | 好(配合插值优秀) |
| 额外参数 | 0 | d×max_len 个 | 0 | 0(cos/sin 表不算参数) |
| 计算开销 | O(d) 加法 | O(d) 查表 | O(n²) 加法 | O(d) 乘法+加法 |
| 适配长序列 | 需重新训练 | 需重新训练 | 天然支持 | 插值即可 |
| 代表模型 | 原始 Transformer | BERT, GPT-2 | BLOOM | LLaMA, Qwen, Mistral |
💡 为什么 RoPE 比 ALiBi 更受欢迎:ALiBi 的线性偏置过于简单,对复杂的位置关系建模能力不足。RoPE 通过不同维度的多频率旋转提供了更丰富的位置特征空间——既有高频(近距离敏感)也有低频(远距离敏感),能更精细地捕捉不同距离上的依赖模式。这也是为什么几乎所有顶级开源模型选 RoPE 而弃 ALiBi。
RoPE 的扩展:突破训练长度限制
问题:训练 2K 长度,推理 10K 会怎样?
虽然 RoPE 理论上有外推能力,但实践中——训 2048 推到 4096 还行,推到 10000 以上 PPL 会急剧上升。原因是训练数据中从未出现过很大的 m·θ 值,模型没有学会如何处理大旋转角度对应的位置关系。
解决方案:把"没见过的大位置"映射到"见过的小位置"上。这称为位置插值(Position Interpolation)。
1、三种主流扩展方法
| 方法 | 提出 | 核心思想 | 公式 | 代表模型 |
|---|---|---|---|---|
| 线性插值 (Linear PI) |
Chen et al., 2023 Meta |
将所有位置等比例"压缩" | m' = m · (L_train / L_target) 如训 2K 推 8K:m' = m / 4 |
LLaMA 2 Long (早期方案) |
| NTK-Aware Scaling |
bloc97, 2023 Reddit / 社区发现 |
调大 base 值,不对位置做插值 | base' = base · αd/(d−2) α = L_target / L_train |
CodeLLaMA |
| YaRN (NTK + 温度) |
Peng et al., 2023 | NTK + 对不同频率的维度做不同程度插值 + 温度调节 | 低频维度不插值(外推好) 高频维度多插值(保持局部性) |
LLaMA 3、Qwen2、 Mistral |
YaRN 的精妙之处:RoPE 的低频维度(i 大,θ 小)天然适合外推——因为它们旋转极慢,即使位置很大角度变化也小;高频维度(i 小,θ 大)旋转快,大位置下角度变化剧烈,需要插值"压缩"。YaRN 对不同维度做差异化处理,而不是一刀切——这就是它比线性插值和纯 NTK 都更好的原因。
2、RoPE base 值的影响
base 越大 → 低频成分越多 → 长距离外推越好,但近距离分辨力下降。
| 模型 | base 值 | 训练长度 | 设计思路 |
|---|---|---|---|
| 原始 RoPE / RoFormer | 10,000 | - | 基准值 |
| LLaMA 1/2 | 10,000 | 2K / 4K | 传统配置 |
| LLaMA 3 | 500,000 | 8K | 大 base → 天然支持更长上下文 |
| Qwen2 | 1,000,000 | 32K / 128K | 超大 base → 极长上下文外推 |
Feed-Forward Network & Layer Normalization
一、Feed-Forward Network (FFN)
Attention 负责"信息聚合"——每个 token 从其他 token 那里收集信息。但聚合后的信息还需要"加工处理"——这就是 FFN 的工作。FFN 对每个 token 独立操作(Position-wise),结构非常简单:
两个线性层 + 一个非线性激活。中间隐层维度通常是 d_model 的 4 倍(512 → 2048 → 512)。
Attention 是线性加权求和——数学上它只能做"把不同 token 的信息按权重加起来"这件事。但很多语言现象是非线性的:否定翻转语义("不 好" ≠ "好")、比喻、推理链条等。
FFN 的激活函数(ReLU/GELU)引入了非线性变换能力。如果说 Attention 是"把所有人的发言汇总",那 FFN 就是"对汇总结果进行独立思考和加工"。
class FeedForward:
def __init__(self, d_model=512, d_ff=2048):
# d_ff: 中间隐层维度,通常是 d_model 的 4 倍(512→2048→512)
# "先升维再降维"的设计:在 2048 维空间做非线性变换后再压回 512 维
scale1 = np.sqrt(2.0 / d_model)
scale2 = np.sqrt(2.0 / d_ff)
self.W1 = np.random.randn(d_model, d_ff) * scale1 # 升维: 512→2048
self.b1 = np.zeros(d_ff) # bias 初始化为 0
self.W2 = np.random.randn(d_ff, d_model) * scale2 # 降维: 2048→512
self.b2 = np.zeros(d_model)
def forward(self, x):
# x: (batch, seq_len, d_model)
# Step 1: 线性变换 + ReLU 非线性激活
hidden = np.maximum(0, x @ self.W1 + self.b1) # ReLU: max(0, z),干掉所有负值
# 现代 Transformer (如 GPT) 用 GELU 代替 ReLU,效果更好
# GELU ≈ 0.5x(1+tanh(√(2/π)(x+0.044715x³)))
# Step 2: 投影回原始维度
output = hidden @ self.W2 + self.b2
return output
# 参数量估算
ffn = FeedForward()
print(f"W1: {ffn.W1.shape} → {ffn.W1.size:,} params") # 512×2048 = 1,048,576
print(f"W2: {ffn.W2.shape} → {ffn.W2.size:,} params") # 2048×512 = 1,048,576
total_ffn = ffn.W1.size + ffn.W2.size + ffn.b1.size + ffn.b2.size
print(f"FFN 总参数: {total_ffn:,}")
# ≈ 2 × 512 × 2048 ≈ 210 万 — 比 Attention (1M) 多了约一倍
# 这也是为什么 Transformer 的"大"主要来自 FFN 层
二、Layer Normalization & Residual Connections
每个子层(Attention 和 FFN)后面都跟着 Add & Norm:
Add: output = LayerNorm( x + Sublayer(x) )
含义: 残差连接 + 层归一化
作用: 残差连接让梯度直接流过(缓解深层网络的梯度消失),LayerNorm 稳定训练。
class LayerNorm:
def __init__(self, d_model, eps=1e-6):
# eps: 防止除以 0 的小常数
self.gamma = np.ones(d_model) # 可学习的缩放参数(初始化为 1)
self.beta = np.zeros(d_model) # 可学习的偏移参数(初始化为 0)
self.eps = eps
def forward(self, x):
# LayerNorm 公式: γ * (x - μ) / √(σ² + ε) + β
# 与 BatchNorm 的区别:LayerNorm 对每个样本自身的所有维度做归一化
# 不依赖 batch 统计,因此训练和推理时行为一致
mean = x.mean(axis=-1, keepdims=True) # 沿最后一维求均值
var = x.var(axis=-1, keepdims=True) # 沿最后一维求方差
x_norm = (x - mean) / np.sqrt(var + self.eps) # 标准化到 N(0,1)
return self.gamma * x_norm + self.beta # 可学习的仿射变换
def add_and_norm(x, sublayer_output, layer_norm):
# ═══════════════════════════════════════════════════════════
# "Add & Norm" — Transformer 中重复最多的操作
# ═══════════════════════════════════════════════════════════
# Step 1: 残差连接 — 把子层的输出"加回"原始输入
# 捷径(shortcut)让梯度可以直接流过,解决深层网络的退化问题
residual = x + sublayer_output # 如果子层学不到任何东西(输出≈0),
# 至少还有原始信息通过残差传递过去
# Step 2: Layer Normalization — 稳定训练、加速收敛
return layer_norm.forward(residual) # 归一化后的结果作为下一层的输入
# ═══════════════════════════════════════════════════════════
# 演示:对比有无残差连接
# ═══════════════════════════════════════════════════════════
x = np.array([[[1.0, 2.0, 3.0, 4.0]]]) # (1, 1, 4) — batch=1, seq=1, d_model=4
sublayer_out = np.array([[[0.1, -0.2, 0.05, -0.1]]]) # 子层输出很小,几乎是噪声
ln = LayerNorm(d_model=4)
without_residual = ln.forward(x + sublayer_out * 0) # 模拟没有残差:子层输入被丢弃
print(f"无残差 (仅 x): {without_residual[0,0]}") # 标准化后的 x 本身
with_residual = add_and_norm(x, sublayer_out, ln)
print(f"有残差 (x+sub): {with_residual[0,0]}") # x + sublayer_out 后标准化
print(f"\n原始 x 的第一位: {x[0,0,0]:.3f}") # 1.000
print(f"有残差后第一位: {with_residual[0,0,0]:.3f}") # 接近但 ≠ 1.000
# 残差连接确保了:即使子层输出很弱,原始信息也不会被"冲掉"
三、Pre-LN vs Post-LN
| Post-LN(原论文) | Pre-LN(现代模型首选) | |
|---|---|---|
| 顺序 | Sublayer → Add → Norm | Norm → Sublayer → Add |
| 训练稳定性 | 需要 warmup | 自然稳定,几乎不需要 warmup |
| 梯度流 | 残差路径经过 Norm | 残差路径不经过 Norm(更直接) |
| 使用方 | 原始 Transformer | GPT-2/3、Llama、BERT 等 |
用 NumPy 从零实现一个 Mini Transformer
精简但完整实现Transformer——包含 Multi-Head Attention、FFN、LayerNorm、残差连接、位置编码和一个可训练的小型 GPT(纯 Decoder)。所有代码仅依赖 NumPy。
import numpy as np
# ═══════════════════════════════════════════════════════════════
# 超参数设置(迷你版 — 仅用于教学演示)
# 真实 GPT-3 的参数比这大 1000 倍以上
# ═══════════════════════════════════════════════════════════════
VOCAB_SIZE = 100 # 词汇表大小(真实模型通常 50K~256K)
D_MODEL = 64 # 模型维度(GPT-3 small: 768, GPT-3: 12288)
NUM_HEADS = 4 # 注意力头数(64/4=16 维每头)
NUM_LAYERS = 2 # Transformer 层数(GPT-3 small: 12, GPT-3: 96)
SEQ_LEN = 16 # 最大序列长度
D_FF = 256 # FFN 中间维度(4 × D_MODEL)
class MultiHeadAttention:
# ═══════════════════════════════════════════════════════════
# 注意:此版本支持 Cross-Attention(encoder_output 可选)
# 如果传入 encoder_output → Cross-Attention (Q≠K,V)
# 如果不传 → Self-Attention (Q=K=V 同源)
# ═══════════════════════════════════════════════════════════
def __init__(self):
d_k = D_MODEL // NUM_HEADS
self.W_Q = np.random.randn(D_MODEL, D_MODEL) * 0.02 # Xavier 缩小版
self.W_K = np.random.randn(D_MODEL, D_MODEL) * 0.02
self.W_V = np.random.randn(D_MODEL, D_MODEL) * 0.02
self.W_O = np.random.randn(D_MODEL, D_MODEL) * 0.02
def forward(self, x, encoder_output=None, causal_mask=None):
# 如果有 encoder_output,Q 来自 x(decoder),K,V 来自 encoder_output
# 否则 Q,K,V 都来自 x(self-attention)
kv_source = encoder_output if encoder_output is not None else x
Q = x @ self.W_Q # (batch, seq, d_model)
K = kv_source @ self.W_K
V = kv_source @ self.W_V
# 拆成多头: (batch, seq, d_model) → (batch, heads, seq, d_k)
batch, seq_q, _ = Q.shape
batch, seq_k, _ = K.shape
Q = Q.reshape(batch, seq_q, NUM_HEADS, D_MODEL//NUM_HEADS).transpose(0,2,1,3)
K = K.reshape(batch, seq_k, NUM_HEADS, D_MODEL//NUM_HEADS).transpose(0,2,1,3)
V = V.reshape(batch, seq_k, NUM_HEADS, D_MODEL//NUM_HEADS).transpose(0,2,1,3)
# Scaled Dot-Product Attention
scores = Q @ K.transpose(0,1,3,2) / np.sqrt(D_MODEL//NUM_HEADS)
if causal_mask is not None:
scores = scores + causal_mask # 上三角 -inf → softmax 后变 0
scores = scores - scores.max(axis=-1, keepdims=True)
attn = np.exp(scores)
attn /= attn.sum(axis=-1, keepdims=True)
context = attn @ V
# 合并多头
context = context.transpose(0,2,1,3).reshape(batch, seq_q, D_MODEL)
output = context @ self.W_O
return output
class LayerNorm:
def __init__(self):
self.gamma = np.ones(D_MODEL)
self.beta = np.zeros(D_MODEL)
def forward(self, x):
mean = x.mean(axis=-1, keepdims=True)
var = x.var(axis=-1, keepdims=True)
return self.gamma * (x - mean) / np.sqrt(var + 1e-6) + self.beta
class FeedForward:
def __init__(self):
self.W1 = np.random.randn(D_MODEL, D_FF) * 0.02
self.W2 = np.random.randn(D_FF, D_MODEL) * 0.02
def forward(self, x):
return np.maximum(0, x @ self.W1) @ self.W2 # ReLU 激活
class TransformerBlock:
# ═══════════════════════════════════════════════════════════
# 一个完整的 Transformer 层(pre-LN 风格)
# 支持 Self-Attention 和 Cross-Attention 两种模式
# ═══════════════════════════════════════════════════════════
def __init__(self):
self.ln1 = LayerNorm() # Attention 前的 LayerNorm
self.attn = MultiHeadAttention()
self.ln2 = LayerNorm() # FFN 前的 LayerNorm
self.ffn = FeedForward()
# 如果是 Encoder-Decoder 架构,还需要 cross-attention 的 ln 和 attn
def forward(self, x, causal_mask=None):
# Sub-layer 1: Pre-LN → Self-Attention → Residual
attn_out = self.attn.forward(self.ln1.forward(x), causal_mask=causal_mask)
x = x + attn_out # 残差连接
# Sub-layer 2: Pre-LN → FFN → Residual
ffn_out = self.ffn.forward(self.ln2.forward(x))
x = x + ffn_out # 残差连接
return x
class MiniGPT:
# ═══════════════════════════════════════════════════════════
# 一个迷你的 Decoder-Only Transformer (GPT-like)
# 包含 Token Embedding + Positional Embedding + N×Blocks + LM Head
# ═══════════════════════════════════════════════════════════
def __init__(self):
# Token Embedding: 把每个 token ID 映射为 d_model 维向量
self.token_embed = np.random.randn(VOCAB_SIZE, D_MODEL) * 0.02
# Positional Embedding: 可学习的位置编码(GPT 风格)
self.pos_embed = np.random.randn(SEQ_LEN, D_MODEL) * 0.02
# N 层 Transformer Blocks
self.blocks = [TransformerBlock() for _ in range(NUM_LAYERS)]
# Final LayerNorm(GTP-2 风格:最后一层后有额外的 LN)
self.final_ln = LayerNorm()
# LM Head: 把 d_model 维向量映射回 vocab_size 维 → 每个 token 的 logits
# 实际 GPT 中 LM head 和 token embedding 共享权重(weight tying)
self.lm_head = np.random.randn(D_MODEL, VOCAB_SIZE) * 0.02
def forward(self, token_ids):
# ═══════════════════════════════════════════════════════
# token_ids: (batch, seq_len) — 待续写的 token ID 序列
# ═══════════════════════════════════════════════════════
batch, seq = token_ids.shape
# Step 1: Token Embedding — 查表获取每个 token 的向量
x = self.token_embed[token_ids] # (batch, seq, d_model)
# Step 2: Positional Embedding — 加上位置信息
x = x + self.pos_embed[:seq] # 广播: (seq, d_model) 加到每个 batch
# Step 3: 经过 N 层 Transformer Blocks
# 每层包含 Self-Attention + FFN(GPT 没有 Cross-Attention)
causal_mask = np.triu(np.ones((seq, seq)), k=1) * (-1e9)
for block in self.blocks:
x = block.forward(x, causal_mask=causal_mask)
# Step 4: 最后一层 LayerNorm → LM Head → logits
x = self.final_ln.forward(x)
logits = x @ self.lm_head # (batch, seq, vocab_size)
return logits
# ═══════════════════════════════════════════════════════════════
# 演示:前向传播一次,看看输出形状
# ═══════════════════════════════════════════════════════════════
np.random.seed(42)
model = MiniGPT()
# 统计参数量
total = 0
for attr in dir(model):
obj = getattr(model, attr)
if isinstance(obj, np.ndarray):
total += obj.size
for block in model.blocks:
for attr in dir(block):
obj = getattr(block, attr)
if isinstance(obj, np.ndarray):
total += obj.size
if isinstance(obj, MultiHeadAttention):
for a in dir(obj):
o = getattr(obj, a)
if isinstance(o, np.ndarray): total += o.size
if isinstance(obj, FeedForward):
for a in dir(obj):
o = getattr(obj, a)
if isinstance(o, np.ndarray): total += o.size
if isinstance(obj, LayerNorm):
for a in dir(obj):
o = getattr(obj, a)
if isinstance(o, np.ndarray): total += o.size
print(f"MiniGPT 总参数量: {total:,}")
# 模拟一个 batch 的输入
input_ids = np.random.randint(0, VOCAB_SIZE, (2, 8)) # batch=2, seq=8
logits = model.forward(input_ids)
print(f"输入形状: {input_ids.shape}") # (2, 8)
print(f"输出 logits: {logits.shape}") # (2, 8, 100) — 每个位置对词汇表的预测分数
# 模拟生成:取最后一个位置的 logits,贪心解码下一个 token
next_token_logits = logits[:, -1, :] # (batch, vocab_size)
next_token = np.argmax(next_token_logits, axis=-1) # 贪心选概率最高的
print(f"预测的下一个 token: {next_token}") # 随机权重下当然是随机的
Transformer 变体家族
| 模型 | 架构 | 参数量 | 关键创新 |
|---|---|---|---|
| Transformer (2017) | Encoder-Decoder | 65M~213M | 提出 Self-Attention + Multi-Head + PE |
| BERT (2018) | Encoder-Only | 110M~340M | 双向上下文(MLM)、只用 Encoder |
| GPT-1 (2018) | Decoder-Only | 117M | 自回归生成、大规模无监督预训练 |
| GPT-2 (2019) | Decoder-Only | 1.5B | LayerNorm 移到子层之前(Pre-LN)、更大规模 |
| GPT-3 (2020) | Decoder-Only | 175B | In-Context Learning、零样本/少样本能力 |
| Llama (2023) | Decoder-Only | 7B~65B | RoPE、SwiGLU 激活、Pre-Norm、RMSNorm |
| Llama 2/3 (2023/24) | Decoder-Only | 7B~405B | GQA(分组查询注意力)、更大词表 |
| Mistral (2023) | Decoder-Only | 7B | Sliding Window Attention、GQA |
三种架构范式
Encoder-Only (BERT)
任务:理解/分类/抽取
Attention:双向(看全句)
训练方式:MLM(挖空填空)
"我能读懂这段话"
Decoder-Only (GPT)
任务:生成/续写/对话
Attention:单向(只看上文)
训练方式:Next Token Prediction
"我能续写这段话"
Encoder-Decoder (T5)
任务:翻译/摘要/问答
Encoder 双向 + Decoder 单向
训练方式:Seq2Seq(文本到文本)
"我能把 A 变成 B"
Llama 系列的关键改进
# ═══════════════════════════════════════════════════════════════
# 原始 Transformer → GPT → Llama 的架构演进链
# ═══════════════════════════════════════════════════════════════
#
# 原始 Transformer (2017) GPT-1/2/3 (2018-2020) Llama (2023)
# ────────────────────── ────────────────────── ───────────
# Post-LN Pre-LN Pre-LN + RMSNorm
# Sinusoidal PE Learned PE RoPE(旋转位置编码)
# ReLU GELU SwiGLU(门控激活)
# Multi-Head Attention Multi-Head GQA(分组查询注意力)
# Encoder + Decoder Decoder Only Decoder Only
#
# ═══════════════════════════════════════════════════════════════
# 关键改进详解:
#
# 1. RMSNorm = 去掉 LayerNorm 中的"减均值"步骤,只保留缩放
# 公式: RMSNorm(x) = x / RMS(x) * γ,其中 RMS(x) = sqrt(mean(x²))
# 优势:计算更快(少了 mean 计算),效果不输完整 LayerNorm
#
# 2. SwiGLU = Swish-Gated Linear Unit
# 公式: SwiGLU(x) = (xW₁ ⊙ Swish(xW₂)) W₃
# 带门控机制的 FFN:一个分支做信息变换,另一个分支做门控(决定保留多少)
# 效果显著优于 ReLU 和 GELU
#
# 3. GQA (Grouped Query Attention)
# 不是每个头都有独立的 K,V → 若干头共享一组 K,V
# 显著减少 KV Cache 大小(推理时省显存),效果几乎无损
#
# 4. RoPE (Rotary Position Embedding)
# 通过对 Q 和 K 施加旋转矩阵注入位置信息
# 使得两个 token 的相似度只依赖相对距离,天然支持长度外推
# ═══════════════════════════════════════════════════════════════
关键问题解答
核心公式速查
| 组件 | 公式 | 维度变化 |
|---|---|---|
| Self-Attention | softmax(QKT/√dk) · V | (seq, d) → (seq, d) |
| Multi-Head | Concat(head1,...,headh) · WO | (seq, d) → (seq, d) |
| FFN | ReLU(x·W1+b1)·W2+b2 | (seq, d) → (seq, 4d) → (seq, d) |
| Positional Encoding | PE(pos,2i)=sin(pos/100002i/d) | (seq) → (seq, d) |
| LayerNorm | γ(x-μ)/√(σ²+ε) + β | (seq, d) → (seq, d) |
| GPT 输出 | softmax(x·Wlm) | (seq, d) → (seq, vocab) |
参数计算速查
# ═══════════════════════════════════════════════════════════════
# 以 GPT-3 Small 为例 (d_model=768, n_heads=12, n_layers=12, vocab=50257)
#
# 1. Multi-Head Attention (每层):
# W_Q, W_K, W_V, W_O 各为 768×768
# 4 × 768² ≈ 2.36M params/层
#
# 2. Feed-Forward (每层):
# W1: 768×3072, W2: 3072×768
# 2 × 768 × 3072 ≈ 4.72M params/层
#
# 3. Token Embedding:
# 50257 × 768 ≈ 38.6M
#
# 4. Position Embedding:
# seq_len × 768 (通常 1024×768 ≈ 0.79M)
#
# 总参数量 ≈ 38.6M + 0.79M + 12×(2.36M + 4.72M)
# ≈ 38.6M + 0.79M + 85.0M ≈ 124M
#
# 注意:FFN 参数占比 ≈ 4.72/(2.36+4.72) ≈ 67%
# 所以 Transformer 的"大"主要来自 FFN 层,不是 Attention!
# ═══════════════════════════════════════════════════════════════
Q1: 为什么 Transformer 比 RNN 好?
这个问题考察的是对架构核心差异的理解。可以从三个维度回答:
| 维度 | RNN / LSTM | Transformer |
|---|---|---|
| 计算方式 | 串行:必须先算 t₀,再算 t₁,再算 t₂……无法并行 | 并行:所有位置同时做 Attention,GPU 利用率极高 |
| 长距离依赖 | 通过隐藏状态一步步传递,路径长度 O(n)。传 100 步后信息严重衰减 | 每个 token 直接与任意位置连接,路径长度 O(1)。"第 1 个词"和"第 1000 个词"之间只需一步 |
| 梯度传播 | BPTT(沿时间反向传播),100 步回传 ≈ 100 次链式乘法 → 梯度消失/爆炸 | 残差连接 + 并行路径,梯度不需要穿过时间轴 |
| 可解释性 | 隐状态是黑盒,很难解释"模型为什么做了这个预测" | Attention 权重可以直接可视化——看到模型在"看"哪些词 |
RNN: 第 1 个人 → 告诉第 2 个人 → 告诉第 3 个人 → ...→ 到第 100 个人时,第 1 个人说了什么已经忘光了
Transformer: 所有人围坐一圈,每个人可以直接听到所有人的发言,想听谁就听谁
Q2:BERT 和 GPT 的核心区别是什么?
| 维度 | BERT(Encoder-Only) | GPT(Decoder-Only) |
|---|---|---|
| 用的组件 | 只用 Transformer 的 Encoder 部分 | 只用 Transformer 的 Decoder 部分(去掉 Cross-Attention) |
| Attention 方向 | 双向 — 每个 token 能看到前后所有 token | 单向(Causal) — token i 只能看到 token 0~i |
| 训练目标 | MLM(Masked Language Model) — 随机遮住 15% 的词,让模型根据上下文填空 | Next Token Prediction — 给定前文,预测下一个 token |
| 擅长的任务 | 分类、NER、问答、文本匹配 — "理解"类任务 | 续写、对话、翻译、摘要 — "生成"类任务 |
| 输出方式 | 一次给出整句的表示(或每个位置的分类结果) | 自回归:一个一个 token 生成,新 token 又被喂回去生成下一个 |
| 典型参数 | BERT-Base: 110M, BERT-Large: 340M | GPT-1: 117M, GPT-3: 175B, GPT-4: 未公开(万亿级) |
BERT (MLM): "The [MASK] sat on the [MASK]" → 模型要预测出 "cat" 和 "mat"
→ 模型可以同时看两边来推断被遮的词("sat" 前面通常是主语,"on the" 后面通常是地点)
GPT (Next Token): "The" → 预测 "cat" → "The cat" → 预测 "sat" → …
→ 模型只看左边,每次只预测下一个词,这个过程天然就是"续写"
Q3: LayerNorm 放在 Attention 之前还是之后?为什么?
| Post-LN(原论文) | Pre-LN(现代标准) | |
| 计算顺序 | x → Sublayer(x) → Add & Norm | x → Norm → Sublayer(x) → Add |
| 公式 | out = LayerNorm(x + Sublayer(x)) | out = x + Sublayer(LayerNorm(x)) |
| 残差路径 | 残差经过 LayerNorm(梯度被"归一化") | 残差绕过 LayerNorm(梯度直接流通) |
| 训练稳定性 | 深层网络(12+ 层)容易梯度爆炸,必须用 warmup | 梯度流更通畅,几乎不需要 warmup |
| 使用方 | 原始 Transformer、早期 BERT | GPT-2/3、Llama、Mistral、几乎所有现代模型 |
Post-LN: 梯度 → [LayerNorm] → [Sublayer 反向] → [LayerNorm 反向] → 上一层
→ 每过一个 LN,梯度的尺度被重新调整。深层的 LN 连乘后梯度可能爆炸或消失
Pre-LN: 梯度 → [Sublayer 反向] → 上一层 (残差通路)
梯度 → [LayerNorm 反向] → [Sublayer 反向] → … (旁路)
→ 残差通路不经过 LayerNorm,梯度无阻碍;旁路通过 LN 提供归一化
Q4: KV Cache 是什么?为什么能加速推理?
问题背景:GPT 自回归生成时,每一步都要计算所有 token 的 Attention。假设已经生成了 100 个 token,生成第 101 个时:
| 步骤 | 不用 KV Cache | 用 KV Cache |
|---|---|---|
| 生成 token 1 | 算 token₀ 的 Q,K,V → 1 次 | 算 token₀ 的 Q,K,V,存 K₀, V₀ |
| 生成 token 2 | 重新算 token₀,₁ 的 Q,K,V → 2 次 | 只算 token₁ 的 Q,K,V + 复用 K₀, V₀ → 1 次 |
| 生成 token 3 | 重新算 token₀,₁,₂ 的 Q,K,V → 3 次 | 只算 token₂ 的 Q,K,V + 复用 K₀,V₀,K₁,V₁ → 1 次 |
| 生成 token n | …重新算前 n-1 个 → n 次 | 只算 tokenn-1 的 + 复用前 n-2 个 → 1 次 |
量化收益:生成 n 个 token,不用 KV Cache 需要 O(n²) 次前向计算,用 KV Cache 只需要 O(n) 次(每步只算一个新 token)。KV Cache 的内存占用为 2 × layers × seq_len × d_model(每层存 K 和 V 两份)。以 Llama-7B 为例,seq_len=4096 时 KV Cache 约占 2 × 32 × 4096 × 4096 × 2 bytes ≈ 2 GB。
Q5: Transformer 的时间/空间复杂度是多少?为什么上下文窗口是瓶颈?
| 组件 | 时间复杂度 | 空间复杂度 | 瓶颈在哪? |
|---|---|---|---|
| Self-Attention | O(n²·d) | O(n²) — 注意力矩阵 | n² 随着 seq_len 平方增长!4K→16K 就是 16 倍 |
| FFN | O(n·d²) | O(n·d) | 线性依赖于 n,不是主瓶颈 |
| 整个 Transformer | O(n²·d + n·d²) | O(n² + n·d) | n 小时 FFN 占主导,n 大时 Attention 占主导 |
当 n 比较小时(如 512),O(n·d²) > O(n²·d);但当 n 很大时(如 128K),O(n²) 会爆炸。这也是为什么 128K 上下文需要特殊优化:
| 优化方法 | 核心思路 | 代表 |
|---|---|---|
| FlashAttention | 用 GPU SRAM 做分块计算,避免把 n² 注意力矩阵写回 HBM。计算量不变但 IO 大幅减少 | FlashAttention-2(几乎所有现代框架的默认实现) |
| Sparse Attention | 不计算全部 n² 对,只算"有意义"的那部分(如局部窗口 + 全局 token) | Longformer、BigBird |
| MQA / GQA | 多个 Q 头共享 K/V,减少 KV Cache 空间(推理时 O(n²) 无关,但缓存是瓶颈) | Llama 2 (GQA)、PaLM (MQA) |
| Sliding Window | 每个 token 只 attend 前后各 W 个 token(W 是固定窗口大小),复杂度降为 O(n·W) | Mistral (W=4096) |
| Ring Attention | 多 GPU 之间传递 K/V 块,每个 GPU 只管一段序列 | 支持百万级 token 上下文 |

浙公网安备 33010602011771号