指令微调(SFT)
在 No Robots 上微调一个 360M 的 base 模型,亲眼看着它从「无限重复问题」变成「回答问题然后停下来」。这一路上 loss 几乎没有下降。
0. 任务目标
第 4 章反复强调的一件事是:SFT 的损失函数和预训练完全一样,变的只有数据格式(chat template)和 loss 在哪些 token 上算(prompt masking)。既然目标函数没变,那 SFT 到底改变了什么?
答案是行为,而不是「困惑度」。base 模型对巴黎是法国首都这件事早就知道,它缺的是「我现在扮演的是一个助手,问题问完了该给答案而不是接着出题,答完了该停下来而不是继续写」这套约定。这个转变是离散的、行为层面的,而交叉熵是连续的、token 层面的平均值。两者不必同步——这正是本次实验要你亲眼确认的事。
- 要看的是在环采样(in-loop generation)面板:训练脚本每 50 个优化步会拿同一组固定 prompt 生成一次。step 0 时的输出和几百步后的输出放在一起,差异是肉眼可见的。
- 不要看 loss 判断成败。本次实测中 loss 全程在 2.0–2.4 之间震荡,末步比首步还高——但模型确实学会了回答并停止。
- 验证「相变」的具体标志:(a) 不再复述 prompt;(b) 给出与问题相关的内容;(c) 会停——不再一路写到
max_new_tokens耗尽。
这一题的上游版本(第 4 章末尾的 Suggested Experiments 第 1 条)用的是 allenai/OLMo-2-0425-1B 跑全量 No Robots。本站因为显存只剩约 6.9 GiB,换成了 HuggingFaceTB/SmolLM2-360M,并相应改小了序列长度、batch 与数据量——改了什么、为什么改,是下一节的内容。
1. 配置讲解
完整配置在 homework/hw1-sft/sft_smol360m.yaml:
# Model
model_name: HuggingFaceTB/SmolLM2-360M
chat_template_source: HuggingFaceTB/SmolLM2-360M-Instruct # base 分词器没有 chat_template
# Dataset (CC BY-NC 4.0; educational use only)
dataset_name: HuggingFaceH4/no_robots
dataset_split: train
max_samples: 4000
max_length: 1024
# Training
lr: 2.0e-5
num_epochs: 2
batch_size: 2
gradient_accumulation_steps: 8 # 有效 batch = 16
warmup_ratio: 0.1
weight_decay: 0.0
max_grad_norm: 1.0
seed: 42
# Hardware
bf16: true
gradient_checkpointing: true
model_device_id: 0
# In-loop generation logging
sample_every: 50
sample_max_tokens: 128
sample_max_input_tokens: 512
sample_temperature: 0.7
sample_top_p: 0.9
sample_do_sample: true
# Logging
wandb_project: null
wandb_run_name: hw1_sft_smollm2_360m_no_robots
相对上游默认改了什么
| 项 | 上游 sft_olmo2_1b.yaml | 本作业 | 为什么 |
|---|---|---|---|
model_name |
allenai/OLMo-2-0425-1B |
HuggingFaceTB/SmolLM2-360M |
1B 全参数 + AdamW(权重 + 梯度 + 两份动量)在约 6.9 GiB 可用显存里会 OOM。360M 留出了足够余量 |
max_length |
2048 | 1024 | 激活显存随序列长度线性增长。No Robots 的绝大多数样本远短于 1024,截断损失有限 |
batch_size |
4 | 2 | 同上,直接压激活峰值 |
gradient_accumulation_steps |
8(有效 32) | 8(有效 16) | micro-batch 减半后有效 batch 也随之减半。有效 batch 16 远小于第 4 章提到的生产级约 256,训练噪声因此更大——这也是后面 loss 曲线抖成那样的原因之一 |
max_samples |
null(全量约 9.5K) |
4000 | 控制单次实验时长。4000 条 × 2 epoch 已足以看到相变 |
num_epochs |
3 | 2 | 同上 |
lr |
5.0e-6 | 2.0e-5 | 小模型需要更大的学习率。这一条不是拍脑袋——HW4 在同一个 360M 上做了 lr 对照:5e-6 时 DPO 的 loss 全程贴着 $\ln 2$ 不动,换成 2e-5 才出现学习信号 |
几个没改、但值得知道为什么在那里的项
chat_template_source。base 模型的 tokenizer 没有 chat_template 字段——它从来没被教过对话格式。上游代码的做法是从一个「同族的 Instruct 版本」把模板搬过来:读取 SmolLM2-360M-Instruct 的 tokenizer,把它的 chat_template 赋给 base 的 tokenizer。这一步如果搞错(比如借了另一个模型族的模板),训练时的格式和你之后推理时用的格式就对不上,模型会学得很好但推理时表现莫名其妙——第 4 章说这是最高频的线上事故来源。
gradient_checkpointing: true 与 bf16: true。前者用重算换显存(前向不保存中间激活,反向时重新算一遍),代价是训练变慢;后者把权重和计算放在 bf16 上。在 6.9 GiB 这个预算下这两项都不是可选项。
sample_every: 50。这是整个配置里最重要的一行。每 50 个优化步,脚本会用一组固定 prompt(What is the capital of France?、Explain quantum computing in simple terms.、Write a haiku about programming.、How does photosynthesis work?)生成一次并打印。本次实验的全部结论都来自这个面板,不是来自 loss。
do_sample=True,不是贪心
sample_do_sample: true 加 temperature 0.7 / top_p 0.9,意味着每次采样面板都带随机性。好处是能看到模型输出分布的真实样貌(贪心解码会掩盖掉「模型其实很不确定」这件事);坏处是相邻两次采样的差异里有一部分只是运气。判断相变要看趋势,不要抓住某一次特别好或特别差的输出下结论。
loss mask 是在哪一步发生的
这是 SFT 与预训练唯一的实质差别,值得看一眼真实代码。上游 instruction_tuning/utils.py 的 _encode_row:
IGNORE_INDEX = -100
prompt_ids = tokenizer.apply_chat_template(
messages[:-1], tokenize=True, add_generation_prompt=True, return_dict=False
)
full_ids = tokenizer.apply_chat_template(
messages, tokenize=True, add_generation_prompt=False, return_dict=False
)
labels = [IGNORE_INDEX] * len(prompt_ids) + list(full_ids[len(prompt_ids):])
手法很朴素:把同一段对话渲染两次——一次只到「该 assistant 说话了」为止(add_generation_prompt=True),一次是完整的;前者的长度就是要被屏蔽的前缀长度。前缀位置的 label 全部置为 -100,F.cross_entropy(..., ignore_index=-100) 会跳过它们。
这个写法的好处是不需要任何字符串匹配或模板解析,因此对模板细节免疫。坏处是每条样本要 tokenize 两遍,以及它只保留了最后一个 assistant 轮次的监督——多轮对话里前面几轮的 assistant 回复也被当成上下文屏蔽掉了。对 No Robots 这种以单轮为主的数据集影响很小,但如果你换成多轮数据集,这是第一个要检查的地方。
2. 运行命令
本次实测使用的命令:
python -m instruction_tuning.train --config homework/hw1-sft/sft_smol360m.yaml
执行前提是 instruction_tuning 这个包能被 import 到,也就是上游的 code/ 目录(本仓库在 _src/code/)在 PYTHONPATH 上;配置文件路径按你机器上的实际位置写。如果你按上游文档的方式工作,等价写法是:
cd _src/code/
uv run python -m instruction_tuning.train \
--config ../../homework/hw1-sft/sft_smol360m.yaml
配置里已经写了 wandb_project: null,所以不需要 W&B 账号,脚本会以 disabled 模式初始化。但这样一来逐步指标就只剩控制台里的那点信息了——想留一份完整的 loss / grad_norm / lr 曲线,走总览页介绍过的包装器:
METRICS_JSONL=homework/hw1-sft/logs/metrics_sft_360m.jsonl \
python homework/tools/run_with_metrics.py instruction_tuning.train \
--config homework/hw1-sft/sft_smol360m.yaml
它把 wandb.log 换成「先写 JSONL、再原样转发」的版本,行为与直接跑训练脚本完全一致,只是多落一份逐步指标。本作业目录下的 logs/metrics_sft_360m.jsonl 就是这么来的。
跑起来会看到什么
脚本先打印一个配置面板(模型、参数量、设备、数据集、有效 batch、总步数与 warmup 步数),本次运行报出总计 496 个优化步。然后进入训练循环,控制台交替出现两类东西:
- 进度条,描述栏实时显示当前优化步的平均 loss;
- 每 50 步一次的
Samples @ step N面板,四个固定 prompt 各占一个框,输出里的特殊 token(<|im_start|>、<|im_end|>等)会被高亮。
generate_samples 里解码的是 out[0] 全长且 skip_special_tokens=False,所以面板里看到的是「system + user + assistant」整段,而不只是新生成的部分。第一次看容易误以为模型在复读——要分清哪一段是模板给的、哪一段是模型写的。判断依据是 <|im_start|>assistant 这一行:它之后的内容才是模型的输出。
这次运行的完整控制台记录保存在 homework/hw1-sft/logs/run_sft_360m.log,下一节引用的所有输出都出自它。
3. 实测结果
运行环境与规模:基座 HuggingFaceTB/SmolLM2-360M(base),chat template 取自 SmolLM2-360M-Instruct;数据 HuggingFaceH4/no_robots 4000 条,2 epoch,max_length 1024;batch 2 × grad_accum 8(有效 16),lr 2e-5,bf16 + gradient checkpointing;总计 496 个优化步。
loss 曲线
| step | 1 | 50 | 99 | 148 | 197 | 246 | 295 | 344 | 393 | 442 | 491 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| loss | 2.1035 | 2.0586 | 1.9849 | 2.3193 | 2.2197 | 2.2627 | 2.4229 | 2.1094 | 2.2559 | 2.2354 | 2.3398 |
末步 loss 2.2383。loss 全程在 2.0–2.4 之间震荡,没有单调下降——step 99 的 1.9849 是全程最低值,而它出现在训练进行到五分之一的时候。最后一步(2.2383)比第一步(2.1035)还高。
如果这是你第一次跑 SFT,看到这条曲线的正常反应是「训练失败了,学习率是不是设错了」。不是。下面是同一次运行里的生成面板。
生成质量:这才是真信号
step 0(还没有做任何更新的 base 模型),prompt What is the capital of France?:
What is the capital of France?<0xEF><0xBF><0xBD>ctory
<0xEF><0xBF><0xBD>ctoryuser
What is the capital of France?<0xEF><0xBF><0xBD>ctory
<0xEF><0xBF><0xBD>ctoryuser
(如此重复直到 max_tokens 用尽,永不停止)
同样是 step 0,prompt Explain quantum computing in simple terms.:
Explain quantum computing in simple terms.
urbandictionary
urbandictionary
urbandictionary
(重复刷屏直到耗尽预算)
step 450,同一个 What is the capital of France?:
Paris ikipedia
# The city of Paris is the capital of France.
# It is the largest city in the country and the fourth-largest in Europe.
结论:答对了、会停了,但仍带 ikipedia 这类残留噪声和多余的 markdown # 前缀。360M + 4000 条数据能完成 base→assistant 的形态转变,但离干净助手还有距离。
把这两组输出摆在一起看
| 观察维度 | step 0(base) | step 450 |
|---|---|---|
| 是否复述 prompt | 是,把问题原样吐回来并反复循环 | 否,直接给答案 |
| 内容是否相关 | 否,ctory / urbandictionary 这类碎片 | 是,Paris,且后续描述基本切题 |
| 是否伪造角色标记 | 是,自己写出了 user 开始下一轮 | 否 |
| 是否会停 | 否,一路写到 token 预算耗尽 | 是 |
| 残留问题 | — | ikipedia 噪声、多余的 # 前缀、事实细节未必可靠 |
「会不会复述 prompt」「会不会伪造角色标记」「会不会停」——这三件事全是离散的行为开关。它们翻转所对应的,是极少数关键位置上的概率重排(比如「在回答结束的那个位置,<|im_end|> 的概率是否超过其它 token」)。一条 1024 长度序列的平均交叉熵里,这几个位置的贡献被稀释到了几乎看不见。
本次运行没有做任何留出评测(held-out eval),也没有记录峰值显存占用,因此本页不给出「验证集 loss」「显存占用 X GiB」之类的数字。上面出现的所有数字仅限于:11 个采样点的训练 loss、末步 loss、总步数,以及两组原样引用的生成输出。
4. 核心教学点:loss 曲线不是进度指标,在环采样才是
这是本次实验唯一真正重要的产出,值得完整论证一遍。
loss 从 2.10 走到 2.24,几乎没动、甚至略升;而同期生成质量发生了质变:从「无限重复 prompt + 伪造角色标记 + 永不停止」变成「答对了、会停了」。两条信号完全脱钩。
原因一:批间方差淹没了训练进展
先看这一步的 loss 是怎么算出来的。compute_loss 做的是标准的因果语言建模交叉熵,在所有未被屏蔽的 token 上求平均:
其中 $\mathcal{M}$ 是这一个 batch 里所有 label 不等于 -100 的位置的集合(也就是 assistant 回复的 token)。注意这是逐 token 平均,不是逐样本平均——一条长回复对 loss 的贡献权重远大于一条短回复。
而 No Robots 是一个刻意做得很杂的人写数据集:里面既有「Chat」这样一两句话的闲聊,也有「Generation」类的长篇写作、「Summarize」「Rewrite」「Extract」这类给定长文本再加工的任务。不同类目的样本,长度和难度差着一个数量级。本作业的有效 batch 只有 16 条(batch 2 × grad_accum 8),意味着每个优化步的 loss 是从 16 条随机抽到的样本上算出来的。
结果就是:这一步碰巧抽到几条创意写作(本来就难预测、交叉熵天然高),loss 就往上跳;下一步抽到几条短问答,loss 就掉下来。这个批间方差的量级,和 2 个 epoch 内模型真实进步带来的那点交叉熵下降,是同一个数量级——甚至更大。于是曲线看起来就是噪声。
- 加大有效 batch。第 4 章提到的生产级设置是约 256 条 prompt,是本作业的 16 倍——方差按 $1/\sqrt{N}$ 缩小,曲线自然平滑得多。这也是为什么论文里的 SFT loss 曲线总是很好看,而你自己跑的总是毛刺。
- 用固定的留出集算 eval loss,而不是看训练 loss。每次都在同一批样本上评估,样本组成这一项方差就消失了,剩下的才是模型的变化。本次运行没有做这件事——这是这组实验最值得补的一个改进。
- 按类目分开看。No Robots 自带类目标签,分类目统计 loss 能立刻看出「变化到底来自模型还是来自采样」。
原因二:行为转变本来就不体现在平均交叉熵上
这一条比方差更根本。下面是一个示意计算(不是实测数据):假设一条 assistant 回复有 200 个 token,模型要学会的「答完就停」这件事,落到概率上是在第 200 个位置让 <|im_end|> 的概率超过其它候选。这一个位置的对数概率哪怕从 $-6$ 改善到 $-0.1$,对 200 个 token 的平均值的影响也只有大约 $5.9/200 \approx 0.03$——完全被前面提到的批间方差盖住。
但从行为上看,这个位置的翻转是决定性的:它是「模型可用」与「模型不可用」的分界线。同理,「不再复述 prompt」「不再自己写出 user 开始下一轮」也都是少数几个关键位置上的概率重排。
交叉熵度量的是整条序列上的平均预测精度;而我们关心的是少数关键位置上的 argmax 是否正确。前者是一个被大量无关 token 稀释过的连续量,后者是离散的、稀疏的、且只在采样时才显形。用前者当后者的代理,分辨率不够。
推论:SFT 阶段该怎么判断「练好了没有」
| 信号 | 能告诉你什么 | 不能告诉你什么 |
|---|---|---|
| 训练 loss | 数值是否爆炸(NaN、突然飙到 10 以上)——这是故障探测器 | 模型好没好。它在正常范围内的起伏几乎不含信息 |
| grad_norm | 是否触发了 max_grad_norm 裁剪、是否有异常尖峰 | 同上 |
| 在环采样 | 格式对不对、会不会停、有没有复述、内容相不相关——SFT 阶段真正要的东西 | 定量比较两个 checkpoint 谁更好(样本太少、还带采样随机性) |
| 留出集评测 | 能定量比较 checkpoint | 本次未运行,见下一节的消融建议 |
这条经验不止适用于 SFT。HW2 里会看到一个对称的版本:奖励模型的 margin 一路上涨,但判别准确率原地踏步——同样是「一个连续指标在动,而你真正关心的离散能力没动」。附录 C 把这类问题归成了一类症状:先确认你在看的指标和你想要的能力之间到底有没有因果关系。
更糟的用法是拿两次运行的 loss 数值直接比大小。SFT 的 loss 绝对值取决于分词器、序列长度、mask 策略、数据类目配比——换任何一项,数值就不可比了。不同 tokenizer 之间的交叉熵尤其没有可比性(同一段文本切成的 token 数不同,逐 token 平均的分母就不同)。想比较,只能在完全相同的数据与 mask 下比,或者干脆去比下游行为。
5. 自选消融
下面四个方向都是一次只改一个变量的对照实验。以下全部未在本次会话中运行,因此不给任何预期数字——只说改什么、看什么、以及为什么这个变量值得单独扫。
(a) 扫学习率
上游第 4 章的建议是复制配置,试 1e-6、5e-6、5e-5,其余不动。本作业已经把默认值定在 2e-5,所以更有信息量的一组是 5e-6 / 2e-5 / 5e-5 / 1e-4。
| 要回答的问题 | 怎么看 |
|---|---|
| 最早在哪个 step 出现「会停」这个行为 | 翻采样面板,找第一次输出以 <|im_end|> 正常结束的那一页。这是本实验唯一靠谱的「速度」度量 |
| 学习率太小会怎样 | 采样面板长时间维持 base 的样子:复述、循环、不停 |
| 学习率太大会怎样 | 模型很快学会格式,但内容退化成模板形状的套话(第 4 章说的 "template-shaped slop");更极端时 loss 出现尖峰或 NaN |
这一组正是第 4 章「SFT 学习率比预训练低 1–2 个数量级」那句话的实操版。注意那句话的参照系是十亿参数级模型:360M 上的合适值明显更高,本配置从 5e-6 提到 2e-5 的依据来自 HW4 在同一基座上的实测对照。
(b) 改 epoch 数
把 num_epochs 设成 1 / 2 / 4,其余不动。要看的是「格式学会了」和「内容开始退化」之间的窗口有多宽:SFT 的过拟合不像分类任务那样表现为验证集指标掉头,而是表现为输出变得模板化、开始重复训练集里的固定句式、多样性下降。采样面板的四个 prompt 如果开始给出结构高度雷同的回答,就是信号。
这里也是补一个留出集的好时机:No Robots 自带 test 划分(脚本默认只用 train)。加一个在固定 500 条上算 eval loss 的钩子,就能把「训练 loss 的噪声」和「模型的真实变化」分开——这是第 3 节那条抖动曲线最直接的解药。
(c) 改数据量
max_samples 取 500 / 1000 / 4000 / null(全量约 9.5K),其余不动,尤其要固定 epoch 数还是固定总步数——这两种控制方式回答的是不同的问题:
- 固定 epoch 数:数据越多,总步数越多,比较的是「更多数据 + 更多更新」的合力。
- 固定总步数(把
num_epochs相应调小):比较的是数据多样性本身的价值,因为梯度更新次数一样多。
后一种设置才是第 4 章「数据质量 > 数据数量」「LIMA 用 1000 条就能做 chat 对齐」那场讨论的正确对照方式。在 360M 这个尺度上,500 条能不能完成 base→assistant 的相变,是一个很值得亲自测一次的问题。
(d) 关掉 loss masking
这是最有教育意义的一个消融,因为它对应第 4 章点名的「最常见的 bug」。改法是在 instruction_tuning/utils.py 的 _encode_row 里把 mask 那一行换掉:
# 原始:只在最后一个 assistant 轮次上算 loss
labels = [IGNORE_INDEX] * len(prompt_ids) + list(full_ids[len(prompt_ids):])
# 消融:整条序列都算 loss(等价于「在对话格式上继续做预训练」)
labels = list(full_ids)
改完之后要看两件事:
- loss 的绝对值会变,而且方向不一定直观——prompt 部分往往比 assistant 回复更好预测(模板 token 是确定的),所以平均 loss 可能反而降低。这恰好再次证明第 4 节那句话:loss 变低不等于模型变好。两种设置下的 loss 数值根本不可比,因为分母(参与平均的 token 集合)都不一样。
- 采样面板里模型学到了什么。理论上的失效模式是:模型把一部分容量花在「学会生成用户会问什么问题」上,因此更容易在回答后自己伪造下一轮
<|im_start|>user继续写下去——也就是 step 0 那种行为的一个变体。这个方向的预测本次未验证,值得你自己跑一遍确认。
它不报错、loss 曲线看起来完全正常(甚至更好看)、模型也确实在学东西。唯一能暴露它的就是看生成,或者做一个专门检查「模型会不会自己续写用户轮」的小评测。这也是本页反复强调「在环采样是主指标」的另一个理由:它是少数几种能同时抓到行为退化和数据管线 bug 的手段。
做完这组实验后回哪一节
- 第 4 章 · 指令微调:chat template 的渲染细节、loss masking 的多轮处理、数据质量与数量之争、超参的实际取值范围。本页所有「为什么这么配」的完整论证都在那里。
- 附录 C · 实践问题:显存逐项核算、超参该先动哪个、「loss 不降」这类症状的排查顺序。
- HW2 · 奖励模型:下一步。SFT 模型是后训练流水线的地基,而奖励模型是它上面的第一层——你会在那里再次遇到「指标在动、能力没动」这个主题。