news 2026/10/20 6:36:27

从头训练奖励模型:Qwen3+DeepSpeed实现RLHF打分器全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从头训练奖励模型:Qwen3+DeepSpeed实现RLHF打分器全流程

RewardModel 这块阵地,最近因为 DeepSpeed 和 Qwen3 的组合又热闹起来了。把 Qwen3 这类本身就具备很强语义理解能力的基座模型,通过 DeepSpeed 做分布式训练,微调成奖励模型,是我正在跑的一条路线。这篇文章把我从零开始构建奖励模型的完整过程写出来,覆盖数据处理、损失函数、DeepSpeed 配置、训练监控和踩坑记录。适合正在做 RLHF、偏好排序,或者想给生成模型加打分的同学参考。不需要你有过模型训练经验,但最好能看懂基本的 PyTorch 张量操作。

很多新人上来就去查训练参数,其实最容易被忽略的是“奖励模型到底在学什么”。我会先把设计思路讲清楚,再给可直接复制的实操配置。整个过程跑通之后,你手里会有一个能给任意文本序列输出标量分数的模型,这个分数可以直接喂给强化学习,也可以单独做成偏好排序接口。

1. 项目整体设计与方案选型

1.1 奖励模型到底在解决什么问题

奖励模型本质上是给一个“文本序列”打分的回归模型。输入通常是 user prompt 和 assistant response 拼起来的完整文本,输出一个连续标量分数。这个分数在 RLHF 里充当裁判:同一个 prompt 下,让模型知道哪个回答更符合人类偏好。

它不是分类器,不需要给句子贴“好/坏”标签。大多数时候,我们只需要一个相对分数,而不是绝对分数。也就是说,模型可以给所有回答打 1.2 分、1.5 分,只要好的那个分数更高就行。真实训练中用的是偏好对数据:一条 prompt 对应两个回答,一个被人类更偏好(chosen),一个没那么偏好(rejected)。模型要学的是把 chosen 的打分压过 rejected。

所以损失函数自然就用 pairwise ranking loss,最常用的是 Bradley-Terry 模型导出的 log-sigmoid 损失:

loss = -log(sigmoid(reward_chosen - reward_rejected))

这个公式的意思是,chosen 和 rejected 的分数差越大,loss 越小。训练过程不关心绝对值,只关心相对排序。这一点会在后续监控里反复提到,很多新手只盯着 loss 下降,却忽略了分数分布正在异常漂移。

1.2 为什么选 Qwen3 作为底座,DeepSpeed 作为训练框架

Qwen3 在对话理解、指令跟随和多语言能力上都很稳。奖励模型虽然只需要最终打一个分,但底层模型必须先理解prompt和response的语义关系,否则分数没有依据。如果用太小的模型,比如几亿参数,可能连基础的指令约束都跟不上,偏好排序也就容易退化成长度、标点、格式等表面特征。

选 Qwen3 还有一个现实原因:它与当前主流训练框架的兼容性很好,权重加载、hidden states 获取、tokenizer 处理都很直接,不需要自己写一堆适配层。

DeepSpeed 解决的则是显存和训练效率问题。奖励模型通常从 7B、14B 甚至更大的底座训练,单卡全参微调几乎不可能。DeepSpeed 的 ZeRO 优化能把优化器状态、梯度、模型参数分布到多卡上,极大降低单卡显存压力。而且它直接把多卡同步、梯度裁剪、混合精度训练这些脏活封装好了,我可以更专注在模型逻辑上。

当然也可以不用 DeepSpeed,用原生 DDP 或者 ZeRO Offload 手动实现,但工程量和踩坑成本会高不少。对于“从零构建奖励模型”这个目标,DeepSpeed 是我目前觉得性价比最高的选择。

1.3 数据形态与得分头的接入位置

奖励模型的训练数据长这样:

{ "prompt": "请给我推荐一款适合办公的机械键盘", "chosen": "办公优先建议选茶轴或静音红轴,键程适中,手感不吵。推荐...", "rejected": "随便买一个就行了,能打字就行。" }

chosen 和 rejected 会构造为两个独立序列,分别送给模型,得到两个 reward 分数。

