跳转到内容

第 34 章 SFT:从续写到遵循指令

“法国的首都是”留出了一个续写位置。“请说出法国首都,简短回答”则提出了要求。模型可能在第二句话后接着写另一条指令、一段对话引文,或者一篇旅行介绍。文字通顺,并不能证明它会按要求作答。

第 22 章区分了训练目标与模型架构。这里仍然使用因果解码器,仍然预测下一个 Token。我们改变的是训练材料:给出一条请求,再给出希望助手作出的回答。用这些示范继续训练,就是监督微调(supervised fine-tuning,SFT)。“监督”指训练记录里有期望输出,不是说每次参数更新都有人在旁边盯着。

这件事有两层验收。首先,训练记录必须在正确的对话前缀下,对真正想教的回答 Token 计算损失。其次,适配后的模型要能应对示范之外的请求,并保留原先需要的行为。数据格式正确,只能说明第一层做对了。KA-1 已经给出一个具体区别:英文输出 750. 可以正确表达现行限额,但用同一句话回答历史限额,就错了。

我们先把一条记录算清楚。第 18 章讲过错开一位的预测目标,第 6 章讲过负对数似然。选做 CPU 实践时会用到已有的 Python 入门。正文不要求 GPU 或模型账号。

例 34.1:展开一个简短回答。 示范只有三条消息:系统消息 brief,用户消息 France?,助手消息 Paris .。前两条分别简写“简短回答”和“法国首都是哪里”。这些写法及下表的分词规则都是作者给定的教学设定,与 MG-2 或真实模型的聊天格式无关。

角色(role)标明消息由谁提供。对话模板(chat template)规定怎样把消息转换成 Token 序列。本例先写一个起始标记,再依次写每条消息的角色标记、内容和回合结束标记。标记是约定了用途的 Token,并不是独立执行权限检查的程序。

表 34.1:完整的教学词表。 数组顺序决定 ID。ID 是标签,数值大小没有语义。

TokenID用途
<bos>0序列开头,只写一次
<system>1系统消息开始
brief2系统消息内容
<eot>3一条消息结束
<user>4用户消息开始
France?5用户消息内容
<assistant>6回答开始
Paris7回答的第一个 Token
.8回答的第二个 Token
<pad>9本例未使用的填充标签

应用模板后得到 11 个 Token。下面的空格只是为了把它们分开显示,不额外占用 ID。

<bos> <system> brief <eot> <user> France? <eot> <assistant> Paris . <eot>
0 1 2 3 4 5 3 6 7 8 3

让这个教学模型开始回答时,我们提供的前缀一直到 <assistant> 为止。训练记录里还放着正确答案,这样预测后面的 Token 时,就可以用前面已经给出的正确答案 Token 作条件。这与此前预测下一个 Token 时采用的教师强制方式相同。因果注意力掩码仍会阻止模型在预测 Paris 时提前读取这个目标或后面的句号。

真实模板支持的角色、分隔符和结束方式各不相同。有的助手前缀包含多个 Token,有的格式不需要单独的助手前缀。应当把模板、分词器和学过这套格式的检查点配套使用。官方模板文档还说明了重复添加特殊 Token 的问题。本例给出的是一套完整的局部规则,不能把它直接套在别的模型上。

34.3 哪一次预测要为错误付出代价

Section titled “34.3 哪一次预测要为错误付出代价”

模型读到某个位置,用此前的前缀预测下一个 Token。这里选择只对助手内容及其结束标记 <eot> 计分,不要求模型通过这条练习学习生成系统消息和用户消息。损失掩码(loss mask)是一串 0 和 1,用来选择哪些预测计入损失。它与注意力掩码不同:某个提示 Token 不计分,仍然可以为回答提供信息。

表 34.2:例 34.1 的逐位置对照。 位置从 0 开始。目标是序列中右边一位的 Token;横线表示没有目标。

输入位置输入 Token / ID下一个目标 / ID损失掩码原因
0<bos> / 0<system> / 10角色标记
1<system> / 1brief / 20系统消息内容
2brief / 2<eot> / 30系统消息结束
3<eot> / 3<user> / 40角色标记
4<user> / 4France? / 50用户消息内容
5France? / 5<eot> / 30用户消息结束
6<eot> / 3<assistant> / 60助手开头由输入提供
7<assistant> / 6Paris / 71学习回答的第一个 Token
8Paris / 7. / 81学习继续回答
9. / 8<eot> / 31学习结束当前回合
10<eot> / 3——没有后续 Token

仔细看位置 7。输入虽然是角色标记,预测的却是回答的第一个 Token。如果按输入 Token 属于哪个角色来决定是否计分,就会漏掉这一项。掩码应当跟着被预测的目标走。十次预测对应的掩码是 0 0 0 0 0 0 0 1 1 1。

