差别都在工程上:对照原论文和 nanoGPT,再看 GPT 怎么变成 ChatGPT
第二十五篇结尾:写到这里的模型,结构上已经是 GPT-2。这一篇没有新代码要写,只做三件事: 对照原论文,看它是哪一半;走读 nanoGPT,看工业级实现差在哪;再看 ChatGPT 是怎么从这样一个模型长出来的。
先说结论:结构上几乎没差,差别都在工程层面。
对照原论文:写出来的是哪一半
2017 年的原版 Transformer 是个翻译模型,由两半组成:
编码器(左半) 解码器(右半)自注意力(无 mask) 自注意力(有 mask) ← 写了Add & Norm Add & Norm ← 写了 k, v ───────→ 交叉注意力(q 来自解码器) ← 没有 Add & Norm前馈网络 FFN 前馈网络 FFN ← 写了Add & Norm Add & Norm ← 写了 × N 层,读中文 × N 层,逐词生成英文第二十五篇写的 = 右半边去掉交叉注意力,GPT 管这叫 decoder-only。任务是「续写」,没有一段原文需要先读进来、 再对照着生成。只有把一种东西转成另一种东西时(中文 → 英文、图片 → 描述),才需要编码器 + 交叉注意力 (第二十四篇注解 ⑤)。
论文图上的 Add & Norm 是先加残差再归一化,也就是 Post-Norm:x = LN(x + f(x));第二十五篇写的是 GPT-2 起的
Pre-Norm:x = x + f(LN(x))。对着论文的图看代码,LayerNorm 的位置不一样不是写错了,是后来改进的写法。
💡 多模态模型(比如 Qwen2.5-VL)两半都有:视觉部分是编码器(ViT,无 mask),语言部分是解码器(有 mask)。 两者之间怎么连 —— 交叉注意力,还是把图像 token 直接拼进文字序列 —— 是多模态设计的核心问题之一。
多头合并:c_attn、view、transpose
这是 nanoGPT 跟第二十五篇最大的区别,也是以后读 Llama、Qwen 源码第一眼就会看到的写法(GQA 也是在它上面改的)。
两种写法
第二十五篇:6 个 Head 对象,依次各算一遍,最后拼接。
self.heads = nn.ModuleList([Head(head_size) for _ in range(num_heads)])out = torch.cat([h(x) for h in self.heads], dim=-1)nanoGPT 的 CausalSelfAttention.forward(手写路径,摘录):
B, T, C = x.size()q, k, v = self.c_attn(x).split(self.n_embd, dim=2) # c_attn = Linear(C, 3C)k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1)))att = att.masked_fill(self.bias[:,:,:T,:T] == 0, float('-inf'))att = F.softmax(att, dim=-1)att = self.attn_dropout(att)y = att @ v # (B, nh, T, hs)y = y.transpose(1, 2).contiguous().view(B, T, C) # = torch.caty = self.resid_dropout(self.c_proj(y)) # = proj + Dropout数学上完全等价(乘加次数一样、结果一样),区别只在「发给 GPU 几次」:第二十五篇每层约 50 次小计算 (6 头 × 3 次 Linear,再加各自的注意力),nanoGPT 约 10 次大计算。GPU 最擅长少量大计算 —— 这也是实验 D 在 Mac 上慢的原因之一。
手算一遍:T = 2、C = 4、2 个头、每头 2 维
省略 batch 维。
① c_attn:权重就是各头的小矩阵并排放 W_attn = [Wq₀ | Wq₁ | Wk₀ | Wk₁ | Wv₀ | Wv₁](4 × 12),
一次矩阵乘法:
x @ W_attn =t0: [ 2, 0, 0, 2, 1, 3, 1, 2, 3, 0, 1, 2 ]t1: [ 2, 1, 1, 2, 0, 3, 3, 1, 1, 1, 2, 2 ] └q₀┘ └q₁┘ └k₀┘ └k₁┘ └v₀┘ └v₁┘② split:每 4 列切一刀 → q、k、v 各 (T, C) = (2, 4)。切的是 q/k/v 三块,不是按头切 ——
q = [[2,0 | 0,2], [2,1 | 1,2]],左两列是头 0、右两列是头 1,每个头的 q 已经是一个完整的列块。
③ view(T, 2, 2):数字一个不动,只是把每行 4 个数看成「2 个头 × 每头 2 维」,得到一张「位置 × 头」的表:
| view 后 | 头 0 | 头 1 |
|---|---|---|
| t0 | [2, 0] | [0, 2] |
| t1 | [2, 1] | [1, 2] |
④ transpose(1, 2):把这张表行列互换,每个格子里的小向量整体搬动、内部不变:
| transpose 后 | t0 | t1 |
|---|---|---|
| 头 0 | [2, 0] | [2, 1] |
| 头 1 | [0, 2] | [1, 2] |
头 0 这一块 [[2,0],[2,1]] 就是 q₀,头 1 [[0,2],[1,2]] 就是 q₁ —— 跟分开算的一模一样。k、v 同理。
写成一句下标公式:
q_heads[b, h, t, d] = q[b, t, h·hs + d]等价于 torch.stack(q.split(hs, dim=-1), dim=1):把列块剪开、叠成一摞。
⑤ 两个头的注意力一次算完(缩放 1/√2、下三角 mask):
头 0:att = [[1.00, 0 ], y₀ = att @ v₀ = [[3.00, 0.00], [0.80, 0.20]] [2.61, 0.20]]头 1:att = [[1.00, 0 ], y₁ = att @ v₁ = [[1.00, 2.00], [0.50, 0.50]] [1.50, 2.00]](头 0 的 t1 行:q = [2,1] 对 k₀ = [1,3]、[0,3] 的点积是 5 和 3,除以 √2 再 softmax 得 [0.80, 0.20];
头 1 的两个点积都是 5,所以各一半。)
⑥ transpose 回来 + contiguous + view:
[[3.00, 0.00, 1.00, 2.00], [2.61, 0.20, 1.50, 2.00]] = torch.cat([y₀, y₁], dim=-1) ✅★ 为什么必须把「头」换到前面
PyTorch 的批量矩阵乘法只在最后两维上做,前面的维度都当成「一摞纸」各算各的。 维度顺序决定了每张纸上写的是什么:
| q 的形状 | 每张纸上是 | q @ kᵀ 算出来的 |
|---|---|---|
view 后 (T, 头, hs) | 一个位置上的所有头 | ❌ 头 × 头:t0 处 [[2,0],[0,2]] @ [[1,3],[1,2]]ᵀ = [[2,2],[6,4]] —— 头 0 的 q 跟头 1 的 k 比,而且 t0 根本没看 t1 |
transpose 后 (头, T, hs) | 一个头的所有位置 | ✅ 位置 × 位置:头 0 得 [[2, ·], [5, 3]],正是注意力要的打分 |
view 只能拆分或合并维度,不能调换顺序(c_attn 的输出在内存里是一个位置接一个位置存的),
所以必须用 transpose 把头换到前面。
两个容易有的误解
「split 之后每个头的 q 已经在一起了,为什么还要变形」 —— 前半句对,左两列就是头 0 的完整矩阵。 问题在于几个头的列块左右并排在同一行里,点积会把整行所有列加在一起:
t1 的 q · t0 的 k = [2, 1, 1, 2] · [1, 3, 1, 2] = (2 + 3) + (1 + 4) = 5 + 5 = 10 └ 头 0 ┘ └ 头 1 ┘两个头的分数被加成了一个,退化成单头。transpose 就是把并排的列块剪开、叠成一摞的那把剪刀。
「transpose(1, 2) 是把 q 矩阵转置」 —— 不是。它交换的是「位置」和「头」两个维度,每个头内部的 (T, hs)
矩阵原封不动;q、k、v 仍是三个独立张量,靠「第 h 张 = 头 h」对齐,矩阵乘法按编号一张对一张。
「头」到底是什么
头 = 一组维度,它们一起算点积、单独做一次 softmax,得到自己的一种关注方式。 同样 4 个数:
不分组(单头,缩放 1/√4),t1 的分配是 [0.73, 0.27];分成 2 组,得到头 0 的 [0.80, 0.20] 和头 1 的
[0.50, 0.50] 两种。view 决定了怎么分组,训练让每组学出不同的规律。
小例子里 T、头数、hs 碰巧都是 2,transpose 前后的形状数字看不出区别,换成 T = 3 就清楚了:
| 步骤 | 小例子 T = 2 | 改成 T = 3 | 实验 D |
|---|---|---|---|
| split 后的 q | (2, 4) | (3, 4) | (64, 256, 384) |
| view 后 (T, 头, hs) | (2, 2, 2) | (3, 2, 2) | (64, 256, 6, 64) |
| transpose 后 (头, T, hs) | (2, 2, 2) | (2, 3, 2) | (64, 6, 256, 64) |
| att (头, T, T) | (2, 2, 2) | (2, 3, 3) | (64, 6, 256, 256) |
为什么要 .contiguous()
transpose 只改「怎么读」(stride),不搬数据(第十篇讲过 view 和 stride)。上面那个 y(头, T, hs)在内存里是
[3, 0, 2.61, 0.20 | 1, 2, 1.50, 2](先头 0 后头 1);view(T, 4) 需要的是 [3, 0, 1, 2 | 2.61, 0.20, 1.50, 2]
(先 t0 后 t1)。所以先 .contiguous() 按新顺序拷贝一份;不加直接 view 会报错,或者换成会在需要时自动拷贝的 .reshape()。
验证:两种写法结果完全一样
随机造一组 B = 2, T = 5, C = 12、3 个头的权重,分别用两种写法算:
ours = torch.cat([one_head(i) for i in range(nh)], dim=-1)W_attn = torch.cat(Wq + Wk + Wv, dim=1) # 各头的小矩阵并排 → (C, 3C)q, k, v = (x @ W_attn).split(C, dim=2)q, k, v = [t.view(B, T, nh, hs).transpose(1, 2) for t in (q, k, v)]...torch.allclose(ours, y.transpose(1, 2).contiguous().view(B, T, C), atol=1e-6) # Truey.transpose(1, 2).view(B, T, C)# RuntimeError: view size is not compatible with input tensor's size and stride ...顺带兑现的伏笔
- 第二十四篇说
k.transpose(-2, -1)用负下标是因为多头时 k 是 4 维 —— 就是这里:(B, nh, T, hs)只交换最后两维 - 第二十五篇说 GPT-2 的配置里两种 Dropout 叫
attn_pdrop/resid_pdrop——attn_dropout就是 Dropout ①,resid_dropout就是 Dropout ② - 头与头之间不通信,跟第二十四篇注解 ③「batch 之间不通信」是同一个原理;头之间的交流发生在后面的
c_proj(第二十五篇那个拼接推导里的[W_A W_B])
一个样本是 T 道题,并行等于串行
训练时的 idx 是 (B, T) 的整数表,不是 (B, T, 384):B = 64 段随机切出来、互不相关的文本,T = 256 是每段的字符数。
查嵌入表之后,每个编号才变成 384 个数。整个模型里 B、T 不变,所有计算都在改最后一维:编号 → 384 维表示 → 65 个分数。
传进去的是已知的原文,一段 T 个字符 = T 道题:
| 位置 | 能看到的 | 要猜的 |
|---|---|---|
| t0 | F | i |
| t1 | Fi | r |
| … | … | … |
| t7 | First Ci | t |
答案 y 是 x 往右挪一格,全是原文里现成的;「未知」只是对模型而言,靠 mask 挡住右边。T 道题的 loss 平均成一个
→ 一次反向 → 只更新一次参数。
串行 vs 并行:串行要把每个前缀单独喂一次,T 次前向里前面的位置被反复重算,一共 T(T+1)/2 个位置,
T = 256 时约 128 倍。Transformer 一次前向算出全部 T 个位置,而且位置 t 的结果跟单独喂前 t+1 个字符完全相同:
嵌入、LayerNorm、FFN 都是各位置各算各的,注意力被 mask 挡住了右边。拿实验 D 训好的权重验证:
idx = torch.tensor([encode('First Citizen:')]) # (1, 14)full = model(idx) # 并行:一次前向,14 个位置全出来for t in range(14): part = model(idx[:, :t+1]) # 串行:只喂前 t+1 个字符 torch.allclose(full[0, t], part[0, -1], atol=1e-5) # 14 个全是 True这是 Transformer 取代 RNN 的关键:RNN 必须按顺序算,训练时没法在 T 这一维上并行。
反过来,生成时下一个字符真的未知,只能串行;第二十五篇的 generate() 每一步把整个序列重喂一遍,就是串行重算 ——
真实推理用 KV Cache 存下前面位置的 k、v,只算新位置。能这样做,正是因为前面的位置不受后面影响。
走读 nanoGPT 的 model.py
karpathy/nanoGPT 的 model.py 约 330 行,分四轮读:
| 轮 | 内容 | 跟第二十五篇的关系 |
|---|---|---|
| 1 | LayerNorm、CausalSelfAttention、MLP、Block | 结构完全一样;多了多头合并、GELU、Flash Attention |
| 2 | GPTConfig、组装、初始化、forward | 多了权重共享、GPT-2 初始化、ignore_index、推理优化 |
| 3 | crop_block_size、generate | 多了温度、top-k |
| 4 | from_pretrained、configure_optimizers、estimate_mfu | 全是工程 |
源码里的新东西按三级分:
| 分级 | 知识点 | 之后在哪再遇到 |
|---|---|---|
| 现在掌握 | Block 结构跟自己写的一样;多头合并;温度 | 多头合并是读 Llama、Qwen、GQA 的前提 |
| 知道有这回事 | Flash Attention、词表补到 64 的倍数、fused AdamW、@dataclass、ModuleDict、crop_block_size、top-k | 「学习式位置编码不能扩展」到 RoPE 正式讲;Flash Attention 以后就是一个开关 |
| 以后再深入 | from_pretrained、ignore_index、权重衰减分组、MFU / 6N 估算、权重共享与 GPT-2 初始化 | 加载 Qwen、微调只算回答部分的 loss、LoRA 省显存、租 GPU 前估算时间和费用 |
第 1 轮:零件
- 自己包的 LayerNorm:只为了能关掉 β(当时
nn.LayerNorm不支持bias=False),计算交给F.layer_norm。weight= γ 初始全 1、bias= β 初始全 0,跟第十六篇手写 BN 的bngain/bnbias一样 - Flash Attention:一行
F.scaled_dot_product_attention(q, k, v, ..., is_causal=True)替代手写的五行;is_causal替代 mask。它不把完整的(B, 头, T, T)矩阵存下来(实验 D 里每层64 × 6 × 256 × 256≈ 2500 万个数), 省显存、更快,T 越长优势越大 - MLP = FeedForward:
c_fc(C→4C)→ GELU(更平滑的 ReLU,负数压成很小的负数而不是截成 0)→c_proj(4C→C)→ dropout。c_前缀来自 OpenAI 原始代码的 Conv1D - Block 一字不差:
x = x + self.attn(self.ln_1(x))、x = x + self.mlp(self.ln_2(x)),只有变量名不同
★ 修正:q 的偏置不是白加的
GPT-2 的 c_attn 带偏置。第二十四篇说「偏置会给同一行所有分数加同一个常数,加了等于没加」—— 只对了一半
(那句已经改了)。把带偏置的打分展开:
(xₜW_q + b_q) · (xₛW_k + b_k) = xₜW_q · xₛW_k + xₜW_q · b_k + b_q · xₛW_k + b_q · b_k看第 t 行(位置 t 去看所有的 s),softmax 是在 s 上做的:
| 项 | 在这一行里 | softmax 之后 |
|---|---|---|
xₜW_q · b_k | 对所有 s 一样 | 被抵消 → k 的偏置确实白加 |
b_q · b_k | 常数 | 被抵消 |
b_q · xₛW_k | 随 s 变化 | 不会被抵消 |
q 的偏置给每个被看的位置 s 加了一个「不管谁来看、这个位置本身值得被看多少」的分数。 用随机权重验证:
只加 k 的偏置,softmax 后的整行不变;只加 q 的偏置,整行变了。(v 的偏置等价于在输出上加一个固定向量,
能被后面 proj 的偏置吸收。)Karpathy 视频里 bias=False 是一种简化,nanoGPT 的训练脚本默认也关掉(注释说略好且更快)——
关掉没问题,但理由不是「完全没用」。
第 2 轮:组装
-
@dataclass GPTConfig:Python 的数据类,默认值就是 GPT-2 small(12 层、12 头、768 维、上下文 1024)。 实验 D 就是GPTConfig(block_size=256, vocab_size=65, n_layer=6, n_head=6, n_embd=384, dropout=0.2)。 GPT-2 的词表 50257 补到 50304 = 64 × 786,GPU 对 64 的倍数更快 -
ModuleDict 让参数名带上
transformer.前缀(transformer.h.0.attn.c_attn.weight),跟 Hugging Face 上 GPT-2 权重文件里的名字一模一样 —— 为加载权重做准备。还多了第 4 处 Dropout:drop(tok_emb + pos_emb)(GPT-2 里叫embd_pdrop) -
权重共享
self.transformer.wte.weight = self.lm_head.weight:token 嵌入表是 (词表, C),lm_head的权重按nn.Linear的存法是 (输出, 输入) = (词表, C),形状一样,让两个名字指向同一个张量。它被用了两次,反向时两处梯度相加 (第四篇的+=)。直觉:logits[j] = x · e_j,跟哪个 token 的嵌入越像,分数越高。GPT-2 靠它省了50257 × 768≈ 3860 万参数(约 31%);实验 D 词表小,只省 24,960 -
GPT-2 初始化:Linear 和 Embedding 全是
N(0, 0.02)、偏置全 0;名字以c_proj.weight结尾的(写回残差流的两个投影) 再缩小到0.02 / √(2 · n_layer)。理由就是第二十五篇那个推导:残差流一共加2 · n_layer次,独立信号相加方差相加,Var(x_L) ≈ Var(x₀) + 2·n_layer · σ² 每个分支先缩小 √(2·n_layer) 倍 → 总的加量回到 σ² 的量级从源头防止残差流膨胀,实验 D 的配置下是
0.02 / √12 ≈ 0.0058。对比 PyTorch 默认:768 维的 Linear 差不多 (约 0.021),但 Embedding 默认是N(0, 1),大了 50 倍 —— 第二十五篇的模型能训好,靠的是 LayerNorm 兜底 -
get_num_params默认减掉位置嵌入(报告模型大小的惯例),共享的参数只算一次。nanoGPT 版的实验 D 一共 10,745,088 个, 打印出来是 10.65M -
forward 的四处不同:① 显式
assert长度不超过block_size;② 只在算 loss 时临时view(-1, V)拉平; ③ignore_index=-1:答案是 −1 的位置不算 loss —— 以后微调时只学回答部分就靠它(Hugging Face 的约定是 −100); ④ 推理时只算最后一个位置self.lm_head(x[:, [-1], :]),省掉 T−1 个位置的lm_head,列表[-1]保住时间维
第 3 轮:生成
logits = logits[:, -1, :] / temperatureif top_k is not None: v, _ = torch.topk(logits, min(top_k, logits.size(-1))) logits[logits < v[:, [-1]]] = -float('Inf')温度:logits 除以 T 再 softmax。设 logits = [3.0, 1.8, 0.6, −0.3, −1.0](空格、g、逗号、s、r,T = 1 时接近实验 D
里 thou 后面的分布):
| T | 空格 | g | 逗号 | s | r |
|---|---|---|---|---|---|
| 0.5 | 0.91 | 0.08 | 0.01 | 0.00 | 0.00 |
| 1.0 | 0.69 | 0.21 | 0.06 | 0.03 | 0.01 |
| 3.0 | 0.37 | 0.25 | 0.17 | 0.12 | 0.10 |
T < 1 差距被放大、更保守;T > 1 更随机;T → 0 就是 argmax,T → ∞ 就是乱选。ChatGPT API 的 temperature 就是这一行
(nanoGPT 的 sample.py 默认 0.8)。第二十四篇注解 ⑥ 的 ÷ √dₖ,本质上也是在给注意力的 softmax 设温度。
top-k:只留分数最高的 k 个,其余设 −∞(跟 mask 同一招)。k = 2 时截断后 softmax = [0.77, 0.23, 0, 0, 0]。
v[:, [-1]] 取第 k 大的值,列表索引保持 (B, 1) 好广播。它砍掉长尾:5 万 token 的词表里几万个低概率 token 加起来
可能有几个百分点,一抽中就是乱码;65 个字符的模型基本用不上。对话模型更常用 top-p。
crop_block_size:缩短上下文只改两样 —— 位置嵌入表(保留前 block_size 行)和 mask(切左上角)。
其余所有参数一个不动,正是第二十五篇「注意力的参数跟上下文长度无关」的具体证据。反过来,学习式的位置编码只能缩、
不能扩(没见过的位置没有对应的行)→ 这就是以后要讲的 RoPE:不查表,按公式旋转 q 和 k,Llama、Qwen 都用它。
第 4 轮:工程
from_pretrained:建一个同结构的空模型 → 两边取 state_dict → 剔掉 mask(buffer 也在 state_dict 里,第二十五篇说过)
→ assert 两边个数一致 → 按名字拷贝。词表 50257、上下文 1024、有偏置都写死(配置必须跟权重对得上),只有 dropout 能改。
| 型号 | 层 | 头 | n_embd | 每头 | 参数 |
|---|---|---|---|---|---|
| gpt2 | 12 | 12 | 768 | 64 | 1.24 亿 |
| gpt2-medium | 24 | 16 | 1024 | 64 | 3.5 亿 |
| gpt2-large | 36 | 20 | 1280 | 64 | 7.74 亿 |
| gpt2-xl | 48 | 25 | 1600 | 64 | 15.58 亿 |
模型大了 12 倍,每头始终 64 —— 第二十五篇「模型越大头越多」的直接证据。
有四种权重拷贝时要转置(c_attn、两个 c_proj、c_fc):OpenAI 原始代码用 Conv1D,权重存成 (输入, 输出)、算 x @ W;
nn.Linear 存成 (输出, 输入)、算 x @ W.T。拷贝时包在 torch.no_grad() 里:原地改参数要关掉求导。
以后用 Hugging Face 的 AutoModelForCausalLM.from_pretrained(…) 就是这套流程的现成版,
报 size mismatch / missing keys 就是这里的 assert。
configure_optimizers:权重衰减 = 每步把权重往 0 拉一点(w ← w − lr·梯度 − lr·wd·w),一种正则化。
p.dim() >= 2 的(矩阵、嵌入表)衰减,偏置和 LayerNorm 的 γ、β 不衰减 —— 把 γ 往 0 拉等于削弱信号。
实验 D 用 AdamW 默认的 0.01、对所有参数一视同仁;nanoGPT 的 train.py 默认 0.1 且只衰减矩阵 ——
这是实验 D(1.4809)跟 nanoGPT(1.4697)有差距的原因之一,另一个是学习率调度。
过滤掉 requires_grad=False 的参数,是 LoRA 省显存的关键:冻结的几十亿参数不进优化器,AdamW 不用给它们存动量和方差。
★ estimate_mfu:6N 那笔账
MFU = 实际每秒计算量 ÷ 硬件峰值。每个 token 的计算量:
每个参数前向:一次乘法 + 一次加法 → 2 次运算反向:对输入求梯度、对权重求梯度,约是前向的 2 倍 → 4 次运算合计 → 每个参数 6 次,整个模型 6N
另加 q @ kᵀ 和 att @ v(不涉及参数,跟 T 成正比):12 · L · H · Q · T(N 取不含位置嵌入的参数数,L、H、Q 是层数、头数、每头维度。)拿实验 D 算一遍:
| 实验 D 的账 | 数值 |
|---|---|
| 每个 token | 6N ≈ 6414 万 + 注意力项 708 万 ≈ 7122 万次 |
| 每步(64 × 256 = 16384 个 token) | 约 1.17 TFLOP |
| Mac 上一步约 0.88 秒 | 约 1.33 TFLOPS,相对 A100 峰值 312 TFLOPS,MFU 约 0.4% |
| A100 跑 nanoGPT(5000 步约 3 分钟) | 约 32 TFLOPS,MFU 约 10%,比 Mac 快约 24 倍 |
| 总共处理的 token | 8192 万,而训练集只有 100 万字符 → 每个字符被看了约 82 遍 |
| 总计算量 | 约 5.8 × 10¹⁵ 次 ÷ 1.33 TFLOPS ≈ 73 分钟(正是测速估出来的训练部分) |
每个字符被看了约 82 遍,就是第二十五篇过拟合的根本原因。 真实的大模型预训练,每份数据通常只看一两遍, 过拟合很少是问题;微调时数据少、要看好几遍,过拟合才又要盯。以后租 GPU 之前,就用「每 token 计算量 × 总 token 数 ÷ 算力」 估时间和费用。
从 GPT 到 ChatGPT
只做过预训练的模型只会续写。 问它「什么是注意力机制?」,它可能接着写「什么是残差连接?什么是 LayerNorm?……」—— 把问题当成一份问题清单的开头,从「续写文档」的角度看完全合理。
| 实验 D | GPT-3 | 差距 | |
|---|---|---|---|
| 参数 | 1079 万 | 1750 亿 | 约 16000 倍 |
| 训练数据 | 约 30 万 token(按 BPE 算) | 约 3000 亿 token | 约 100 万倍 |
结构上几乎没有区别 —— GPT-3 就是更大的 gpt.py,区别几乎全在规模上(就是实验 D「代码不改、调大数字就大幅提升」的放大版)。 从续写到对话,OpenAI 公开的做法分三步:
- 监督微调 SFT:人工写几万条「问题 → 好回答」,还是用预测下一个 token 来训练,只是换了数据 —— 跟第二十五篇的训练循环是同一套代码
- 奖励模型:同一个问题生成几个回答,人来排序,训练一个给回答打分的模型
- 强化学习 RLHF:用奖励模型的分数继续调整语言模型 —— 优化的是「整个回答人类喜不喜欢」, 不再是下一个 token 猜得准不准
预训练花掉绝大部分算力;微调用的数据和算力少得多,但决定了好不好用。现在也有 DPO 这类更简单的替代方案。 这个系列后面要做的是第 1 步:Qwen 已经由阿里完成了预训练和对齐,拿它做监督微调 —— 不需要自己预训练, 但要理解里面的每一块,这正是从 V1 一路写到这里的意义。
V7 收尾
Karpathy 这一集到此结束。从手写自动求导引擎(第四篇)开始,到一个结构上等同 GPT-2 的模型:
| 节点 | 模型能做什么 | val |
|---|---|---|
| 第二十三篇 bigram | 只看前 1 个字符 | 2.49 |
| 第二十五篇 注意力 + FFN | 看前 8 个、通信一轮 | 2.2495 |
| 第二十五篇 实验 C | 堆 3 层、多轮通信 | 2.0613 |
| 第二十五篇 实验 D | 看前 256 个、6 层、1079 万参数 | 1.4809 |
对照 nanoGPT 之后可以确认:从零写的这个模型跟 GPT-2 在结构上几乎没有差别,差别都在工程上 —— 多头一次算完、Flash Attention、权重共享、初始化、优化器分组。
下一段不是现代架构,是分词器(第八集)—— 这份代码里那 65 个字符的词表是自己定的, 而真实模型的词表从语料统计出来,统计的对象还是字节不是字符。再往后是用真实网页数据复现 GPT-2 (124M)。
RMSNorm、RoPE、SwiGLU、GQA 要等到那之后:它们是在 124M 那份代码上改的,对照的是迷你 Qwen3, 不是接着这份莎士比亚模型往下走。换法本身不难 —— RMSNorm 是 LayerNorm 的简化, RoPE 替代位置嵌入表,GQA 就在上面那三行多头合并上改一处。