SFT 微调
第 10 课:SFT 微调
SFT(监督微调)就是——用人工写好的问答样例继续训练模型,让它学会按指令回答。上一课我们把模型训出来了,但它只会"续写文本",不会"回答问题"。这一课讲清这条最短的路径:数据要多好、loss 该算在哪、多轮对话怎么拼、显存不够怎么办、训完怎么知道没学坏。
② 判断一份指令数据的质量,并解释 loss masking 为什么必须
③ 会算全参、LoRA(低秩适配:冻结原模型权重,只在少数层插入一组很小的可训练矩阵)、QLoRA(把冻结的基座权重进一步压到低比特表示,再训练上述那些很小的矩阵)三种方案的显存,并给出选型
<|eot_id|>),否则模型分不清"谁在说话"。上一课我们把预训练跑完了。但跑出来的模型只会做一件事:预测下一个 token。所以问它"法国首都是哪",它可能续写出"德国首都是哪?意大利首都是哪?"——为什么?
因为它学到的是"文本如何接续",而不是"请求需要被执行",所以必须补一次行为层面的训练。可以把这类比成请来一位读过全图书馆的学者:知识都在,但没人告诉他"有人提问时要回答"——SFT 就是那场入职培训。
| 维度 | 基座模型 | SFT 之后 |
|---|---|---|
| 训练目标 | 预测下一个 token | 完全相同(仍是预测下一个 token) |
| 训练数据 | 万亿级网页 | 数万条指令-回答对 |
| 数据量级 | 15.6T token | 约几十 M token(小 5~6 个数量级) |
| 行为 | 续写文本 | 把输入当任务执行 |
| 对照实验 | 参数量 | 结果 |
|---|---|---|
| InstructGPT | 1.3B | 人类偏好优于 175B 基座 GPT-3 |
| 参数量对比 | 1.3B vs 175B | 约 1/135 |
| SFT 数据量 | 约 13,000 条示范 | 相对预训练语料可忽略 |
| 阶段 | 数据量 | 耗时 | 学到了什么 |
|---|---|---|---|
| 预训练 | 15.6T token | 数月、上万卡 | 语言与知识 |
| SFT | 约 10M~100M token | 小时到天、少量卡 | 回答的格式与风格 |
| 量级比 | 15.6T ÷ 约 50M ≈ 3 × 10⁵ 倍 | —— | |
SFT 的第一性认识是:它和预训练用的是同一个目标函数,唯一区别是数据。所以"模型答不好问题"不是知识不够,而是行为没被教过——用 1/135 的参数、约 1/3×10⁵ 的数据量就能补上。这也解释了为什么 SFT 通常只跑 1~3 轮、几小时就结束。
上一张卡片我们看到 SFT 只需几万条样本。那问题就来了:同样是几万条,差别在哪?
因为SFT 样本直接示范"什么样的回答是好的",所以样本的风格就是模型的上限。可以把这类比成学做菜:跟 10 道大厨做的菜学,和跟 10000 份速冻食品学,前者学到的是手艺,后者学到的是"速冻食品长什么样"。
| 来源 | 质量 | 规模量级 | 典型代表 |
|---|---|---|---|
| 人类专业撰写 | 最高 | 约 1 万条级 | InstructGPT(约 13,000 条) |
| 强模型蒸馏 | 高 | 约 5 万条级 | Alpaca(52,000 条) |
| 社区 / 开放集 | 参差 | 几十万条级 | 各类开源指令集 |
| 精挑极少样本 | 极高 | 1,000 条 | LIMA |
| 对比 | 样本数 | 学到的东西 |
|---|---|---|
| 少量精品 | 1,000 条 | "好答案的标准"(格式、语气、推理痕迹) |
| 大量杂样本 | 100,000 条 | 各种风格的平均值(含大量低质模式) |
| 比值 | 100,000 ÷ 1,000 = 100 倍 | 但后者并不更强 |
| 维度 | 合格 | 不合格 |
|---|---|---|
| 正确性 | 事实与推理都对 | 编造、算错 |
| 格式 | 遵循指令要求的格式 | 答非所问 |
| 风格一致 | 全数据集语气统一 | 一部分口语一部分学术腔 |
| 多样性 | 任务类型与长度(token 数)分布合理 | 全是同一类问题 |
| 无泄漏 | 不含评测题 | 把 benchmark 答案抄进去 |
指令数据的质量压过数量:1,000 条精选样本(LIMA)能与 52,000 条蒸馏样本(Alpaca)竞争,13,000 条人工示范(InstructGPT)能让 1.3B 模型胜过 175B 基座。因为 SFT 拟合的是样本分布,样本里有多少"敷衍"、"编造"、"格式错乱",模型就会学多少——所以筛数据比堆数据更重要。
-100(PyTorch 的 ignore_index),只保留回答部分的标签。注意掩码只作用于损失,不作用于注意力——回答仍能看到问题。上一张卡片我们备好了数据。但直接拿"问题 + 回答"算损失会出问题——为什么?
因为如果问题部分也计入损失,模型会被训练去"复述问题",所以必须把问题部分屏蔽掉。可以把这类比成批改作文:你只该按"学生的回答"打分,而不是把"题目"也抄一遍再打分——抄题目不是考核目标。
更早的做法是照搬预训练那一套——对整段文本算损失,毕竟预训练就是这么干的、也跑得通。可预训练语料是一段连续文本、每个 token 都该被预测;SFT 样本里问题占了大头(示例中约 80%),照搬过来,模型每学 1 份「怎么回答」,就顺带学 4 份「怎么复述问题」,训久了会养成"抢着替用户把问题续下去"的坏习惯。
| 部分 | 典型长度 | 是否计入损失 |
|---|---|---|
| 系统提示 + 用户问题 | 约 80 token | 否(置 -100) |
| 助手回答 | 约 20 token | 是 |
| 合计 | 约 100 token | —— |
| 方案 | 损失覆盖的 token | 梯度去向 |
|---|---|---|
| 不掩码 | 100 个(含 80 个问题 token) | 约 80% 用于"复述问题" |
| 正确掩码 | 20 个(只回答) | 100% 用于"生成回答" |
| 差异 | —— | 约 5 倍的有效梯度被浪费 |
| 张量 | 内容 |
|---|---|
input_ids | [问题 token…, 回答 token…](共 100 个) |
labels | [−100 × 80, 回答 token…](前 80 个被屏蔽) |
| 损失 | cross_entropy(logits, labels, ignore_index=-100)(logits = 模型对词表里每个 token 打的原始分数) |
input_ids 没有变——问题 token 仍在输入里,只是不算损失。所以回答仍然能"看到"问题,这正是我们要的:条件于问题,但不模仿问题。Loss masking 的判据是"你要模型做什么":SFT 要它生成回答,就只对回答算损失。不掩码时,被浪费的梯度比例 = 问题 token 占比(示例中约 80%)。关键区别在于掩码只作用于损失、不作用于注意力——模型依然能看到问题,只是不被训练去复述它。
<|begin_of_text|> 开头,每一轮用 <|start_header_id|>角色名<|end_header_id|> 起头,用 <|eot_id|>(end of turn)收尾。这些特殊 token 需要在 tokenizer 里注册,模型由此学会"该我说话了"与"我该停了"。上一张卡片解决了"算哪部分损失"。但一条多轮样本里有多段文本,模型怎么区分它们?
因为朴素拼接会让模型分不清"哪句是用户说的、哪句是自己该说的、说到哪停",所以必须用显式的边界标记。可以把这类比成剧本:台词前面要写角色名、每句结束要有句号——否则演员不知道该谁开口、什么时候停。
更早的做法就是把多段文本首尾直接拼接(像预训练语料那样不留任何标记)。这样模型学不到「轮次在哪结束」,最直接的症状是回答写完后不收尾、继续替用户编出下一句问答;而放进 <|eot_id|> 这类结束标记后,「该我停了」才第一次变成模型可以学习的显式信号。
| 片段 | 内容 | 作用 |
|---|---|---|
<|begin_of_text|> | 1 个 token | 序列起点 |
<|start_header_id|>system<|end_header_id|> | 若干 token | 声明系统角色 |
| 系统提示正文 | n₁ 个 token | 行为约束 |
<|eot_id|> | 1 个 token | 本轮结束 |
user 段 + 正文 + <|eot_id|> | n₂ + 若干 | 用户输入 |
assistant 段 + 正文 + <|eot_id|> | n₃ + 若干 | 要学的那一段 |
| 轮次 | 片段 | 是否计入损失 |
|---|---|---|
| 第 1 轮 | system / user | 否 |
| 第 1 轮 | assistant | 是 |
| 第 2 轮 | user | 否 |
| 第 2 轮 | assistant | 是 |
| 方案 | 利用率 | 风险 |
|---|---|---|
| 一条样本一个序列,不足则填充 | 低(大量 padding 浪费) | 无 |
| 打包:多条短样本拼进同一序列 | 高 | 需保证跨样本不互相注意 |
| 示例:4 条 1000 token 的样本 | 4 × 1000 = 4000 token | 省约 4 倍序列数 |
Chat template 的作用是把"谁在说话、说到哪停"变成可学习的显式信号:角色标记界定说话者,<|eot_id|> 界定轮次结束。没有它,模型学不到"该我停下来了"——这也是"模型不会停、一直续写"的主要成因之一。而打包能把短样本的利用率提上来,前提是做好样本间的注意力隔离。
ΔW = BA(A 是 r×d、B 是 d×r,两者相乘即得到 d×d 的增量),把可训练参数减少约 10,000 倍、显存减少约 3 倍,且推理时无额外延迟(可合并回原权重)。QLoRA(Dettmers et al., arXiv:2305.14314, NeurIPS 2023):把基座量化到 4-bit NF4(4 位 NormalFloat:按正态分位设计的 4 比特编码)并冻结,在 16-bit 适配器上反传,配合双重量化(再省约 0.37 bit/参数)与分页优化器,让 65B 模型在单张 48GB 卡上微调——而 16-bit 全参微调该模型需要 >780GB。上一张卡片把样本格式定好了。现在要真正开始训练——但显存够吗?
因为全参微调要存完整的权重、梯度和优化器状态,所以模型一大就跑不动,必须选更省的方式。可以把这类比成装修:全参=把整栋楼的墙都重刷一遍;LoRA=只在墙上贴几张便利贴;QLoRA=连墙都换成轻质材料再贴便利贴。
更早、更自然的做法就是全参微调(预训练怎么训、微调就怎么训),但它先撞的其实是优化器状态这道墙:以 7B 为例,Adam 状态要 84 GB,是权重(14 GB)的 6 倍,加上梯度合计 112 GB——单张 80GB 的卡根本放不下,必须切上两张以上。
| 项目 | 每参数字节 | 7B 总计 |
|---|---|---|
| BF16(训练常用的半精度浮点格式)权重 | 2 | 14 GB |
| BF16 梯度 | 2 | 14 GB |
| Adam 优化器状态(fp32 主权重 + 一阶 + 二阶矩) | 12 | 84 GB |
| 合计 | 16 | ≈ 112 GB |
| 项目 | 计算 | 结果 |
|---|---|---|
| 注入位置 | q / k / v / o 四个投影 × 2 个矩阵 | 8 个矩阵/层 |
| 单个矩阵参数(A:d×r,r=16) | 4096 × 16 | 65,536 |
| 每层合计 | 8 × 65,536 | 524,288 |
| 32 层合计 | 524,288 × 32 | ≈ 16.8M |
| 占 7B 的比例 | 16.8M ÷ 7000M | ≈ 0.24% |
| 方案 | 7B 需要显存 | 65B 需要显存 | 相对全参 |
|---|---|---|---|
| 全参微调(BF16,含梯度与优化器状态) | ≈ 112 GB | > 780 GB | 1× |
| LoRA(基座 BF16,含适配器训练态) | ≈ 16 GB | ≈ 130 GB(仅基座权重,65B × 2 字节;不含适配器训练态) | 约 1/7 |
| QLoRA(基座 4-bit,含适配器训练态) | ≈ 6 GB | < 48 GB(含适配器训练态) | 约 1/19 |
| 场景 | 建议 | 理由 |
|---|---|---|
| 基座能装进显存、追求最好效果 | LoRA | 比 QLoRA 快约 20%~40%(示意,需人工核实;无逐次反量化开销) |
| 基座装不下或显存紧张 | QLoRA | 显存约为 LoRA 的 1/2.7(16GB → 6GB),质量损失通常 < 0.5 分(示意,需人工核实;论文只称质量相当) |
| 需要改变模型"底子"(如换语言能力) | 全参微调 | LoRA 的表达能力受低秩约束 |
三档的本质是"训练哪些参数、以什么精度存基座":全参要存全部 16 字节/参数的训练状态(7B 需 112GB);LoRA 只训 0.24% 的参数,基座 14GB 冻结(7B 约 16GB);QLoRA 再把基座压到 4-bit(7B 约 6GB,65B < 48GB)。选型判据很简单:基座装得下就用 LoRA,装不下就用 QLoRA,需要改底子才上全参。
上一张卡片选好了训练方式。但还有一个反向风险:微调会不会把原有能力弄丢?
因为梯度只服务于新数据的目标,所以在窄领域上训久了,通用能力会退化。可以把这类比成让一位全科医生去专科进修半年:专科能力上去了,但全科手感可能生疏——所以进修前后都要考一次全科。
| 时间点 | 测什么 | 作用 |
|---|---|---|
| 微调前 | 通用能力基准 + 验证集 perplexity(困惑度 = 模型对文本的"意外程度",越低表示预测越准) | 建立基线 |
| 微调后 | 同样的基准 + 目标任务评测 | 净变化 = 微调后分数 − 微调前分数 |
| 只看微调后 | 只有目标任务的分数 | 无法发现遗忘 |
| 手段 | 做法 | 代价 |
|---|---|---|
| 用 LoRA | 只训练 0.24% 的参数 | 表达能力受低秩约束,改不了"底子" |
| 控制学习率 | 全参 1e-5~2e-5 / LoRA 1e-4~2e-4 | 收敛更慢 |
| 减少轮数 | 1~3 轮 | 可能欠拟合任务 |
| 混合通用数据 | 指令数据中掺入通用语料做重放 | 稀释目标任务的信号 |
| 轮数 | 训练损失 | 通用能力 | 判断 |
|---|---|---|---|
| 1~3 | 下降 | 基本保持 | 推荐区间 |
| 5~10 | 继续下降 | 开始退化 | 已过拟合样本风格 |
| > 10 | 很低 | 明显退化 | 开始"背答案" |
微调的代价是可能在原能力上退化,而发现它的唯一方法是微调前后都测同一套基准。工程上的默认组合是"LoRA + 受控学习率(LoRA 档 1e-4~2e-4)+ 1~3 轮",共同逻辑都是尽量少动原权重。只报目标任务的提升、不报通用能力的变化,是最常见的自欺。