第 12 章:Supervised Fine-Tuning,SFT¶
1. 本章要解决的问题¶
第 11 章里,我们已经把评估这件事讲清楚了一个关键结论:
模型好不好,不是只看 pretraining loss,也不是只看某个 benchmark 排名,而是要看它能不能在目标任务上稳定地产生我们想要的行为。
但这时马上会出现一个非常现实的问题:
如果一个预训练模型已经“会语言、会知识、会续写”,为什么它还是经常不会好好回答人话指令?
比如你把一个纯预训练模型拿来直接问:
它可能会出现几种常见情况:
- 继续补全成一段像网页正文的文字,而不是老老实实回答问题
- 输出风格飘忽,不知道什么时候该分点、什么时候该解释
- 明明知道相关知识,但不一定按用户要求的格式组织答案
- 在多轮对话里分不清 system、user、assistant 各自扮演什么角色
这说明一件很重要的事:
预训练学到的是“语言分布”,但用户真正需要的往往是“按指令完成任务的行为”。
这就是 SFT 要解决的问题。
从全书结构上看,这一章有三个作用:
- 它承接第 11 章,把“评估希望模型表现出什么行为”推进到“我们怎样把这些行为教给模型”
- 它作为后训练的起点,解释 instruction tuning 到底在优化什么
- 它也为第 13 章的 LoRA / QLoRA 做准备,因为后面很多微调工程,本质上都是在用更省资源的方法完成同一类 SFT 任务
如果第 11 章回答的是:
我们怎样判断一个模型是不是真的更好。
那么这一章要回答的就是:
当我们已经有一个预训练底座后,怎样用监督数据把它进一步塑形成“会听指令、会对话、会按格式完成任务”的助手模型。
2. 你学完后应该会什么¶
- 能解释为什么预训练模型往往还需要 SFT
- 能说清 instruction tuning 和 pretraining 在训练目标上的区别
- 能看懂常见的指令数据格式:
instruction-input-output、messages - 能理解 chat template 为什么重要,以及它到底把什么东西拼进了序列
- 能理解
labels、ignore_index、loss mask 分别在做什么 - 能描述一个最小可用的 SFT 数据流和训练流
- 能把这一章自然衔接到第 13 章的 LoRA / QLoRA 实践
3. 为什么预训练之后,还要再做一次 SFT¶
先把最核心的判断说清楚:
预训练不是没用,而是它解决的问题和 SFT 不一样。
第 9 章讲过,GPT 类模型在预训练阶段学的是 next-token prediction。它看到的是海量文本,目标是:
给定前文,预测下一个 token。
这个目标非常强大,所以模型能学到:
- 基础语法和语言流畅性
- 大量世界知识和文本模式
- 一部分推理、代码、数学结构
- 长上下文里的统计依赖
但它也有天然局限。
因为预训练数据大多不是按“用户提问,助手回答”的格式组织的,所以模型虽然学会了“如何像互联网上的文本那样继续写下去”,却不一定学会:
- 什么时候该直接回答
- 什么时候该拒答
- 什么时候该输出 JSON
- 什么时候该简洁,什么时候该展开
- 多轮对话里怎样区分不同角色
3.1 一个直觉类比¶
可以把预训练模型想成一个读了大量书的人。
他:
- 词汇量很大
- 知识面很广
- 看过很多不同风格的文本
但如果你突然让他做客服、做助教、做代码助手,他未必立刻知道:
- 用户真正要的输出格式是什么
- 回答要多长
- 需要遵守哪些交互规则
- 遇到不确定信息时该怎么处理
而 SFT 更像是:
在已有知识和语言能力的基础上,再做一轮“岗位培训”。
3.2 从“会续写”到“会完成任务”¶
这也是很多初学者最容易忽略的一点:
SFT 的价值不主要在于给模型硬塞更多知识,而在于把已有能力重新组织成更符合任务目标的输出行为。
例如同样一个底座模型,在做过 SFT 之后,往往会更擅长:
- 指令遵循
- 多轮对话
- 格式约束
- 风格控制
- 任务边界感
所以 SFT 不是在替代预训练,而是在把预训练得到的潜力,朝可用助手的方向“对齐成型”。
4. SFT 到底在训练什么¶
所谓 SFT,最直接的定义是:
在人工或半人工构造的“输入 - 理想输出”数据上,用监督学习继续训练预训练模型。
注意这里的“监督”,和传统分类任务里的监督不完全一样。
它不是给一句话打一个类别标签,而是给定一段提示,让模型学会生成一段目标回答。
4.1 它和预训练目标的连续性¶
很多人会以为 SFT 和 pretraining 是两套完全不同的训练机制,其实不是。
从底层 loss 看,它们仍然非常像。
SFT 训练时,模型本质上还是在做:
给定前文,预测下一个 token。
不同的是,这里的“前文”不再只是普通文本,而是被组织成了某种任务格式,比如:
于是模型被训练成:
- 在看到“用户提问”后,更倾向于接“助手回答”
- 在看到特定格式要求后,更倾向于输出符合要求的结构
- 在看到多轮上下文后,更倾向于延续正确的对话角色
所以可以把 SFT 理解成:
仍然用 language modeling 的方式训练,但训练分布已经从“普通文本”切换成了“高质量任务示范”。
4.2 一个最小训练样本长什么样¶
最朴素的例子可以写成:
如果把它变成训练样本,模型看到的其实是一段拼接后的序列。它需要学会的是:
当前面出现这类指令时,后面应该长出这种类型的回答。
4.3 SFT 的核心不是“让模型背答案”¶
如果数据质量好、覆盖面足够,SFT 学到的通常不是死记硬背某一句话,而是更一般化的行为模式,例如:
- “翻译任务应该直接给译文”
- “摘要任务应该压缩冗余信息”
- “代码修复任务应该输出可执行修正方案”
- “要求列表时应尽量按列表格式回答”
当然,前提是数据本身足够一致、足够高质量。否则模型也可能学到混乱行为。
5. 指令数据到底长什么样¶
理解 SFT,最重要的工程入口之一,就是理解数据格式。
因为你最终喂给模型的,不是抽象的“任务”,而是一条条具体样本。
5.1 早期常见格式:instruction / input / output¶
很多 instruction tuning 数据集会写成这样:
或者:
{
"instruction": "根据给定文本生成摘要",
"input": "Transformer removes recurrence and relies on self-attention...",
"output": "Transformer 用自注意力替代循环结构,从而提升并行计算能力。"
}
这种格式优点是简单直观,很适合教学和单轮任务。
但它也有边界:
- 不够自然地表示多轮对话
- 不方便表达 system prompt
- 不方便表达工具调用或更复杂角色结构
5.2 现在更常见的格式:messages¶
现代 chat 模型更常见的是 messages 结构,例如:
[
{"role": "system", "content": "你是一名简洁、严谨的 AI 助手。"},
{"role": "user", "content": "请解释为什么 self-attention 比 RNN 更容易并行化。"},
{"role": "assistant", "content": "因为 self-attention 在一个序列位置上不依赖前一时刻隐藏状态的递归传递,所以同一层里的多个位置可以并行计算。"}
]
这种格式的好处是:
- 更贴近真实产品中的对话接口
- 能显式区分 system、user、assistant
- 更容易扩展到多轮对话
- 更容易和后续推理接口保持一致
这也是为什么现在很多开源模型训练、部署、推理 API 都围绕 messages 组织。
5.3 数据质量比格式更重要¶
不过这里要特别提醒一个常见误区:
格式标准化很重要,但真正决定 SFT 效果的,往往首先是数据质量。
高质量指令数据通常会具备这些特点:
- 指令明确,不模糊
- 回答风格稳定
- 尽量少自相矛盾
- 覆盖你真正关心的任务类型
- 拒答、格式化、多轮约束等行为边界清晰
反过来说,如果数据里同一种任务的输出风格互相打架,模型就很难学得稳定。
6. chat template:为什么不能直接把 messages 扔进模型¶
这里是很多人第一次做 SFT 时最容易踩坑的地方。
模型并不能直接理解 Python 字典或 JSON 结构。
它真正接收的,永远还是:
一串 token。
所以 messages 只是人类和程序方便操作的数据结构。真正送进 tokenizer 和模型之前,必须先把它们渲染成字符串模板,这就是 chat template。
6.1 chat template 到底做了什么¶
它的任务可以概括成一句话:
把带角色的信息,转换成某个模型在训练和推理时约定好的文本格式。
例如同样一组消息,不同模型可能会渲染成不同形式:
也可能是:
或者别的特殊 token 方案。
6.2 为什么 template 这么重要¶
因为模型在 SFT 里学到的,不只是回答内容,还包括:
- 角色边界在哪里
- 哪一段是用户输入
- 哪一段是 assistant 应该继续生成的区域
- 一条对话什么时候结束,下一轮怎么开始
如果 template 和模型原本预期的不一致,就可能出现:
- 角色混乱
- 回答起始位置错位
- 特殊 token 用错
- 推理时格式异常
所以一个非常重要的工程原则是:
训练时怎么拼,推理时就尽量怎么拼。
6.3 为什么很多微调翻车,根源在模板不一致¶
有些人会觉得:
“我数据没问题,loss 也在降,为什么模型一推理就变怪了?”
很常见的原因不是训练算法错了,而是:
- 训练时手写了一套 prompt 格式
- 推理时又换成了另一套 chat 接口格式
- 模型从来没学过这种新边界
这会让模型表现得像“听不懂话”,其实它只是没在自己熟悉的对话协议里工作。
7. labels、loss mask 和 ignore_index 到底在干什么¶
这一节是 SFT 训练里最关键的技术细节之一。
因为表面看起来,你只是把一整段“system + user + assistant”拼起来送给模型;但训练时真正的问题是:
loss 应该算在哪些 token 上?
7.1 如果全都算 loss,会发生什么¶
假设一条样本被拼成:
如果你对整段序列所有 token 都计算 loss,那么模型不只会学:
- assistant 回答应该怎么写
还会被迫学:
- user 提示本身的 token
- 固定模板前缀
- 各种重复出现的系统文本
这并不是完全不能做,但通常不是我们最想优化的目标。
因为在 instruction tuning 里,我们更关心的是:
给定提示后,assistant 该怎么回答。
7.2 更常见的做法:只让 assistant 部分承担主要 loss¶
所以很多 SFT 数据处理会把:
- prompt 部分的 label 设成
-100 - assistant 回答部分保留真实 token id
在 PyTorch / Hugging Face 的交叉熵实现里,-100 常被当作 ignore_index,表示这个位置不参与 loss。
于是 labels 可能长成这样:
input_ids = [101, 23, 45, 88, 99, 102, 501, 502, 503]
labels = [-100, -100, -100, -100, -100, -100, 501, 502, 503]
这背后的意思是:
- 前面是提示和模板,不计入监督损失
- 后面 assistant 的目标回答,才是真正需要拟合的部分
7.3 为什么这件事本质上是在定义“你希望模型学什么”¶
很多初学者把 loss mask 当成一个很机械的实现细节,但它其实对应的是训练目标本身。
如果你 mask 掉用户输入部分,你表达的就是:
我默认这些提示在推理时已经给定,我现在要优化的是模型如何响应。
而如果你把整段都训练进去,你优化的目标就更接近:
让模型继续拟合整条对话序列。
两者不是绝对谁对谁错,但在大多数助手型 SFT 里,前者更常见,也更符合直觉。
7.4 和第 9 章的 shift 是什么关系¶
这里很容易和第 9 章混淆。
第 9 章讲的是:
- 输入和 labels 在时间上要错开一位,也就是
shift
而这一章额外多出来的是:
- labels 里有些位置虽然在序列里存在,但我们故意不让它们参与 loss,也就是
mask
所以可以把两件事分开理解:
shift决定“预测下一个 token”mask决定“哪些 token 的预测误差要计入优化目标”
8. 一个最小可用的 SFT 训练流程¶
到这里,我们就可以把整条链路串起来了。
8.1 第一步:准备高质量样本¶
假设原始数据长这样:
{
"messages": [
{"role": "system", "content": "你是一名耐心的机器学习助教。"},
{"role": "user", "content": "请解释训练集、验证集、测试集的区别。"},
{"role": "assistant", "content": "训练集用于拟合模型参数,验证集用于调参与早停,测试集用于在最终固定方案后评估泛化表现。"}
]
}
8.2 第二步:用 chat template 渲染成文本¶
伪代码可以写成:
text = tokenizer.apply_chat_template(
sample["messages"],
tokenize=False,
add_generation_prompt=False,
)
这一步之后,你得到的不再是结构化对象,而是一段带角色标记的完整文本。
8.3 第三步:tokenize,并构造 labels¶
encoded = tokenizer(
text,
truncation=True,
max_length=1024,
)
input_ids = encoded["input_ids"]
labels = input_ids.copy()
如果你的策略是“只训练 assistant 回复”,那么接下来还要根据模板边界,把 user 和 system 对应的位置改成 -100。
真正的实现里,多轮对话往往需要更精细地定位每个 assistant span,但核心思想就是这样。
8.4 第四步:送入模型计算 causal LM loss¶
这时 Hugging Face 的 AutoModelForCausalLM 往往会帮你处理好内部的 shift。
所以从训练者视角看,你最该关注的是:
- 序列拼得对不对
- label mask 打得对不对
- 长度截断有没有破坏答案边界
8.5 第五步:监控的不只是训练 loss¶
SFT 里一个很常见的问题是:
训练 loss 在降,但实际对话体验没有同步变好。
所以第 11 章讲的评估在这里马上就要接回来。
至少要看:
- 几个核心任务样本的人工抽查
- 格式遵循是否稳定
- 拒答边界有没有被破坏
- 领域任务的小型验证集是否真的变好
也就是说,SFT 不是“把数据喂完、loss 降了”就结束,而是要看它有没有把你在评估里关心的行为真正推上去。
9. 一个最小代码例子¶
下面给一个偏教学化的最小示意。它不追求工业完备,但足够帮助你把“模板 - tokenize - mask - loss”这条线看通。
from transformers import AutoTokenizer, AutoModelForCausalLM
model_name = "Qwen/Qwen2.5-0.5B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name)
sample = {
"messages": [
{"role": "system", "content": "你是一名简洁的 AI 助手。"},
{"role": "user", "content": "请用一句话解释什么是过拟合。"},
{
"role": "assistant",
"content": "过拟合是指模型在训练集上表现很好,但对未见数据的泛化能力较差。"
},
]
}
text = tokenizer.apply_chat_template(
sample["messages"],
tokenize=False,
add_generation_prompt=False,
)
enc = tokenizer(text, return_tensors="pt")
input_ids = enc["input_ids"]
labels = input_ids.clone()
# 下面这行只是示意:真实项目里需要更稳妥地定位 assistant 回复起点
assistant_text = sample["messages"][-1]["content"]
prefix_text = text.split(assistant_text)[0]
assistant_start = len(tokenizer(prefix_text, add_special_tokens=False)["input_ids"])
labels[:, :assistant_start] = -100
outputs = model(input_ids=input_ids, labels=labels)
print(outputs.loss.item())
这个例子最值得注意的不是 API 名字,而是其中的三个动作:
- 用模型自带 template 保持格式一致
- 先构造完整序列,再决定哪些 token 参与 loss
- 把“用户提示已知,assistant 回复待学习”这件事明确编码进 labels
10. SFT 的价值和边界¶
讲到这里,一个常见误解是:
“只要做了 SFT,模型就会全面变强。”
这并不准确。
10.1 SFT 最擅长优化什么¶
SFT 往往最擅长改善这些维度:
- 指令遵循
- 回答风格一致性
- 输出格式稳定性
- 特定任务的示范式学习
- 某些领域场景下的实用性
也就是说,它特别适合做“行为塑形”。
10.2 SFT 不太擅长单独解决什么¶
但如果底座模型本身能力就不够,SFT 通常不能凭空创造出非常强的新能力。
例如:
- 底座推理能力太弱,少量 SFT 很难神奇补出来
- 底座知识储备不足,SFT 也不等于持续更新知识库
- 数据质量差时,SFT 甚至可能损伤模型原本的泛化性
所以更合理的理解是:
SFT 更像是把已有能力朝目标任务重新排布,而不是无中生有。
10.3 为什么后面还需要偏好对齐¶
即便做完 SFT,模型也可能仍然存在这些问题:
- 回答太长或太啰嗦
- 明明能答,却总是风格不稳定
- 对多个“都差不多对”的答案,不知道哪种更符合人类偏好
这就是为什么很多训练链路里,SFT 后面还会接:
- RLHF
- DPO
- RLAIF
因为 SFT 主要学的是“示范答案长什么样”,而偏好对齐进一步优化的是:
在多个可行答案之间,哪一种更符合人类偏好。
11. SFT 和下一章 LoRA / QLoRA 的关系¶
到这里,你已经可以理解一个关键事实:
第 13 章讲的 LoRA / QLoRA,解决的不是“要不要做 SFT”,而是“怎样更省资源地做 SFT”。
也就是说,任务目标没有变:
- 还是 instruction tuning
- 还是 chat 数据
- 还是 labels 和 loss mask
- 还是围绕评估目标做行为塑形
变的是参数更新方式:
- 全量微调会更新大量参数,成本高
- LoRA 只训练低秩增量参数,更省显存和存储
- QLoRA 在量化基础上进一步降低训练资源门槛
所以本章讲清的是“训练什么”,下一章要讲的是“怎么更便宜地训练”。
12. 常见误区¶
误区 1:SFT 就是在给模型灌知识¶
不完全对。
SFT 当然可能带来一些领域知识补充,但它最核心的价值通常是行为塑形,而不是替代大规模预训练。
误区 2:把数据格式转成 messages,就等于做好了指令微调¶
不对。
messages 只是容器。真正决定效果的仍然是数据内容质量、模板一致性和 loss 定义。
误区 3:训练 loss 下降,就说明助手体验一定提升¶
不对。
loss 只说明模型更擅长拟合这批训练样本,不保证它在真实任务、真实输入分布上一定更好,所以必须接回第 11 章的评估思路。
误区 4:所有 token 都应该参与 loss,信息越多越好¶
不一定。
很多场景里,我们最关心的是 assistant 回复质量,而不是让模型重新背用户输入和固定模板,所以 label mask 往往是必要设计,而不是可有可无的“小优化”。
误区 5:SFT 是一个纯算法问题¶
也不对。
在实际工程里,SFT 的成败常常更依赖:
- 数据清洗
- 模板统一
- 截断策略
- 样本混合比例
- 评估闭环
算法本身反而未必是最难的部分。
13. 面试问题¶
Q1:为什么一个预训练模型通常还需要做 SFT?¶
因为预训练主要优化的是通用 next-token prediction,它让模型学会语言和知识分布,但不保证模型会按用户期望完成指令、遵守格式和维持对话角色。SFT 用高质量示范数据把这些行为进一步塑形成助手式输出。
Q2:SFT 和 pretraining 的 loss 从底层看有什么共同点?¶
两者底层通常都还是 causal language modeling,本质都是预测下一个 token。区别在于训练数据分布不同:pretraining 用通用文本,SFT 用高质量的任务示范和对话数据。
Q3:为什么 SFT 里经常要把一部分 labels 设成 -100?¶
因为很多助手场景只希望 assistant 回复部分参与 loss,而 system / user 提示是已知条件,不是主要优化目标。把这些位置设成 -100 相当于用 ignore_index 把它们从 loss 中排除。
Q4:chat template 的作用是什么?¶
它负责把结构化的对话消息渲染成模型实际能读入的一串 token 序列,并显式编码角色边界、轮次边界和生成起点。训练和推理模板不一致,往往会直接导致模型行为异常。
Q5:为什么说 LoRA / QLoRA 和 SFT 不是对立关系?¶
因为 LoRA / QLoRA 主要解决的是微调成本问题,而 SFT 解决的是训练目标和数据形式问题。很多时候我们做的其实是“用 LoRA 的方式来完成 SFT”。
14. 小结¶
这一章最重要的主线,其实只有一句话:
SFT 不是重新发明语言模型训练,而是把预训练得到的能力,借助高质量示范数据,重新塑形成更符合人类指令和应用目标的行为。
如果把核心链路压缩成四步,就是:
- 准备高质量 instruction / chat 数据
- 用正确的 chat template 把它们拼成模型熟悉的序列
- 用合适的 labels 和 loss mask 定义“到底学什么”
- 用评估去验证模型是否真的更会完成目标任务
理解了这条线,后面你再看 LoRA、QLoRA、偏好对齐,思路就会顺很多。
下一章我们就沿着这条主线继续往前,看看:
当全量 SFT 太贵时,我们怎样用参数高效微调的方法,把同样的任务做得更轻、更便宜,也更适合个人项目和求职作品。