准备训练一个自己的奖励模型,我踩过不少坑。去年某段时间,我负责一个对齐项目,需要给生成模型组里的回复做质量排序,最开始图省事直接调外部打分接口,结果成本高、延迟大,而且接口返回的分数分布和业务场景完全不匹配。后来决定自己训一个基于偏好数据的奖励模型,技术栈选了 DeepSpeed 加 Qwen3 底座,整个过程从数据构造、环境对齐到 Loss 收敛,一路踩坑一路解决。这篇文章就把我从零构建 DeepSpeed 奖励模型的全过程拆开讲,包括环境配置、数据细节、训练脚本里的关键逻辑,以及部署时那些容易忽略的坑。
1. 从一次失败的对齐实验说起:为什么要单独训练一个奖励模型
1.1 奖励模型在 RLHF 链路中的真实位置
很多刚开始接触强化学习的人对奖励模型有个误解,以为它就是给文本打个分,或者以为直接用大模型当裁判就行。实际在 RLHF 链路里,奖励模型是策略模型和对齐目标之间的"翻译器",它的任务是把人类偏好变成稳定的标量信号。强化学习阶段,策略模型每生成一个回复,奖励模型就要给这个回复打分,这个分数直接决定策略梯度往哪个方向走。如果奖励模型不稳定或者有偏,后面的一切都会跟着歪掉。
用现成的大模型 API 当奖励模型确实省事,但我在实际项目里遇到两个硬伤:第一,接口延迟不稳定,强化学习阶段每轮要采集成千上万个样本,一个样本来回几百毫秒,训练节奏全被打乱。第二,外部模型的打分分布和我们的业务数据分布不一致,比如某个回答在我们场景里明显是安全但无用的,外部打分可能给高分。这就是为什么很多团队最终都会走到自己训奖励模型这条路。
自己训奖励模型的本质,就是用人类标注好的偏好数据,训练一个带回归头的语言模型,让它学会"哪个回复更好"这件事。这个任务的样本格式通常是三元组:提示、好回复、差回复。模型需要学会给好回复打高分,给差回复打低分。这个任务看起来比生成任务简单,但对数据质量和训练细节的要求非常高。
1.2 为什么底座选 Qwen3
底座模型的选择决定了奖励模型的上限。我用过几个候选,最终选 Qwen3 主要出于三点考虑。
第一是基础能力足够强。奖励模型不是从零学习语言,而是在已有语言理解能力之上学习"判断"。Qwen3 的指令遵循能力和长文本处理能力在同量级底座里表现稳定,尤其在中文数据上优势明显,我们的业务数据恰好是中文为主。
第二是开源生态完整。加载权重、做对话模板、微调脚本这些都有现成实现,相比一些冷门底座省去大量造轮子的时间。更关键的是它在 Hugging Face 和 ModelScope 都有权重,下载和部署都比较方便。
第三是模型结构适合加回归头。Qwen3 的 hidden state 维度适中,在顶部加一个线性层做 scalar 输出,参数量增加很少,训练和推理的开销都可控。我做了一个简单对比,同样数据量下不同底座的奖励模型在验证集上的准确率表现:
| 底座 | 参数量 | 验证集准确率 | 训练显存占用(batch=4) |
|---|---|---|---|
| 某 1.5B 底座 | 1.5B | 62.3% | 约 24GB |
| 某 3B 底座 | 3B | 66.1% | 约 36GB |
| Qwen3-4B | 4B | 69.8% | 约 38GB |
这个结果不是绝对结论,因为不同底座在不同数据分布上的表现差异很大,但它至少说明 Qwen3 在我们的业务数据上具备足够优势。最终我选择了 4B 规模,在效果和资源消耗之间取得了平衡。当然如果显存充足,8B 底座的奖励模型上限会更高,训练收敛也通常更平稳。
2. 环境准备与依赖版本对齐:最容易翻车的一步
2.1 硬件选型与显存预估
不少人一上来就卡在环境上。奖励模型本身不算重,但由于训练需要同时加载底座、优化器状态和梯度,显存压力比纯推理高一个量级。以 Qwen3-4B 为例,底座权重大概占 8GB(BF16),AdamW 优化器状态需要额外 12GB 左右,梯度还需要 8GB,单卡 40GB 勉强能跑一个小 batch,想跑得舒服一点还是得靠多卡。
我在实际项目里用的是两张 24GB 显存的消费级显卡做数据并行,配合 DeepSpeed 的 ZeRO Stage 2 把优化器状态切分到多卡。有人问为什么不用 ZeRO Stage 3,我的经验是:奖励模型训练通常 batch size 不需要特别大,Stage 2 已经能把单卡显存占用压到可控范围,而 Stage 3 的通信开销更大,在小规模集群上反而拖慢速度。如果只有单卡 24GB 显存,也可以跑,但 batch size 只能压到 1,配合梯度累积到 8 步来模拟更大的有效 batch。
2.2 依赖安装的版本陷阱
训练代码出现莫名其妙的报错,十有八九是版本没对齐。我整理了一份经过验证的版本组合,这组组合在我的环境里跑了完整训练流程没有报错:
| 依赖 | 推荐版本 | 说明 |
|---|---|---|
| Python | 3.10+ | 版本太低会导致某些新库不支持 |
| PyTorch | 2.1.2+ | 必须支持 BF16 自动混合精度 |
| Transformers | 4.44+ | 低版本可能不认识 Qwen3 的模型结构 |
| DeepSpeed | 0.14.4 | 0.15 以后接口有变化,遇到报错先回退 |
| Accelerate | 0.33.0 | 与 DeepSpeed 集成比较稳定 |
| FlashAttention | 2.5.8 | 可选,但能显著降低显存占用 |
值得单独说的是 FlashAttention 的安装。组件本身能省不少显存,但如果你的显卡是旧架构,或者驱动版本偏低,FlashAttention 编译会直接报错。我的做法是先在纯 PyTorch 环境下把训练流程跑通,确认逻辑没问题之后再考虑加 FlashAttention 提速,避免一上来就是环境问题,根本分不清是代码错了还是环境错了。
2.3 基于 DeepSpeed 的加载脚本骨架
开始训练之前,需要确认模型能正常加载并能输出 hidden state。一个常规做法是先把底座加载出来,跑一次简单的前向传播,确认 tokenzier 和模型结构匹配,再进入正式的训练脚本。这个验证步骤看起来多余,但能省下后面至少半小时的调试时间。
import torch from transformers import AutoModelForCausalLM, AutoTokenizer model_name = "your_qwen3_model_path" tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) model = AutoModelForCausalLM.from_pretrained( model_name, torch_dtype=torch.bfloat16, device_map="auto", trust_remote_code=True, ) inputs = tokenizer(["这是一个测试"], return_tensors="pt") outputs = model(**inputs, output_hidden_states=True) print(outputs.hidden_states[-1].shape)这一步能确认三件事:tokenizer 的词汇表是否正常、模型能否自动把输入切分到多卡、hidden state 的形状是否符合预期。hidden state 的形状一般是 [batch_size, seq_len, hidden_dim],Qwen3-4B 的 hidden_dim 是 2560。这个数字后面会被用到,因为回归头的输入维度就是它。
3. 偏好数据的组织细节:pairwise 格式、长度对齐与 tokenizer
3.1 三元组数据的来源与质量过滤
奖励模型的训练数据是偏好三元组,形式是"提示、好回复、差回复"。这块数据的质量直接决定奖励模型的天花板,数据构造上的偷懒,后期无论怎么调 Loss 都补不回来。
数据来源一般有三种渠道。第一种是人工标注,找标注人员对同一个提示写两个回复,再让标注员选择哪个更好。这种数据质量最高,但成本也最高。第二种是线上日志,把用户点击、点赞、采纳等行为作为隐式反馈,这种方式量大但噪声大。第三种是先用一个强模型生成多个回复,再用成对比较的方式筛选,类似 AI-assisted 标注。第三种方式适合冷启动,我一开始就是用这种方式构建了第一批训练数据。
数据质量过滤是关键步骤。我在清洗时重点处理了三类坏样本:一是两个回复长度差异极大,比如一个只有 5 个 token,另一个有 200 个 token,这种样本会让模型学会"长就是好"而不是"好就是好"。二是两个回复内容高度重复,模型学不到任何区分信号。三是两个回复都是明显的低质量回复,比如全是重复的话,这种样本即便标注了偏好,对模型也没有正向帮助。最终有效数据占比通常在 60% 到 70% 之间,剩下的大部分是长度失衡或者内容重复。
3.2 训练样本的拼接策略
奖励模型和生成模型对输入组织方式不同。生成模型只需要一个单向序列,而奖励模型需要同时处理 prompt、chosen(好回复)和 rejected(差回复)。这里有两个常用策略。
第一种是"双序列策略",把 prompt 和 chosen 拼接成一个序列,prompt 和 rejected 拼接成另一个序列,每个样本在 batch 里是两条独立数据,最后把两个分数做差得到 loss。这种策略实现简单,很多开源代码都用这种方式。第二种是"单序列策略",把 prompt、chosen、rejected 三部分拼成一个序列,用特殊 token 分隔,通过一个回归头输出两个分数。后者节省了重复计算 prompt 的开销,在推理阶段更高效,但训练实现更复杂。
我在训练阶段用的是第一种方案,推理阶段用第二种。如果你刚开始做,建议直接用第一种,代码实现最直观。
3.3 Tokenizer 对齐与长度截断的细节
数据预处理里最容易忽略的是 tokenizer 长度对齐问题。chosen 和 rejected 的长度往往不一致,如果直接按固定长度截断,可能导致一边已经被截没了,另一边还很完整。我的处理方式是:先分别 tokenize chosen 和 rejected,然后设置最大长度为 2048,超长部分优先保留尾部。这是因为模型的判断依据通常集中在回复的中后段,开头部分主要是上下文。
max_length = 2048 def preprocess_pair(prompt, chosen, rejected): prompt_ids = tokenizer.encode(prompt, add_special_tokens=False) chosen_ids = tokenizer.encode(chosen, add_special_tokens=False) rejected_ids = tokenizer.encode(rejected, add_special_tokens=False) chosen_input = prompt_ids + chosen_ids + [tokenizer.eos_token_id] rejected_input = prompt_ids + rejected_ids + [tokenizer.eos_token_id] chosen_input = chosen_input[:max_length] rejected_input = rejected_input[:max_length] chosen_input = chosen_input + [tokenizer.pad_token_id] * (max_length - len(chosen_input)) rejected_input = rejected_input + [tokenizer.pad_token_id] * (max_length - len(rejected_input)) return chosen_input, rejected_input这段代码里 eos_token_id 用来标记回复结束,pad_token_id 用来对齐长度。训练时通过 attention mask 区分真实 token 和 padding token,这样 Loss 计算时才不会把 padding 部分算进去。实际情况中 Qwen3 的 tokenizer 没有默认 pad token,需要在加载后手动设置,通常把 eos token 作为 pad token 使用。
4. DeepSpeed 训练主流程:从配置文件到 Loss 实现
4.1 DeepSpeed 配置文件的核心参数逻辑
进入正式训练前,需要写好 DeepSpeed 配置文件。我不建议用命令行参数硬塞,维护一个 JSON 文件更清晰,也方便多卡复用。下面这个配置是我在实际项目里验证过的:
{ "train_batch_size": 16, "train_micro_batch_size_per_gpu": 2, "gradient_accumulation_steps": 4, "optimizer": { "type": "AdamW", "params": { "lr": 5e-6, "weight_decay": 0.01 } }, "fp16": { "enabled": false, "loss_scale": 0, "loss_scale_window": 1000 }, "bf16": { "enabled": true }, "zero_optimization": { "stage": 2, "allgather_partitions": true, "reduce_scatter": true, "overlap_comm": true, "contiguous_gradients": true }, "gradient_clipping": 1.0 }几个关键参数我解释一下。train_batch_size 是全局 batch size,等于 micro batch size 乘以卡数再乘以梯度累积步数,这里 2 x 2 x 4 = 16。学习率用 5e-6 而不是常见的 5e-5,因为奖励模型的底座已经具备很强的语言理解能力,学习率太大会把底座学坏,这个教训是我用 5e-5 跑了一次之后发现的,模型很快就出现了对训练集过拟合但验证集准确率不涨的迹象。
fp16 和 bf16 的区别值得说清楚。fp16 的动态范围小,loss 一放大就溢出,经常出现 loss 直接变 NaN 的情况。bf16 的指数范围和 fp32 一样,只是精度低一点,但训练奖励模型这种对梯度精度要求不高的任务完全够用。如果显卡支持 bf16,优先用 bf16。
4.2 回归头的设计:把生成模型改成评分模型
Qwen3 本身没有回归头,需要自己在模型顶部加一个线性层。这里的关键是回归头的初始化。我一开始直接随机初始化,结果前几个 step 的 loss 波动特别大。后来改成从零初始化并降低学习率,让回归头在训练早期慢慢适应底座的特征分布,收敛才平稳下来。
import torch.nn as nn class RewardModel(nn.Module): def __init__(self, base_model, hidden_dim=2560): super().__init__() self.base_model = base_model self.value_head = nn.Linear(hidden_dim, 1) def forward(self, input_ids, attention_mask): outputs = self.base_model( input_ids=input_ids, attention_mask=attention_mask, output_hidden_states=True, ) hidden = outputs.hidden_states[-1] masked_hidden = hidden * attention_mask.unsqueeze(-1) pooled = masked_hidden.sum(dim=1) / attention_mask.sum(dim=1, keepdim=True) reward = self.value_head(pooled) return reward这里的池化方式用了 mean pooling,取所有非 padding token 的 hidden state 的平均值再映射到标量。有人用最后一个 token 的 hidden state,也有人用 first token 的 hidden state,各有道理。我的经验是 mean pooling 对短回复和长回复之间的不公平有较好的抑制作用,训练出来的分数更稳健。
4.3 Pairwise Ranking Loss 的原理与实现
奖励模型的核心 Loss 是 pairwise ranking loss,公式表达为:模型对好回复的分数要高于差回复的分数,且差值越大越好。通俗地说,我们不关心模型打分的绝对值,只关心相对高低是否正确。这和普通回归任务的 Loss 有本质区别。
def compute_loss(chosen_reward, rejected_reward): logsigmoid_diff = torch.nn.functional.logsigmoid(chosen_reward - rejected_reward) loss = -logsigmoid_diff.mean() return loss这段代码看起来很简单,但背后的直觉是:当 chosen_reward 明显大于 rejected_reward 时,log 部分接近 0,loss 趋近于 0;当大小关系搞反时,loss 会很大,梯度会推动模型修正。这个 Loss 对所有样本对求平均,因此 batch 内不同样本之间的难度会被平衡掉。实际操作中还有一个细节:除了这个 Loss,有人还会加一个辅助的 margin loss,强制 chosen 和 rejected 的分数差异至少达到某个阈值。我实验发现,对 Qwen3-4B 底座来说加 margin 收益不大,反而多了一个超参数要调。建议先不加,跑一个 baseline,如果发现 chosen 和 rejected 分数差异很小,再加 margin 不迟。
4.4 完整的训练循环与梯度累积
把前面所有环节串起来,完整的训练循环大概长这样:
from deepspeed.ops.adam import FusedAdam def train_step(batch, model, optimizer): chosen_input_ids = batch["chosen_input_ids"].cuda() chosen_attention_mask = batch["chosen_attention_mask"].cuda() rejected_input_ids = batch["rejected_input_ids"].cuda() rejected_attention_mask = batch["rejected_attention_mask"].cuda() chosen_reward = model(chosen_input_ids, chosen_attention_mask) rejected_reward = model(rejected_input_ids, rejected_attention_mask) loss = compute_loss(chosen_reward, rejected_reward) model.backward(loss) model.step() return loss.item()这里用的是 DeepSpeed 封装的model.backward()和model.step(),它内部会处理梯度累积、梯度裁剪和 ZeRO 同步,不需要手写optimizer.zero_grad()、loss.backward()这些操作。训练过程中我会把每个 step 的 loss 打印出来,同时每隔一定步数在验证集上算一次准确率。准确率定义为 chosen_reward > rejected_reward 的比例,这个指标比 loss 值更直观。
5. 收敛效果与实用踩坑记录:从训练到推理部署的完整闭环
5.1 训练过程中的显存与 loss 监控
训练前先算好显存预算,避免训练途中 OOM。以 Qwen3-4B 为例,两个序列拼接后的长度 2048,micro batch size 2,ZeRO Stage 2,双卡 24GB,单卡显存占用实测在 18GB 左右,留有一定的余量给临时张量。如果 OOM,先把 micro batch size 减半,或者把 max length 缩短到 1536,优先保 batch 正常跑通。
训练的正常 loss 走势我是这样观察的:最开始几个 step,loss 应该在 0.7 到 0.75 之间波动,这个值接近-log(0.5),说明模型刚开始基本是瞎猜。大约训练 200 到 300 个 step 之后,loss 会下降到 0.5 以下,对应准确率从 50% 提升到 70% 以上。如果 loss 下降很慢,优先检查数据质量,九成情况是数据里混入了大量"两个回复都很差"的坏样本。
更新完每个 epoch 的参数之后,我通常会在验证集上算指标。一个简单有效的做法是,每次保存 checkpoint 之后就批量跑一遍验证集,记录准确率、chosen 平均分、rejected 平均分,画成表格观察趋势。如果特定 epoch 准确率反而掉了,说明模型开始过拟合,回退到之前的 checkpoint 即可。
5.2 推理部署时省略底座加载的坑
训完奖励模型之后的部署环节,有个非常隐蔽的坑:模型保存时不小心把完整的底座参数也存了下来,导致推理时加载了多余参数,或是在推理脚本里没有屏蔽掉底层的生成模块,导致打分速度极慢。
正确的做法是只保存 value head 的权重,或者只保存底座的 LoRA 权重。推理时用一个统一的模型类把底座和回归头加载起来,但关闭梯度计算,并确保 tokenizer 里不会生成多余内容。我经常遇到的一个问题是想当然地调model.generate(),奖励模型根本没有生成能力,直接报错或者输出空内容。奖励模型的输出就是一个标量分数,拿这个分数排序才是正确的用法。
推理阶段还有一个小优化:因为奖励模型只需要评分,不需要生成,所以可以一次性把 batch 拉到足够大。我实测在单卡 24GB 上把 batch size 拉到 32 推理耗时也不会显著增加,因为瓶颈在底座前向传播,而前向传播对 batch 的利用率很高。所以奖励模型部署的成本远低于生成模型部署的成本,这也是奖励模型适合自建的原因之一。
5.3 实际应用中遇到的三个隐藏问题
训练完成并不代表一劳永逸,真正上线后我才发现奖励模型还有很多边界情况要处理。
第一个是上下文长度敏感。我们的业务提示里经常有很长的背景说明,如果训练时 max_length 设成 2048,线上出现超过这个长度的输入,tokenizer 截断后会把关键信息截掉,打分明显异常。这个问题的做法我是在数据处理时就按线上分布来设置 max_length,宁可牺牲一些训练效率,也要保证长度分布一致,不要只图快图省事。
第二个是"套话偏好"问题。奖励模型很容易学会给某种固定话术打高分,比如包含特定措辞、特定结构的回复总是拿到高分。这其实是数据分布的偏置。解决思路是在数据里刻意加入一些"好话术但坏内容"的反例,用模型学"句子漂亮不等于内容好"。尽管没法完全消除,但加上这些样本之后,模型在人工评测里的误判率显著下降。
第三个是校准问题。奖励模型输出 0.8 分和 0.9 分之间的差值,到底表示多大的偏好强度?这其实没有严格定义。如果要拿奖励分数去做阈值决策,需要额外做一步校准,比如收集一些线上样本,让标注员排序,找到分差和真实偏好概率之间的映射。这一步看起来繁琐,但对业务落地很重要,否则奖励分数只能做相对排序,不能做绝对判断。
5.4 后续可以继续探索的方向
在现有的 DeepSpeed + Qwen3 奖励模型基础上,后续有几个方向值得尝试。一个是把训练好的奖励模型接进强化学习管线,用 PPO 或者 GRPO 算法继续对齐策略模型,这是奖励模型最有价值的应用。另一个方向是尝试更高质量的数据构造方案,用迭代式标注让奖励模型和生成模型互相促进。还有一个方向是试一下多任务学习,同时让模型学会打分和给出解释,输出不仅能告诉策略模型"哪里不好",还能告诉它"为什么不好"。
我在实际使用中发现,奖励模型的调优过程有点像给一个资深评审员做入职培训:它不需要自己会写文章,但要有一双看得出好坏的眼睛。数据喂得好,它判断就准;数据喂得杂,它判断就会飘。这也是整个训练过程中最需要耐心的地方。如果做好了,你会发现后续所有对齐实验的迭代速度都会明显变快,很多方向性问题在奖励模型这一关就能被挡下来。