第 11 章 LLMLlamaQwen

第 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,算完再转回原来的低精度类型,省显存。

对应的公式是:

yi=xiRMS(x)γi,其中RMS(x)=ϵ+1n∑ixi2y_i = \frac{x_i}{\text{RMS}(x)} \gamma_i, \quad \text{其中}\quad \text{RMS}(x) = \sqrt{\epsilon + \frac{1}{n} \sum_i x_i^2}

原 notebook 里还做了一步验证——把我们手写的 RMSNorm 和 PyTorch 内置的 torch.nn.RMSNorm 跑同一个输入,断言两者输出完全一致,确认手写实现没写错。

这种"两套实现跑出同一个结果"的断言,是验证手写模块最省心的办法。后面 RoPE、SiLU 也都是这么验的。

11.2 改造第二步:GELU → SiLU → SwiGLU

Llama 的激活函数先从 GELU 换成了 SiLU(也叫 Swish):

silu(x)=x⋅σ(x),σ(x) 是 logistic sigmoid\text{silu}(x) = x \cdot \sigma(x), \quad \sigma(x) \text{ 是 logistic sigmoid}

实现上就是一行——把 x 乘上它自己的 sigmoid,也就是 x * torch.sigmoid(x)。

但 Llama 前馈网络用的其实不是单独的 SiLU,而是它的"门控版"——SwiGLU:

SwiGLU(x)=SiLU(Linear1(x))∗Linear2(x)\text{SwiGLU}(x) = \text{SiLU}(\text{Linear}_1(x)) * \text{Linear}_2(x)

公式的意思是:用两条线性层各算一份,第一份过 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_vec

GQA 和 MHA 的差别,集中在两处:

  1. W_key/W_value 的输出维度变小了:不再是 d_out,而是 num_kv_groups * head_dim。Llama 3.2 1B 是 32 个头、8 个 KV 组,所以 K/V 的行数只有 Q 的 1/4。
  2. 多了一步 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 这些"标配"之外,还有三个值得一提的改动:

  1. 并行 transformer 块(parallel transformer block):注意力和 MLP 从同一份归一化后的输入并行计算,最后一并加回残差。这样能减少层内的串行依赖,提升计算吞吐。
  2. 滑窗注意力:采用 3:1 的"局部:全局"比例(和 Arcee Trinity、Olmo 3 类似),窗口大小为 4096。而且和 Arcee 一样——滑窗层用 RoPE,全注意力层用 NoPE(不加位置编码)。
  3. 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_dim 2048、32 头、16 层、hidden_dim 8192、8 个 KV 组、rope_base 500000。
  • 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。

动手练习

  1. 对照跑通改造:按顺序读原仓库的两个 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 说出来的话复现出来。

  2. 验证手写模块:像原 notebook 那样,把你手写的 RMSNorm、SiLU 分别和 PyTorch 内置的 torch.nn.RMSNorm、torch.nn.functional.silu 做断言比对,确认输出一致。

  3. 数一数 GQA 省了多少:取一组虚构的配置(比如 32 个头、emb_dim=4096),分别按 MHA 和 GQA(8 个 KV 组)算一遍 Q/K/V 投影的参数量,感受一下 GQA 把 K/V 缩到了几分之一。

  4. 换模型即插即用:把第 5 章的预训练代码里的 GPTModel 换成 Qwen3Model(仓库的 llms-from-scratch 包里有),看看训练循环需要改多少——你大概会发现,"几乎不用改"。这正说明现代 LLM 共享同一套骨架。

  5. 看一眼别的架构:随便挑一个本节的巡礼对象(Qwen3、Gemma 3、Olmo 3、Tiny Aya、Qwen3.5、Gemma 4),读它的 standalone notebook,找出它的架构和你手写的 Llama 3.2 到底差在哪几行代码。