指令微调 SFT 本节摘要:基座模型只预测下一个 token,仅此而已。它不遵循指令、不回答问题、不拒绝有害请求。SFT(监督微调)是「token 预测器」与「有用助手」之间的桥梁——你用过的每个模型(Claude、GPT、Llama Chat)都走过这一步。本节带你实现 SFT:把基座模型变成遵循指令的助手。核心是一个大多数教程略过的技术细节——掩码损失(只在响应 token 上算损失,指令 token 只提供上下文)。你会理解为什么 27,000 个高质量样本就够了(预训练给了知识,SFT 只给「礼貌」),以及学习率为何要降 15 倍以防灾难性遗忘。 学习目标 阅读完本节,你应当能够: 实现监督微调(SFT),把基座语言模型变成遵循指令的助手。
本节摘要:基座模型只预测下一个 token,仅此而已。它不遵循指令、不回答问题、不拒绝有害请求。SFT(监督微调)是「token 预测器」与「有用助手」之间的桥梁——你用过的每个模型(Claude、GPT、Llama Chat)都走过这一步。本节带你实现 SFT:把基座模型变成遵循指令的助手。核心是一个大多数教程略过的技术细节——掩码损失(只在响应 token 上算损失,指令 token 只提供上下文)。你会理解为什么 27,000 个高质量样本就够了(预训练给了知识,SFT 只给「礼貌」),以及学习率为何要降 15 倍以防灾难性遗忘。
阅读完本节,你应当能够:
你在第 04 节训了个模型,能给定序列预测下一 token。喂「Transformer 架构」它可能续「彻底改变了自然语言处理」,作为下一 token 预测器很厉害。
现在试这个:喂「法国的首都是什么?」基座模型不答「巴黎」,它续写模式——可能产出「德国的首都是什么?西班牙的首都是什么?」(因为它学过含问题列表的文档),或产出「是一个很多人问的问题」(合理的下一 token 续写)。模型没有「回答」的概念,只知「续写」。
这就是 GPT-3(基座,2020 年 6 月)与 ChatGPT(指令微调,2022 年 11 月)的鸿沟。同样架构、同样预训练。差别是 2 万到 10 万对精心制作的(指令,响应)配对,教会模型遵循对话模式。
Stanford Alpaca 证明了你不需要百万样本。2023 年 3 月,他们用 52,000 对 GPT-3.5 生成的指令-响应对微调 Llama 7B,总成本 600 美元,数小时训练,得到一个能遵循指令、回答问题、对话的聊天机器人——虽不及 ChatGPT,但 600 美元能做到如此接近,令人震惊。Meta 的 Llama 2 Chat 初始 SFT 阶段只用了约 27,000 个高质量样本。关键洞见:质量重于数量,27,000 个熟练标注员写的样本胜过 100 万条从网上抓的噪声样本。
监督微调延续预训练的同一训练循环——前向、算损失、反向、更新权重——但换了一种数据。不再是原始文本,而是结构化对话:
{"system":"你是有帮助的助手。","user":"法国的首都是什么?","assistant":"法国的首都是巴黎。"}
模型早知道巴黎是法国首都——这在维基百科、教科书、网页的预训练里学过。SFT 不教模型新事实,它教模型一种新行为:看到问题就产出答案,看到指令就产出完成,看到有害请求就产出拒绝。预训练给知识,SFT 给礼貌。
业界三大格式,编码同样信息(谁说了什么),分隔符不同。Alpaca 格式(Stanford):instruction/input/output 三字段,52K 样本 600 美元,开启了开源指令微调运动。ShareGPT 格式(社区):conversations 列表,支持多轮对话,Vicuna 在 70,000 条 ShareGPT 对话上训练。ChatML 格式(OpenAI,许多开源模型用):用 <|im_start|>、<|im_end|> 特殊 token 分隔角色,Qwen、Yi 等用之。三种格式做同一件事:告诉模型「这是指令,这是响应,学这个模式」。
这是 SFT 中最重要的技术细节,大多教程略过。
预训练时,你在每个 token 上算损失——模型学着预测序列里每个下一 token。SFT 时,你只在响应 token 上算损失。指令 token 提供上下文,但模型不为「预测错」它们而受罚。为什么?因为你不想让模型学会生成指令,你想让它学会响应指令。若在指令 token 上算损失,等于训练模型去预测「法国的首都是什么?」仿佛它在提问——这浪费梯度信号,还会混淆模型对自己角色的认知。
实践中你造一个损失掩码:响应 token 为 1,指令 token 为 0,逐 token 损失乘掩码再平均。
token: [SYS] 你是有帮助的 [USER] 法国首都? [ASST] 巴黎是首都 [EOS] 损失掩码: 0 0 0 0 0 0 0 1 1 1 1 1 1
只有 [ASST] 之后的 token 贡献损失。前向时模型看到完整对话(它需要指令才能产出正确响应),但只按预测响应的好坏更新权重。
SFT 用与预训练截然不同的超参数。你不是从零训练,而是在调一个已经能用的模型。
| 参数 | 预训练(Llama 2 7B) | SFT(Llama 2 Chat) |
|---|---|---|
| 学习率 | 3e-4(峰值) | 2e-5 |
| 轮数 | 1(过一遍数据) | 2 |
| 批大小 | 4M token | 64 样本 |
| 数据量 | 2T token | 27,000 样本 |
学习率低 15 倍是关键——微调时高学习率会摧毁预训练知识,模型「忘掉」所学、过拟合到小数据集,这就是灾难性遗忘。三种缓解:(1) 低学习率(1e-55e-5);(2) 短训练(13 轮,小数据集超 3 轮就开始逐字背诵训练样本);(3) 混入预训练数据(Llama 2 Chat 把 2~5% 原始预训练数据混进 SFT 数据集,「提醒」模型通用能力)。
7B 模型在 10,000 对高质量指令上微调,单张 A100 80GB 约需 1 小时:10,000 样本 × 512 token = 5.12M token,2 轮 = 10.24M token,A100 对 7B 微调约 3000 token/秒,合计约 3400 秒 ≈ 57 分钟。
INSTRUCTION_DATA = [ {"instruction":"法国的首都是什么?","response":"法国的首都是巴黎。"}, {"instruction":"用一句话解释引力。","response":"引力是使有质量的物体相互吸引的力。"}, # ... 共 8 条样本(生产中 27K~52K) ]
8 条很小(Alpaca 用 52K),但无论 8 还是 52K 机制相同:分词、掩码、只在响应上算损失。
把指令-响应对转成带特殊角色标记的 token 序列。
SPECIAL_TOKENS = {"INST_START":253, "INST_END":254, "RESP_START":255} def tokenize_instruction_pair(instruction, response, vocab_size=256): inst = [min(t, vocab_size-4) for t in instruction.encode("utf-8")] resp = [min(t, vocab_size-4) for t in response.encode("utf-8")] return [253] + inst + [254] + [255] + resp # [INST] 指令 [INST_END] [RESP] 响应 def create_loss_mask(tokens): mask = np.zeros(len(tokens), dtype=np.float32) in_resp = False for i, t in enumerate(tokens): if t == SPECIAL_TOKENS["RESP_START"]: in_resp = True; continue if in_resp: mask[i] = 1.0 # 只有 [RESP_START] 之后掩码为 1 return mask
标准交叉熵,但乘损失掩码,只有响应 token 贡献梯度。分母是响应 token 数而非序列长度——若除序列长度,长指令会稀释梯度信号。
def masked_cross_entropy_loss(logits, targets, loss_mask): log_softmax = ... # 标准数值稳定实现 per_token = -log_softmax[arange, targets] masked = per_token * loss_mask return masked.sum() / loss_mask.sum() # 除以响应 token 数
复用第 04 节的 MiniGPT,循环几乎与预训练相同,只是多了指令格式化与掩码损失。学习率 2e-5(Llama 2 Chat),对比预训练的 3e-4——小 15 倍。梯度被掩码:指令 token 产零梯度,只有响应 token 推权重。
SFT 的全部意义是行为改变。测模型如何响应指令格式输入,而非续写原始文本。小模型 8 样本上,响应不会有意义——预期之内。重要的是结构:模型学会了在响应标记后产出输出,而非继续生成更多指令。
对比 SFT 前后模型在原始文本上的下一 token 预测能力。若 SFT 损伤通用能力,原始文本损失会上升。真实微调中全程追踪此指标——若原始文本损失升超 10~15%,说明 SFT 太激进,降学习率或减轮数。
HuggingFace TRL 的 SFTTrainer 把上述封装成几行:
from trl import SFTTrainer, SFTConfig trainer = SFTTrainer(model=model, train_dataset=ds, args=SFTConfig(...)) trainer.train()
它自动处理聊天模板、损失掩码、数据集打包。底层与我们手写的逻辑相同。生产中你用 TRL,但理解掩码损失才能诊断「为什么模型在生成指令而非响应」这类问题。
本节产出 outputs/prompt-sft-data-curator.md——一个帮你设计与策展 SFT 指令数据集的提示。给定目标能力(代码生成、数学、对话),它产出含格式规范、质量标准、多样性要求的数据采集计划。
(Easy) 给 tokenize_instruction_pair 加系统提示支持,在指令前加系统消息。造 5 条不同系统提示(「你是诗人」「你是数学导师」)的样本。
(Medium) 实现数据混合:SFT 数据集中 5% 是原始文本(不掩码)、95% 是指令对(掩码),跑 3 轮对比纯 SFT 的遗忘指标。
(Medium) 构建数据质量打分器:对每对算响应 token 数、指令响应比、词汇多样性(唯一/总数),过滤响应<10 token 或多样性<0.3 的样本,看过滤如何影响最终损失。
(Hard) 实现多轮对话训练:扩展分词处理 3 轮对话(用户-助手-用户-助手-用户-助手),损失掩码覆盖三个助手轮次。
(Hard) 对比学习率:用 1e-4、2e-5、1e-6 各训一次画损失曲线。1e-4 初期降快但最终损失高(过拟合),1e-6 几乎不动,2e-5 是甜点。
<|im_start|>),都是「这是指令这是响应」。下一节,SFT 之上的进阶:RLHF。SFT 教模型遵循指令,但教不了哪个响应更好——RLHF 把人类判断编码进模型行为。