关键点在于得分头(reward head)接在哪里。Qwen3 是 decoder-only 结构,最后几层隐藏状态已经编码了整个序列的语义。我们把最后一层 hidden states 拉出来,每个 token 位置都会有一个 hidden_size 维向量,然后只取序列最后一个非 padding token 的向量,经过一个线性层映射到 1 维标量。

为什么不取所有 token 的平均?这样做容易让模型把分数平滑掉,丢失关键位置的信息。大模型在很多任务里都是靠最后 token 作为整个序列的 summary,奖励模型沿用这个习惯也很自然。

2. 环境准备与数据预处理

2.1 依赖版本选择

训练奖励模型的前置环境并不复杂,但版本搭配不对会踩各种隐蔽的坑。我自己实测过的组合如下:

组件推荐版本说明
Python3.10对 DeepSpeed 兼容性最好
PyTorch2.1 ~ 2.3支持 bf16、FlashAttention
DeepSpeed0.14 以上ZeRO、CPUAdam、bf16 支持完整
Transformers4.43 以上适配 Qwen3 权重
Accelerate0.30 以上部分启动流程会用到

安装直接用 pip:

pip install deepspeed==0.14.4 transformers accelerate torch

注意不要随手把 transformers 升到最新版,有些中间版本和 DeepSpeed 的 ZeRO-3 兼容性有问题,报错信息还不直观。如果已经装了,出问题就按上面这组版本回退。

2.2 偏好数据集的整理与 tokenization

数据质量直接决定奖励模型上限。我的处理流程是:

  1. 按 prompt 字段 group by,一个 prompt 下可能有多条偏好对。
  2. 过滤掉 chosen 和 rejected 完全一样的样本。
  3. 过滤掉长度过长、明显语无伦次的样本。
  4. 对每个独立的 prompt 记录它对应的所有 pair。

然后做 tokenization。这里有一个容易犯的错误:直接把 prompt 和 response 用普通文本拼接。Qwen3 有自己约定好的 chat template,包含角色标识 token,如果训练和后续推理时的格式不一致,打分会漂移。

我建议先用apply_chat_template把 prompt 和 chosen/rejected 分别构造成完整文本,再 tokenize:

from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("qwen3-7b", trust_remote_code=True) def build_text(prompt, response): messages = [ {"role": "user", "content": prompt}, {"role": "assistant", "content": response} ] return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)

然后对两个完整文本分别做 tokenize,加上 truncation 和 padding:

max_len = 2048 def tokenize_pair(example): chosen_text = build_text(example["prompt"], example["chosen"]) rejected_text = build_text(example["prompt"], example["rejected"]) chosen_enc = tokenizer(chosen_text, truncation=True, max_length=max_len) rejected_enc = tokenizer(rejected_text, truncation=True, max_length=max_len) return { "chosen_input_ids": chosen_enc["input_ids"], "chosen_attention_mask": chosen_enc["attention_mask"], "rejected_input_ids": rejected_enc["input_ids"], "rejected_attention_mask": rejected_enc["attention_mask"] }

padding 策略我建议用padding="longest",让同一个 batch 内对齐到最长样例,而不是固定 max_len。固定长度会浪费大量显存,尤其当多数样本都只有几百 token 时。训练时的动态 padding 交给 DataLoader 里的collate_fn统一做。

2.3 训练集/验证集拆分原则

奖励模型的验证集拆分和普通分类任务不一样。必须按 prompt 维度拆,不能按 sample 随机拆。

举个例子:如果同一条 prompt 的 10 对数据里,8 对进了 train,2 对进了 eval,那么 eval 里出现的 prompt 在 train 里已经被模型见过了。模型可能记住了这个 prompt 对应回答的打分模式,验证集准确率虚高。奖励模型要评估的是“对没见过的问题能正确排序”,而不是“对见过的 prompt 记住排序”。所以我的做法是:

unique_prompts = train_data.unique_prompts train_prompts = set(random.sample(unique_prompts, int(len(unique_prompts) * 0.9))) eval_prompts = set(unique_prompts) - train_prompts train_set = data[data.prompt.isin(train_prompts)] eval_set = data[data.prompt.isin(eval_prompts)]

