组装完整模型
LLaMA
前四期,我们把 GPT-2 里的四个零件一个个拆下来、换成新的:
GPT-2 → 现代 LLM LayerNorm → RMSNorm (EP1) GELU FFN → SwiGLU (EP2) 可学习绝对位置向量 → RoPE (EP3) MHA → GQA (EP4)这一期不讲新零件,只做一件事:把它们装回去,看看装好的东西是不是就是 LLaMA。
三个问题:LLaMA Block 和 GPT-2 Block 到底差在哪几行?四个零件分别装在哪个位置?LLaMA 的配置文件里每个参数,对应前面哪一期?
一、先说结论:骨架没动,零件全换
先把推理篇 EP11 写过的 GPT-2 Block 拿出来,和 LLaMA Block 并排放:
# GPT-2 Block(推理篇 EP11)
x = x + attn(ln_1(x)) # LayerNorm → MHA(位置信息在进 Block 之前就加好了)
x = x + mlp(ln_2(x)) # LayerNorm → GELU FFN
# LLaMA Block
x = x + attn(norm_1(x)) # RMSNorm → GQA(RoPE 在 attn 内部,对 q、k 旋转)
x = x + mlp(norm_2(x)) # RMSNorm → SwiGLU
两行代码的形状一模一样:
- 都是 Pre-LN:先归一化,再进子层。
- 都是 残差连接:加回去的是原始的
x,不是归一化之后的x(原系列反复强调过的那个坑,LLaMA 里照样成立)。 - 都是 两个子层:先注意力,后前馈。
所以 LLaMA 并不是一个"全新架构"。Transformer 的骨架一行没改,改的是骨架上挂的四个零件。 这也是整个进阶篇「从 GPT-2 到 LLaMA」能成立的前提:GPT-2 吃透了,LLaMA 就只是一份"换件清单"。
二、逐个位置对照:零件装在哪
把一次完整的前向过程按顺序摊开,左边 GPT-2,右边 LLaMA:
GPT-2 LLaMA
───── ─────
token → wte 查表 embed_tokens 查表
+ wpe 位置向量 ←────── 删掉 ────→ (没有这一步)
┌ Block × N ──────────────────┐ ┌ Block × N ──────────────────┐
│ ln_1 LayerNorm │ │ input_layernorm RMSNorm │ ← EP1
│ attn MHA │ │ self_attn GQA │ ← EP4
│ │ │ └ q、k 上做 RoPE │ ← EP3
│ + 残差 │ │ + 残差 │
│ ln_2 LayerNorm │ │ post_attention_layernorm │ ← EP1
│ mlp GELU FFN │ │ mlp SwiGLU │ ← EP2
│ + 残差 │ │ + 残差 │
└─────────────────────────────┘ └──────────────────────────────┘
ln_f LayerNorm norm RMSNorm ← EP1
lm_head (和 wte 共享权重) lm_head (独立权重)
(右边用的是 HuggingFace LLaMA 实现里的模块名,后面加载权重时会直接用到。)
有一处值得多看一眼:位置信息的注入点变了。
- GPT-2 在最开头加一次位置向量,之后每一层靠残差把它"带下去"。
- LLaMA 开头什么都不加,而是在每一层的注意力里,对 q、k 做一次旋转。
这正是 EP3 说的:RoPE 只作用在 q·k 的点积上,让注意力分数只依赖相对距离。它不碰 v,也不碰残差流。
三、四个大零件之外,还有三处小改动
逐行对比时,还会发现三处不在"四大零件"里的差别。它们不值得单独开一期,但加载权重时都会碰到。
1. 所有 Linear 层去掉了 bias
GPT-2 的注意力投影、FFN、LayerNorm 都带 bias。LLaMA 全部去掉:q/k/v/o 投影、gate/up/down 投影都是 bias=False,RMSNorm 本身也只有缩放参数、没有偏移参数(EP1 讲过)。
对效果影响不大,好处是参数和代码都更干净。
2. 位置向量表没了
GPT-2 有一张 wpe,形状 [1024, 768],是要训练的参数。1024 这个数直接写死了模型能处理的最大长度。
LLaMA 的 RoPE 是用公式算出来的 cos/sin,没有任何可学习参数。配置文件里的 max_position_embeddings 只是"训练时用过的长度",不是一张权重表的行数。
3. 输入 embedding 和输出层不再共享权重
GPT-2 的 lm_head 直接复用 wte 的权重(weight tying),省了一份 [50257, 768] 的矩阵。
LLaMA-1/2/3 的主力尺寸都不共享,embed_tokens 和 lm_head 各有一份。词表一大,这两份就很可观:LLaMA-3 8B 的词表是 128256,每份是 128256 × 4096 ≈ 5.25 亿 个参数,两份加起来占了总参数的 13% 左右。
共不共享是个权衡,不是谁对谁错。Meta 后来的小尺寸模型(比如 Llama 3.2 1B)又重新共享了,因为模型越小,词表矩阵占的比例越大。
四、猫吃鱼:把一个"迷你 LLaMA"从头走到尾
这一期不手算具体数值(每个零件的数值前四期都算过了),只把形状一路标出来,确认零件装上去之后能严丝合缝地接上。
还是"猫吃鱼",d_model=4。迷你配置取:
vocab_size = 3 词表只有 猫、吃、鱼
d_model = 4
n_layers = 2
n_heads = 2 → head_dim = 4 / 2 = 2
n_kv_heads = 1 → 2 个 Q 头共用 1 份 KV
ffn_hidden = 3 沿用 SwiGLU 那期手算用的 h=3
为什么不沿用 GQA 那期的 4 个 Q 头?因为那样 head_dim=1,而 RoPE 是两两配对旋转的,head_dim 必须是偶数。2 个头、head_dim=2 正好是"一对",能转起来。这只是为了凑出能手算的最小配置。
输入
token id: [0, 1, 2] 猫、吃、鱼
embed_tokens 查表: [3] → [3, 4]
(GPT-2 这里还要 + wpe,LLaMA 没有)
第 1 个 Block · 注意力子层
input_layernorm (RMSNorm): [3, 4] → [3, 4] EP1
q_proj: [3, 4] → [3, 4] → 拆头 → [2, 3, 2] 2 个 Q 头
k_proj: [3, 4] → [3, 2] → 拆头 → [1, 3, 2] 1 个 KV 头 EP4
v_proj: [3, 4] → [3, 2] → 拆头 → [1, 3, 2]
RoPE 旋转 q、k: 形状不变 EP3
head_dim=2 只有一对维度,频率 θ^0 = 1
猫 转 0 弧度,吃 转 1 弧度,鱼 转 2 弧度
(KV Cache 存在这里:[1, 3, 2],只有 1 个头)
repeat_kv: k、v [1, 3, 2] → [2, 3, 2] EP4
scores = q @ kᵀ / √2: [2, 3, 3],加因果掩码,softmax
weights @ v: [2, 3, 2]
拼接 2 个头: [3, 4]
o_proj: [3, 4] → [3, 4]
残差: h = x + 上面的结果 [3, 4]
第 1 个 Block · 前馈子层
post_attention_layernorm (RMSNorm): [3, 4] → [3, 4] EP1
gate_proj: [3, 4] → [3, 3] EP2
up_proj: [3, 4] → [3, 3]
SiLU(gate) * up: [3, 3]
down_proj: [3, 3] → [3, 4]
残差: out = h + 上面的结果 [3, 4]
第 2 个 Block
[3, 4] → 和第 1 个 Block 完全一样的流程 → [3, 4]
输出
norm (RMSNorm): [3, 4] → [3, 4]
lm_head: [3, 4] → [3, 3] 每个位置对 3 个词的 logits
把这条链看完,有一个和推理篇 EP12 一模一样的结论:每个 Block 进去是 [3, 4],出来还是 [3, 4]。 形状不变,才能一层层堆下去。四个零件换完之后,这个性质一点没受影响:
- RMSNorm 和 LayerNorm 一样不改形状;
- GQA 虽然 K、V 变窄了,但 repeat_kv 之后又对齐回去,拼接输出仍是
d_model; - RoPE 只是旋转,不改形状;
- SwiGLU 中间维度变了,但 down_proj 又投回
d_model。
五、参数都去哪了
形状对上了,再看参数量。为了公平,拿同样 d_model = 4096 的两种 Block 比:
- GPT-2 式 Block:MHA(32 个头)+ 4 倍宽 GELU FFN,带 bias,LayerNorm
- LLaMA-3 8B 的 Block:GQA(32 个 Q 头、8 个 KV 头)+ SwiGLU(中间维度 14336),无 bias,RMSNorm
GPT-2 式 Block LLaMA-3 8B Block
注意力 约 6713 万 约 4194 万 ↓ 37.5%
q / k / v / o 4096×4096 各一份 k、v 只有 4096×1024
前馈 约 1.34 亿 约 1.76 亿 ↑ 31%
结构 2 个矩阵,4d 宽 3 个矩阵,3.5d 宽
归一化 1.6 万 0.8 万
─────────────────────────────────────────────────────
合计 约 2.01 亿 约 2.18 亿
两个观察:
1. GQA 把注意力的参数砍掉了三分之一多。 EP4 讲的是它省 KV Cache,顺带也省了 W_k、W_v 的参数:从 4096×4096 变成 4096×1024。
2. 参数在往 FFN 那边挪。 SwiGLU 有三个矩阵,按 EP2 的说法,为了让参数量和原来的 FFN 持平,中间维度应该取 2/3 × 4d。LLaMA-1/2 的 7B 就是这么做的:2/3 × 4 × 4096 ≈ 10923,再向上取到 256 的倍数,得到 11008。LLaMA-3 8B 则直接给到了 14336(3.5 倍),比"持平"还多了一截。
这里只是陈述两个版本的配置差异。LLaMA-3 为什么把 FFN 加宽,Meta 没有单独解释过,不要脑补成"GQA 省下来的参数挪给了 FFN"这种因果关系。
整体规模再对一下数:32 个这样的 Block 是 69.8 亿,加上两份 5.25 亿的词表矩阵,总数 约 80.3 亿,这就是"8B"的来历。作为对照,GPT-2 small 是 1.24 亿。
六、读懂 LLaMA 的配置文件
这一期最实用的部分。下面是 HuggingFace 上 LLaMA-3 8B 的 config.json 里和结构相关的字段,逐个对到前面的内容:
"vocab_size": 128256 词表大小 推理篇 EP02 Tokenizer(GPT-2 是 50257)
"hidden_size": 4096 d_model 原系列 Embedding
"num_hidden_layers": 32 Block 堆几层 推理篇 EP12 堆叠
"num_attention_heads": 32 Q 头数 原系列 多头注意力
"num_key_value_heads": 8 KV 头数 进阶篇「从 GPT-2 到 LLaMA」 EP4 GQA
"intermediate_size": 14336 SwiGLU 中间维度 进阶篇「从 GPT-2 到 LLaMA」 EP2
"hidden_act": "silu" SwiGLU 的门 进阶篇「从 GPT-2 到 LLaMA」 EP2
"rms_norm_eps": 1e-05 RMSNorm 的 ε 进阶篇「从 GPT-2 到 LLaMA」 EP1
"rope_theta": 500000.0 RoPE 的底数 进阶篇「从 GPT-2 到 LLaMA」 EP3
"max_position_embeddings": 8192 训练用过的长度 (不是权重表)
"attention_bias": false 注意力无 bias 本期第三节
"tie_word_embeddings": false 不共享词表矩阵 本期第三节
head_dim 没有直接写,是算出来的:4096 / 32 = 128。
有两个字段值得多说一句:
num_key_value_heads。 如果它等于 num_attention_heads,就是普通 MHA。比如 LLaMA-2 7B 这一项是 32,和 Q 头数相同,说明它没用 GQA;LLaMA-2 里只有 70B 这类大尺寸用了。到 LLaMA-3,8B 也换上了 GQA。所以"LLaMA 用 GQA"这句话要说清楚是哪一代、哪个尺寸。
rope_theta。 EP3 里的频率是 θ_i = base^(-2i/d),这个 base 默认是 10000,LLaMA-2 就是 10000。LLaMA-3 调到了 500000,底数越大,低频那几对维度转得越慢,"钟表的时针"走一圈要更久,能区分的距离就更远。这和长上下文直接相关,EP3 结尾预告过,这里先不展开。
这张表的意义在于:以后拿到任何一个 LLaMA 系的模型(包括 Qwen、Mistral 等很多结构相近的模型),打开 config.json,你都能说出每一行对应 Transformer 里的哪个零件。
七、一个类比:流水线没改,工位上的设备换了
推理篇 EP12 把堆叠的 Block 比作一条装配流水线:每个工位(Block)接过半成品,加工一下,原样尺寸传给下一个工位。
LLaMA 的升级,相当于流水线的布局一点没动,只是把每个工位上的设备换成了新型号:
- 质检台(归一化):从"既校准中心又校准幅度"的老设备,换成"只校准幅度"的轻量设备。(RMSNorm)
- 主加工机(FFN):加了一个阀门,先判断这批料该放多少过去,再加工。(SwiGLU)
- 定位系统(位置编码):以前是进厂时贴一张绝对编号的标签;现在是每个工位上,按两件货之间的距离现场校准。(RoPE)
- 资料室(注意力的 K、V):以前每个调查员一间,现在几个人共用一间。(GQA,EP4 的类比)
流水线没改,所以原系列学到的东西全部还有效;设备换了,所以同样的厂房能做出更好、更快的产品。
八、完整代码:一个能跑的迷你 LLaMA
把前四期的零件拼成一个完整模型。为了让这一期自成一体,下面每个零件都重新写了一遍,函数签名可能和你前几期的版本略有出入,如果你已有自己的实现,直接替换对应模块即可。
RoPE 这里采用 HuggingFace 的前后半配对写法(rotate_half),因为下一步要加载的就是 HuggingFace 格式的权重,EP3 讲过这两种配对方式的区别。
import torch
import torch.nn as nn
import torch.nn.functional as F
from dataclasses import dataclass
@dataclass
class LlamaConfig:
vocab_size: int = 3
d_model: int = 4
n_layers: int = 2
n_heads: int = 2
n_kv_heads: int = 1
ffn_hidden: int = 3
max_len: int = 16
rope_theta: float = 10000.0
eps: float = 1e-5
# ---------- EP1: RMSNorm ----------
class RMSNorm(nn.Module):
def __init__(self, d, eps=1e-5):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(d)) # 只有缩放,没有偏移
def forward(self, x):
rms = torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
return x * rms * self.weight
# ---------- EP2: SwiGLU ----------
class SwiGLU(nn.Module):
def __init__(self, d, hidden):
super().__init__()
self.gate_proj = nn.Linear(d, hidden, bias=False)
self.up_proj = nn.Linear(d, hidden, bias=False)
self.down_proj = nn.Linear(hidden, d, bias=False)
def forward(self, x):
return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
# ---------- EP3: RoPE(前后半配对,HF 风格) ----------
def precompute_rope(head_dim, max_len, theta=10000.0):
inv_freq = 1.0 / (theta ** (torch.arange(0, head_dim, 2).float() / head_dim)) # [head_dim/2]
pos = torch.arange(max_len).float()
freqs = torch.outer(pos, inv_freq) # [max_len, head_dim/2]
emb = torch.cat([freqs, freqs], dim=-1) # [max_len, head_dim]
return emb.cos(), emb.sin()
def rotate_half(x):
half = x.shape[-1] // 2
x1, x2 = x[..., :half], x[..., half:]
return torch.cat([-x2, x1], dim=-1)
def apply_rope(x, cos, sin):
# x: [B, H, T, head_dim]
T = x.shape[-2]
return x * cos[:T] + rotate_half(x) * sin[:T]
# ---------- EP4: GQA ----------
def repeat_kv(x, n_rep):
if n_rep == 1:
return x
B, H_kv, T, D = x.shape
x = x[:, :, None, :, :].expand(B, H_kv, n_rep, T, D)
return x.reshape(B, H_kv * n_rep, T, D)
class Attention(nn.Module):
def __init__(self, cfg):
super().__init__()
self.n_heads = cfg.n_heads
self.n_kv_heads = cfg.n_kv_heads
self.head_dim = cfg.d_model // cfg.n_heads
self.n_rep = cfg.n_heads // cfg.n_kv_heads
self.q_proj = nn.Linear(cfg.d_model, cfg.n_heads * self.head_dim, bias=False)
self.k_proj = nn.Linear(cfg.d_model, cfg.n_kv_heads * self.head_dim, bias=False)
self.v_proj = nn.Linear(cfg.d_model, cfg.n_kv_heads * self.head_dim, bias=False)
self.o_proj = nn.Linear(cfg.n_heads * self.head_dim, cfg.d_model, bias=False)
def forward(self, x, cos, sin):
B, T, _ = x.shape
q = self.q_proj(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
k = self.k_proj(x).view(B, T, self.n_kv_heads, self.head_dim).transpose(1, 2)
v = self.v_proj(x).view(B, T, self.n_kv_heads, self.head_dim).transpose(1, 2)
q = apply_rope(q, cos, sin) # EP3:只转 q、k
k = apply_rope(k, cos, sin) # v 不转
# KV Cache 应该存在这里:旋转之后、repeat 之前
k = repeat_kv(k, self.n_rep) # EP4
v = repeat_kv(v, self.n_rep)
scores = q @ k.transpose(-2, -1) / (self.head_dim ** 0.5)
mask = torch.triu(torch.ones(T, T, dtype=torch.bool, device=x.device), diagonal=1)
scores = scores.masked_fill(mask, float("-inf"))
out = F.softmax(scores, dim=-1) @ v # [B, n_heads, T, head_dim]
out = out.transpose(1, 2).contiguous().view(B, T, -1) # [B, T, d_model]
return self.o_proj(out)
# ---------- 装配:LLaMA Block ----------
class LlamaBlock(nn.Module):
def __init__(self, cfg):
super().__init__()
self.input_layernorm = RMSNorm(cfg.d_model, cfg.eps)
self.self_attn = Attention(cfg)
self.post_attention_layernorm = RMSNorm(cfg.d_model, cfg.eps)
self.mlp = SwiGLU(cfg.d_model, cfg.ffn_hidden)
def forward(self, x, cos, sin):
x = x + self.self_attn(self.input_layernorm(x), cos, sin) # 和 GPT-2 同一个骨架
x = x + self.mlp(self.post_attention_layernorm(x))
return x
# ---------- 装配:完整模型 ----------
class Llama(nn.Module):
def __init__(self, cfg):
super().__init__()
self.embed_tokens = nn.Embedding(cfg.vocab_size, cfg.d_model)
# 注意:没有位置向量表
self.layers = nn.ModuleList([LlamaBlock(cfg) for _ in range(cfg.n_layers)])
self.norm = RMSNorm(cfg.d_model, cfg.eps)
self.lm_head = nn.Linear(cfg.d_model, cfg.vocab_size, bias=False) # 不和 embed 共享
cos, sin = precompute_rope(cfg.d_model // cfg.n_heads, cfg.max_len, cfg.rope_theta)
self.register_buffer("cos", cos, persistent=False) # buffer:跟着 .to(device) 走,但不算参数
self.register_buffer("sin", sin, persistent=False)
def forward(self, idx):
x = self.embed_tokens(idx) # [B, T, d_model]
for layer in self.layers:
x = layer(x, self.cos, self.sin) # 形状不变
x = self.norm(x)
return self.lm_head(x) # [B, T, vocab_size]
和推理篇 EP12 的 GPT 类对比,Llama 类只有三处不同:少了位置向量表、多了 cos/sin 两个 buffer、lm_head 不和 embedding 共享。其余的变化全部藏在 Block 内部的零件里。
验证:形状和参数量
torch.manual_seed(0)
cfg = LlamaConfig()
model = Llama(cfg)
idx = torch.tensor([[0, 1, 2]]) # 猫 吃 鱼
logits = model(idx)
print(logits.shape) # torch.Size([1, 3, 3])
print(sum(p.numel() for p in model.parameters())) # 212
212 这个数可以手算核对:
每个 Block:
q_proj 4×4=16,k_proj 4×2=8,v_proj 4×2=8,o_proj 4×4=16 → 48
gate/up/down 各 4×3=12 → 36
两个 RMSNorm 各 4 → 8
合计 92,两层 184
embed_tokens 3×4=12,lm_head 4×3=12,最后的 norm 4 → 28
总计 184 + 28 = 212
cos/sin 是用 register_buffer 注册的,不计入参数,这也从代码层面印证了"RoPE 没有可学习参数"。
和之前每一期一样:这是随机初始化的模型,输出的 logits 没有任何语义。要让它真的说出话来,得加载预训练权重。
九、加载真实权重前,先把坑列出来
拿这份代码去加载 HuggingFace 上的 LLaMA 权重,会踩的坑前面几期其实都埋过伏笔,汇总一下:
1. 模块名要对上。 上面的代码已经刻意用了 HF 的命名(self_attn.q_proj、mlp.gate_proj、input_layernorm 等),外面还要包一层 model.,比如 model.layers.0.self_attn.q_proj.weight。
2. 不用转置了。 原系列加载 GPT-2 时,HF 的 GPT-2 用的是 Conv1D,权重形状是 [in, out],要转置才能塞进 nn.Linear。HF 的 LLaMA 用的就是 nn.Linear,形状直接对得上。这个坑这次反而没有了。
3. RoPE 配对方式要一致(EP3 的坑)。 Meta 原始权重按相邻配对训练,HF 在转换格式时对 q_proj、k_proj 的权重做过一次重排,所以 HF 权重要配 rotate_half 写法。混用的话不报错,输出全乱。
4. repeat_kv 的顺序(EP4 的坑)。 必须是 kv0, kv0, kv1, kv1,不能是 kv0, kv1, kv0, kv1。
5. 不要共享 lm_head。 习惯性地照搬 GPT-2 的 weight tying,会把 lm_head.weight 覆盖掉。
这五个坑有一个共同点,原系列已经总结过:形状对了,不代表语义对了。
十、这一期的要点
- LLaMA 的骨架和 GPT-2 相同:Pre-LN、残差、注意力 + 前馈两个子层、多层堆叠。变的是骨架上的零件。
- 四个零件各就各位:两处归一化换成 RMSNorm,注意力换成 GQA,位置编码从"开头加一次"变成"每层对 q、k 旋转"的 RoPE,FFN 换成 SwiGLU。
- 还有三处小改动:去掉 bias、去掉位置向量表、embedding 和 lm_head 不共享。
- 每个 Block 进出形状不变,所以零件全换之后,仍然能像原系列一样一层层堆起来。
- config.json 的每一行都能对应到具体零件。看懂这张表,就看懂了一大类开源模型的结构。
系列收尾与后续方向
到这里,进阶篇「从 GPT-2 到 LLaMA」的主线完成了:从 GPT-2 出发,换完四个零件,组装出一个结构上和 LLaMA 一致的模型。
不过 EP4 里留了一句话:教学版的 repeat_kv 会真的把 K、V 复制一份,生产环境的注意力内核不这么干。不止如此,我们一直在用的那段注意力代码,scores 是一个 [T, T] 的完整矩阵,序列一长,光存它就是灾难。
后续可以继续学习 FlashAttention:注意力的数学公式一个字都不改,只改计算顺序,为什么就能又快又省显存?
参考论文
- Touvron, H., et al. (2023). LLaMA: Open and Efficient Foundation Language Models.
- Touvron, H., et al. (2023). Llama 2: Open Foundation and Fine-Tuned Chat Models.
- Grattafiori, A., et al. (2024). The Llama 3 Herd of Models.
- Zhang, B., & Sennrich, R. (2019). Root Mean Square Layer Normalization.
- Shazeer, N. (2020). GLU Variants Improve Transformer.
- Su, J., et al. (2021). RoFormer: Enhanced Transformer with Rotary Position Embedding.
- Ainslie, J., et al. (2023). GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints.