模型微调做到一定阶段,几乎都会撞上一堵墙:交叉熵 loss 降得很漂亮,验证集困惑度也好看,可模型真跑起来就是“不太懂事”——该简短的时候啰嗦,该列步骤的时候给一段散文,工具调用格式隔三差五错一个括号。这时候再堆标注数据,边际收益已经很低了。把强化学习接到 finetuning 后面,本质上就是给模型换一套优化目标:不再问“下一个 token 像不像标注数据”,而是问“这整段回答好不好”。这篇东西我想把“强化学习 + finetuning”这条路线从头拆一遍,包括它到底补上了监督微调的哪个缺口、几类主流方法(偏好优化类的 PPO、DPO,以及组相对策略优化这一类)各自适合什么场面、显存怎么估、参数怎么定、跑起来以后哪些坑必须提前防。适合已经能独立跑完一次 LoRA 微调、但还没系统做过强化学习后训练的人,也适合带团队做模型落地的同学当一份选型参考。
1. 动手之前先把问题想清楚:强化学习到底补了哪块
1.1 监督微调的天花板在哪儿
监督微调的本质是极大似然:给定输入,让模型在标注答案的每个位置上提高概率。这套机制有两个隐含假设——答案唯一、答案等价。但真实场景里这两条基本都不成立。写一段产品文案,十个人能写出十种都不错的版本;排查一个报错,先给结论再给细节和先给细节再给结论都算合格。交叉熵没法表达“这十个里 A 比 B 好,B 比 C 好”,它只会把十个版本一视同仁地往上推,模型学到的是一种“平均风格”,输出趋于保守、中庸、四平八稳。
更麻烦的是负样本的浪费。标注数据里如果你放了一条错误示范,交叉熵的做法是“把这条答案的概率压下去”,但它压的是具体那些 token 的位置,而不理解“错在逻辑链第三步”。模型的梯度信号非常局部,一个错误的长回答可能只影响它自己那几个 token 的分布,对整体策略几乎没有约束。所以你会看到一种典型现象:SFT 之后模型能模仿格式,但不会权衡;能复述流程,但不会取舍。这不是数据量的问题,是目标函数本身的表达能力不够。
第三个天花板是多目标冲突。一个模型同时要满足“准确”“简洁”“格式合规”“语气得体”,这四个目标在 token 级别上是互相打架的。交叉熵把这些冲突平均掉了,最后得到一个哪边都不极致的折中解。而强化学习的奖励函数可以把这些目标显式地加权组合,让模型在生成完整回答之后再被评价一次,从而学到取舍关系。
1.2 强化学习带来的三样东西
强化学习接到 finetuning 上,最直接的价值是让优化目标从“token 级”升到“序列级”。一条回答生成完,我们给它一个分数,这个分数可以来自人工偏好训练的奖励模型,也可以来自可验证的规则(答案对不对、JSON 能不能解析、单元测试过没过、长度在不在区间内)。这个分数不需要可导,这就打开了一个很大的口子:所有你没法写成损失函数的质量标准,都能变成奖励信号。
第二样东西是探索。强化学习的采样过程本身就在生成分布外的新回答,模型会自己产出一些标注数据里根本没有的解法。如果这些解法得分高,就会被强化。这就是为什么很多团队发现,强化学习阶段能逼出一些监督微调学不到的推理路径——不是因为算法多神奇,而是因为它真的在“试”。
第三样东西是对齐的抓手。人类偏好这件事,很难用“标准答案”表达,但很容易用“两个回答你选哪个”表达。偏好数据天然是成对或成组的,这正好对上强化学习和偏好优化类方法的输入形态。
1.3 三条技术路线的分野
现在主流做法大致分三支。第一支是经典的三阶段路线:监督微调 → 训练奖励模型 → 用 PPO 做策略优化。它的优点是表达能力强、奖励可以很复杂;代价是训练时同时挂着策略模型、参考模型、奖励模型、价值模型四份,显存和工程复杂度都高,而且价值模型的估计误差会直接传导到策略更新上,调参很折磨人。
第二支是直接偏好优化(DPO)这一类。它绕开了显式奖励模型和在线采样,直接用成对偏好数据构造一个闭式损失,本质上是在做“带 KL 约束的最大似然”。工程上极简,只需要策略模型和参考模型两份,数据是离线的,跑起来稳。但它的天花板也明显:完全离线,没有探索,模型只能被你给的那批偏好对“推着走”,遇到训练分布外的输入泛化能力偏弱。
第三支是组相对策略优化(GRPO)这类“去 critic”的在线方法。它对同一个 prompt 采样一组回答,用组内的平均分当基线,从而省掉了价值模型。既保留了在线探索的能力,又把显存占用从四模型降到两模型加采样开销。近一年很多开源复现都往这个方向走,原因很实在:省下来的显存可以换成更大的 batch 或者更长的上下文。
2. 方法选型:先算清楚资源,再谈算法好坏
2.1 一份能用的显存粗算模型
选型第一步不是比论文指标,是算自己有多少卡。以 7B 参数、bf16 精度、全参数训练为例做个粗算,单位是 GB:
| 项目 | 计算方式 | 占用 |
|---|---|---|
| 模型权重 | 7B × 2 字节 | 约 14 |
| 梯度 | 7B × 2 字节 | 约 14 |
| 优化器状态(Adam,fp32 一阶二阶矩) | 7B × 8 字节 | 约 56 |
| 激活值与临时缓存 | 与序列长度、batch 强相关 | 30 到 60 |
也就是说,单个 7B 模型全参数训练的常驻开销在 115GB 以上,配 80GB 的卡至少要两张,用 ZeRO 或分片再叠加。这时候你按方法数模型:PPO 需要策略 + 参考 + 奖励 + 价值四份,参考模型和价值模型通常不训练,只做前向,各按 14GB 权重算,奖励模型如果也是 7B 再加 14GB,光权重就 56GB,加上可训练部分的梯度和优化器状态,四卡 80GB 是起步价。GRPO 去掉价值模型,只剩策略可训练 + 参考前向 + 奖励前向,三卡能跑。DPO 只有策略可训练 + 参考前向,两卡能跑,甚至单卡 80GB 用 LoRA 也能挤进去。
这张表的意义在于:方法没有绝对优劣,只有和你手里的资源匹不匹配。我见过不少团队一上来就要复现 PPO 全流程,结果卡在显存上反复改并行策略,两周没跑出一个完整 epoch,还不如先用 DPO 把数据管线和评估体系搭起来。
2.2 奖励信号的四种来源,优先级从高到低
奖励函数是整个流程的灵魂,选错了后面全白搭。按可靠性排序大致是这样:
可验证奖励(最可靠)。答案是唯一且能自动判定的场景:数学题的最终数值、代码的单元测试、结构化输出的 schema 校验、工具调用的参数是否齐全。这类奖励不会骗人,只要判定器写得对,信号就是准的。我强烈建议所有团队的第一条强化学习流水线都从可验证奖励开始,因为它把“奖励模型不准”这个最大的不确定性排除掉了。
规则奖励。长度区间、是否包含必需的字段、是否出现了禁用词、格式正则是否匹配。这类奖励写起来快,但特别容易引发作弊——模型会学会用最省力的方式满足规则,比如往回答里塞满关键词来混过“包含必需字段”这一条。
人类偏好训练的奖励模型。收集成对比较数据,用 Bradley-Terry 模型拟合一个打分器。这是最通用的方案,能覆盖“写得好不好”这种主观维度。问题在于奖励模型本身是有偏的,而且它的偏差会被策略模型精准地利用,也就是常说的奖励黑客。
模型自我评价。让一个能力更强的模型给回答打分。工程上最省事,但引入的偏差最难排查,因为打分器的偏好和你的业务目标可能压根不是一回事。
我的实际经验是:能用可验证奖励就不要用学习出来的奖励模型。可验证奖励覆盖不到的部分,再补一个偏好奖励模型,并且给两者做加权组合,权重比例按业务重要性定,但规则类奖励的权重一定要设小,否则模型会钻空子。
2.3 PPO / DPO / GRPO 的取舍对照
| 维度 | PPO 三阶段 | DPO | GRPO 类在线方法 |
|---|---|---|---|
| 显存占用 | 最高(四模型) | 最低(两模型) | 中等(两模型 + 组采样) |
| 数据形态 | 在线采样 + 奖励打分 | 离线成对偏好 | 在线采样 + 组内打分 |
| 探索能力 | 强 | 无 | 强 |
| 调参难度 | 高(clip、KL、价值损失) | 低(主要调 beta 和学习率) | 中(组大小、KL、长度惩罚) |
| 训练稳定性 | 一般,容易崩 | 很稳 | 较稳,但对奖励尺度敏感 |
| 适合场景 | 奖励复杂、追求极限效果 | 有现成偏好数据、资源有限 | 奖励可自动判定、需要探索 |
一句话总结:有偏好数据没算力选 DPO,有算力且奖励可自动判定选 GRPO 类方法,奖励极其复杂且必须在线优化才考虑 PPO。
3. 实操:把一条强化学习微调流水线真正跑起来
3.1 数据准备:prompt 池比答案重要
强化学习和监督微调在数据上的最大区别是:它只需要 prompt,不需要答案(偏好优化类需要成对答案)。这一点经常被低估——很多人把原来 SFT 的数据原样搬过来,结果 prompt 池子太窄,模型在训练分布内极度自信,出了分布立刻塌。
Prompt 池子要按“任务类型 × 难度 × 输入长度”做网格化覆盖。我的做法是先列业务里真实出现的任务类型,每类至少 200 条,然后每个类型按难度分三档,再对每一档做长度分层(短输入、中等输入、长上下文)。总数按经验不要少于 2000 条,否则组采样的多样性撑不起来。每条 prompt 要单独做去重和长度截断,长 prompt 在采样时会把 KV cache 撑爆,直接导致 OOM。
注意:prompt 池里一定要留出 5% 到 10% 的“反向样本”——也就是模型大概率会答错的、或者语料里罕见的输入。这些样本在训练初期的得分会非常低,但它们恰恰是策略梯度信号最强的地方。
还有一个细节:prompt 模板必须和线上推理时完全一致。我踩过的坑是训练时用了带 system 提示的模板,上线时 system 提示被省略了,结果模型的表现断崖式下跌,排查了半天以为是算法问题,最后发现是模板不一致。
3.2 奖励函数落地:三段式打分器
一个可用的打分器通常是分段组合的,我一般写成三段:
def reward_fn(prompt, response, meta): score = 0.0 # 1. 硬性可验证项:答案正确性、格式合规 if check_format(response): # 正则/schema 校验 score += 1.0 if meta.get("answer") and check_answer(response, meta["answer"]): score += 3.0 # 2. 约束项:长度、禁用词、重复率 n_tok = count_tokens(response) if n_tok > meta["max_len"]: score -= 0.5 * (n_tok - meta["max_len"]) / 100.0 # 线性惩罚 if has_degenerate_repeat(response): score -= 1.5 # 3. 模型打分:偏好奖励模型(归一化到 0~1) if pref_model is not None: score += 2.0 * pref_model.score(prompt, response) return score关于长度惩罚,必须强调一点:不要用固定阈值一刀切,用超出部分按 token 数线性扣分。因为一刀切会让模型学会卡在阈值下方一个 token,回答质量反而下降;线性惩罚则让它自己权衡“多说一句值不值”。
关于奖励归一化,这是新手最容易忽略的一步。不同数据源的奖励尺度差异极大,可验证奖励可能动辄 +3,模型打分只在 0 到 1 之间,混在一起做组内归一化时,高尺度项会支配整个梯度。我的做法是每一项先各自做 running mean/std 归一化,再按权重相加。
3.3 组采样的参数怎么定
以 GRPO 为例,几个关键参数需要算而不是抄:
组大小 G。对每条 prompt 采样 G 条回答,用组内均值和标准差做优势归一化:
import torch def group_advantage(rewards, eps=1e-8): # rewards: [G] mu = rewards.mean() std = rewards.std(unbiased=False) return (rewards - mu) / (std + eps)G 太小,均值和方差的估计噪声大,优势值抖动剧烈;G 太大,采样开销线性上涨。经验值 8 到 16 之间,8 是性价比拐点。如果奖励是 0/1 二值的(对或错),G 至少要 8,否则一组里全是 1 或全是 0,归一化后优势全为 0,这条 prompt 就白采了。这也是为什么二值奖励场景下我一般用 G=16。
采样温度。训练时的 rollout 温度要比推理时高一点,常用 0.9 到 1.0。温度太低,组内回答高度相似,优势趋近于 0,等于没采样;温度太高,输出质量整体下滑,正样本采不到。
KL 系数 beta。这是控制“允许多大程度偏离参考模型”的旋钮。beta 太小(比如 1e-4 以下),策略会迅速漂移到参考模型之外,语言开始崩坏、出现乱码和重复;beta 太大(1e-1 以上),策略几乎不动,训练半天等于没做。我一般在 0.01 到 0.05 之间起调,先跑 50 步看 KL 的实际值,稳定在 5 到 20 之间算健康。KL 涨到 50 以上就该调大 beta 或者停一停。
学习率。全参数强化学习阶段的学习率要比监督微调小一到两个数量级,通常 1e-6 到 5e-6。用 LoRA 的话可以放到 1e-5。原因很直白:强化学习的梯度方差大,学习率一高,策略在几步之内就飞到不可恢复的地方。
每步的有效 batch。这个要按显存倒推。假设 prompt batch 是 8,组大小 8,那就是每步要前向 64 条回答。每条回答平均 1024 token,就是 65536 token 的采样量。如果你用 4 张 80GB 卡,单卡能承受到 16384 token 左右的 rollout,那就得靠梯度累积把有效 batch 凑上去,累积步数设为 4。这个计算在上手之前一定要做一遍,否则跑了半天发现有效 batch 只有 8,梯度噪声大到根本不收敛。
3.4 用 LoRA 把显存压到能接受的范围
资源紧张的时候,把强化学习阶段做成 LoRA 是很务实的选择。参考模型的权重全程冻结,策略模型只在低秩适配器上更新,梯度、优化器状态全部只针对适配器,这部分开销可以忽略不计。真正占显存的是两件事:参考模型的前向权重,和采样阶段的 KV cache。
from peft import LoraConfig lora_cfg = LoraConfig( r=16, lora_alpha=32, # 常用 2r target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM", )关于 rank 的选择,我在强化学习阶段倾向于比 SFT 阶段更大一点,r 从 16 起,因为策略需要在不破坏基础能力的前提下做细粒度调整,秩太小表达不出来。alpha 保持 2r,这样缩放因子固定,换 rank 的时候不用重新调学习率。
还有一招是显存换时间的经典操作:把参考模型的前向用更低的精度跑,或者干脆把参考模型的 logprob 缓存下来离线算。DPO 这种离线方法可以把参考模型的输出提前算好存成文件,训练时直接读,能省下一整份模型常驻显存。但在线方法省不掉,因为每步采样的回答都是新的。这里就体现出一个取舍:在线方法效果上限高,但显存和算力成本是硬性的。
4. 跑起来之后:故障现象、根因与处置
4.1 奖励一路涨,人工评估一路跌
这是最经典的奖励黑客。现象是训练日志里 reward 曲线漂亮地往上走,KL 也在合理范围,但每隔 200 步人工抽检一次,发现回答越来越油滑:堆砌套话、无意义地讨好、把结论重复三遍。根因通常不在算法,在奖励函数——偏好奖励模型在训练数据上见过太多“礼貌的废话”,给它高分的往往是这类文本。
处理方法有三条,我一般同时上。第一,在评估集上单独跑一个“反作弊指标”,比如信息密度、去重后的实质内容长度、关键实体覆盖率,一旦这个指标开始下滑就报警。第二,给奖励做长度分桶归一化:把回答按长度分桶,桶内做 z-score,这样长短回答的奖励尺度被拉平了,模型没法靠“说得多”刷分。第三,加入负向规则惩罚重复和模板句,权重不要太大,0.5 左右即可。
4.2 训练到第几十步,输出突然变成乱码
这是 KL 失控的典型表现。表现是 KL 值在几步之内从个位数跳到三位数,策略模型的输出开始出现重复片段、半截句子、夹杂奇怪符号。触发原因往往是学习率过高,或者某一步的奖励出现了极端离群值(比如打分器返回了极大值),导致优势值爆炸。
处置顺序是这样:先看优势值的分布,如果出现了很大的异常值,检查奖励函数的边界有没有截断;再确认学习率,把它减半重跑;最后确认 kl_coef 有没有随训练动态调整。很多实现里有自适应 KL 的机制,会在 KL 超过目标值时调大系数,但如果目标值本身设得不合理(比如设成 1),那模型会一直被打压,学不动。
注意:KL 监控要看分布不要只看均值。我习惯同时记录 KL 的 p50 和 p99,p99 突然飙高但 p50 平稳,说明是少数长样本在飘,这种情况往往和超长输出有关,加硬截断就能压住。
4.3 常见故障速查表
| 现象 | 高概率根因 | 排查动作 | 处置 |
|---|---|---|---|
| reward 完全不涨 | 组内奖励全同,优势恒为 0 | 打印单个 batch 的奖励分布 | 提高采样温度、加大组大小、换更有区分度的奖励 |
| loss 是 nan | 除零或 bf16 溢出 | 检查归一化里的 eps、logprob 是否出现 -inf | 加 eps、用 fp32 算 logprob |
| 响应长度暴涨 | 奖励隐性偏好长回答 | 统计长度与奖励的相关性 | 加长度惩罚、分桶归一化 |
| 训练后期输出退化 | 过拟合奖励模型 | 对比训练奖励与留出集奖励的差距 | 提前 stop、加 KL 约束、扩大奖励模型训练数据 |
| 显存频繁 OOM | 长尾 prompt 撑爆 KV cache | 记录每个 batch 的最大序列长度 | 按长度分桶采样、限制最大生成长度 |
| 单步耗时抖动大 | 变长序列导致负载不均 | 看每张卡的 token 分布 | 用长度排序后再分片、开启打包 |
| 训练集指标好、线上差 | prompt 模板不一致 | 逐字段比对训练与推理的模板 | 统一模板,加模板指纹校验 |
4.4 一个容易忽略的坑:采样和训练不同步
在线方法里,采样通常在一个独立的推理进程里做,训练在主进程里做。如果推理引擎和训练框架对 tokenizer 的处理不一致(比如特殊 token 的添加规则、截断策略不同),就会出现“采样的 logprob 和训练时重算的 logprob 对不上”的情况。这个错位在日志里几乎看不出来,但会让梯度方向变歪,表现为训练很慢但说不清为什么慢。
我的检查方法是:在开始正式训练前,固定一个 batch,用采样端算一遍 logprob,用训练端重算一遍,两者差值应该在 1e-3 以内。超过这个量级就说明有一端的处理有问题,必须停下来对齐。这一步只花二十分钟,能省掉后面两天。
5. 评估怎么做才不至于自欺欺人
5.1 三层评估,缺一不可
第一层是自动指标,包括可验证任务的准确率、格式合规率、平均长度、重复率。这层跑得快,用来做日常回归,但它只能反映“有没有硬伤”,反映不了“好不好”。
第二层是成对偏好评估。用一组固定的、训练时绝对没见过的 prompt,让新旧两版模型各生成一条,交给人工或一个固定的强模型做二选一。这里必须强调“固定”:prompt 集固定、评测脚本固定、对比基准固定。只要有一项在变,历史数据就不可比,你就分不清是模型变好了还是评测变松了。我一般会保留 300 到 500 条这样的硬评测集,每次训练完都跑一遍,记成一个长期曲线。
第三层是业务回归。挑 20 到 30 个真实场景的端到端用例,从输入到最终产出全流程跑一遍,人工看结果。这层最费人力但最有价值,因为它能发现前两层都发现不了的问题,比如“单个回答都对,但多轮对话里上下文丢了”。
5.2 上线前值得写进 checklist 的几条
- 训练时的最大生成长度和线上一致吗?线上如果限制 512 token,训练时放开到 2048,模型学到的表达习惯在线上会被截断。
- 参考模型用的是哪个版本的基座?如果用 SFT 之后的模型当参考,KL 的语义就变了,它约束的是“不要偏离 SFT 风格”,而不是“不要偏离基座”。
- 奖励函数里的正则有没有可能被输入内容触发?我见过禁词列表里混进了一个常见字,导致大批正常回答被扣分。
- LoRA 权重合并后有没有重新跑一遍评估?合并本身可能引入数值差异,尤其是用低精度合并的时候。
- 有没有准备回滚版本?强化学习阶段的训练曲线不是单调的,第 300 步的模型未必比第 200 步好,一定要保留多个 checkpoint 并且都做过评估。
5.3 关于训练步数的一个经验判断
强化学习阶段不是训得越久越好。我的经验是,在自动指标上的提升会在某个点之后停滞,而人工偏好评估往往更早到顶——经常是自动指标还在缓慢涨,人工已经分辨不出差别甚至在个别维度上变差了。这时候继续训只会加深对奖励模型的过拟合。
比较务实的做法是把训练切成小段,每 100 步存一次 checkpoint,全部存完再统一做偏好评估,从里面挑最优的那个版本,而不是用最后一步的权重。这个做法牺牲一点训练效率,换来的是对过拟合的免疫力,我认为非常值。
6. 我个人在这条路上踩过的几个具体坑
第一个坑是过早追求复杂奖励。最开始我把奖励函数写成了五项加权,每项一个权重,调权重调了两周。后来把四项全砍掉只留“格式合规 + 答案正确”这两项可验证的,效果反而更好,训练也稳。这件事让我确认了一个判断:奖励函数的复杂度应该由信号的可靠性倒推,而不是由你想要的东西倒推。你想要的东西可以很多,但能被可靠测量的往往很少,先把可靠的做扎实。
第二个坑是忽略了参考模型的显存常驻。第一次跑在线方法时按“策略模型 + 采样”估的显存,跑起来就 OOM,因为参考模型虽然不训练但要一直挂着做 KL 计算。后来把参考模型单独放一张卡做前向服务,用批量请求的方式调,虽然多了网络开销,但整体反而更稳,因为主训练卡的显存压力下来了,batch 可以开大。
第三个坑是评估集泄漏。有一版模型的表现好得离谱,兴冲冲上线之后被打回来。查了半天发现评估集里的 prompt 有一批和训练池重叠了,模型是在背答案。从那以后我在数据管线上加了一道硬校验:训练池和评估集做精确去重加近似去重(用编辑距离和 embedding 相似度双阈值),重叠率必须为 0,否则流水线直接中断。
第四个坑是冷却期的处理。强化学习训练有个现象:跑完之后模型在某些通用能力上会退化,比如多语言、长文摘要。我的处理办法是在强化学习阶段结束后,混入 5% 到 10% 的通用监督数据做一次很短的回炉微调,学习率压到 1e-6 级别,几百步就停。这一步几乎不花时间,但对通用能力的保持帮助很明显,尤其是你的强化学习数据全部集中在单一任务上的时候。
如果这条流水线后面还要扩展,我会优先做两件事:一是把奖励函数做成可插拔的插件,每个业务线自己写自己的判定器,共用同一套归一化和监控;二是把评估从“每次训练完跑一次”改成“训练中定期抽样跑”,尽早发现偏离,而不是等一整个训练跑完才知道白干了。踩过的坑告诉我,强化学习这套东西,工程上的确定性比算法上的先进性重要得多。