指令微调 SFT:从"会说话"到"会干活"
你给 Base 模型一段话:“请把下面这句话翻译成英文:今天天气不错。”
它可能会接着写:“请把下面这句话翻译成英文:今天天气不错。请把下面这句话翻译成英文:今天天气不错……”
你当场沉默。它不是不会翻译,它是没搞懂你现在是在提问,不是在给它一段要续写的文本。
本文的关系图:
Base 模型:会续写,不会听指令 ↓ 准备数据:指令 + 回答(带角色模板) 一条条 (prompt, response) 样本 ↓ 前向 → 只对回答算 loss → 反向 学会:看到「问题」就输出「回答」,而不是继续编问题 ↓ Instruct / Chat 模型:会听话,但知识还是原来那些一、Base 模型缺的不是知识,是"上班经验"
先把一个常见的误会掰正:Base 模型不是笨,它只是没上过班。
它读过海量文本,知道语法、事实、代码、翻译,甚至知道很多冷门知识。但它学到的唯一技能是"接着往下写"。你给它一个问句,它最自然的反应不是回答,而是把"问句后面通常会跟什么"继续写出来。
所以在它眼里:
请介绍一下杭州不是"一个问题",而是"一段文章的开头"。它可能接着写:
请介绍一下杭州,并说明它有哪些著名景点、适合几月份去、人均消费多少……它以为自己在帮你完善提纲,其实你只是想让它说两句话。
SFT(Supervised Fine-Tuning,监督微调)干的事,就是给这个读完了整座图书馆的人做岗前培训。不是重新上学,是教他:
- 客户问什么,你先回答问题,别接着替客户提问;
- 先给结论,再给理由;
- 不知道就说不知道,别硬编;
- 回答完就停,别没完没了。
知识还是那些知识,变的是"什么时候开口、怎么开口"。
顺便说一句:严格意义上的"指令微调"(instruction tuning)是 SFT 最常见的一种用法;日常语境里两者基本混着叫,本文也不抠这个字眼。
二、SFT 到底在训什么
SFT 的数据长这样:
指令:请把下面这句话翻译成英文:今天天气不错。 回答:The weather is nice today.一条一条,都是"输入 + 标准答案"。训练目标和预训练一模一样——还是猜下一个 Token。区别在于:
只对"回答"那部分算 loss,指令部分不参与。
这里有个容易望文生义的地方:prompt 当然要喂给模型,不然它根本不知道在回答什么。mask 掉的只是"对 prompt 的预测误差",不是 prompt 本身。它看得见问题,只是不用为"复述问题"负责。
这就是 SFT 最核心、也最容易被写错的一个细节。
为什么要这样?因为你要教的是:
P(回答 | 指令) 要高 P(继续编问题 | 指令) 要低如果你把指令也拿来算 loss,模型就会花很多力气去学"怎么把问题复述一遍"——那不是你要的。你要的是一个客服,不是一个复读机。
所以 SFT 的本质不是"往脑子里灌知识",而是把条件概率的形状掰过来:同样一个开头,以前它倾向于继续提问,现在它倾向于给出答案。
用一张表对比一下:
| 维度 | 预训练 | SFT |
|---|---|---|
| 数据 | 无标注文本 | 指令 + 回答对 |
| 数据量 | 几万亿 Token | 几万到几百万条 |
| 目标 | 猜下一个 Token | 同样,但只算回答部分 |
| 主要学到 | 语言、知识、推理种子 | 格式、任务、语气、边界 |
| 成本 | 极高,一次 | 低几个数量级 |
| 产出 | Base 模型 | Instruct / Chat 模型 |
一句话:预训练给它"能力",SFT 教它"把能力用出来"。
三、一条训练样本长什么样
真实训练时,一条样本不是一个字符串,而是一串带角色的 Token:
<|system|> 你是助手。 <|user|> 中国的首都是哪里? <|assistant|> 北京。 <|end|>四个特殊标记,各干各的活:
<|system|>:设定人设和约束,比如"你是简洁的助手"“只回答与客服相关的问题”;<|user|>:用户的指令;<|assistant|>:轮到模型说话了,这个头本身不参与学习,它只是一个发令枪;<|end|>:回答结束。没有它,模型不知道什么时候该停,可能一直说到天荒地老。
下面这段代码把一条样本拼出来,并标出哪些 Token 参与 loss:
importmathfromcollectionsimportdefaultdict SYSTEM="你是助手。"SAMPLES=[("中国的首都是哪里?","北京。"),("日本的首都是哪里?","东京。"),("法国的首都是哪里?","巴黎。"),("德国的首都是哪里?","柏林。"),("1 加 1 等于几?","2。"),("天空为什么是蓝色的?","因为瑞利散射。"),]defbuild(system,user,answer):tokens=["<|system|>"]+list(system)tokens+=["<|user|>"]+list(user)tokens.append("<|assistant|>")start=len(tokens)# 从这里开始才是要学的部分tokens+=list(answer)+["<|end|>"]returntokens,start ALL=[build(SYSTEM,u,a)foru,ainSAMPLES]seq,resp_start=ALL[0]print("① 一条训练样本")print(" 不学习:"+" ".join(seq[:resp_start]))print(" 要学习:"+" ".join(seq[resp_start:]))print(f" prompt{resp_start}个 Token,回答{len(seq)-resp_start}个 Token")N,K,BOS=2,0.01,"<|bos|>"V=len({tfors,_inALLfortins}|{BOS})defpairs(tokens,n):p=[BOS]*(n-1)+tokensreturn[(tuple(p[i-n+1:i]),p[i])foriinrange(n-1,len(p))]deftrain(sequences,n):m=defaultdict(lambda:defaultdict(int))fors,_insequences:forctx,nxtinpairs(s,n):m[ctx][nxt]+=1returnmdefnll(m,ctx,tok):c=m.get(ctx,{})return-math.log((c.get(tok,0)+K)/(sum(c.values())+K*V))m=train(ALL,N)losses=[nll(m,ctx,tok)forctx,tokinpairs(seq,N)]p_loss=sum(losses[:resp_start])r_loss=sum(losses[resp_start:])p_n,r_n=resp_start,len(seq)-resp_start total=p_loss+r_lossprint("② loss 被谁贡献")print(f" prompt:{p_n}个 Token,loss{p_loss:.2f},占{p_loss/total:.0%}")print(f" 回答:{r_n}个 Token,loss{r_loss:.2f},占{r_loss/total:.0%}")print(f" prompt 平均 loss:{p_loss/p_n:.2f}")print(f" 回答平均 loss:{r_loss/r_n:.2f}")print(f" 不 mask 平均 loss:{total/(p_n+r_n):.2f}")print(f" mask 后平均 loss:{r_loss/r_n:.2f}")真实运行结果(本机实测,完整脚本见code/09-sft-demo.py):
① 一条训练样本 不学习:<|system|> 你 是 助 手 。 <|user|> 中 国 的 首 都 是 哪 里 ? <|assistant|> 要学习:北 京 。 <|end|> prompt 17 个 Token,回答 4 个 Token ② loss 被谁贡献 prompt:17 个 Token,loss 6.02,占 65% 回答: 4 个 Token,loss 3.18,占 35% prompt 平均 loss:0.35 回答平均 loss:0.80 不 mask 平均 loss:0.44 mask 后平均 loss:0.80这里的 n-gram 只是把 mask 的账算清楚的小玩具;真实 SFT 用的是 Transformer,但 loss 的定义和 mask 的逻辑一模一样。
四、为什么必须 mask:65% 的 loss 在教它复述问题
看 ② 那段输出,这是整篇最值得盯住的数字。
在这条样本里,prompt 有 17 个 Token,回答只有 4 个。但如果不 mask,prompt 贡献了 65% 的 loss。真实对话里,system prompt 和多轮历史更长,这个比例只会更夸张。
注意,这不是因为 prompt 难——prompt 的平均 loss 只有 0.35,回答是 0.80。prompt 的单个 Token 更容易,但它人多。17 个人一起交作业,总分自然被它拉走。
后果是什么?
模型会把大部分梯度花在"怎么复述问题"上,而不是"怎么回答问题"。你训了半天,它学会的是:看到"中国的首都是哪里?"就把它再写一遍;看到"请翻译这句话"就把它再抄一遍。它成了一个非常擅长重复你说话的模型。
mask 掉之后,梯度只从回答那 4 个 Token 来。loss 数字看起来变高了(0.44 → 0.80),这不是变差,是分母换成了真正要学的部分。就像考试只算大题分,平均分当然会变,但那才是你关心的。
实现上,常见做法是把 prompt 部分的 label 设成-100(PyTorch 的ignore_index),或者维护一个 0/1 的 loss mask。另外两个坑:
- padding 不能被算进 loss,否则模型会努力学习"怎么生成空白";
- packing 时要注意 attention mask,别让后一条样本偷看前一条样本的内容。
很多 SFT 翻车,不是模型笨,是 mask 写错了。
五、数据从哪来:SFT 的胜负手在数据,不在模型
SFT 的模型结构通常不变,超参也不复杂。真正决定效果的是数据。
| 来源 | 优点 | 风险 |
|---|---|---|
| 人工编写 | 质量高、意图清楚、覆盖可控 | 贵、慢、规模有限 |
| 强模型合成 | 便宜、量大、风格统一 | 同质化、错误被放大、许可证问题 |
| 真实用户日志 | 分布最真实、覆盖长尾 | 隐私 / PII、噪声大、质量参差 |
| 公开指令数据集 | 起步快、可对比 | 风格杂、领域不匹配、可能被污染 |
人工数据是天花板,但请人写一万条高质量指令-回答,成本不低。合成数据是现在的主流:用强模型批量生成,再用规则或另一个模型筛。它能把量做起来,但有两个副作用:风格越来越像同一个模子,以及错误会被继承和放大——老师错了,学生跟着错。
真实日志最宝贵,因为它就是用户真正会问的东西。但里面有手机号、地址、内部项目名,清洗不干净就是事故。
还有一个反直觉的结论:SFT 数据不是越多越好。
Meta 的 LIMA 实验用一千条左右精心筛选的指令数据,就训出了相当不错的指令跟随能力。这说明质量和多样性,比堆量更重要。当然,生产环境要覆盖的任务多,几千条通常不够;但"十万条低质数据"往往打不过"一万条高质量、覆盖广的数据"。
数据配比也要讲究:
- 任务要杂:问答、改写、总结、抽取、分类、写代码、拒答、多轮对话;
- 长度要杂:一句话的问题和长文档摘要都得有;
- 要教它说"不知道":不然它遇到不会的也会硬编;
- 混一点预训练数据:防止它在窄领域里训过头,把通用能力忘了;
- 工具调用要单独教:JSON 格式、参数名、什么时候该调工具,这些不会凭空出现。
一句话:SFT 数据不是"越多越好",是"越像你希望它面对的真实请求越好"。你拿十万条写诗数据训它,它写诗确实厉害;你问它报销流程,它可能给你写一首诗。
六、训练细节:低学习率、少轮次、别训忘了
SFT 是"微调",不是"重训",所以有几个和预训练相反的习惯:
学习率要低。预训练可能用 1e-4 量级,SFT 常用 1e-5 到 2e-5(LoRA 可以到 1e-4)。你是在给一个已经会走路的人纠正姿势,不是在教婴儿站立。步子太大,它会把原来会的东西也踩坏。
轮次要少。通常 1 到 3 个 epoch。同一批指令反复训太多遍,模型会开始背答案,甚至在无关问题上也套同一个模板。
小心灾难性遗忘。如果你只用"客服话术"训它,它可能连写代码、做数学的能力都退化。常见做法是混入一部分预训练数据,或者用 LoRA 这类参数高效方法,只动一小部分权重。
LoRA / QLoRA值得单独说一句:它们不更新全部参数,只训练一小块低秩适配器。好处是显存小、成本低、不容易把底座训坏,而且一个底座可以挂多个 LoRA 切换不同任务。代价是上限通常略低于全量微调——但对大多数团队,这个性价比是压倒性的。
模板必须一致。训练时用<|user|>、<|assistant|>,推理时也得用同一套。训练时一套模板、推理时另一套,等于教它普通话、考它粤语。特殊 Token 还要在分词器里正确注册,别让它们被拆成普通字符。
评测别只看 loss。SFT 之后要看的至少有三类:
- 指令跟随:该答的答了没有,格式对不对;
- 事实性:有没有为了"像答案"而编造;
- 回归:原来会的任务,现在还会不会。
人工评测最可靠,但慢;LLM-as-judge 便宜,但有偏见;任务专用测试集最客观,但覆盖有限。三样搭配着用,别拿一个 loss 数字当结论。
七、SFT 能给什么,不能给什么
| 能力 | SFT 能给吗 | 说明 |
|---|---|---|
| 听指令 | ✅ | 这是 SFT 的主业 |
| 输出格式 | ✅ | JSON、Markdown、固定字段,都能教 |
| 语气 / 人设 | ✅ | 客服、老师、编程助手,都能演 |
| 工具调用格式 | ✅ | 前提是数据里教过 |
| 拒答 / 边界 | ⚠️ | 能教例子,但不保证稳 |
| 新知识 | ⚠️ | 能灌一点,但不可靠,容易变成自信的胡说 |
| 实时信息 | ❌ | 训练数据没有就是没有 |
| 复杂推理 | ⚠️ | 能教"解题格式",教不了"解题能力" |
| 安全对齐 | ⚠️ | SFT 是起点,不是全部 |
最值得记住的是"新知识"这一行。
你当然可以用 SFT 教它一条新事实:“我们公司的报销上限是 5000 元。” 它会记住,至少在训练集上。但这种方式不可靠:数据一多、问题一绕、上下文一变,它可能就答错,而且错得很自信。
事实性知识更适合放在模型外面,用检索的方式在推理时喂给它。SFT 更适合教"怎么回答",而不是"答案是什么"。
这就是那句话:
SFT 是把"能力"翻译成"行为",不是把"知识"灌进"脑子"。
它能让模型"会按格式回答",但不能让它"知道答案"。一个是演技,一个是台词。
八、五个常见误区
误区一:SFT 就是给模型灌知识。
不是。SFT 主要改的是行为:什么时候答、怎么答、答多长、遇到不会的怎么办。知识的上限在预训练就定得差不多了。SFT 能让它"像个专家",但不能让它"真的是专家"。
误区二:数据越多越好。
十万条低质数据,可能打不过一万条高质量、覆盖广的数据。重复、同质、错误的指令数据,会让模型学会一套固定的废话。先想清楚要它面对什么请求,再决定收什么数据。
误区三:loss 越低,模型越好。
SFT 的 loss 只算回答部分,低 loss 可能只是"背下了训练集"。要看留出集、看人工评测、看原来会的任务有没有退化。loss 是体温计,不是体检报告。
误区四:随便套个模板就行。
模板里的角色标记、特殊 Token、结束符,都是模型学习"轮到谁说话"的信号。训练和推理不一致,效果会莫名其妙地崩。模板不是格式问题,是语义问题。
误区五:SFT 能替代 RLHF。
SFT 教的是"模仿标准答案",RLHF / DPO 优化的是"在两个回答里更喜欢哪个"。前者教"像不像",后者教"好不好"。SFT 让模型会答题,RLHF 让它答得更符合人类偏好——两件事,不能互相替代。
九、把这件事放回地图上
把大模型的概念按依赖关系从下往上摞成几层——最底下是算力和数据,往上依次是文字的表示、模型结构、训练、调用,最顶上才是增强和智能体:
L6 智能体层 Agent / Function Calling / ReAct / 记忆 / MCP L5 增强层 RAG / 向量检索 / Rerank / GraphRAG / 微调 L4 调用层 Prompt / Few-shot / CoT / 温度 / Top-p L3 训练层 预训练 / ★ SFT ★ / RLHF / DPO / LoRA / Scaling Law L2 结构层 Transformer / 注意力 / 参数量 / 上下文窗口 / 开源闭源 L1 表示层 Token / 分词 / 词表 / Embedding L0 底座 算力 / 数据 / 成本SFT 在 L3 的中间:预训练之后,偏好对齐之前。
顺便澄清一个容易混的点:L5 里的"微调"指的是面向业务的领域微调,和 L3 的 SFT 不在一个层面——SFT 是训练流程的一步,业务微调是"拿模型去适配场景"的用法。
往下看:预训练给了它知识和语言能力,SFT 只是把这些能力"接"到指令格式上。底座不行,SFT 数据再好也救不回来;底座行了,SFT 才能四两拨千斤。
往上看:RLHF / DPO 在 SFT 的基础上继续调偏好;RAG 在推理时补事实;Agent 用的工具调用格式,很多时候就是 SFT 教出来的。SFT 是"从模型到产品"的第一道加工,也是性价比最高的一道。
它的边界也很清楚:SFT 能让模型"会干活",但改不了它"能干多大的活"。想让模型知道新事实,靠检索;想让模型更符合偏好,靠对齐;想让模型更强,只能回到预训练。
三条可以带走的结论
- SFT 教的是"接口"和"行为",不是"知识"。它把 Base 模型的续写能力,翻译成听指令的能力。
- 关键是 mask:只对回答算 loss。不 mask,大部分梯度会花在教模型复述问题上。
- 数据质量和多样性 > 数量。模板一致性、防遗忘、评测,比调参更能决定 SFT 的成败。
《大模型概念地图》专栏 · 第 9 篇