跳转至

第 12 章:Supervised Fine-Tuning,SFT

1. 本章要解决的问题

第 11 章里,我们已经把评估这件事讲清楚了一个关键结论:

模型好不好,不是只看 pretraining loss,也不是只看某个 benchmark 排名,而是要看它能不能在目标任务上稳定地产生我们想要的行为。

但这时马上会出现一个非常现实的问题:

如果一个预训练模型已经“会语言、会知识、会续写”,为什么它还是经常不会好好回答人话指令?

比如你把一个纯预训练模型拿来直接问:

请用三点总结 Transformer 为什么适合并行训练。

它可能会出现几种常见情况:

  • 继续补全成一段像网页正文的文字,而不是老老实实回答问题
  • 输出风格飘忽,不知道什么时候该分点、什么时候该解释
  • 明明知道相关知识,但不一定按用户要求的格式组织答案
  • 在多轮对话里分不清 system、user、assistant 各自扮演什么角色

这说明一件很重要的事:

预训练学到的是“语言分布”,但用户真正需要的往往是“按指令完成任务的行为”。

这就是 SFT 要解决的问题。

从全书结构上看,这一章有三个作用:

  • 它承接第 11 章,把“评估希望模型表现出什么行为”推进到“我们怎样把这些行为教给模型”
  • 它作为后训练的起点,解释 instruction tuning 到底在优化什么
  • 它也为第 13 章的 LoRA / QLoRA 做准备,因为后面很多微调工程,本质上都是在用更省资源的方法完成同一类 SFT 任务

如果第 11 章回答的是:

我们怎样判断一个模型是不是真的更好。

那么这一章要回答的就是:

当我们已经有一个预训练底座后,怎样用监督数据把它进一步塑形成“会听指令、会对话、会按格式完成任务”的助手模型。

2. 你学完后应该会什么

  • 能解释为什么预训练模型往往还需要 SFT
  • 能说清 instruction tuning 和 pretraining 在训练目标上的区别
  • 能看懂常见的指令数据格式:instruction-input-outputmessages
  • 能理解 chat template 为什么重要,以及它到底把什么东西拼进了序列
  • 能理解 labelsignore_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 一个最小训练样本长什么样

最朴素的例子可以写成:

输入:请把下面这句话翻译成英文:机器学习正在改变软件行业。
输出:Machine learning is changing the software industry.

如果把它变成训练样本,模型看到的其实是一段拼接后的序列。它需要学会的是:

当前面出现这类指令时,后面应该长出这种类型的回答。

4.3 SFT 的核心不是“让模型背答案”

如果数据质量好、覆盖面足够,SFT 学到的通常不是死记硬背某一句话,而是更一般化的行为模式,例如:

  • “翻译任务应该直接给译文”
  • “摘要任务应该压缩冗余信息”
  • “代码修复任务应该输出可执行修正方案”
  • “要求列表时应尽量按列表格式回答”

当然,前提是数据本身足够一致、足够高质量。否则模型也可能学到混乱行为。

5. 指令数据到底长什么样

理解 SFT,最重要的工程入口之一,就是理解数据格式。

因为你最终喂给模型的,不是抽象的“任务”,而是一条条具体样本。

5.1 早期常见格式:instruction / input / output

很多 instruction tuning 数据集会写成这样:

{
  "instruction": "用一句话解释什么是梯度下降",
  "input": "",
  "output": "梯度下降是一种通过沿损失函数负梯度方向迭代更新参数来最小化误差的优化方法。"
}

或者:

{
  "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 到底做了什么

它的任务可以概括成一句话:

把带角色的信息,转换成某个模型在训练和推理时约定好的文本格式。

例如同样一组消息,不同模型可能会渲染成不同形式:

<|system|>
你是一名简洁、严谨的 AI 助手。
<|user|>
请解释为什么 self-attention 比 RNN 更容易并行化。
<|assistant|>
因为……

也可能是:

[INST] 请解释为什么 self-attention 比 RNN 更容易并行化。 [/INST]
因为……

或者别的特殊 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,会发生什么

假设一条样本被拼成:

<|user|>
请解释什么是梯度爆炸。
<|assistant|>
梯度爆炸指的是在反向传播过程中,梯度值变得异常大,导致训练不稳定。

如果你对整段序列所有 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

labels[:assistant_start] = [-100] * assistant_start

真正的实现里,多轮对话往往需要更精细地定位每个 assistant span,但核心思想就是这样。

8.4 第四步:送入模型计算 causal LM loss

outputs = model(
    input_ids=input_ids,
    labels=labels,
)
loss = outputs.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 不是重新发明语言模型训练,而是把预训练得到的能力,借助高质量示范数据,重新塑形成更符合人类指令和应用目标的行为。

如果把核心链路压缩成四步,就是:

  1. 准备高质量 instruction / chat 数据
  2. 用正确的 chat template 把它们拼成模型熟悉的序列
  3. 用合适的 labels 和 loss mask 定义“到底学什么”
  4. 用评估去验证模型是否真的更会完成目标任务

理解了这条线,后面你再看 LoRA、QLoRA、偏好对齐,思路就会顺很多。

下一章我们就沿着这条主线继续往前,看看:

当全量 SFT 太贵时,我们怎样用参数高效微调的方法,把同样的任务做得更轻、更便宜,也更适合个人项目和求职作品。