例 34.2:计算回答损失。 依次给三个正确目标指定概率 0.5、0.25、0.5。它们各自以不同前缀为条件,不是同一个分布里的三个备选项。分别取负自然对数,得到约 0.693147、1.386294、0.693147 nat。把代价相加,再除以计分的三次预测:

L=−ln⁡(0.5)−ln⁡(0.25)−ln⁡(0.5)3=ln⁡(16)3=0.924196.L=\frac{-\ln(0.5)-\ln(0.25)-\ln(0.5)}{3} =\frac{\ln(16)}{3}=0.924196.

分母是 3,不是存储的 11 个 Token,也不是可作出的 10 次预测。句号这一项的代价是另外任一项的两倍。如果把它的指定概率提高到 0.5,平均损失就降到 0.693147。这说明了给定概率下的算术关系,并不是一次真实训练带来的改善。

把记录一般地写成 x0,…,xTx_0,\ldots,x_T。用 mtm_t 表示读到位置 tt 后作出的预测是否计分;用 pθ(xt+1∣x0,…,xt)p_\theta(x_{t+1}\mid x_0,\ldots,x_t) 表示参数为 θ\theta 时,模型给正确下一个 Token 的概率。所选目标的平均损失为:

L(θ)=∑t=0T−1mt[−ln⁡pθ(xt+1∣x0,…,xt)]∑t=0T−1mt.L(\theta)=\frac{\sum_{t=0}^{T-1}m_t\left[-\ln p_\theta(x_{t+1}\mid x_0,\ldots,x_t)\right]}{\sum_{t=0}^{T-1}m_t}.

本例 T=10T=10,只有 m7,m8,m9m_7,m_8,m_9 为 1,代入后就是例 34.2。如果一条记录没有任何计分目标,分母就是零,应当拒收,或者明确排除后再求平均。把它的损失记成零,会把“没有训练信号”说成“预测完全正确”。合并一个批次时,要先分别累加损失和计分 Token 数,再相除。若先算各条记录的平均损失,再对记录求平均,就变成了每条记录权重相同的另一种目标,必须明确说明。

预测回答的第一个 Token
  1. 文本片段前缀:… France? <eot> <assistant>
  2. 处理步骤输入位置 7 → 目标 Paris;m₇ = 1
  3. 处理步骤−ln(1/2) = 0.693147 nat

助手开头由输入提供,但在它之后预测的回答要计分。

所选损失与参数更新
  1. 处理步骤Paris、句号、结束标记:3 个计分目标
  2. 处理步骤ln(16) / 3 = 0.924196
  3. 模型保存的数值共享参数可以改变;提示不计分不等于参数被冻结

选择损失项与选择可训练参数,是两件不同的事。

图 34.1:损失掩码跟着目标走,不能按输入角色判断。 原创教学图,概率为作者给定 · CC BY-SA 4.0

图 34.1 按这套损失掩码,把前缀、第一个计分目标和共享参数连在一起。损失位为零,不会冻结处理那个位置时使用的参数。回答损失仍可能沿着用到 France? 的计算向后传播。优化器究竟允许改哪些参数,是另一个选择。损失掩码也不意味着可以读取未来的答案 Token;因果掩码仍然有效。

34.4 记录看似合格,也可能教错任务

Section titled “34.4 记录看似合格,也可能教错任务”

现在把模型运行时前缀里的 <assistant> 去掉。末尾就变成用户消息的 <eot>,不再是训练时预测 Paris 所依据的助手开头。模型面对的是另一个条件下的分布。在正确模板上多训练,并不能修好运行时的序列化错配。首先应当核对两条路径实际产生的 ID。

多轮对话要逐段检查助手回答及其结束标记。“从第一个助手标记开始全部计分”还会把后续用户消息算进去。增加两个填充位置,也不会增加两个回答目标:填充对应的损失位仍为零,而且不应作为有用上下文。截断长记录时,可能把回答或结束标记切掉;默默接受残片,就改变了教学内容。把多条对话拼接到一起,也需要明确边界与注意力规则。

本节的小检查程序先把结构化消息展开,再按规则核对序列和错开一位的掩码。固定输入放在 data/part-vi/sft-v1.json,实现放在 code/part-vi/core.py。结果在下面直接可读。

表 34.3:按教学规则检查格式。 这些确定性结果不是模型质量分数。

记录结果原因
完整的三条消息接受11 个 Token,10 次预测,3 个计分目标
把用户角色改成 customer拒绝不支持这种角色序列
助手内容为空拒绝必须提供示范回答
删除末尾 <eot>拒绝序列不完整
掩码整体向右错一位拒绝漏掉第一个回答目标
内容中插入控制标记拒绝内容冒充了模板边界

