10. 监督微调:学会对话和工具
预训练结束后,模型已经能预测下一个词元,但它还不是一个对话助手。
你给它一句:
法国的首都是哪里?基础模型可能续写出另一个问题、一段百科文字,或者用完全不符合对话格式的方式继续。它没有学过“用户消息之后应该跟助手消息”。
监督微调(SFT)的任务,就是把基础模型整理成一个会按格式回答、会调用工具的助手。
SFT 不是重新学知识
基础模型在预训练中已经读过大量文本,形成语言和事实能力。SFT 的数据量小得多,主要作用是:
- 教会模型对话的角色和边界。
- 教会模型在什么时候结束回答。
- 教会模型在数学题中调用 Python 工具。
- 让回答更符合助手的行为方式。
如果把预训练完全跳过、只做 SFT,模型没有足够的语言基础,效果会很差。SFT 是整理已有能力,不是凭空灌入全部知识。
训练数据由三部分混合
读 SFT 数据混合scripts/chat_sft.py第 161–173 行↗| 数据 | 规模(源码注释) | 作用 |
|---|---|---|
| SmolTalk | 约 46 万条 | 通用对话,学习自然的多轮交流 |
| MMLU 辅助训练集 | 约 10 万条 × 3 轮 | 学习多项选择题的格式和知识 |
| GSM8K 训练集 | 约 8 千条 × 4 轮 | 学习数学推理和 Python 工具调用 |
MMLU 和 GSM8K 被重复加入,是为了让模型多见几次这些任务。重复轮数由 --mmlu-epochs 和 --gsm8k-epochs 控制。
TaskMixture 会把所有样本按固定随机种子打乱。这样训练时不会先把 SmolTalk 全部看完,再看数学题,而是三类数据交错出现。
从基础模型开始
读模型加载和超参数继承scripts/chat_sft.py第 93–118 行↗SFT 默认加载:
~/.cache/nanochat/base_checkpoints/<model-tag>/然后继承基础模型的一些设置:
- 最大序列长度。
- 每卡批大小。
- 总批大小。
- 学习率。
命令行显式传入的值会覆盖继承值。这个设计避免了在 SFT 阶段重复手动设置所有参数。
优化器也会从预训练检查点加载动量等状态,但会重置学习率。原因是预训练结束时学习率已经降到很低,直接沿用会让 SFT 几乎不更新。代码会先保存新的 SFT 学习率,加载优化器状态后再恢复。
一条对话怎样变成训练样本
读对话渲染和掩码nanochat/tokenizer.py第 140–224 行↗以一轮对话为例:
用户:巴黎是哪个国家的首都?
助手:法国。2
分词器会渲染成:
<|bos|>
<|user_start|> 巴黎是哪个国家的首都? <|user_end|>
<|assistant_start|> 法国。 <|assistant_end|>2
3
同时返回两个等长列表:
ids:每个词元的编号。mask:每个位置是否计算损失。
<|bos|>、用户消息及其 <|user_start|> / <|user_end|>、<|assistant_start|> 的掩码是 0;助手回答和 <|assistant_end|> 的掩码是 1。
为什么要屏蔽用户部分
如果用户问题也计算损失,模型会被训练成“复述用户问题”。SFT 的目标是学习“给定用户消息后怎样回答”,所以损失只放在助手生成的内容上。
系统消息怎样处理
当前实现会把第一条系统消息合并到第二条用户消息中,用两个换行分隔。它没有单独的系统角色词元。这样可以保持对话结构简单,但也意味着系统提示和用户消息在格式上不再是两个独立角色。
工具调用怎样处理
GSM8K 的数据里包含计算步骤。分词器会渲染成:
<|python_start|> 12*3 <|python_end|>
<|output_start|> 36 <|output_end|>2
- 工具调用的表达式和 Python 标记:掩码为 1,模型学习“什么时候调用、调用什么表达式”。
- 工具输出:掩码为 0,模型不学习“36 这个结果”。因为输出是外部程序产生的,不是模型应该预测的内容。
这和推理时的状态机正好配合:模型负责产生调用请求,推理引擎负责执行并注入结果。
数据打包:对话不能截断
SFT 使用和预训练不同的打包方式:
读 SFT 数据加载器scripts/chat_sft.py第 180–298 行↗- 每行仍然以 BOS 开始。
- 从缓冲区中找能完整放下的最长对话。
- 如果还有空间但没有完整对话能放下,用 BOS 填充。
- 填充位置的损失掩码为 0。
- 输入和目标仍然错开一位。
预训练可以截断文档,因为少一句不会破坏文档身份;对话截断会破坏 用户 → 助手 的结构,所以这里选择填充。
训练循环
SFT 的训练循环和预训练相似,但多了几处对话特有的逻辑:
- 读取的是
TaskMixture生成的对话,而不是普通文本分片。 - 损失掩码来自
render_conversation。 - 定期计算 ChatCORE。
- 保存到
chatsft_checkpoints。
scripts/chat_sft.py第 322–451 行↗学习率计划略有不同:
- 初始学习率是基础学习率的 0.8。
- 不做预热(默认
warmup_ratio=0.0)。 - 最后 50% 的进度线性降到 0。
训练进度按“已经消费的对话数 ÷ 数据集大小”估算,而不是按预先知道的步数。因为 SFT 可以在完整跑完一轮数据后停止。
运行 SFT
正式训练(8 张 GPU):
torchrun --standalone --nproc_per_node=8 -m scripts.chat_sft -- \
--run=my-sft2
小模型流程:
python -m scripts.chat_sft \
--model-tag=d4-demo \
--max-seq-len=256 \
--device-batch-size=8 \
--total-batch-size=4096 \
--num-iterations=50 \
--eval-every=25 \
--eval-tokens=8192 \
--chatcore-every=-1 \
--run=dummy2
3
4
5
6
7
8
9
10
输出保存在:
~/.cache/nanochat/chatsft_checkpoints/d4-demo/检查对话渲染
训练前可以先渲染一条对话,确认掩码和特殊词元是否正确:
from nanochat.tokenizer import get_tokenizer
from tasks.smoltalk import SmolTalk
tokenizer = get_tokenizer()
dataset = SmolTalk(split="train")
conversation = dataset[0]
ids, mask = tokenizer.render_conversation(conversation)
print(tokenizer.visualize_tokenization(ids, mask))
print("词元数:", len(ids))
print("参与损失的位置:", sum(mask))2
3
4
5
6
7
8
9
10
11
第一次运行会下载 SmolTalk 数据。visualize_tokenization 会用不同颜色标出受监督和不受监督的词元,适合在终端里快速检查。
SFT 不能保证什么
- 不能保证事实正确。 模型可能流畅地说出错误内容。
- 不能保证格式永远正确。 小模型可能忘记结束标记或生成多余内容。
- 不能替代预训练。 没有足够的基础能力,SFT 数据再干净也学不会复杂推理。
- 不能只靠 SFT 学会所有任务。 代码、数学和多轮工具使用往往需要更多专门数据。
本章要记住的三件事
- SFT 用对话数据把基础模型整理成助手,重点是格式、行为和工具调用。
- 只有助手部分和工具调用动作参与损失,用户消息和工具输出被屏蔽。
- SFT 数据打包不截断对话,放不下时用 BOS 填充,并屏蔽填充位置。
下一章看可选的强化学习:强化学习。