4. GPT 模型:一层一层看
分词器把文本变成整数,数据加载器把它们排成 (B, T) 的矩阵。现在进入模型:这些整数怎样变成对下一个词元的预测?
nanochat 的模型是一个只包含解码器的 Transformer(decoder-only Transformer),代码集中在 nanochat/gpt.py。它没有引入复杂的模型工厂,所有结构都写在一个文件里。
nanochat/gpt.py第 28–39 行↗先记住四个形状
阅读模型代码时,最容易被张量形状打断。先记住这四个:
| 符号 | 形状 | 含义 |
|---|---|---|
B | 批量大小 | 一次处理多少条样本 |
T | 序列长度 | 每条样本有多少个词元 |
C | 隐藏维度 | 每个词元被表示成多长的向量 |
V | 词表大小 | 模型可以输出多少种词元 |
输入是 (B, T) 的整数矩阵,经过词嵌入后变成 (B, T, C),输出层再变成 (B, T, V)。B 和 T 基本不变,变化的是最后一个维度。
模型配置由“深度”决定
nanochat 刻意把模型复杂度压缩成一个旋钮:--depth。
scripts/base_train.py第 129–143 行↗训练脚本使用下面的规则:
基础宽度 = depth × 64
隐藏维度 = 向上取整到 head_dim 的整数倍
注意力头数 = 隐藏维度 ÷ head_dim2
3
默认 head_dim = 128。例如:
depth=12:基础宽度 768,隐藏维度 768,注意力头数 6。depth=20:基础宽度 1280,隐藏维度 1280,注意力头数 10。depth=24:基础宽度 1536,隐藏维度 1536,注意力头数 12。
如果宽度不能整除 head_dim,代码会向上补齐。这样每个头的维度都恰好是 128,方便 Flash Attention 等内核处理。
模型配置里还有:
| 参数 | 默认含义 |
|---|---|
sequence_len | 最大上下文长度 |
vocab_size | 词表大小,必须和分词器一致 |
n_layer | Transformer 块的数量,也就是深度 |
n_head | 查询头的数量 |
n_kv_head | 键和值的头数。nanochat 支持 GQA,但默认训练把头数设为和查询头相同 |
n_embd | 隐藏维度 |
window_pattern | 滑动窗口模式,例如 SSSL |
整体数据流
词元编号 (B, T)
→ 词嵌入 (B, T, C)
→ RMSNorm
→ N 个 Transformer 块
→ 最终 RMSNorm
→ lm_head
→ logits (B, T, V)
→ softmax 或交叉熵2
3
4
5
6
7
8
词嵌入和位置信息
wte 是一个查找表:输入一个词元编号,取出对应的一行向量。
词元 id 12345 → 向量 [0.12, -0.07, ...] (长度 C)nanochat 没有单独的位置嵌入表。词元之间的顺序信息由**旋转位置编码(RoPE)**在注意力内部注入。RoPE 会对 Query 和 Key 的向量做旋转,使点积结果带有相对位置信息。
这也是为什么推理时可以用键值缓存:位置信息编码在 K 的值里,不需要额外查一张位置表。
Transformer 块里发生了什么
每个块包含两个子层:
x = x + 注意力(RMSNorm(x))
x = x + 多层感知机(RMSNorm(x))2
nanochat/gpt.py第 144–153 行↗注意力子层
注意力负责让当前位置读取其他位置的信息。它会生成三组向量:
- Query:当前位置想找什么。
- Key:每个位置有什么标签。
- Value:每个位置实际携带的内容。
这些向量经过注意力权重加权后,回到残差流。具体计算在下一章展开。
多层感知机子层
MLP 对每个位置独立做变换:
C → 4C → 4C → C中间使用 ReLU 的平方:
x = F.relu(x).square()它比普通 ReLU 更平滑,也更适合这个模型的训练配方。这个子层不混合不同位置的信息,位置之间的信息交换全部由注意力完成。
残差连接
两个子层都采用:
输出 = 输入 + 子层输出这就是残差连接。它让梯度和信息可以沿着一条直通路径传播,深层模型更容易训练。
nanochat 在标准结构上加了什么
这些改动不影响“GPT”这个基本判断,但在阅读代码时会遇到,所以先认识它们。
| 结构 | 位置 | 一句话作用 |
|---|---|---|
| RMSNorm | 每个子层前、模型最后 | 把向量尺度稳定在可训练范围;nanochat 不使用可学习缩放参数 |
| RoPE | 注意力内部 | 注入词元之间的相对位置 |
| QK Norm | 注意力内部 | 对 Query 和 Key 做归一化,稳定注意力分数 |
| 滑动窗口 | 部分层 | 只允许回看固定长度的上下文,降低长序列计算量 |
| 值嵌入 | 每隔一层 | 把当前词元的嵌入直接加到 Value 上,给注意力补充词元身份信息 |
| x0 混合 | 每个块前 | 把最初归一化后的词嵌入按比例加回深层表示 |
| Smear | 进入 Transformer 前 | 把前一个位置的向量混入当前位置,提供廉价的前文信息 |
| Backout | 最终归一化前 | 减去中间层残差,减少低层特征对输出层的干扰 |
| Logit softcap | 输出层后 | 用 tanh 平滑限制分数范围,避免 logits 过大 |
| 不共享词嵌入 | 输出层 | 输入嵌入和输出投影是两套参数 |
这些技巧的细节可以在源码中逐个查,但第一遍不需要全部背下来。先抓住主结构:词嵌入 → N 个“注意力 + 多层感知机”块 → 输出层。
为什么词嵌入和输出层不共享参数
词嵌入矩阵负责“编号 → 向量”,输出层负责“向量 → 每个词元的分数”。两者形状相似,但承担的任务不同。
共享参数可以减少参数量,也可能改善小模型训练;nanochat 选择不共享,让输入和输出各自有独立的表达能力。代码中 lm_head 是单独的 Linear,没有和 wte 绑定。
输出层和词表填充
输出层把每个位置的 C 维向量投影成 V 维分数:
(B, T, C) → (B, T, V)nanochat 会把词表大小向上填充到 64 的倍数,以提升 GPU 计算效率。模型输出后再切回真实词表大小,所以填充不会出现在最终结果里。
训练时,这些分数会和正确词元计算交叉熵;推理时,只取最后一个位置的分数来选下一个词元。
参数量从哪里来
粗略地看,每个 Transformer 块的参数量主要来自:
- 注意力的 Query、Key、Value 和输出投影。
- 多层感知机的两个线性层。
- 部分层的值嵌入。
深度增加会同时增加宽度和层数,所以参数量增长很快。depth 每增加一点,显存、训练时间和数据需求都会一起上升。nanochat 用缩放规律自动调整其他超参数,目的就是让不同深度的模型都尽量处在合适的训练配置上。
这也解释了为什么“把 depth 从 12 改成 24”不是简单地把训练时间翻倍:模型宽度、批量大小、训练词元数和学习率都会随配置变化。
亲手检查模型结构
不需要训练,就可以创建一个很小的模型并打印结构:
import torch
from nanochat.gpt import GPT, GPTConfig
config = GPTConfig(
sequence_len=128,
vocab_size=32768,
n_layer=2,
n_head=4,
n_kv_head=4,
n_embd=256,
window_pattern="L",
)
with torch.device("meta"):
model = GPT(config)
print(model)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
这段代码只创建形状,不分配真实内存。你会看到 wte、多个 Block、lm_head,以及每个块中的 attn 和 mlp。
本章要记住的三件事
- 输入是词元编号,输出是每个词元的分数;中间始终是
(B, T, C)的向量序列。 - Transformer 块由“注意力 + 多层感知机”组成,两个子层都有残差连接。
- nanochat 通过
depth自动推导宽度和头数,并在标准结构上加入多项稳定训练的技巧。
下一章专门拆开注意力:注意力。