← 返回首页
08_sft_viz.html

SFT:把 base 调成会答题

白皮书讲了 SFT 的轮廓,这一关动手把它跑通 —— 直接拿 Phase 1 的 124M base 做监督微调。一句话打底:SFT 和预训练用同一个 loss,只动三处 —— 模板EOSloss mask。 配套代码 phase2-sft-lora/06_sft.py

STEP 1
起点:base 停不下来
STEP 2
拼样本:模板 + EOS
STEP 3
loss mask
STEP 4
训练循环
STEP 5
前后对比 & 达标
这一关 base 124M Phase1 训的底座 数据 16 条 玩具指令对 lr 2e-5 比预训练小30× EOS 50256 教它停

① 起点:同一个问题,base 只会跑题、停不下来

Phase 1 的 124M 是个base —— 它只会"顺着上文接着写"。你问它问题,它不答,而是把你的问题当开头继续编,直到撞上长度上限才被强行截断。 下面是 06_sft.py 训练对 base 的真实采样:

指令 → base 的"回答"(实测)
What is the capital of France?
→ "For the purposes of this article, we do not consider the capital of France as the capital of the states. In fact…" 没停 · 被长度上限截断
我们想要的样子
What is the capital of France?
→ "The capital of France is Paris." 答完 · 主动停
base 不是"笨",它只是没学过"被问就该答、答完就停"这件事。这恰恰是 SFT 要教的。 而且注意:它续写里冒出来的 "capital / France" 说明知识预训练时就有了,SFT 不灌新知识,只拧"回答的姿态"。
↳ 下一步:要教它,先得把"指令 + 理想回答"拼成一条它能学的训练样本。

② 拼一条训练样本:对话模板 + EOS

SFT 的数据是「指令 → 回答」成对的。要喂给模型,得先用一个固定模板把它俩拼成一条序列, 并在回答末尾放一个 EOS。点下面换不同样本,看它怎么被拼起来:

模板标记 标出"哪段是指令、哪段该我答" 指令 回答 EOS 答完就停
模板标记用什么文字不重要,重要的是"全程一致"。 真实 chat 模型常用 <|user|>/<|assistant|>; 06_sft.py 用的是 Alpaca 风格的纯文字模板 ### Instruction / ### Response —— 好处是不必给词表加新 token(vocab 保持 50304、权重绑定不变)。EOS 直接复用 GPT-2 的 <|endoftext|>(id 50256)。
↳ 代码:06_sft.pyPROMPT_TEMPLATE + build_example
↳ 下一步:整条都喂进去,但 loss 只能算"回答"那一段 —— 这是 SFT 唯一的新动作。

③ loss mask:整条都看,但只对"回答"算账

把拼好的序列喂进模型做前向, 每个位置都会预测"下一个 token"。但我们只对落在"回答段"的预测算 loss, 指令/模板段的标签全标成 -100 —— PyTorch 的 cross_entropy 默认 ignore_index=-100,会自动跳过它们。

灰底 × = 指令/模板,标签 -100,不算 loss 橙底 ↑ = 回答,算 loss
为什么只算回答? 我们要教的是"给定指令,该怎么答"。如果连指令段也算 loss, 模型会分心去学"怎么生成用户的指令",注意力被带偏、答题质量下降。所以指令段必须 mask 掉。 回答末尾的 EOS 也算 loss —— 这正是它学会"答完就停"的地方。
↳ 代码:06_sft.pybuild_examplelabels = [-100]*len(prompt_ids) + resp_ids
那个 "错位一格"(x = full[:-1], y = labels[1:])是怎么回事?
语言模型是"用第 i 个 token 预测第 i+1 个"。所以输入 x 取整条序列去掉最后一个, 标签 y 取整条往左挪一格。掩码也跟着挪:第一个"回答 token"由"最后一个指令 token"预测出来, 这一对要算 loss(它是"从指令跨进回答"的关键一步);再往前的、预测指令内部 token 的位置,才是 -100。 这和 Phase 1 里 x=chunk[:-1], y=chunk[1:] 的错位是同一套,只是 y 多了 -100 掩码。
↳ 下一步:有了带掩码的 (x, y),训练循环就和预训练几乎一模一样了。

④ 训练循环:就是预训练那个循环 + 一个掩码

SFT 的循环和 Phase 1 的 04_gpt2_124m.py 几乎逐字相同: zero_grad → forward(算 loss) → backward → step。唯一的差别是 forward 里的 loss 只来自回答段(靠上一步的 -100 掩码)。点"跑一个 epoch"看回答段 loss 怎么掉:

zero_grad梯度清零
forward算 loss(只回答段)
backward反向求梯度
step更新权重
实测起点 2.16 → 收敛到 ~0.15(ckpt10b 上,几十秒)
回答段 loss:
和预训练比,SFT 只改两个旋钮:学习率小 1~2 个量级(2e-5 vs 预训练 6e-4,别把底座学崩)、 只过几遍数据(epoch 少,数据少容易过拟合)。其余 —— 优化器、反向、裁剪 —— 全照搬。
↳ 代码:06_sft.pymain 训练循环 + make_batches(右侧补位用 -100,不产生梯度)
↳ 下一步:训完再问一遍同样的问题 —— 看行为有没有真的变。

⑤ 训练前 vs 训练后:行为真的变了

下面是 06_sft.py 在 124M 上的真实采样(同一指令、同一随机种子)。 点切换看每条指令训练前后的差别 —— 重点不在"答得多对",而在有没有套上格式、有没有在 EOS 处停下

达标玩具级 · 看行为不刷榜
诚实交代:这是玩具级。翻译那条 "J'aime leur." 就不太准 —— 16 条数据、124M、只见过英文, 学到的是回答的姿态与格式,不是可靠的知识/能力。要能真聊天得换更大、含中文的底座(另一条线)。
↳ 代码:06_sft.pychat(碰到 EOS 就 break)· 跑法:python 06_sft.py --ckpt ../phase1-124m/ckpt10b/latest.pt --epochs 30
Q&A常见疑问
SFT 会不会把预训练学到的东西"洗掉"?
会有一点(叫"灾难性遗忘"),所以才用很小的学习率 + 很少的 epoch,轻轻拧、别学崩。 从结果也看得出:SFT 后它还知道 Paris、Berlin —— 知识基本还在,只是被套上了"答题"的姿态。
为什么用 16 条就能看出变化?真实 SFT 要多少?
16 条是为了演示机制:格式和"停"这种姿态很容易学,几十步就上身。 真实的指令微调通常要几千到几十万条高质量数据,才能覆盖各种任务、答得有用。这里只求"看见机制",不求能力。
接下来两关会做什么?
第 9 章 · 手搓 LoRA:这一关是全量微调(更新全部参数);下一关改成只训旁挂的低秩小矩阵,~1% 参数拿到同款效果。 第 10 章 · DPO:从"会答"进一步到"答得合人意"(偏好对齐)。机制见 白皮书的任务×手段表
🎉 SFT · 监督微调 · 通关
你已经把 base 亲手调成了"会答题、会停"的模型,并看懂了模板 / loss mask / EOS。下一关:第 9 章 · 手搓 LoRA