8. SFT 基础:从续写到对话
8.1 为什么需要 SFT
预训练模型是「世界模型 + 语言模型」的复合体:它在万亿 token 上学会了英语、中文、Python、HTML、棋谱、化学方程式……几乎一切。但如果你直接拿一个预训练好的 LLaMA-3-base 跑下面这条 prompt:
What is the capital of France?得到的输出大概率是:
What is the capital of Spain? What is the capital of Germany? ...为什么?因为预训练目标是 next-token prediction——它在互联网上见过大量「QA 列表」,于是把你的提问当作了 QA 列表的开头,继续生成更多问题,而不是回答。
模型有「能力」(capability),但没有「对齐」(alignment)。
8.1.1 能力 vs 对齐
LIMA 论文(Zhou et al., 2023)提出的 Superficial Alignment Hypothesis(表面对齐假设)是这一观点的经典表述:
"A model's knowledge and capabilities are learnt almost entirely during pretraining, while alignment teaches it which subdistribution of formats should be used when interacting with users."
也就是说:
- 预训练阶段:模型学到了所有的知识、语法、推理能力。这部分占据了 99% 以上的训练计算。
- 对齐阶段(SFT + RLHF):仅仅是教会模型「用什么格式输出」——以助手的身份、清晰、有礼貌、按指令。
可以用一个比喻:预训练像是把一个天才关在图书馆里十年,他读完了所有书;SFT 像是把他从图书馆请出来,告诉他「现在有客人来了,请用礼貌的语气回答他们的问题」。知识本来就有,对齐只是改变行为分布。
这个观点有重要的实战意义:SFT 数据不应该试图「教模型新知识」,而应该「调取并组织模型已有的知识」。如果一个事实模型在预训练时没学到,再多 SFT 数据也救不回来——这是为什么 LIMA 用 1,000 条高质量样本就足以做出可用的 chat model。
8.1.2 SFT 的目标定义
形式上,SFT 是在指令-响应对数据集
其中
注意这里只对
8.1.3 三阶段流水线中的位置
回顾一下现代 LLM 训练的标准流水线:
Pre-training (PT) ──→ Supervised Fine-Tuning (SFT) ──→ Reinforcement Learning (RL)
万亿 token 数万~数百万指令对 偏好数据 / RLHF / DPO
学到「能力」 学到「指令格式 + 助手风格」 学到「人类偏好」InstructGPT(Ouyang et al., 2022)首次完整地提出三阶段:
- SFT:用人工标注的指令-响应对监督微调。
- RM:训练奖励模型预测人类偏好。
- PPO:用 RL 进一步优化策略。
其中 SFT 是后续 RM 和 RL 的基础——RM 需要的偏好数据是在 SFT 模型输出上标注的;PPO 的策略也是从 SFT 模型初始化的。如果 SFT 没做好,后续阶段无从谈起。
8.2 历史脉络:FLAN、T0、InstructGPT
理解 SFT 的演化需要回到 2021-2022 年。
8.2.1 FLAN(Wei et al., 2021)
Google 提出 Finetuned Language Net:把 137B LaMDA-PT 在 62 个 NLP 数据集 × 620 个 prompt 模板上微调。每个数据集(如 SST-2、SQuAD)配多个 prompt 模板:
Template 1: Is the following review positive or negative?
"{review}"
Template 2: Sentiment of "{review}":
Template 3: Answer with positive or negative.
Review: {review}
Sentiment:FLAN 是 "instruction tuning" 这个术语的提出者。其关键发现:
- 任务多样性比数据量更关键:训练任务种类从 10 增加到 60,零样本性能持续提升。
- 规模门槛:8B 以下的模型 instruction tuning 收益不明显,137B 才显著超过零样本基线。
8.2.2 T0(Sanh et al., 2021)
BigScience 团队基于 11B T5(encoder-decoder),使用 P3(Public Pool of Prompts)数据集:170 个任务 × 1939 个模板。T0 证明:
- 多模板 prompt 显著提升泛化(同一任务用不同表述)。
- 即使是 11B 这样的「中等」模型,在大量任务上 SFT 也能获得强零样本能力。
8.2.3 InstructGPT(Ouyang et al., 2022)
OpenAI 基于 175B GPT-3 训练,与 FLAN/T0 的关键区别:
- 数据来源:用 OpenAI Playground 真实用户的指令,而不是学术 NLP 数据集模板。
- 数据多样性:开放式生成、头脑风暴、改写、闲聊等,远比 FLAN/T0 的「分类、QA、摘要」类任务广。
- 加入 RLHF:SFT 之后还做了 RM + PPO。
InstructGPT 的论文做了对照实验:把 GPT-3 在 FLAN 数据上 SFT,再在「真实 API 分布」上评测,结果仍不如用真实用户数据 SFT 的模型。结论:
数据分布 ≫ 数据规模
这是 InstructGPT 留给整个社区最深刻的工程教训:与其从公开 NLP 数据集堆 100 万条样本,不如从真实使用场景获取 5 万条样本。
8.2.4 三者对比
| 维度 | FLAN | T0 | InstructGPT |
|---|---|---|---|
| 基座 | LaMDA-PT 137B | T5 11B | GPT-3 175B |
| 架构 | Decoder | Encoder-Decoder | Decoder |
| 数据来源 | 62 NLP 数据集 + 620 模板 | P3(170 数据集 + 1939 模板) | OpenAI API + 人工标注 |
| 训练方式 | 仅 SFT | 仅 SFT | SFT + RLHF |
| 指令风格 | 学术 NLP 任务 | 学术 NLP 任务 | 真实用户开放任务 |
| 关键发现 | 任务多样性 + 规模都重要 | 多模板提升泛化 | 真实分布 ≫ 数据规模 |
8.3 数据格式
SFT 样本的核心是「(指令, 输入, 输出)」三元组,但具体编码方式经历了几代演变。
8.3.1 Alpaca 格式(单轮,遗产)
Stanford Alpaca(Taori et al., 2023)使用的简洁格式:
{
"instruction": "Translate the following sentence to French.",
"input": "I love programming.",
"output": "J'aime la programmation."
}或不带 input 的:
{
"instruction": "Write a haiku about autumn.",
"input": "",
"output": "Crimson leaves descend / Whispers in the chilly breeze / Nature's quiet song"
}训练时拼接为 prompt 模板:
Below is an instruction that describes a task, paired with an input that provides further context.
Write a response that appropriately completes the request.
### Instruction:
{instruction}
### Input:
{input}
### Response:
{output}现状:Alpaca 格式现在被视为遗产格式。它无法表示多轮对话、无法承载工具调用、特殊 token 不属于 tokenizer 词表。新的项目应使用 ChatML 或 OpenAI 格式。
8.3.2 ShareGPT 格式(多轮)
源自 sharegpt.com 收集的 ChatGPT 对话,原生支持多轮:
{
"conversations": [
{"from": "human", "value": "你好"},
{"from": "gpt", "value": "你好!有什么可以帮你?"},
{"from": "human", "value": "讲个笑话"},
{"from": "gpt", "value": "为什么程序员喜欢黑暗?因为光会带来 bug。"}
]
}LLaMA-Factory 等工具扩展了角色集:human / gpt / observation / function / system,用于工具调用训练。observation 是 tool 返回的结果,function 是 model 的工具调用 JSON。
8.3.3 OpenAI Chat 格式(事实标准)
{
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is the capital of France?"},
{"role": "assistant", "content": "Paris."},
{"role": "user", "content": "And of Spain?"},
{"role": "assistant", "content": "Madrid."}
]
}支持 tool_calls、tool 角色、多模态 content(图片、音频)。这是当前训练 + 推理的事实标准:HuggingFace、TRL、vLLM、SGLang、Ollama 全部支持。OpenAI 格式可以视为 ShareGPT 的特例(字段名不同:role/content vs from/value)。
工具调用扩展示例:
{
"messages": [
{"role": "user", "content": "What's the weather in Tokyo?"},
{
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_abc",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\": \"Tokyo\"}"}
}]
},
{"role": "tool", "tool_call_id": "call_abc", "content": "{\"temp\": 22, \"condition\": \"sunny\"}"},
{"role": "assistant", "content": "Tokyo is currently 22°C and sunny."}
]
}8.3.4 三种格式对照
| 格式 | 多轮 | 工具调用 | 多模态 | 推荐 |
|---|---|---|---|---|
| Alpaca | × | × | × | 历史/教学 |
| ShareGPT | ✓ | 扩展支持 | × | 中文社区常用 |
| OpenAI Chat | ✓ | ✓ | ✓ | 事实标准 |
实战中可以用脚本互转。HuggingFace datasets 库下许多新发布的 SFT 数据集已直接采用 OpenAI 格式。
8.4 损失掩码(Loss Masking):SFT 的工程命门
SFT 训练最容易出错也最关键的工程细节,是 loss masking:把 system / user 部分的 token 标签设为 -100(PyTorch CrossEntropyLoss 的 ignore_index),只在 assistant 输出上计算并反向传播 loss。
8.4.1 为什么必须 mask
考虑一条简单的训练样本:
User: 写一首关于秋天的诗。
Assistant: 红叶飘落 / 微风轻拂 / 自然在低语不 mask 时模型在每一个 token 位置都要计算 loss,包括「写一首关于秋天的诗」。这意味着:
- 模型在浪费容量学习「预测用户提问」——但推理时它根本不需要生成 user 部分。
- 在多轮对话中,user 输入往往远长于 assistant 输出(如长文档总结),模型会被 user 主导,真正要学的 assistant 风格反而被稀释。
- 极端例子:Universal-NER 数据集中 user 部分(输入文本)经常是 assistant 部分(NER 标签)的 100 倍长度,不 mask 会让 SFT 完全失效。
mask 之后,损失函数变为:
其中 shift_logits 自动处理)。
8.4.2 实证:mask 与不 mask 的差异
Yoni Gottesman 在博客中复现的实验(2024):
| 数据集 | user/assistant 长度比 | 不 mask EM | mask 后 EM |
|---|---|---|---|
| Alpaca-cleaned | 1 : 2 | 60.2 | 61.5 |
| Universal-NER | 100 : 1 | 8.4 | 71.3 |
| Code-Alpaca | 1 : 5 | 55.1 | 56.0 |
可以看到:
- 均衡数据(user/assistant 长度差不多):mask 收益小但仍正向。
- 极不均衡(user 远长于 assistant):mask 是生死线——不 mask 训练几乎无效。
经验法则:总是 mask。代价为零,收益至少非负。
8.4.3 HuggingFace TRL 的实现
trl.SFTTrainer 自 0.10 版本起原生支持 assistant-only loss:
from trl import SFTTrainer, SFTConfig
from transformers import AutoTokenizer, AutoModelForCausalLM
from datasets import load_dataset
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen3-7B")
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3-7B")
ds = load_dataset("HuggingFaceH4/ultrachat_200k", split="train_sft")
config = SFTConfig(
output_dir="qwen3-sft",
assistant_only_loss=True, # 仅在 assistant tokens 上算 loss
completion_only_loss=False, # prompt-completion 数据集时用
max_seq_length=4096,
per_device_train_batch_size=2,
gradient_accumulation_steps=8,
learning_rate=2e-5,
bf16=True,
num_train_epochs=2,
)
trainer = SFTTrainer(model=model, args=config, train_dataset=ds, tokenizer=tokenizer)
trainer.train()assistant_only_loss=True 要求 chat template 中包含 {% generation %} / {% endgeneration %} 标记,TRL 据此生成 assistant_masks。Qwen3、Llama-3、SmolLM2 等知名模型 TRL 会自动 patch 模板;冷门模型需要手动添加。
8.4.4 手动实现 mask
如果需要更精细的控制(如多轮对话中只对最后一轮 mask),可以手写 collator:
import torch
from typing import List, Dict
def tokenize_with_assistant_mask(
messages: List[Dict[str, str]],
tokenizer,
max_len: int = 4096,
):
"""对一条多轮对话样本生成 input_ids 和 labels(user/system 处置 -100)。"""
input_ids: list[int] = []
labels: list[int] = []
for i, msg in enumerate(messages):
# 单独 tokenize 这一段(含其后的特殊 token)
is_last = i == len(messages) - 1
segment_ids = tokenizer.apply_chat_template(
messages[: i + 1],
tokenize=True,
add_generation_prompt=False,
)[len(input_ids):] # 只取增量部分
input_ids.extend(segment_ids)
if msg["role"] == "assistant":
labels.extend(segment_ids) # 学这段
else:
labels.extend([-100] * len(segment_ids)) # 跳过
# 截断
input_ids = input_ids[:max_len]
labels = labels[:max_len]
return {"input_ids": input_ids, "labels": labels}更鲁棒的做法是利用 chat template 中 assistant 起止 token 的 id 做扫描;TRL 的 DataCollatorForCompletionOnlyLM 即采用此思路。
8.4.5 常见 bug
- 忘记 mask:模型 loss 看起来正常下降,但 eval 时输出经常重复用户问题。
- mask 越界:用户问题里包含 assistant 起始 token(如 user 在问 chat template 的写法),mask 索引找错位置。
- label shift 重复计算:HuggingFace 内部已做 shift,自己再 shift 一次会错位。
- packing 导致跨样本 mask 错位:sequence packing 后必须用
cu_seqlens或 4D attention mask 隔离样本。
8.5 Chat 模板与特殊 token
不同模型族使用不同的对话格式(chat template),它们通过特殊 token 区分对话角色和边界。训练和推理必须使用同一个模板,这是 SFT 工程的另一个铁律。
8.5.1 ChatML(OpenAI 提出,Qwen / SmolLM2 等沿用)
<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
What is the capital of France?<|im_end|>
<|im_start|>assistant
Paris.<|im_end|>- 特殊 token:
<|im_start|>/<|im_end|>标记每轮对话的开始与结束。 - 角色名(system/user/assistant/tool)写在
<|im_start|>后的第一行。 - Qwen 还使用
<|endoftext|>作为文档分隔符。
8.5.2 Llama-3 模板
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
You are a helpful assistant.<|eot_id|><|start_header_id|>user<|end_header_id|>
What is the capital of France?<|eot_id|><|start_header_id|>assistant<|end_header_id|>
Paris.<|eot_id|><|begin_of_text|>:BOS(仅出现一次)。<|start_header_id|>...<|end_header_id|>:包裹角色名。<|eot_id|>:每轮结束(end-of-turn)。- 注意 header 后必须有两个换行符。
8.5.3 Llama-2 模板
<s>[INST] <<SYS>>
You are a helpful assistant.
<</SYS>>
What is the capital of France? [/INST] Paris. </s>[INST]/[/INST]包裹用户输入(不是 token,是字面字符串!)。<<SYS>>...<</SYS>>包裹 system prompt。- 多轮时用
</s><s>[INST]串联。 - Llama-2 的模板没有 explicit assistant token——assistant 部分就是
[/INST]之后到下一个</s>之间。
8.5.4 Mistral 模板
<s>[INST] What is the capital of France? [/INST] Paris.</s>类似 Llama-2 但更精简,无 <<SYS>>(system 直接拼到第一轮 user 前)。
8.5.5 工程要点
使用
apply_chat_template:HuggingFace tokenizer 会自动加载模型的chat_template(Jinja2 字符串):pythontext = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)训练 vs 推理一致性:训练时
add_generation_prompt=False(包含 assistant 内容),推理时add_generation_prompt=True(只到 assistant 起始 token,让模型续写)。避免双 BOS:
apply_chat_template(tokenize=False)已经包含 BOS,再 tokenize 时务必add_special_tokens=False:pythonids = tokenizer(text, add_special_tokens=False).input_ids否则会变成
<s><s>[INST]...,模型在训练时没见过这种序列,行为会异常。不要乱改 BOS:把
bos_token设置成<|im_start|>是常见错误(看着像「开始 token」),会导致第一轮出现双起始符。special token 必须在 tokenizer 词表中:自定义模板若用了新 token(如
<|tool_call|>),需tokenizer.add_special_tokens({"additional_special_tokens": [...]})并model.resize_token_embeddings(len(tokenizer))。
8.5.6 模板调试技巧
把 apply_chat_template 的输出直接打印出来检查:
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello!"},
]
print(repr(tokenizer.apply_chat_template(messages, tokenize=False)))
# 注意 repr 能看出末尾换行/空格的差异ids = tokenizer.apply_chat_template(messages, tokenize=True)
for tok_id in ids:
print(tok_id, repr(tokenizer.decode([tok_id])))逐 token 打印能立刻发现「BOS 是否重复」「换行是否正确」「assistant 起始 token 是否包含」等问题。
8.6 Tokenizer 注意事项
8.6.1 Padding 策略
SFT 训练通常使用 batch 训练,需要 padding:
- 左填充(left padding):推理时使用,确保最后一个有效 token 在同一位置。
- 右填充(right padding):训练时使用,PyTorch causal attention mask 自动处理。
tokenizer.padding_side = "right"是 SFT 训练的标准选择。pad_token:很多 base model 没有 pad_token,常见做法是tokenizer.pad_token = tokenizer.eos_token,但要注意 label 中应把 pad 位置设为 -100。
8.6.2 特殊 token 添加
如果引入新角色(如 tool_call),需要:
new_tokens = ["<|tool_call|>", "<|tool_response|>"]
tokenizer.add_special_tokens({"additional_special_tokens": new_tokens})
model.resize_token_embeddings(len(tokenizer), pad_to_multiple_of=64)pad_to_multiple_of=64 是 GPU 友好的对齐(matmul 在 64 倍数上吞吐最高)。
新 token 的 embedding 是随机初始化的,建议训练前用现有 embedding 的均值初始化:
with torch.no_grad():
emb = model.get_input_embeddings().weight
avg = emb[:-len(new_tokens)].mean(dim=0)
emb[-len(new_tokens):] = avg8.6.3 序列长度统计
训练前务必统计数据集的序列长度分布:
import numpy as np
lens = [len(tokenizer.apply_chat_template(s["messages"])) for s in dataset]
for q in [50, 90, 95, 99, 100]:
print(f"P{q}: {np.percentile(lens, q):.0f}")典型决策:
- 如果 P95 = 2048,P99 = 4096,P100 = 16K:选
max_seq_length = 4096,截断 1% 极长样本。 - 如果分布很长尾(P99 = 32K),考虑 sequence packing 或 truncation。
8.7 一个最小可运行示例
下面是一个完整的、可运行的 SFT 最小示例(使用 TRL):
import torch
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer
from trl import SFTConfig, SFTTrainer
MODEL_ID = "Qwen/Qwen3-1.7B"
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
tokenizer.padding_side = "right"
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID,
torch_dtype=torch.bfloat16,
attn_implementation="flash_attention_2",
)
ds = load_dataset("HuggingFaceH4/ultrachat_200k", split="train_sft[:5000]")
def to_messages(example):
return {"messages": example["messages"]}
ds = ds.map(to_messages, remove_columns=ds.column_names)
config = SFTConfig(
output_dir="qwen3-1.7b-sft",
num_train_epochs=1,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
gradient_checkpointing=True,
learning_rate=2e-5,
lr_scheduler_type="cosine",
warmup_ratio=0.05,
bf16=True,
max_seq_length=2048,
assistant_only_loss=True,
logging_steps=10,
save_strategy="epoch",
optim="adamw_torch_fused",
)
trainer = SFTTrainer(model=model, args=config, train_dataset=ds, tokenizer=tokenizer)
trainer.train()
trainer.save_model("qwen3-1.7b-sft/final")这个脚本在单张 80GB A100 上跑 5K 样本约 30 分钟,可以作为后续章节实验的起点。
8.8 本章小结
- SFT 的本质是对齐而非授知:预训练赋予能力,SFT 调整模型的输出格式分布,让它从「续写文本」变为「以助手身份遵循指令」。
- 数据格式正在收敛到 OpenAI Chat 格式:Alpaca 是历史,ChatML/OpenAI 格式是现在和未来。
- Loss masking 是工程命门:必须只在 assistant token 上计算 loss;漏 mask 在不均衡数据上会让 SFT 失效。
- Chat template 训推一致是铁律:训练时用什么模板,推理时必须一致。
apply_chat_template是首选 API。 - InstructGPT 的核心教训是「数据分布 ≫ 数据规模」:5 万条真实分布数据胜过 100 万条学术任务模板。
思考题
为什么 LIMA 用 1,000 条数据就能 SFT 出可用的助手模型?这是否意味着所有 LLM 都可以用 1K 数据微调? 思考 base model 能力上限、数据分布与质量、任务复杂度等因素。
如果你拿到一个新的 base model,发现它的 tokenizer 没有任何 chat 特殊 token,你会如何为它设计 chat template? 列出至少三种可选方案(复用现有 ASCII 标记、添加新 special token、扩展词表),并讨论各自的优劣。
在多轮对话 SFT 中,是否应该对每一轮 assistant 都计算 loss,还是只对最后一轮? 给出两种策略的实现差异,并设计一个实验来比较它们的效果。