实验课:在我们自己的模型上做一次 SFT
这一课不讲新概念。第 1 课的结论——SFT 用同一个交叉熵、只在回答上算 loss、教的是格式和行为、结束符要进 loss——放到我们自己从 0 训练的 3.7M 参数模型上核对。所有数字直接读取实验导出的 c12-posttrain.json;组件下方的「数字口径」写明了脚本、配置与日期。
这一课先不碰:偏好与 RL(3.7M 的模型连指令都刚学会,RL 部分用第 2–5 课的教学世界演示)。
实验是怎么设计的
为什么这里换例子:我们的模型是在《三國志演義》上预训练的,词表只有 2,048 个 token,它不认识「小明把苹果给了小王」这样的现代汉语。指令数据于是就地取材:
- 数据:从训练集里用正则抽出「某某曰:「……」」,上文 + 说话人做提示,台词做回答:
問:{上文}{说话人}說了什麼?\n答:{台词}<|endoftext|>。训练 6,750 对,验证集里抽出的 652 对做留出集。 - 结束符:
<|endoftext|>是分词器自带的特殊 token(id 2047),预训练语料里从来没有出现过;不新增 token、不扩 embedding。 - loss:只在台词和结束符上算,提示部分的目标设为 −1(项目单测
TestSFTMask验证提示 token 的梯度恰为 0)。 - 训练:从
ref_baseline的 checkpoint 出发(与项目基线同配置的预训练模型),AdamW 学习率 3×10⁻⁴、weight decay 0.1、cosine(warmup 20 步)、batch 32、300 步、梯度裁剪 1.0、种子 1337;每 50 步在留出集上评估一次。 - 三把尺子:① 留出集回答 loss(台词 token 与结束符分开报);② eos_rate:100 条留出提示贪心生成 64 个 token,期间输出结束符的比例;③ 预训练验证集的 val loss(看「续写」能力掉了多少)。
- 解决了同一个交叉熵,只在回答 token 上算:Qwen3.8 模板里一条 87 token 的样本只有 24 个算 loss;LIMA 只用 1,000 条示范。卡在只有正例:教学世界里只示范 A,SFT 对另一个好回答 B 和拒答 F 的梯度逐位相同,两者概率永远相等(第 2 课浏览器实算)。
一、先猜,再看
先在心里写下三个预测,再往下看:① 300 步后,eos_rate 从 0% 变成多少?② 台词 token 的 loss 会降很多吗?(提示:台词能从上文推出来吗?)③ 预训练验证集的 loss 会变好、不变还是变差?
读表(2026-09-26 导出的数据,以组件显示为准):
- 格式学会了:eos_rate 0% → 98%,平均生成长度从顶满的 64 个 token 降到 13.6 个。
- 结束符从「几乎不可能」变成「很自然」:SFT 前它的 loss 是 16.84,比均匀猜测的 ln 2048 = 7.62 还高一倍多——这个 token 在预训练里从没当过目标,它的输出行每一步都被往下压,和第 1 课「说不出名字」是同一个机制(我们的模型共享输入输出 embedding,它在输入侧也从没出现过)。300 步后是 0.37。
- 内容几乎没学会:台词 token 的 loss 只从 5.23 降到 4.67。台词本来就无法从上文推出——这个实验看的是格式,不是知识,正合「SFT 教的是表层行为」。
- 有代价:预训练验证集 loss 从 4.6405 涨到 4.909,涨了 0.27,是噪声底线(C14:同配置 5 次重跑极差 0.016)的十几倍 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条04-实践项目 exp_c12_posttrain.py,单种子、3.7M 参数、玩具指令数据;只在这个规模与配置下成立。起点 4.6405 是参照 run ref_baseline,与 runs/baseline 的 4.626 同配置同种子,差的 0.014 是重跑波动。 出处待补 。
- 提示也算 loss 时(表的第三列,
c12_sft_fullloss,同配置同种子,只改这一项):留出集回答 loss 4.480 对主 run 的 4.470,差 0.010,在重跑噪声以内;预训练 val loss 只涨到 4.839(主 run 4.909,起点 4.6405)——提示也算 loss 时少忘一些。这是第 1 课误解一 Shi et al. 2024 在我们这个规模上的影子:回答没变好,遗忘变少了。
二、它是什么时候学会的
几乎所有变化都发生在前 50 步:第 50 步 eos_rate 已经是 100%,结束符 loss 从 16.84 降到 0.63;之后 250 步,回答 loss 只从 4.49 慢慢挪到 4.47,预训练 loss 却一直在涨(4.869 → 4.909)。学会格式很快,多训只是在付遗忘的代价——这正是 LIMA「1,000 条就够」在我们这个规模上的影子,也是决策 3(训多少步)的依据。
三、同一个提示,前后各生成了什么
SFT 前:模型把「答:」后面当成小说正文接着往下写,64 个 token 用完也不停(有的还陷入「踐踐踐」式的重复)。SFT 后:十几个 token 的一句台词,停在结束符上。台词和参考答案对不上,但形式完全对了。
四、把结论对回前面几课
| 课 | 那一课的结论 | 在我们的模型上看什么 |
|---|---|---|
| 第 1 课 | SFT 是同一个交叉熵,只改求和范围 | 训练引擎、优化器与预训练完全相同,只有 targets = −1 的位置不同 |
| 第 1 课 | 教的是格式和行为,不是知识 | eos_rate 0% → 98%,台词 loss 只降 0.56 |
| 第 1 课 | 结束符要进 loss,模型才学得会停 | 结束符 loss 16.84 → 0.37;SFT 前 100 条里没有一条停下来 |
| 第 1 课 · 深一层 | 从不当目标的 token,输出行被一路压低 | SFT 前结束符 loss 16.84 > ln 2048 = 7.62 |
| 第 2–5 课 | 偏好、RL、奖励作弊 | 3.7M 的模型做不了;下面用教学世界把 SFT 与偏好学习放在一起再看一次 |
教学世界:候选回答、初始概率、偏好对是手写的教学数据(不是任何模型的输出或真实标注);token 数来自 Qwen3.8 真 tokenizer;其余数字由浏览器按真算法现算。
只有示范时,SFT 对「另一个好回答」和「拒答」一视同仁(第 2 课);我们的项目数据里每个提示只有一句台词,情况一样——模型学会的是「写一句台词然后停」,而不是「哪句台词更好」。要教「更好」,得有比较或奖励。
五、为项目拍板
你要拍板的三件事(对应章首页的项目里程碑):
- 结束符用哪个?本实验用分词器自带、预训练里没出现过的
<|endoftext|>:不扩词表,代价是它的初始 loss 高达 16.84。另一条路是新增<|im_end|>这类特殊 token,要扩 embedding、改vocab_size。 - 提示部分要不要算 loss?我们的提示(上文 + 「某某說了什麼?」)比回答长得多、样本只有 6,750 条——正是 Shi et al. 2024 说「对指令也算 loss」可能更好的条件。对照已经跑了(第一节表的第三列):回答 loss 分不出高下,预训练 loss 少涨约 0.07。你更看重哪一个?
- 训多少步?依据是上面那条曲线:格式在第 50 步就学会了,之后每一步都在涨预训练 loss。
验收标准:TestSFTMask 通过(提示 token 梯度恰为 0);SFT 后留出提示的 eos_rate ≥ 95%;预训练 val loss 的涨幅写进表里,任何改动都不能让它比本页基线(+0.27)更大;两个设置的差异小于噪声底线(C14:同配置 5 次重跑极差 0.016,3σ ≈ 0.020)时不下结论。
🔍 自己复现这个实验
在 04-实践项目/ 下运行 python3.12 experiments/exp_c12_posttrain.py(已有且配置一致会直接复用 runs/c12_sft/,加 —retrain 强制重训),导出到 知识平台/src/data/project/c12-posttrain.json,本页所有组件自动读它。代码在 minillm/sft.py:extract_pairs 抽对子、encode_example 构造 x / y 并把提示部分的 y 设为 −1、collate 右侧补齐(补齐位置同样是 −1)、answer_loss 分开统计台词与结束符的 loss。
一句话
同一个 checkpoint、只多 300 步 SFT:格式在 50 步内学会(eos_rate 0% → 98%),内容几乎没变,代价是预训练 loss 涨了 0.27——SFT 教行为,不教知识,而且不是免费的。
下一个缺口
RL 的 rollout 就是 decode:模型一个 token 一个 token 地生成,为什么这么慢、怎么变快? → C13:推理