在实际项目中,一个 prompt 下往往有多个 chosen/rejected 对,拆分时要把这些 pair 看成一个整体移动,不要打散。

3. DeepSpeed 训练奖励模型的核心实现

3.1 加载 Qwen3 并替换为回归头

直接用AutoModel加载 Qwen3 的隐藏表示部分,不需要加载 lm_head。这样既省内存,又避免模型误用语言建模头。

核心模型结构如下:

import torch import torch.nn as nn from transformers import AutoModel, AutoConfig class RewardModel(nn.Module): def __init__(self, model_path, hidden_size=3584): super().__init__() config = AutoConfig.from_pretrained(model_path, trust_remote_code=True) self.base_model = AutoModel.from_pretrained( model_path, config=config, torch_dtype=torch.bfloat16, trust_remote_code=True, use_cache=False, device_map=None ) self.reward_head = nn.Linear(hidden_size, 1, bias=False) self._init_head() def _init_head(self): nn.init.zeros_(self.reward_head.weight) def forward(self, input_ids, attention_mask): outputs = self.base_model( input_ids=input_ids, attention_mask=attention_mask, output_hidden_states=True ) hidden_states = outputs.hidden_states[-1] # [B, S, H] last_index = attention_mask.sum(dim=1) - 1 # [B] batch_index = torch.arange(input_ids.size(0), device=input_ids.device) last_hidden = hidden_states[batch_index, last_index, :] # [B, H] reward = self.reward_head(last_hidden).squeeze(-1) # [B] return reward

hidden_size 要根据具体 Qwen3 规格调整。7B 级别一般是 3584,14B 级别可能是 5120,14B 一般 5120。最稳的办法是从config.hidden_size读取。

初始化 reward_head 权重为 0 是我习惯的做法。这样初始阶段所有输入的 reward 都是 0,chosen 和 rejected 的差也是 0,不会刚开始训练就给模型一个错误的方向。

另外注意这里没有device_map="auto"。 DeepSpeed 对模型的管理和 transformers 的 device_map 容易冲突,尤其 ZeRO-3 会把参数切分到多卡,如果 transformers 已经把模型分好卡了,后面会报出奇怪的 place 错误。

3.2 损失函数与训练循环

训练循环比较直接。每步需要把 chosen 和 rejected 分别过一遍模型得到两个 reward 向量,再算差值。完整片段:

import torch.nn.functional as F def compute_loss(model, chosen, rejected): chosen_rew = model(chosen["input_ids"], chosen["attention_mask"]) rejected_rew = model(rejected["input_ids"], rejected["attention_mask"]) logits = chosen_rew - rejected_rew loss = -F.logsigmoid(logits).mean() with torch.no_grad(): acc = (logits > 0).float().mean().item() diff = logits.mean().item() return loss, acc, diff

这个logits就是 chosen 和 rejected 的分数差。如果差大于 0,说明模型判断正确。

我还会加一个很小的 L2 正则,防止 reward 分数无边界漂移:

if reward_l2_reg > 0: reg = (chosen_rew.pow(2) + rejected_rew.pow(2)).mean() loss = loss + reward_l2_reg * reg

reward_l2_reg 我通常设为 0.001 ~ 0.01。这个正则不是为了优化精度,而是为了让分数保持在一个合理区间。后期接强化学习的时候,reward 分过大会导致 KL 项和 reward 比例失衡。

优化器和学习率建议直接用 DeepSpeed 配置里的参数,不单独写在训练脚本里。逻辑保持简单:

for step, batch in enumerate(dataloader): chosen = {"input_ids": batch["chosen_input_ids"].cuda(), "attention_mask": batch["chosen_attention_mask"].cuda()} rejected = {"input_ids": batch["rejected_input_ids"].cuda(), "attention_mask": batch["rejected_attention_mask"].cuda()} loss, acc, diff = compute_loss(model, chosen, rejected) loss.backward() model_engine.step() model_engine.zero_grad() if step % 20 == 0: print(f"step {step}, loss {loss.item():.4f}, acc {acc:.4f}, diff {diff:.4f}")

