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) = sin( pos / 100002i/dmodel )
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 维对",每对独立旋转

RdΘ,m =   [ R(m·θ₀)          0        ...          0  ]
    [     0       R(m·θ₁)      ...            0  ]
    [    ...         ...         ⋱                    ...  ]
    [     0         0       ...   R(m·θd/2−1) ]

这是一个 分块对角矩阵(block-diagonal)。每一块是一个 2×2 的旋转矩阵,对应向量中的一个"维度对"(dimension pair)。

 

2 、各维度对的旋转角频率如何设定

不同维度对使用不同的旋转速度(频率),这是 RoPE 的关键设计:

θi = base−2i/d     其中 i = 0, 1, 2, ..., d/2 − 1
参数含义典型值
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₁,旋转 θ 等价于乘以 e = cos θ + i·sin θ

(q₀ + i·q₁) · e = (q₀·cos θ − q₁·sin θ) + i·(q₀·sin θ + q₁·cos θ)

右边展开后实部和虚部分别恰好等于旋转矩阵乘法的两个分量。所以对于高维向量,RoPE 等价于——把每对相邻维度看作一个复数,乘以 ei·m·θᵢ

3.2 RoPE 的复数形式定义(论文原版)
RoPE(xm, m) = xm ⊙ ei m Θ

其中:

  • 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 复数形式下的相对位置推导

在复数视角下,注意力分数(点积)的实部为:

⟨ RoPE(q, m), RoPE(k, n) ⟩
= 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 的扩展:突破训练长度限制)。

实现细节

一、三步走流程

1、预计算频率表:对所有位置 m = 0, 1, ..., max_seq_len−1 和所有维度对 i,提前算好 cos(m·θᵢ) 和 sin(m·θᵢ)。这是两组形状为 [max_seq_len, d/2] 的矩阵。
2、将 Q/K 向量 reshape 为"维度对"格式:将 [..., d] 的向量 reshape 为 [..., d/2, 2],分离出每对的 (x₀, x₁)。
3、施加旋转:对每对维度,x₀' = x₀·cos θ − x₁·sin θ,x₁' = x₀·sin θ + x₁·cos θ。然后 flatten 回 d 维。

二、关键优化:不要真的做矩阵乘法

虽然理论上"分块对角旋转矩阵",但实际代码中不需要构造 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 存储确保随模型保存/加载。

与其他位置编码方案深度对比

特性SinusoidalLearnedALiBiRoPE ★
编码方式 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),结构非常简单:

FFN(x) = ReLU( x · W1 + b1 ) · W2 + b2

两个线性层 + 一个非线性激活。中间隐层维度通常是 d_model 的 4 倍(512 → 2048 → 512)。

为什么需要 FFN?Attenton 不够吗?

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 / LSTMTransformer
计算方式 串行:必须先算 t₀,再算 t₁,再算 t₂……无法并行 并行:所有位置同时做 Attention,GPU 利用率极高
长距离依赖 通过隐藏状态一步步传递,路径长度 O(n)。传 100 步后信息严重衰减 每个 token 直接与任意位置连接,路径长度 O(1)。"第 1 个词"和"第 1000 个词"之间只需一步
梯度传播 BPTT(沿时间反向传播),100 步回传 ≈ 100 次链式乘法 → 梯度消失/爆炸 残差连接 + 并行路径,梯度不需要穿过时间轴
可解释性 隐状态是黑盒,很难解释"模型为什么做了这个预测" Attention 权重可以直接可视化——看到模型在"看"哪些词
直观对比 — RNN 像传话游戏,Transformer 像圆桌会议:
RNN: 第 1 个人 → 告诉第 2 个人 → 告诉第 3 个人 → ...→ 到第 100 个人时,第 1 个人说了什么已经忘光了
Transformer: 所有人围坐一圈,每个人可以直接听到所有人的发言,想听谁就听谁
一句话回答:RNN 必须串行处理(时间步之间强依赖),导致训练慢 + 长距离信息丢失;Transformer 的 Self-Attention 让每个位置直接与所有位置交互,路径长度恒为 O(1),天然可并行,且 Attention 权重提供了可解释性。

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: 未公开(万亿级)
训练方式对比 — 同样一句话 "The cat sat on the mat"

BERT (MLM): "The [MASK] sat on the [MASK]" → 模型要预测出 "cat" 和 "mat"
   → 模型可以同时看两边来推断被遮的词("sat" 前面通常是主语,"on the" 后面通常是地点)

GPT (Next Token): "The" → 预测 "cat" → "The cat" → 预测 "sat" → …
   → 模型只看左边,每次只预测下一个词,这个过程天然就是"续写"
关键认知:现代大语言模型几乎全部采用 Decoder-Only 架构(GPT 路线)。原因在于:任何 NLP 任务都可以被重新表述为"给定前缀,续写后缀"——翻译 = "英文原文\n中文翻译:", 摘要 = "原文\n总结:", 对话 = "用户:xxx\n助手:"。这种统一的接口让一个模型能做所有任务,而不需要像 BERT 那样为每个任务加一个分类头。

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 次
核心洞察:Self-Attention 中,token i 的 K 和 V 只依赖于 token i 自身的内容,不依赖后面的 token(causal mask 保证后面的 token 不会影响前面的 K/V)。所以已生成 token 的 K 和 V 一旦算出来就永远不会变——缓存起来,不需要每步重算。

量化收益:生成 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 上下文
posted @ 2026-07-22 10:40  黄艺龙  阅读(0)  评论(0)    收藏  举报