第 11 章 从 GPT 到 Llama 3.2 与现代 LLM 架构
第 11 章 从 GPT 到 Llama 3.2 与现代 LLM 架构
本章来源:本文整理自 LLMs-from-scratch 仓库 ch05 的 GPT-to-Llama 与 LLM Architectures From Scratch bonus 材料,原书为 Sebastian Raschka《Build a Large Language Model (From Scratch)》。
这章要解决什么问题
前面十章,我们是从零把一台 GPT 造了出来:分词、注意力、GPT 骨架、预训练、微调,乃至 KV Cache、GQA/MLA、MoE 这些"省钱绝招"。到这里,你手上已经有一个能跑的、结构完整的解码器模型了。
这一章要回答一个很自然的问题:我们写的这台 GPT,离今天真正在用的开源大模型(Llama、Qwen、Gemma)到底差多远?
答案可能有点出乎意料——差得不多。 从 GPT 到 Llama 2,只需要改四个零件;从 Llama 2 到 Llama 3,再改三处小地方。改完之后,我们就能把 Meta 官方发布的 Llama 3.2 权重直接塞进自己写的模型里,让它开口说人话。
所以这章做三件事:
- 第一步,逐项改造:把第 4 章的 GPT 一步步变成 Llama 2,再变成 Llama 3.2。要改的正是四个模块——RMSNorm、RoPE、SwiGLU、GQA。
- 第二步,加载真权重:用 Meta 发布的 Llama 3.2 权重跑通我们的手写模型,验证改造没出错。
- 第三步,架构巡礼:看看今天主流开源模型(Qwen3、Gemma 3、Olmo 3、Tiny Aya、Qwen3.5、Gemma 4)各自在这套骨架基础上动了哪些手脚。
一句话记住这一章的地位:它不仅是一次"改造练习",更是一张"现代 LLM 架构地图"。 学完它,你再看任何一篇开源模型的架构博客,都能一眼认出里面的积木。
用大白话讲原理
先说结论:Llama 和 GPT 是同门师兄弟
很多人以为 Llama 是"另一套完全不同的架构",其实不是。Llama 属于 GPT 这一门——都是"解码器类 Transformer"(decoder-like Transformer),都是"一层层重复的积木 + 注意力 + 前馈",都是自回归地一个词一个词往外吐。
它俩的关系,更像同款车型的两代改款:发动机原理一样,但换了几个更省油、更耐跑的零件。
原仓库给了一张很直观的对照图(gpt-and-all-llamas),把 GPT-2 → Llama 2 → Llama 3 的变化全画在一张图上。画出来的结论就是:
- GPT-2 → Llama 2:改了 4 个零件(LayerNorm→RMSNorm、GELU→SwiGLU、绝对位置编码→RoPE,外加去掉所有 bias、去掉 dropout、引入 dtype)。
- Llama 2 → Llama 3:又改了 3 处(改造过的 RoPE、把多头注意力换成 GQA、换一套新分词器),架构主体不动。
四个新零件,各自解决什么问题
在动手之前,先用大白话把四个新零件为什么要存在讲清楚。每个都对应一个"GPT 的老设计不够好"的痛点。
1)RMSNorm:把 LayerNorm 里"没必要"的那一步砍掉
第 4 章的 LayerNorm 做两件事:把向量减去均值(居中),再除以标准差(归一化),最后乘一个可训练的缩放。RMSNorm 的想法很直接:居中那一步,好像没那么重要,干脆省掉。 它只用"均方根"(root mean square,RMS)来缩放,公式里就没有"减均值"这一项。省一步计算,效果却几乎不差——这就是"更便宜还够用"的典型例子。
类比:LayerNorm 是把一张照片既调亮度又调对比度;RMSNorm 发现"只调对比度"效果也挺好,那就少调一道。
2)RoPE:让位置信息从"加法"变成"旋转"
第 4 章我们怎么告诉模型"谁在前谁在后"?直接给每个位置学一个向量,加到 token 向量上(绝对位置编码)。这办法简单,但有两个问题:一是位置向量和内容向量混在一起,不太好解耦;二是模型学过 1024 个位置就只能用 1024 个,扩展上下文很别扭。
RoPE(Rotary Position Embedding,旋转位置编码)换了思路:不给位置"加"信息,而是根据位置,把 query 和 key 向量"旋转"一个角度。 位置越靠后,转的角度越大。它的妙处是——两个词之间的注意力只和"它们的角度差"有关,这个差恰好对应"它俩隔多远"。于是同一套机制同时编码了绝对位置(自己转了多少)和相对位置(彼此差多少)。 这也是 Llama 能轻松扩到 128K 上下文的关键。
类比:LayerNorm 那种"加位置向量"好比给每节车厢贴一张编号牌;RoPE 则是给每节车厢装一个不同角度的方向盘——两车之间的相对朝向,天然就带着"相距多远"的信息。
3)SwiGLU:给前馈网络加一道"门"
第 4 章的前馈网络是"撑大 → GELU → 压回",中间夹一个 GELU 激活。Llama 换成了 SwiGLU:它把"撑大"这一步做成两条并行的支路,一条当门(经过 SiLU 激活),另一条当货物,两者逐元素相乘,再压回去。
类比:GELU 像一扇开关,决定"这个信号留下多少";SwiGLU 则多给一条支路专门算"留多少",用乘法控制信息通过的比例。多一个乘法、多一层表达,效果更好。
4)GQA:多个注意力头"共用"一套 Key/Value
多头注意力(MHA)里,每个头都有自己独立的 Q、K、V。GQA(Grouped-Query Attention,分组查询注意力)的做法是:Query 还是每个头一份,但 Key 和 Value 让几个头"组队共用"一套。 这样 K、V 的矩阵一下子小了好几倍,参数和显存都省了,推理时 KV Cache 也小得多,而效果几乎不掉。这是今天几乎所有主流模型(Llama 3、Qwen、Gemma……)的标配。
好,四个零件的原因讲完了。下面我们照着原仓库的顺序,一个一个改、一行一行看代码。
11.1 改造第一步:LayerNorm → RMSNorm
第 4 章的 LayerNorm 做两件事——先把向量减去均值(居中),再除以标准差(归一化),最后乘一个可训练的缩放,外加一个可训练的偏移。Llama 把它换成 RMSNorm:
class RMSNorm(nn.Module):
def __init__(self, emb_dim, eps=1e-5):
super().__init__()
self.eps = eps
self.emb_dim = emb_dim
self.weight = nn.Parameter(torch.ones(emb_dim)).float() # 只有缩放,没有 shift
def forward(self, x):
means = x.pow(2).mean(dim=-1, keepdim=True) # 算均方(不平移,只平方求均值)
x_normed = x * torch.rsqrt(means + self.eps) # 除以均方根(rsqrt = 1/sqrt)
return (x_normed * self.weight).to(dtype=x.dtype) # 乘上可训练的缩放对照着看,改动一目了然:
- 去掉了
mean和shift:不再减均值、不再加偏移,只剩下"除以均方根 + 乘缩放"。 torch.rsqrt:这是"平方根的倒数"(reciprocal square root),x * rsqrt(means + eps)等价于x / sqrt(means + eps),一步到位、更快。to(dtype=x.dtype):算归一化时可能临时升到 float32,算完再转回原来的低精度类型,省显存。
对应的公式是:
原 notebook 里还做了一步验证——把我们手写的 RMSNorm 和 PyTorch 内置的 torch.nn.RMSNorm 跑同一个输入,断言两者输出完全一致,确认手写实现没写错。
这种"两套实现跑出同一个结果"的断言,是验证手写模块最省心的办法。后面 RoPE、SiLU 也都是这么验的。
11.2 改造第二步:GELU → SiLU → SwiGLU
Llama 的激活函数先从 GELU 换成了 SiLU(也叫 Swish):
实现上就是一行——把 x 乘上它自己的 sigmoid,也就是 x * torch.sigmoid(x)。
但 Llama 前馈网络用的其实不是单独的 SiLU,而是它的"门控版"——SwiGLU:
公式的意思是:用两条线性层各算一份,第一份过 SiLU 当"门",第二份当"货物",两者逐元素相乘,最后再过一个 Linear_3 压回原尺寸。落到代码上,第 4 章那个"撑大再压回"的前馈网络,就变成了三条线:fc1 当"门"(输出过一个 SiLU 激活)、fc2 当"货物"(不激活),两者逐元素相乘后再由 fc3 压回原尺寸。
对比第 4 章的三层结构,注意两个关键差别:
- 中间维度不再固定是 4 倍:GPT-2 用的是
4 * emb_dim,而 Llama 把它单独抽成一个配置项hidden_dim(Llama 2 7B 是 11008,Llama 3.2 1B/3B 都是 8192)。这个数不再是简单倍数,所以必须写进配置。 - 多了一条支路做门控:这正是 SwiGLU 的"GLU(门控线性单元)"之处——不是为了多算,而是让网络能动态决定"让多少信息通过"。
还有两个细节值得单独提一句(原 notebook 特别标注了):
bias=False:Llama 里所有线性层都不用偏置,这是硬设定,不是可选项。dtype=cfg["dtype"]:允许直接以低精度(如 bfloat16)实例化模型,而不是先用 32 位建好再转换——省显存,加载预训练权重时也用得上。
11.3 改造第三步:绝对位置编码 → RoPE
这是四个改造里最"反直觉"的一个。第 4 章的做法,是用一个 nn.Embedding 给每个位置学一个向量,再在 forward 里把它直接加到 token 向量上(即 x = tok_embeds + pos_embeds)。
在 GPT 里,位置信息是"加"到 token 向量上的;而在 Llama 里,位置信息是"旋转"施加到 query 和 key 上的。 位置信息作用的位置、方式都变了。
预计算旋转参数:compute_rope_params
RoPE 里每个位置、每个维度对,都要一个"旋转角度"。这些角度和输入无关,可以一次性算好存起来,避免每次前向都重算。Llama 3.2 用的 compute_rope_params 如下:
def compute_rope_params(head_dim, theta_base=10_000, context_length=4096, freq_config=None, dtype=torch.float32):
assert head_dim % 2 == 0, "Embedding dimension must be even"
# 1) 算逆频率:维度越低(靠前)转得越快,越高转得越慢,形成多尺度的"时钟"
inv_freq = 1.0 / (theta_base ** (torch.arange(0, head_dim, 2, dtype=dtype)[: (head_dim // 2)].float() / head_dim))
# 2) 频率缩放(Llama 3 特有):把长上下文所需的低频"压慢",以支持超长上下文
if freq_config is not None:
low_freq_wavelen = freq_config["original_context_length"] / freq_config["low_freq_factor"]
high_freq_wavelen = freq_config["original_context_length"] / freq_config["high_freq_factor"]
wavelen = 2 * torch.pi / inv_freq
inv_freq_llama = torch.where(
wavelen > low_freq_wavelen, inv_freq / freq_config["factor"], inv_freq
)
smooth_factor = (freq_config["original_context_length"] / wavelen - freq_config["low_freq_factor"]) / (
freq_config["high_freq_factor"] - freq_config["low_freq_factor"]
)
smoothed_inv_freq = (
(1 - smooth_factor) * (inv_freq / freq_config["factor"]) + smooth_factor * inv_freq
)
is_medium_freq = (wavelen <= low_freq_wavelen) & (wavelen >= high_freq_wavelen)
inv_freq_llama = torch.where(is_medium_freq, smoothed_inv_freq, inv_freq_llama)
inv_freq = inv_freq_llama
# 3) 位置索引 → 角度矩阵:位置 i 乘以各维度的逆频率
positions = torch.arange(context_length, dtype=dtype)
angles = positions.unsqueeze(1) * inv_freq.unsqueeze(0) # 形状: (context_length, head_dim // 2)
# 4) 把角度"复制一份",拼成 head_dim 长度,方便直接和 x 逐元素相乘
angles = torch.cat([angles, angles], dim=1) # 形状: (context_length, head_dim)
# 5) 预计算 cos / sin
cos = torch.cos(angles)
sin = torch.sin(angles)
return cos, sin这里第 2 步的**频率缩放(freq rescaling)**是 Llama 3 相对 Llama 2 的关键升级:它把不同波长的分量区别对待——低频(波长超过阈值)整体除以一个 factor,中频做平滑过渡,高频不动。目的就是让模型在把上下文从 8K 扩到 128K 时,位置编码还"够用"。Llama 3.2 1B 的配置里,theta_base=500000.0、factor=32.0、original_context_length=8192。
应用旋转:apply_rope
预计算好的 cos/sin,前向时作用到 query、key 上:
def apply_rope(x, cos, sin):
# x: (batch_size, num_heads, seq_len, head_dim)
batch_size, num_heads, seq_len, head_dim = x.shape
assert head_dim % 2 == 0, "Head dimension must be even"
# 把 head_dim 劈成前后两半
x1 = x[..., : head_dim // 2] # 前半
x2 = x[..., head_dim // 2 :] # 后半
# 调整 cos/sin 形状,方便广播
cos = cos[:seq_len, :].unsqueeze(0).unsqueeze(0) # (1, 1, seq_len, head_dim)
sin = sin[:seq_len, :].unsqueeze(0).unsqueeze(0)
# 旋转:把 (-x2, x1) 当作"垂直分量",与 (x1, x2) 一起做二维旋转
rotated = torch.cat((-x2, x1), dim=-1)
x_rotated = (x * cos) + (rotated * sin)
return x_rotated.to(dtype=x.dtype) # 旋转后可安全用低精度这段就是 RoPE 的核心。你可以把 (x1, x2) 想成平面上的一个向量,(-x2, x1) 就是把它逆时针转 90°。于是 x * cos + rotated * sin 恰好是标准的二维旋转公式——每个维度对都在自己的小平面上,按位置转了不同角度。
一个容易踩的坑:RoPE 有"劈成前后两半"(split-halves)和"奇偶交错"(interleaved)两种等价写法。原仓库用的是前者(和 Hugging Face 的
transformers一致),而 Meta 官方权重用的是后者。两者数学上等价,但把维度的配对顺序搞混就会算错——所以在加载权重时需要做一次维度重排,后面 11.6 会讲到。
11.4 改造第四步:多头注意力 → GQA
Llama 2 用的还是多头注意力(MHA),只是把 RoPE 塞了进去,并且去掉了 qkv_bias、加了 dtype。但到了 Llama 3,注意力换成了 GQA(分组查询注意力)。
GQA 的思想一句话:Query 保持每头一份,Key/Value 让若干个头"组队共用"。 原 notebook 里是这样描述的:GQA 可以看成 MHA 的一个更省算力、更省参数的版本——它减少了 Key/Value 投影的数量,让它们被多个注意力头共享;每个头仍有自己独立的 Query,但这些 Query 去看同一组 Key/Value。
下面是 GQA 的关键实现(只保留改动重点):
class GroupedQueryAttention(nn.Module):
def __init__(self, d_in, d_out, num_heads, num_kv_groups, dtype=None):
super().__init__()
assert d_out % num_heads == 0, "d_out must be divisible by num_heads"
assert num_heads % num_kv_groups == 0, "num_heads must be divisible by num_kv_groups"
self.d_out = d_out
self.num_heads = num_heads # 注意力头数(Llama 3.2 1B 是 32)
self.head_dim = d_out // num_heads
self.num_kv_groups = num_kv_groups # Key/Value 组数(Llama 3.2 1B 是 8)
self.group_size = num_heads // num_kv_groups # 每组多少个 Query 头共用 = 32/8 = 4
# Query 投影到 d_out;Key/Value 只投影到 num_kv_groups * head_dim(比 Query 小得多)
self.W_query = nn.Linear(d_in, d_out, bias=False, dtype=dtype)
self.W_key = nn.Linear(d_in, num_kv_groups * self.head_dim, bias=False, dtype=dtype)
self.W_value = nn.Linear(d_in, num_kv_groups * self.head_dim, bias=False, dtype=dtype)
self.out_proj = nn.Linear(d_out, d_out, bias=False, dtype=dtype)
def forward(self, x, mask, cos, sin):
b, num_tokens, d_in = x.shape
queries = self.W_query(x) # (b, num_tokens, d_out)
keys = self.W_key(x) # (b, num_tokens, num_kv_groups * head_dim)
values = self.W_value(x) # (b, num_tokens, num_kv_groups * head_dim)
# 拆头 + 转置到位:(b, 头数, num_tokens, head_dim)
queries = queries.view(b, num_tokens, self.num_heads, self.head_dim).transpose(1, 2)
keys = keys.view(b, num_tokens, self.num_kv_groups, self.head_dim).transpose(1, 2)
values = values.view(b, num_tokens, self.num_kv_groups, self.head_dim).transpose(1, 2)
# RoPE 只作用在 query 和 key 上
keys = apply_rope(keys, cos, sin)
queries = apply_rope(queries, cos, sin)
# 关键一步:把 K/V 沿着"组"复制,展开成 num_heads 份,才能和 Q 一一对应
keys = keys.repeat_interleave(self.group_size, dim=1) # (b, num_heads, num_tokens, head_dim)
values = values.repeat_interleave(self.group_size, dim=1)
# 标准缩放点积注意力 + 因果掩码
attn_scores = queries @ keys.transpose(2, 3)
attn_scores = attn_scores.masked_fill(mask, -torch.inf)
attn_weights = torch.softmax(attn_scores / keys.shape[-1]**0.5, dim=-1)
context_vec = (attn_weights @ values).transpose(1, 2)
context_vec = context_vec.reshape(b, num_tokens, self.d_out) # 合并所有头
context_vec = self.out_proj(context_vec)
return context_vecGQA 和 MHA 的差别,集中在两处:
W_key/W_value的输出维度变小了:不再是d_out,而是num_kv_groups * head_dim。Llama 3.2 1B 是 32 个头、8 个 KV 组,所以 K/V 的行数只有 Q 的 1/4。- 多了一步
repeat_interleave:K/V 本身的份数不够分给所有头,所以要沿着"组"维度复制展开。注意这里必须用repeat_interleave(重复成[K1, K1, K2, K2])而不是repeat(会得到[K1, K2, K1, K2]),顺序错了对应关系就乱了。
原 notebook 里给了一个直观的参数账:同是 32 个头,用 GQA、8 个 KV 组时,Key/Value 矩阵的行数直接缩到原来的 1/4(32 个头 ÷ 8 组 = 4)。省的不只是参数,还有推理时的 KV Cache 显存——这正是第 8、9 章讲的"省钱"思路在真实模型里的落地。
把这四个改造拼回 TransformerBlock,就得到 Llama 的积木块。它的骨架和第 4 章几乎一样——注意力子块、前馈子块各自"归一化 → 计算 → 加回残差",只是零件全换了。
和第 4 章的
TransformerBlock比:LayerNorm 全换成了 RMSNorm,dropout 去掉了,前馈换成 SwiGLU,注意力换成 GQA,而且forward多带了mask, cos, sin三个参数(因为 RoPE 参数要在块里传下去)。
11.5 Llama 3.2 的配置和模型骨架
改造完成后,用一个配置表告诉模型每层多大。Llama 3.2 1B 的关键配置是:词表大小 128256、上下文长度 131072(128K)、嵌入维度 emb_dim 2048、注意力头数 n_heads 32、层数 n_layers 16、SwiGLU 前馈的中间维度 hidden_dim 8192、GQA 的 KV 组数 n_kv_groups 8、RoPE 基数 rope_base 500000,数据类型用 bfloat16 省显存,并启用 RoPE 频率缩放(factor=32.0、low_freq_factor=1.0、high_freq_factor=4.0、original_context_length=8192)。
3B 版本只改三个数(emb_dim 3072、n_heads 24、n_layers 28),其余一样。模型主体 Llama3Model 则把 RoPE 参数一次性算好、注册成 buffer 复用:
class Llama3Model(nn.Module):
def __init__(self, cfg):
super().__init__()
self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"], dtype=cfg["dtype"])
# 注意:没有 pos_emb!位置信息交给 RoPE
self.trf_blocks = nn.ModuleList(
[TransformerBlock(cfg) for _ in range(cfg["n_layers"])])
self.final_norm = nn.RMSNorm(cfg["emb_dim"], eps=1e-5, dtype=cfg["dtype"])
self.out_head = nn.Linear(cfg["emb_dim"], cfg["vocab_size"], bias=False, dtype=cfg["dtype"])
# 预计算 RoPE 的 cos/sin,注册为 buffer(不算参数,但会随模型搬上设备)
cos, sin = compute_rope_params(
head_dim=cfg["emb_dim"] // cfg["n_heads"],
theta_base=cfg["rope_base"],
context_length=cfg["context_length"],
freq_config=cfg["rope_freq"])
self.register_buffer("cos", cos, persistent=False)
self.register_buffer("sin", sin, persistent=False)
self.cfg = cfg
def forward(self, in_idx):
tok_embeds = self.tok_emb(in_idx)
x = tok_embeds # 只有 token 嵌入,没有绝对位置嵌入
num_tokens = x.shape[1]
# 因果掩码:上三角为 True,注意力里把 True 的地方填成 -inf
mask = torch.triu(torch.ones(num_tokens, num_tokens, device=x.device, dtype=torch.bool), diagonal=1)
for block in self.trf_blocks:
x = block(x, mask, self.cos, self.sin)
x = self.final_norm(x)
logits = self.out_head(x.to(self.cfg["dtype"]))
return logits数一下参数:Llama 3.2 1B 的模型总参数约 14.98 亿(1,498,482,688),但因为做了权重绑定(weight tying)——把 token 嵌入矩阵复用为输出层——真正独立的参数是 12.36 亿(1,235,814,400),这也是"1B"这个名字的来历。
顺带提一句性能:原 notebook 给了几个加速技巧,都能直接换用。
Llama3ModelFast把自注意力换成 PyTorch 的scaled_dot_product(在较新的 GPU 上走 FlashAttention);torch.compile最多能提速约 4 倍(但有几分钟的编译成本);换成 KV Cache 版本在 CPU 上提速尤其明显。
11.6 加载 Meta 的预训练权重
架构对了还不够——要让模型真的会说话,得把 Meta 训练好的权重装进去。这套官方权重是"别人训练的",键名和我们手写模型的对不上,所以要写一个映射函数,把官方权重的每一块"对号入座"。
先看元数据:官方 Llama 3.2 权重用的是 model.layers.{l}.self_attn.q_proj.weight 这种键名,而我们的模型里叫 trf_blocks[l].att.W_query.weight。映射函数做的就是这种翻译工作:
def assign(left, right, tensor_name="unknown"):
if left.shape != right.shape:
raise ValueError(f"Shape mismatch in tensor '{tensor_name}'. Left: {left.shape}, Right: {right.shape}")
with torch.no_grad():
if isinstance(right, torch.Tensor):
left.copy_(right) # 原地拷贝,保持参数对象不变
else:
left.copy_(torch.as_tensor(right, dtype=left.dtype, device=left.device))
return left
def load_weights_into_llama(model, param_config, params):
# 1) 词嵌入
model.tok_emb.weight = assign(model.tok_emb.weight, params["model.embed_tokens.weight"])
for l in range(param_config["n_layers"]):
# 2) 注意力四件套:q/k/v/o 投影
model.trf_blocks[l].att.W_query.weight = assign(
model.trf_blocks[l].att.W_query.weight, params[f"model.layers.{l}.self_attn.q_proj.weight"])
model.trf_blocks[l].att.W_key.weight = assign(
model.trf_blocks[l].att.W_key.weight, params[f"model.layers.{l}.self_attn.k_proj.weight"])
model.trf_blocks[l].att.W_value.weight = assign(
model.trf_blocks[l].att.W_value.weight, params[f"model.layers.{l}.self_attn.v_proj.weight"])
model.trf_blocks[l].att.out_proj.weight = assign(
model.trf_blocks[l].att.out_proj.weight, params[f"model.layers.{l}.self_attn.o_proj.weight"])
# 3) 注意力前的 RMSNorm
model.trf_blocks[l].norm1.weight = assign(
model.trf_blocks[l].norm1.weight, params[f"model.layers.{l}.input_layernorm.weight"])
# 4) SwiGLU 三条线:gate / up / down
model.trf_blocks[l].ff.fc1.weight = assign(
model.trf_blocks[l].ff.fc1.weight, params[f"model.layers.{l}.mlp.gate_proj.weight"])
model.trf_blocks[l].ff.fc2.weight = assign(
model.trf_blocks[l].ff.fc2.weight, params[f"model.layers.{l}.mlp.up_proj.weight"])
model.trf_blocks[l].ff.fc3.weight = assign(
model.trf_blocks[l].ff.fc3.weight, params[f"model.layers.{l}.mlp.down_proj.weight"])
# 5) 前馈前的 RMSNorm
model.trf_blocks[l].norm2.weight = assign(
model.trf_blocks[l].norm2.weight, params[f"model.layers.{l}.post_attention_layernorm.weight"])
# 6) 收尾的 RMSNorm
model.final_norm.weight = assign(model.final_norm.weight, params["model.norm.weight"])
# 7) 输出层:有 lm_head 就用它,否则复用词嵌入(权重绑定)
if "lm_head.weight" in params.keys():
model.out_head.weight = assign(model.out_head.weight, params["lm_head.weight"])
else:
model.out_head.weight = model.tok_emb.weight几个值得注意的点:
assign用copy_而不是直接赋值:保持参数对象本身不变,只改写数值,这样后面model.to(device)才管用。- SwiGLU 的三条线和官方的三个名字一一对应:
fc1↔gate_proj、fc2↔up_proj、fc3↔down_proj。如果对错了,模型不是报错就是输出乱码。 - 权重绑定:Llama 3.2 1B/3B 没有单独的
lm_head,直接复用 token 词嵌入。原 notebook 还专门验证了一把——model.tok_emb.weight.data_ptr() == model.out_head.weight.data_ptr()为True,说明两者共享同一块内存。
装上权重、接上分词器,跑一句 "What do llamas eat?",手写的 Llama 3.2 1B 就能答出通顺的英文了(原 notebook 在 A100 上出了:"Llamas are herbivores, which means they primarily eat plants...")。
一个必须知道的细节:加载 Llama 2 权重时,官方存的 Q、K 用的是"旋转对相邻"的排布,而我们的 RoPE 用"前后两半"的排布。直接搬会算错,所以 Llama 2 的加载函数里有一个
permute(原 notebook 里叫permute,注释里也提到slices_to_interleave这类辅助),专门把维度重排一遍。这也是 11.3 节提醒过的"两种等价写法不能混用"的实战体现。到了 Llama 3,transformers习惯的排布和我们的实现对齐了,这一步就不再需要。
11.7 现代架构巡礼
到这里,你已经掌握了"GPT → Llama"的全部改造。但今天开源模型的世界远不止 Llama。好消息是:它们绝大多数都是在这套骨架(RMSNorm + RoPE + SwiGLU + GQA)上做加减法。 下面按原仓库 bonus 材料的顺序,把几个代表模型过一遍。每讲一个,我都会说清楚它"和 Llama 像在哪、不一样在哪"。
Qwen3:Dense 与 MoE 两开花
Qwen3 的原仓库实现了 0.6B、1.7B、4B、8B、32B 这几个 Dense(稠密)版本,以及一个 30B-A3B 的 MoE(混合专家) 版本,还覆盖了 Thinking、Instruct、Coder 等不同取向的变体。
最值得点赞的一点是:Qwen3Model 的写法刻意和 GPTModel 保持一致,可以当作"即插即用"的替代品,直接拿去跑第 5 章的预训练、第 6/7 章的微调。也就是说,前面章节里你写的训练循环,换一个模型类就能继续用。
- Dense 版:0.6B 是最小的那个,跑 150 个 token 的生成大约只要 1.5 GB 显存,非常适合本地练手。
- MoE 版(30B-A3B):名字里的
30B-A3B是社区惯例——总参数约 30B,但每个 token 只激活约 3B。这正是第 10 章讲的 MoE 思路:用多个"专家"替换单个前馈层,路由器只把每个 token 交给少数几个专家,于是"装得下更多知识,却不用全量计算"。仓库里 MoE 版本单独有 notebook,也有带 KV Cache 的版本。
一句话对比 Llama:Qwen3 的骨架和 Llama 3 高度相似(RMSNorm、RoPE、SwiGLU、GQA 都在),区别主要在规模选择、分词器,以及是否采用 MoE。
Gemma 3:把"小"做到极致
Gemma 是 Google 的开源模型系列。原仓库这里实现的是 Gemma 3 270M——一个只有 2.7 亿参数的小模型,跑起来大约只需要 2 GB 内存。
它的特点是"麻雀虽小,五脏俱全":架构上同样是现代解码器的那一套,仓库还给了带 KV Cache 的版本。原 notebook 特意把 Gemma 3 270M 和 Qwen3 0.6B 摆在一起做了张对照图,方便你看清两者架构差异。
一句话对比 Llama:Gemma 3 270M 的意义不在于"多强",而在于它小到能在普通机器上跑完整推理,是理解现代架构的绝佳样本。
Olmo 3:有些"复古"的选择
Olmo 3 来自 Allen Institute for AI,原仓库实现了 7B 和 32B 两个版本(跑起来大约需要 13 GB 内存)。它在架构上做出了几个和主流不太一样的选择:
- 用 post-norm(后置归一化)而不是 pre-norm(前置归一化)。我们前面写的模型都是 pre-norm(归一化放在子模块之前)。Olmo 团队在 Olmo 2 的论文里发现,post-norm 能让训练更稳定,于是 Olmo 3 沿用了这个做法。
- 7B 版本还在用多头注意力(MHA),和 Olmo 2 一样;不过为了更高效、顺便缩小 KV Cache,它用了滑窗注意力(sliding-window attention)(思路类似 Gemma 3)。
- 32B 版本才换成 GQA,其他部分基本是把 7B"放大"而来,各种比例(比如前馈中间维度的扩张倍数)也和 Qwen3 大致对齐。
Olmo 3 也有多个"口味"(base / instruct 等),它们架构完全相同,区别只在训练流水线。
一句话对比 Llama:Olmo 3 最有意思的地方,是它证明了"主流通用的那套不是唯一解"——post-norm、7B 继续用 MHA,都是有意为之的工程取舍。
Tiny Aya:主打多语言的 3B 小钢炮
Tiny Aya 是 Cohere 推出的一个"小"模型,3.35B 参数,官方称其为 3B 量级里"多语言能力最强的开源权重模型"(据其发布公告,它在多语言上超过了 Qwen3-4B、Gemma 3 4B 和 Ministral 3 3B)。它非常适合本地实验,唯一要注意的是许可证较严格,只允许非商业用途。
它提供多个按地区优化的版本:base、global(默认)、fire(南亚语言)、water(欧洲与亚太语言)、earth(西亚与非洲语言)。架构上,它是一个经典的解码器 Transformer,除了 SwiGLU 和 GQA 这些"标配"之外,还有三个值得一提的改动:
- 并行 transformer 块(parallel transformer block):注意力和 MLP 从同一份归一化后的输入并行计算,最后一并加回残差。这样能减少层内的串行依赖,提升计算吞吐。
- 滑窗注意力:采用 3:1 的"局部:全局"比例(和 Arcee Trinity、Olmo 3 类似),窗口大小为 4096。而且和 Arcee 一样——滑窗层用 RoPE,全注意力层用 NoPE(不加位置编码)。
- LayerNorm(不是 RMSNorm):大多数模型都转向了更省算力的 RMSNorm,Tiny Aya 却保留了 LayerNorm 的一个改良版——实现上相当于标准 LayerNorm 去掉偏移(bias)参数。
一句话对比 Llama:Tiny Aya 证明了"标配之外还有空间"——并行块、滑窗与 NoPE 混用、退回 LayerNorm,都是它针对多语言和效率做的取舍。
Qwen3.5:把"线性注意力"混进来
Qwen3.5(原仓库实现的是 0.8B)基于 Qwen3-Next 架构。它最核心的特点,是把两类注意力交替堆叠:
linear_attention(线性注意力)层full_attention(全注意力)层
也就是说,它不再是"每层都用标准注意力",而是线性层和全注意力层混合出现。这种"混合"是第 9 章讲注意力变体时的一个大方向——用一部分更省算力的层替换标准注意力,在大幅降低长上下文成本的同时,尽量保住效果。仓库里的 notebook 为保持可读性,复用了 Hugging Face 的线性注意力组件(Apache 2.0 许可)。
一句话对比 Llama:Llama 3 每层都是标准(GQA)注意力;Qwen3.5 则把标准注意力"掺着"线性注意力用,是更长上下文、更低成本的方向。
Gemma 4:Dense 的延续
原仓库里的 Gemma 4 notebook,是以 Gemma 3 的参考 notebook 为底板改出来的一个纯文本实现,适配 google/gemma-4-E2B 和 google/gemma-4-E4B 两个稠密(dense)checkpoint。同一个 standalone-gemma4.ipynb 里实现了二者共享的 Gemma 4 稠密架构,通过 CHOOSE_MODEL 在两个配置间切换。
一句话对比 Llama:Gemma 4 延续了 Google Dense 路线,是对 Gemma 3 的自然升级;
E2B/E4B是同一套架构的两个规格。
把巡礼收个尾
把这些模型排一排,你会发现一个清晰的规律:
| 模型 | 规模 | 骨架特点 |
|---|---|---|
| Llama 3.2 | 1B / 3B | RMSNorm + RoPE + SwiGLU + GQA(本章主角) |
| Qwen3 | 0.6B~32B(Dense)+ 30B-A3B(MoE) | 类 Llama 骨架,含 MoE 版本,可即插即用 |
| Gemma 3 | 270M | 现代解码器架构,主打小而全 |
| Olmo 3 | 7B / 32B | post-norm;7B 用 MHA + 滑窗,32B 用 GQA |
| Tiny Aya | 3.35B | 并行块;3:1 滑窗;LayerNorm(无 bias) |
| Qwen3.5 | 0.8B | 线性注意力与全注意力交替(Qwen3-Next) |
| Gemma 4 | E2B / E4B | Dense,Gemma 3 的延续 |
它们中没有一个推翻了 Transformer,全都是在这套骨架上做加减法。 你能读懂 Llama 3.2,就基本能读懂今天绝大多数的开源大模型。
关键概念
- RMSNorm(均方根归一化):去掉"减均值"这一步,只用均方根缩放激活;比 LayerNorm 更省算力,效果几乎不降。Llama 全系使用。
- SiLU / Swish:
x · σ(x)形式的平滑激活函数,Llama 用它替换 GELU。 - SwiGLU:
SiLU(Linear₁(x)) * Linear₂(x)再过一个Linear₃的门控前馈网络。中间维度由hidden_dim决定,不再是固定 4 倍。 - RoPE(旋转位置编码):不给位置"加"向量,而是按位置把 query/key 做旋转;同时编码绝对位置和相对位置,是长上下文扩展的关键。
- RoPE 频率缩放(freq rescaling):Llama 3 引入的技术,对低/中/高频分量区别对待,把上下文从 8K 扩到 128K。
- GQA(分组查询注意力):Query 每个头一份,Key/Value 每若干头共用一套;省参数、省显存、推理更快。是当今主流模型的标配。
- 权重绑定(weight tying):复用 token 嵌入矩阵作为输出层。Llama 3.2 1B/3B 都这么做,所以"1B"模型的独立参数其实是约 12.36 亿。
- Llama 3.2 1B 配置:词表 128256、上下文 131072、
emb_dim2048、32 头、16 层、hidden_dim8192、8 个 KV 组、rope_base500000。 - MoE(混合专家):把前馈层换成多个"专家" + 路由器,每个 token 只激活少数专家。Qwen3 30B-A3B 即此思路(总参数约 30B、每 token 激活约 3B)。
- pre-norm / post-norm:归一化放在子模块之前(pre,主流选择)还是之后(post,Olmo 3 为训练稳定性而用)。
- 滑窗注意力(sliding-window attention):每个 token 只看附近一段而非全部历史。Olmo 3、Tiny Aya 等用它缩小 KV Cache。
动手练习
对照跑通改造:按顺序读原仓库的两个 notebook——
converting-gpt-to-llama2.ipynb(GPT→Llama 2 的四步改造)和converting-llama2-to-llama3.ipynb(Llama 2→Llama 3 的三处改动),再跑一遍standalone-llama32.ipynb,把 Llama 3.2 1B 或 3B 说出来的话复现出来。验证手写模块:像原 notebook 那样,把你手写的
RMSNorm、SiLU分别和 PyTorch 内置的torch.nn.RMSNorm、torch.nn.functional.silu做断言比对,确认输出一致。数一数 GQA 省了多少:取一组虚构的配置(比如 32 个头、
emb_dim=4096),分别按 MHA 和 GQA(8 个 KV 组)算一遍 Q/K/V 投影的参数量,感受一下 GQA 把 K/V 缩到了几分之一。换模型即插即用:把第 5 章的预训练代码里的
GPTModel换成Qwen3Model(仓库的llms-from-scratch包里有),看看训练循环需要改多少——你大概会发现,"几乎不用改"。这正说明现代 LLM 共享同一套骨架。看一眼别的架构:随便挑一个本节的巡礼对象(Qwen3、Gemma 3、Olmo 3、Tiny Aya、Qwen3.5、Gemma 4),读它的 standalone notebook,找出它的架构和你手写的 Llama 3.2 到底差在哪几行代码。