第 3 讲:Transformer 架构与 Hyperparameters
1. Self-Attention 机制
Self-Attention 是 Transformer 的核心组件。它允许序列中的每个位置"关注"序列中的其他位置, 从而捕获长距离依赖关系。
1.1 Query, Key, Value
Self-Attention 的核心思想借鉴了信息检索:给定一个 Query(查询), 在所有 Key(键)中找到最相关的,然后返回对应 Value(值)的加权组合。
具体地,对于输入序列 X ∈ ℝT×d:
# Linear projections
Q = X @ W_Q # (T, d) @ (d, d_k) = (T, d_k)
K = X @ W_K # (T, d) @ (d, d_k) = (T, d_k)
V = X @ W_V # (T, d) @ (d, d_v) = (T, d_v)
其中 WQ、WK、WV 是可学习的投影矩阵。 直觉上:Q 代表"我在找什么",K 代表"我提供什么",V 代表"我的实际内容"。
1.2 Scaled Dot-Product Attention
计算 attention 的完整公式:
逐步分解:
- 计算相似度:QKT ∈ ℝT×T,每个元素表示两个位置之间的相关性
- 缩放:除以 √dk,防止 dot product 在高维空间中值过大导致 softmax 饱和
- Causal Mask:在 decoder 中,将未来位置的 attention score 设为 -∞,确保每个位置只能看到自己和之前的内容
- Softmax:归一化为概率分布
- 加权求和:用 attention weights 对 V 加权求和
import torch
import torch.nn.functional as F
def scaled_dot_product_attention(Q, K, V, mask=None):
"""
Q: (B, T, d_k)
K: (B, T, d_k)
V: (B, T, d_v)
mask: (T, T) causal mask
"""
d_k = Q.shape[-1]
# Step 1-2: Compute scaled attention scores
scores = (Q @ K.transpose(-2, -1)) / (d_k ** 0.5) # (B, T, T)
# Step 3: Apply causal mask
if mask is not None:
scores = scores.masked_fill(mask == 0, float('-inf'))
# Step 4: Softmax
attn_weights = F.softmax(scores, dim=-1) # (B, T, T)
# Step 5: Weighted sum
output = attn_weights @ V # (B, T, d_v)
return output
为什么要缩放?
假设 Q 和 K 的每个元素独立服从 N(0,1),那么 QKT 的每个元素的方差为 dk。当 dk 很大时(如 128),dot product 的值会很大,softmax 会接近 one-hot 分布,梯度几乎为零。除以 √dk 将方差归一化为 1,缓解这个问题。
2. Multi-Head Attention
单一的 attention 只能捕获一种"关注模式"。Multi-Head Attention 通过并行运行多个 attention head,让模型同时关注不同的表示子空间。
where headi = Attention(X · WQi, X · WKi, X · WVi)
关键参数关系:
d_model:模型总维度(如 4096)n_heads:attention head 数量(如 32)d_k = d_model / n_heads:每个 head 的维度(如 128)
class MultiHeadAttention(nn.Module):
def __init__(self, d_model: int, n_heads: int):
super().__init__()
self.n_heads = n_heads
self.d_k = d_model // n_heads
# Combined QKV projection for efficiency
self.qkv_proj = nn.Linear(d_model, 3 * d_model, bias=False)
self.out_proj = nn.Linear(d_model, d_model, bias=False)
def forward(self, x):
B, T, d = x.shape
# Project to Q, K, V
qkv = self.qkv_proj(x) # (B, T, 3*d)
Q, K, V = qkv.chunk(3, dim=-1) # each (B, T, d)
# Reshape for multi-head: (B, T, d) -> (B, n_heads, T, d_k)
Q = Q.view(B, T, self.n_heads, self.d_k).transpose(1, 2)
K = K.view(B, T, self.n_heads, self.d_k).transpose(1, 2)
V = V.view(B, T, self.n_heads, self.d_k).transpose(1, 2)
# Scaled dot-product attention
attn = scaled_dot_product_attention(Q, K, V, causal_mask)
# Reshape back: (B, n_heads, T, d_k) -> (B, T, d)
attn = attn.transpose(1, 2).contiguous().view(B, T, d)
return self.out_proj(attn)
Grouped Query Attention (GQA)
LLaMA 2/3 采用了 GQA:多个 Query head 共享同一组 Key 和 Value head。例如,32 个 Q head 可能只有 8 个 KV head。这减少了 KV cache 的大小,在推理时尤其有用。GQA 是 Multi-Head Attention(每个 head 独立 KV)和 Multi-Query Attention(所有 head 共享一组 KV)之间的折中方案。
3. Positional Encoding
Self-Attention 本身是置换不变(permutation-invariant)的—— 它不关心 token 的顺序。因此需要显式注入位置信息。
3.1 绝对位置编码
原始 Transformer 使用正弦/余弦函数生成固定的位置编码,或者使用可学习的 position embedding。 缺点是:无法泛化到训练时未见过的序列长度。
3.2 RoPE(Rotary Position Embedding)
RoPE 是目前 LLM 中最主流的位置编码方法(LLaMA, Mistral 等均采用)。 其核心思想是:通过旋转 Query 和 Key 向量来注入位置信息, 使得两个位置之间的 attention score 只依赖于它们的相对距离。
对于位置 m 处的向量 x,RoPE 将相邻的两个维度视为一个 2D 向量,并旋转角度 mθ:
其中 R(m) 是分块对角旋转矩阵
θi = 10000-2i/d
def apply_rope(x, positions):
"""
x: (B, n_heads, T, d_k)
positions: (T,) position indices
"""
d_k = x.shape[-1]
# Compute rotation angles
freqs = 1.0 / (10000.0 ** (torch.arange(0, d_k, 2).float() / d_k))
angles = positions.unsqueeze(-1) * freqs.unsqueeze(0) # (T, d_k/2)
cos = angles.cos() # (T, d_k/2)
sin = angles.sin() # (T, d_k/2)
# Split into pairs and rotate
x1 = x[..., ::2] # Even dimensions
x2 = x[..., 1::2] # Odd dimensions
rotated = torch.stack([
x1 * cos - x2 * sin,
x1 * sin + x2 * cos,
], dim=-1).flatten(-2)
return rotated
RoPE 的数学美妙之处在于:
即两个位置的 attention score 只取决于相对距离 (n-m),而非绝对位置。 这使得模型可以更好地泛化到不同长度的序列。
4. Feed-Forward Network
Transformer 中每个 Attention 层后都跟着一个 Feed-Forward Network(FFN)。 标准 FFN 是一个两层的 MLP:
4.1 SwiGLU 激活函数
现代 LLM(如 LLaMA)通常使用 SwiGLU 替代传统的 ReLU/GELU。 SwiGLU 结合了 Swish 激活函数和 Gated Linear Unit (GLU) 的思想:
where Swish(x) = x · σ(x), σ is sigmoid
class SwiGLUFFN(nn.Module):
def __init__(self, d_model: int, d_ff: int):
super().__init__()
self.w1 = nn.Linear(d_model, d_ff, bias=False)
self.w_gate = nn.Linear(d_model, d_ff, bias=False)
self.w2 = nn.Linear(d_ff, d_model, bias=False)
def forward(self, x):
# Gate branch with Swish activation
gate = F.silu(self.w_gate(x)) # silu = swish = x * sigmoid(x)
# Up projection
up = self.w1(x)
# Element-wise product and down projection
return self.w2(gate * up)
参数量变化
注意 SwiGLU 有三个权重矩阵(W1, Wgate, W2),而标准 FFN 只有两个。为了保持总参数量不变,使用 SwiGLU 时通常将 d_ff 设为 (2/3) × 4d,而非标准的 4d。例如 LLaMA-7B 的 d_model=4096, d_ff=11008 ≈ (2/3) × 4 × 4096。
5. Layer Normalization
5.1 Pre-Norm vs Post-Norm
原始 Transformer 采用 Post-Norm 结构(先 sublayer 再 norm), 但现代 LLM 几乎全部使用 Pre-Norm(先 norm 再 sublayer):
# Post-Norm (original Transformer)
x = LayerNorm(x + Attention(x))
x = LayerNorm(x + FFN(x))
# Pre-Norm (modern LLMs)
x = x + Attention(RMSNorm(x))
x = x + FFN(RMSNorm(x))
Pre-Norm 的优势是训练更稳定,不需要 learning rate warmup 也能训练。 直觉上,Pre-Norm 中的 residual connection 直接连接输入和输出, 梯度可以更顺畅地从输出层流回输入层。
5.2 RMSNorm
RMSNorm 是 LayerNorm 的简化版本,去掉了均值中心化步骤,只做方差归一化:
RMSNorm(x) = γ ⊙ x / √(mean(x²) + ε)
class RMSNorm(nn.Module):
def __init__(self, d_model: int, eps: float = 1e-6):
super().__init__()
self.weight = nn.Parameter(torch.ones(d_model))
self.eps = eps
def forward(self, x):
# RMS = root mean square
rms = torch.sqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
return x / rms * self.weight
RMSNorm 比 LayerNorm 更快(少了一次 mean 计算和减法操作), 而且在实践中效果几乎一样好。
6. Transformer Block 完整结构
将以上组件组合起来,一个现代 Transformer Block(如 LLaMA 风格)的完整结构如下:
class TransformerBlock(nn.Module):
def __init__(self, d_model, n_heads, d_ff):
super().__init__()
self.attn_norm = RMSNorm(d_model)
self.attention = MultiHeadAttention(d_model, n_heads)
self.ffn_norm = RMSNorm(d_model)
self.ffn = SwiGLUFFN(d_model, d_ff)
def forward(self, x, positions):
# Self-attention with pre-norm and residual
h = self.attn_norm(x)
h = self.attention(h, positions)
x = x + h
# FFN with pre-norm and residual
h = self.ffn_norm(x)
h = self.ffn(h)
x = x + h
return x
class Transformer(nn.Module):
def __init__(self, vocab_size, d_model, n_heads, n_layers, d_ff):
super().__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
self.layers = nn.ModuleList([
TransformerBlock(d_model, n_heads, d_ff)
for _ in range(n_layers)
])
self.norm = RMSNorm(d_model)
self.output = nn.Linear(d_model, vocab_size, bias=False)
def forward(self, token_ids):
x = self.embedding(token_ids)
positions = torch.arange(token_ids.shape[1])
for layer in self.layers:
x = layer(x, positions)
x = self.norm(x)
logits = self.output(x)
return logits
Weight Tying
一些模型(如 GPT-2)将 embedding 层和 output projection 层共享权重(weight tying),即 self.output.weight = self.embedding.weight。这减少了参数量(节约 vocab_size × d_model 个参数),但在大模型中收益递减。LLaMA 系列不使用 weight tying。
7. 超参数选择
Transformer 有多个关键超参数,它们之间存在经验性的比例关系:
| 超参数 | 含义 | 典型关系 |
|---|---|---|
d_model | 模型隐藏维度 | 基础参数 |
n_heads | Attention head 数量 | d_model / d_k(d_k 通常为 64 或 128) |
n_layers | Transformer 层数 | 与 d_model 成正比 |
d_ff | FFN 中间维度 | ≈ (8/3) × d_model(SwiGLU 时) |
vocab_size | 词表大小 | 32K - 128K |
context_length | 最大序列长度 | 2K - 128K |
7.1 参数量估算
对于一个 L 层的 Transformer,总参数量近似为:
N ≈ 12 × L × d² + V × d (含 embedding,V 为词表大小)
其中 12d² 来自每层的:QKV projection (3d²) + output projection (d²) + FFN up/down (8d²,SwiGLU 时)。
7.2 代表性模型配置
| 模型 | 参数量 | d_model | n_heads | n_layers | d_ff |
|---|---|---|---|---|---|
| GPT-2 Small | 124M | 768 | 12 | 12 | 3072 |
| GPT-2 XL | 1.5B | 1600 | 25 | 48 | 6400 |
| LLaMA 2 7B | 6.7B | 4096 | 32 | 32 | 11008 |
| LLaMA 2 70B | 70B | 8192 | 64 | 80 | 28672 |
| LLaMA 3 405B | 405B | 16384 | 128 | 126 | 53248 |
8. 现代架构变体
相比原始 Transformer,现代 LLM 做了多项改进。以 LLaMA 系列为例:
| 改进点 | 原始 Transformer | LLaMA 风格 |
|---|---|---|
| Normalization | Post-Norm + LayerNorm | Pre-Norm + RMSNorm |
| 位置编码 | Sinusoidal / Learned | RoPE |
| 激活函数 | ReLU / GELU | SwiGLU |
| Attention | Multi-Head | Grouped Query (GQA) |
| Bias | 有 bias | 无 bias(所有 Linear 层) |
| Weight Tying | 通常使用 | 通常不使用 |
为什么去掉 Bias?
去掉 bias 的主要原因是简化模型,减少参数量和计算量。实验表明,在大规模预训练场景下,bias 对模型性能的影响可以忽略不计,而去掉 bias 后可以简化 Tensor Parallelism 等并行策略的实现。
9. 本讲总结
- Self-Attention 通过 QKV 机制实现序列内的全局信息交互
- Multi-Head Attention 让模型同时关注不同的表示子空间
- RoPE 通过旋转 Q/K 向量注入相对位置信息,是目前最主流的位置编码方案
- SwiGLU 是现代 LLM 的标准激活函数,结合了 Swish 和 GLU 的优势
- Pre-Norm + RMSNorm 提供了更稳定的训练和更快的计算速度
- 超参数之间存在经验性的比例关系:d_ff ≈ (8/3)d, d_k ≈ 64-128, N ≈ 12Ld²
- LLaMA 风格的架构已成为现代 LLM 的事实标准