这里model_engine是 deepspeed 初始化的 engine,不是原始 model。初始化方式看 3.3。

3.3 DeepSpeed ZeRO 配置与启动命令

DeepSpeed 的配置文件是训练的核心之一。我用得最多的配置如下:

{ "train_batch_size": 32, "gradient_accumulation_steps": 4, "gradient_clipping": 1.0, "zero_optimization": { "stage": 2, "offload_optimizer": { "device": "cpu" }, "contiguous_gradients": true }, "bf16": { "enabled": true }, "steps_per_print": 20 }

train_batch_size代表全局 batch size,它必须等于 micro batch size × GPU 数 × gradient_accumulation_steps。比如每个 GPU 上实际跑 4 条样本,4 张卡,累计 4 步,那么全局 batch size 就是 4 × 4 × 4 = 64。如果这里配置错了,DeepSpeed 会直接报错提示。

训练脚本初始化 engine 的部分:

import deepspeed ds_config = json.load(open("ds_config.json")) model_engine, optimizer, _, scheduler = deepspeed.initialize( model=model, model_parameters=filter(lambda p: p.requires_grad, model.parameters()), config_params=ds_config )

启动命令:

deepspeed --num_gpus=4 train_reward_model.py --deepspeed_config ds_config.json

ZeRO stage 的选择。对于 7B 级别全参微调,stage 2 基本够用。如果显存还是紧,可以开 stage 3,让模型参数也切分到多卡。stage 3 的通信开销更大,训练速度会慢一些,所以能 stage 2 就不要盲目上 stage 3。CPU offload 优化器状态能进一步压显存,但会明显拖慢迭代速度,建议只在 batch size 提不上去的时候打开。

4. 训练过程中的监控与调参

4.1 关键指标怎么看

训练奖励模型不是只看 loss。我平时重点监控四个指标:

指标计算方式健康参考范围
pairwise accuracychosen_rew > rejected_rew 的比例0.6 ~ 0.85
loss-log(sigmoid(chosen_rew - rejected_rew))稳定下降即可
reward diffchosen_rew - rejected_rew 的均值1.0 ~ 3.0
reward std所有样本 reward 的标准差0.5 ~ 2.5

acc 超过 0.85 不一定说明模型好,可能是数据里有明显偏离的样本被吃进去了。reward diff 太小则说明模型区分度不足,后续做强化学习时模型很难根据微小分数差优化。reward std 是用来防漂移的,如果标准差持续涨到 3 以上,就要考虑加正则或降低学习率。

4.2 超参调整经验

学习率:全参数微调时,我推荐1e-5起步;如果是 14B 更大底座,降到5e-6。奖励模型的数据量一般不是海量,大部分场景在几十万条以内,学习率太大很容易让底座原有的语义表征被破坏。

训练轮数:1 到 2 个 epoch 就够。偏好数据相比预训练语料非常少,模型反复看同一个 pair,很容易把排序“背下来”,而不是学会泛化。如果你发现 eval acc 不再上升甚至下降,train acc 还在继续涨,那就是典型的过拟合信号。

Batch size:微调阶段 32 到 64 是比较稳的区间。batch 太小会让 pairwise 排序的目标函数方差很大,reward diff 起起伏伏,不好收敛。

数据截断长度:7B 模型推荐 2048,如果显存允许可以用 4096。但不建议为了强行覆盖长文本把所有样本 padding 到 4096。很多关于回答质量的信号其实集中在前几百个 token,过长序列反而把训练重点带偏。

4.3 奖励分数漂移的应对方案

我踩过最明显的一个坑:训练到一半,模型对几乎所有输入都打出越来越高的 reward,chosen 和 rejected 的分差却保持不变。这种情况通常是 reward head 权重在持续增大,底座的 hidden states 也在朝偏大的方向更新。

我处理漂移有两个办法:

  1. 给 reward head 的权重加 L2 约束,前面代码里的reward_l2_reg干的就是这个。
  2. 定期打印 train 和 eval 的 reward 均值,如果两边同时持续抬升,就降低学习率,或者把 L2 系数调大。