真实分词器可能把形似标记的普通文字编码成另一种形式。本练习选择拒收,没有实现转义协议。这个范围很窄的检查器无法认证生产环境的格式。它真正检查的是:记录、模板、分词器、目标偏移与掩码是否一致。

34.5 格式正确之后,还要检查示范本身

Section titled “34.5 格式正确之后,还要检查示范本身”

假设住宿例题提供的是新版 750 元限额,回答却写“现行限额为 600 元”。文件可以通过前面所有格式检查,训练却会奖励过时答案。又如只拿到订单状态,就回答“已取消”,这教给模型的是没有依据的操作声明,助手角色和结束标记再正确也无济于事。

因此要用示范附带的证据核对内容,不能只看语气是否讨喜。把现行和历史问题配对,避免“永远答 750”成为捷径;纳入信息缺失、尚未批准、中英文请求,以及对回答长短的不同要求。相互冲突的标签需要处理,数据来源和使用权也要留下记录。SFT 同样可以用解题过程作示范,但过程再长,答案错了也不会自动成为好样本。

参数更新可能改善新行为,同时损害旧行为。这里的遗忘(forgetting)指适配后测得的既有能力下降,不意味着旧事实都被抹掉了。数据过于集中,可能反复把共享参数推向某一种格式或领域。在选择训练配置之前,应固定一套保留能力的评测,例如法国首都、红蓝钥匙配对线索、新旧限额和审批边界,并分类型、分语言查看结果。减少更新次数、调整数据配比或缩小更新范围,都只是需要检验的办法,不能保证不会退化。

InstructGPT 展示了先学示范、再进行偏好训练的做法,也讨论了评测与取舍。Ouyang 等人的论文提醒我们直接检查行为,不能只凭训练损失选择模型。

SFT 说明用什么示范、按什么目标训练;全量更新和 LoRA 说明怎样改变参数。因此一次训练可以是“用 LoRA 做 SFT”,也可以是“全量更新参数做 SFT”。第 39 章会算清这个区别。在此之前,还得回答一个问题:两个看上去都有道理的回答,究竟该更偏好哪一个?

因果模型可以在继续预测下一个 Token 的同时,学习遵循指令。模板提供对话结构,错开一位的损失掩码选择回答目标。第一个回答 Token、结束标记和计分分母都要逐项核对。格式、示范质量与旧能力保留是三件不同的事。SFT 既没有规定可训练参数的数量,也没有保证回答真实。

  1. 保持例 34.2 前两个概率不变,把结束标记的概率从 0.5 提高到 1。计算平均损失。这能说明首都答案变得更容易生成了吗?
  2. 第二条记录只有一个计分目标,代价为 1.386294 nat。按计分 Token 求平均,将它与例 34.2 合并,再与“先按记录求平均”的结果比较。
  3. 程序只对输入 Token 属于助手内容的位置计分。表 34.2 中哪次预测会漏掉?这样会不会阻止模型读取用户问题?
  4. 训练集里只有回答 750 的现行限额问题。设计两个测试,区分模型是在使用版本证据,还是只会重复 750,并写出期望答案。
  5. 一项训练冻结基座矩阵,用期望回答训练低秩因子。这是 SFT、LoRA、两者都是,还是两者都不是?需要什么证据才能认定双语助手得到改善?
  1. 结束标记的代价变成零。平均损失为 (ln⁡2+ln⁡4)/3=ln⁡(8)/3=0.693147(\ln 2+\ln 4)/3=\ln(8)/3=0.693147。改变的只有结束概率,Paris 的概率仍为 0.5。总损失下降不代表每一项都改善。
  2. 先把 ln⁡(16)\ln(16) 与 ln⁡(4)\ln(4) 相加,再除以 3+1=43+1=4,得到 ln⁡(64)/4=1.039721\ln(64)/4=1.039721。按记录求平均则为 (0.924196+1.386294)/2=1.155245(0.924196+1.386294)/2=1.155245,相当于让只有一个目标的记录与有三个目标的记录权重相同。
  3. 位置 7 后预测 Paris 的这一项会消失,因为该位置的输入是角色标记。句号和结束标记可能仍会计分,相当于训练了怎样收尾,却漏掉回答的第一个内容目标。选择损失项并不会把用户 Token 从上下文中删除。
  4. 先给出旧限额 600、现行限额 750,询问历史限额,要求答 600;再构造一个明确的新版本,现行限额 800、旧限额 750,要求答 800。两种语言都要保留清楚的证据与问题范围。这些对照仍不能替代新文档族上的评测。
  5. 两者都是:SFT 指示范与目标,LoRA 指训练哪些因子。应按原契约比较适配前后,包括中文、历史范围、未知信息、审批和保留能力。训练损失下降或适配器文件很小,都不能证明这些项目得到改善。