不要指望模型自己把 reward 控制在合理区间,因为损失函数只关心排序,不关心分数绝对值。监管分数分布是训练者的责任。

5. 踩坑记录与问题排查实录

5.1 显存不足的解决顺序

训练 7B 奖励模型最常见的痛点是显存爆掉。我的排查顺序固定是这样:

处理手段作用代价
开启 gradient checkpointing减少激活显存训练速度下降 20%~30%
从 ZeRO stage 2 切到 stage 3参数、梯度、优化器状态全切分通信量变大
开启 CPU offload optimizer优化器状态放内存CPU 瓶颈,迭代变慢
降低 max length减少激活值长文本信息丢失
减小 per_gpu micro batch最直接batch 太小影响稳定性

这五招按顺序从“影响小”到“影响大”尝试。很多时候开一个 gradient checkpointing 就够了,不用急着上 stage 3。

5.2 运行报错排查

训练过程中常见的报错和对应解法:

现象大概率原因对策
DeepSpeed 初始化时报 train_batch_size mismatch配置里 train_batch_size 和 micro_batch × GPU数 × 累积步数不匹配重新计算并修正
加载模型后 forward 报 device mismatch模型被 transformers 的 device_map 手动分配过,和 DeepSpeed 产生冲突移除device_map,交给 DeepSpeed 管理
多卡训练出现 reward 指标不一致日志里打印了 rank 0 的局部指标,没有聚合打印平均指标时用分布式 allreduce
训练中途出现 NaN学习率过高、head 初始化异常、数据有空 id调低 lr,确认 head 权重为零初始化,检查分词结果
验证时 reward 和训练时差很多tokenization 不一致,或 padding 方式变了确保验证数据走同一个 tokenize 函数

其中一个容易被忽略的坑是:加载 Qwen3 时如果用了AutoModelForCausalLM,会把巨大的 lm_head 也加载进显存。奖励模型完全用不到这个 lm_head,白白占了几个 GB。改用AutoModel或者显式把 lm_head 丢掉,是很好的降显存技巧。

5.3 数据泄漏与质量盲点

数据问题比框架问题更容易让奖励模型“看着正常,实际很糟”。

位置偏置是典型问题。如果训练数据构造时总是 chosen 在前、rejected 在后,模型可能学到“这段文本在完整序列里靠前,分数高”,而不是真正理解内容。我一开始吃过这个亏,后来构造 pair 时会随机交换 chosen 和 rejected 的位置,并把交换标记存下来,loss 计算时根据标记重新对齐。

另一个盲点是重复内容。我遇到过一个数据集里大量回答只是模板加参数替换,比如“以下是推荐:A 产品,理由:...”这种结构。奖励模型很快就会发现模板特征和排序的关系,导致真实场景里换个说法就不会打分。

所以现在我会在清洗阶段做文本去重,并抽样可视化 reward 和文本长度的关系。如果发现 reward 显著偏向长回答,就要临时把长度作为一个特征做正则,或者在数据里手动平衡长中短回答的比例。

6. 部署与后续扩展

6.1 将训练好的奖励模型导出与推理

训练完成后的模型和普通模型一样,可以用save_pretrained把权重存下来。保存时我会单独保存 reward head 的权重,或者直接把整个 RewardModel 包装成 transformers 的 PreTrainedModel 格式。

推理时最关键的是和训练保持一致的 tokenize 方式。我用 bf16 加载模型,推理脚本很短:

import torch def score_pairs(prompt, response_list): texts = [build_text(prompt, r) for r in response_list] encodings = tokenizer(texts, padding="longest", truncation=True, max_length=2048, return_tensors="pt") encodings = {k: v.cuda() for k, v in encodings.items()} with torch.no_grad(): rewards = model(encodings["input_ids"], encodings["attention_mask"]).float() return rewards.tolist()

输出可以接 Python 的排序服务,也可以把分数写入日志做离线分析。注意推理时不要把 padding_side 改掉,训练时右 padding,推理时改成左 padding,可能会导致 attention mask 定位的“最后一个非 padding token”不再准确。如果要换,两边必须同时换。

6.2 后续工程化扩展

奖励模型最有价值的场景是接强化学习。在 PPO 里,模型生成一个 response 后,奖励模型的输出和 KL penalty 结合起来:

final_reward = reward_model_score - beta * kl_penalty

这里的 beta 要先去小规模试跑几轮才能定,reward 分布太集中时,beta 稍微调大一点就会让模型变得极其保守。我建议先把奖励模型的输出保存一批样本,统计 mean 和 std,再设置 KL 惩罚的权重。

如果预算允许,还可以训练多个初始化不同的奖励模型做集成。单个奖励模型难免有偏向,几个模型对同一文本的分数做平均,能平滑掉一部分随机性和偏好噪声。这个技巧在数据量少的时候尤其有用。

最后再分享一点个人体会

跑完整个流程,我最大的感觉是:训练奖励模型的瓶颈不在 DeepSpeed,也不在 Qwen3,而在数据准备和验收标准。DeepSpeed 只是把显存和并行问题解决得很干净,真正决定模型质量的是我们交给它的 pair 样本是否一致、评测集是否干净。我现在每接手一批新数据,都会先画一张 chosen 和 rejected 的 reward 分布图,观察两者分界是否清晰,而不是只盯着 accuracy。这个习惯帮我抓住了很多隐藏的数据问题。如果你也在搭类似的奖励模型,我建议把“可视化 reward 分布”加入你的必做清单,它值得花这十几分钟。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 6:36:11

Agent技能体系设计:从工具调用到可复用能力编排的工程化实践

一开始接触“agent-skills”,我其实挺警惕的。市面上太多项目把“给Agent加个工具”包装成“让AI变全能”,结果真到自己上手,不是技能和模型耦合太死,就是换个场景就崩。我自己的体会是:Agent能不能真的落地干活&#…

作者头像 李华
网站建设 2026/10/20 6:35:45

光伏产线IV测试仪:光源补偿与智能采样实现精度与节拍兼得

1. IV测试仪在光伏产线中的位置:为什么精度和节拍会打架光伏行业对IV测试仪的要求,其实是两拨人凑不到一块去。研发实验室搬来一台高精度源表,扫描一条曲线能花一两分钟,数据漂亮得像艺术品,但放到产线上,节…

作者头像 李华
网站建设 2026/10/20 6:35:45

ppt-master:基于Python的声明式PPTX生成引擎

1. 项目概述:这不是一个PPT模板库,而是一套可编程的幻灯片生成引擎“ppt-master”这个名字刚看到时,我下意识以为是某个网红设计师打包出售的PPT模板合集——毕竟市面上叫“XX-master”的资源站太多了,点开就是几十套渐变风、玻璃…

作者头像 李华
网站建设 2026/10/20 6:35:13

Python实现WPA2握手验证:WiFi密码找回与安全自检指南

简介:Python破解WiFi密码工具是一份基于pywifi模块的轻量级实践代码,面向网络安全初学者、渗透测试爱好者或有无线安全需求的开发者,旨在帮助用户快速搭建WiFi密码检测环境并验证密钥安全性。压缩包内共有3个文件,两个Python脚本分…

作者头像 李华
网站建设 2026/10/20 6:34:44

Vulnhub靶机AI: Web: 1渗透实战:从PHP eval RCE到Dirty COW提权

1. 项目概述:这不是一次“打靶”,而是一场Web安全能力的全链路压力测试“Vulnhub靶机渗透之 AI: Web: 1”——这个标题里藏着三个关键信号:Vulnhub,代表一个高度仿真的、面向实战的渗透测试训练平台;AI: Web: 1&#x…

作者头像 李华
网站建设 2026/10/20 6:33:29

DAC能量读取与数据清零:解决音频爆音与待机判断的关键接口

做音频方案调试的朋友应该都有过这种体验:设备明明在播放,你却不确定DAC到底有没有把数据真正送出去;好不容易把声音停下来,喇叭里却“啪”地冒出一声恼人的爆音。这些问题很多人第一反应是查软件通路、查硬件连接、调增益&#x…

作者头像 李华