大模型的推理能力到底是怎么来的?有人说是靠喂海量的训练数据“背”出来的,有人说是因为模型参数量足够大之后自己“涌现”出来的。但真正了解DeepSeek R1训练内幕的人都知道,背后那个让模型突然“开窍”的关键,是一种叫GRPO的强化学习算法。
GRPO到底是什么?为什么DeepSeek用它替换掉了传统的PPO?字节和清华联合提出的DAPO又对GRPO做了哪些改进?这些问题,本文将用最通俗的方式讲清楚,并且通过一个完整的24点游戏训练项目,带你亲手用DAPO微调一个Qwen2.5-3B模型。
一、为什么要用强化学习训练大模型?从“背答案”到“学解题”
先来看一个扎心的事实:传统的大模型训练,本质上是在让模型“背答案”。
给你一大堆问题和标准答案,喂给模型让它学,学完了考试,考的是训练集里见过的问题。遇到没见过的问题怎么办?模型就开始“胡编乱造”。这叫监督微调(SFT),核心逻辑就是模仿。
强化学习的逻辑完全不同。它不要求模型背诵标准答案,而是给模型一个“奖励信号”——做对了给高分,做错了给低分,让模型自己摸索出什么样的行为能拿到高分。
类比一下:就像训练一只小狗,你想让它在草地上走一条特定的路。背着走(监督学习)和小狗自己探索(强化学习),哪个效果更好?显然是后者,因为小狗是自己学会的,而不是被硬塞进去的。
应用到LLM的场景里:
-
问题
= 当前环境状态
-
模型生成的回答
= 小狗踩出的每一步
-
奖励
= 正确就加分,错误就扣分
-
策略网络
= 小狗脑子里那个“该怎么走”的判断系统
通过反复尝试,模型逐渐学会“面对什么样的问题,应该生成什么样的回答”。这种学习方式,让模型获得的是解题能力,而不只是“见过这道题”。
二、PPO:曾经的主流方法,但有两个致命问题
在GRPO出现之前,大模型强化学习的主流算法叫PPO(近端策略优化)。PPO在RLHF(人类反馈强化学习)中被广泛采用,但它有两个让工程师头疼的问题。
第一个问题:太吃显存。 PPO在训练时需要同时维护三个模型:
-
策略模型Actor:负责生成回答,是我们要训练的那个模型
-
奖励模型Reward Model:负责给生成的回答打分
-
评论家模型Critic:负责评估当前状态的好坏
三个模型同时运行在当前最好的GPU上,显存说爆就爆。训练一个7B的模型,至少需要4张A100,普通开发者根本玩不起。
第二个问题:训练不稳定。 Critic模型本身也是一个庞大的神经网络,它的学习过程会带来额外的误差,导致策略模型“学歪了”。类似于批评家自己也没搞清楚状况,还去指导演员,结果可想而知。
GRPO正是为了解决这两个问题而诞生的。
三、GRPO的聪明之处:用一个小组的人互相比较,代替那个不靠谱的“评论家”
GRPO是Group Relative Policy Optimization的缩写,中文叫“组相对策略优化”。DeepSeek团队在DeepSeekMath论文中首次提出这套方法,后来R1的一系列成功,让GRPO名声大噪。据相关分析,DeepSeek-R1使用GRPO替代PPO做强化学习训练后,成本降低了40%,推理能力与OpenAI o1并驾齐驱。
GRPO到底聪明在哪?一句话说清楚:它直接扔掉了又耗显存又不靠谱的Critic价值网络,改用“小组内的比较”来评估每个回答的好坏。
来看具体操作:
第一步,针对同一个问题,用当前的模型生成G条不同的回答。在典型的训练中,G通常取8到64之间。
第二步,用规则奖励函数给每条回答打分,得到G个分数。
第三步,计算这组分数的平均值μ和标准差σ。
第四步,每条回答的优势 = (它的分数 - 平均值) / 标准差。
就这么简单。不需要训练Critic网络,不需要额外的显存开销,直接用一个数学公式就完成了“好回答”和“差回答”之间的比较。
为什么这样可行?因为同一个问题下生成的回答之间确实可以直接比较——A回答了“42”,B回答了“43”,正确答案是“42”,前者的优势就应该是正的,后者的优势就应该是负的。组内的平均分天然就是一个基准线。
与其他主流LLM强化学习算法相比,GRPO在目标函数和训练方式上做了多项简化,核心是不依赖独立的价值函数模型,这不仅简化了训练流程,也显著减少了内存消耗。
四、DAPO:字节和清华给GRPO做的四项关键升级
GRPO虽好,但研究人员在复现DeepSeek的工作时发现一个问题:按照论文里的方法操作,效果远不如预期。有人用Qwen2.5-32B和GRPO进行测试,在AIME 2024基准上只拿到了30分,和DeepSeek宣称的47分差了一大截。这说明论文里可能省略了一些工业级系统所必需的关键细节。
字节跳动和清华AIR联合实验室SIA Lab决定解决这个问题。2025年3月,他们正式发布了DAPO算法——全称Decoupled Clip and Dynamic sAmpling Policy Optimization(解耦剪辑与动态采样策略优化)。DAPO的核心改进就四个字: “不把每个token区别对待”。DAPO的最大特点是优化目标在token层面而非样本层面,同时引入了动态采样和非对称裁剪机制,在训练稳定性和探索能力上实现了显著提升。
这正是DAPO命名里“Decoupled”(解耦)的含义——把上下裁剪阈值解绑、把token贡献方式解绑,每一项改进都独立解决一个GRPO遗留的问题。
在AIME 2024基准上,Qwen2.5-32B经过DAPO强化学习训练后取得了50分的成绩,超越了同等规模下使用GRPO的DeepSeek-R1-Zero-Qwen,而且训练步数还减少了50%。以下四项关键技术是DAPO超越GRPO的核心原因:
4.1 改进一:平等对待每一个token(Token-Level Loss)
GRPO在计算损失时采用“样本级平均”:先把每条回答里所有token的损失相加,再除以这条回答的token数量,得到一个“回答平均损失”;再把这些“回答平均损失”加起来,除以回答的数量,得到最终的损失。
这种做法的问题在于:长回答里的每个token被“稀释”了。 一个token的贡献 = (1 / 回答长度) × (1 / G)。回答越长,它的每个token能起到的作用就越小。
DAPO的思路是:直接把所有回答的所有token损失放在一起,除以总的token数,每个token贡献完全相同,回答再长也不会被稀释。
用DAPO训练长思维链(Long CoT)模型时,每一步的推理token都能得到应有的训练信号,不再出现“开头错了整个链崩掉,但后续正确步骤得不到鼓励”的问题。
4.2 改进二:非对称裁剪(Clip-Higher)
GRPO对策略更新做了裁剪限制:无论token当前概率是高还是低,更新幅度都不能超过ε(通常设为0.2)。这里面藏着一个问题:高概率token很容易被进一步强化,但低概率token却很难增加概率。训练到后面,模型对所有问题的回答越来越趋同,多样性丧失。
DAPO把上下裁剪阈值解耦:下裁剪仍然是0.2,但上裁剪扩大到0.28。这会带来什么变化?
-
低概率token获得更大的探索空间
:一个目前概率不到随机的token,有机会在一次更新中将概率提升到更合理的水平
-
过高概率token仍然受到抑制
:防止模型过早固化到某个固定模式
-
熵值维持在健康范围
:确保模型始终保持探索的活力
这一机制被形象地称为“Clip-Higher”。实验表明,保持策略熵的缓慢上升趋势有助于提升模型最终性能。
4.3 改进三:动态采样(Dynamic Sampling)
当一个批次里所有问题的回答全对或全错时,奖励全部相同,归一化后的优势全部为零,梯度信号完全消失——这种情况被称为“零梯度问题”。
动态采样的解决方法是:在采样阶段就做一次预筛选,保留那些有奖励差异(即有对有错)的样本,丢掉全是全对或全是全错的样本。这确保每次策略更新都有有效的梯度信号,不会白白浪费计算资源。
4.4 改进四:超长奖励塑造(Overlong Reward Shaping)
DAPO的最后一个重要变化是明确移除了传统PPO和GRPO中使用的KL散度惩罚项。在RLHF(人类反馈强化学习)的传统场景中,KL惩罚的作用是不让模型偏离初始模型太远,以此来维持训练稳定和数据效率。但训练长思维链推理模型时,研究者发现模型的有效分布与初始模型存在显著差异,此时的KL约束反而限制了模型的探索空间。基于这种实践观察,DAPO移除了这个约束,让模型能自由地探索新的推理路径。同时,不再需要维护额外的参考策略网络π_ref,显存占用也进一步降低。
五、动手实战:用DAPO训练一个会玩24点游戏的Qwen模型
理论说完了,我们来写代码。本节将手把手实现DAPO算法,在Countdown Task(24点类数字挑战)上训练一个Qwen2.5-3B模型,让它学会用思维链一步步推理,最终给出正确的数学表达式。
5.1 Countdown Task是什么?
给定3到4个数字和一个目标值,模型需要用+、-、×、÷四种运算生成一个数学表达式,表达式的求值结果等于目标值。每个数字必须恰好用一次。
一个例子:
-
数字:[37, 81, 10]
-
目标值:34
-
正确答案:(81 - 37) - 10
模型输出必须遵循指定格式:推理过程放在 标签中间,最终答案放在 标签中间。以下是最佳实践的提示词设计:
SYSTEM_MESSAGE = "你是一个有用的助手。你首先在脑海中思考推理过程,然后为用户提供答案。"
USER_TEMPLATE = (
"使用这些数字 {numbers},创建一个等于 {target} 的等式。"
"你可以使用基本算术运算(+、-、*、/),每个数字只能使用一次。"
"在 <think> </think> 标签中展示你的解题过程。"
"并在 <answer> </answer> 标签中返回最终答案,例如 <answer> (1 + 2) / 3 </answer>。"
)
RESPONSE_PROMPT = "让我一步步来解决这个问题。\n<think>"
这种格式设计是关键——它强制模型先组织逻辑再输出结论,思维过程被分离出来,这种结构化的输出模式为后续的强化学习提供了稳定的奖励信号。
5.2 奖励函数:如何给模型的回答打分
强化学习的一切,都围绕“奖励”两个字展开。奖励函数写得好,模型自然学会正确的行为。对于Countdown Task,奖励函数分两层设计:
-
第1层:格式正确性(最高1.0分
):
-
完全匹配 ...... 格式 → 1.0分
-
和标签都存在但格式有瑕疵 → 0.1分
-
和标签都存在但格式有瑕疵 → 0.5分
-
-
第2层:答案正确性(最高1.0分
):
-
大括号内必须只包含数字和运算符号,不能有额外文字
-
必须使用所有给定数字,且每个数字恰好一次
-
表达式求值结果必须等于目标值
-
最终得分 = 格式分 × 0.1 + 正确分。这个设计让格式占10%权重——既要保证格式规范,又不能让格式喧宾夺主。
格式奖励的核心代码:
def format_reward_function(response: str, end_token: Optional[str] = None) -> float:
"""
检查模型是否正确遵循了<think>和<answer>标签格式
代码逻辑说明:
1. 如果response末尾有结束符,先把它切掉(不参与格式匹配)
2. 用正则表达式分别匹配<think>、<answer>以及完整格式
3. 完全匹配满分(1分);部分匹配累加得分(<think>给0.1分,<answer>给0.5分)
"""
# 去除结尾的结束符(如果存在)
if end_token and response.endswith(end_token):
response = response[:-len(end_token)]
# 用正则表达式匹配标签对(re.DOTALL让.能匹配换行符,因为思维链可能跨多行)
think_regex = r"<think>.*?</think>"
answer_regex = r"<answer>.*?</answer>"
full_format_regex = r"^(think>.*?[/think]\n<answer>.*?[/answer]$)"
think_match = re.search(think_regex, response, re.DOTALL)
answer_match = re.search(answer_regex, response, re.DOTALL)
full_format_match = re.match(full_format_regex, response, re.DOTALL)
if full_format_match:
return 1.0 # 格式完全正确,给满分
reward = 0.0
if think_match:
reward += 0.1 # <think>标签对存在,加0.1分
if answer_match:
reward += 0.5 # <answer>标签对存在,加0.5分
return reward
答案正确性的核心代码:
def answer_reward_function(response: str, numbers: List[int] = None, target: int = None) -> float:
"""
检查模型给出的答案是否正确
验证步骤(按执行顺序):
1. 用正则表达式提取<answer>标签内的表达式内容
2. 检查提取的内容是否为空
3. 检查表达式是否只包含数字和允许的运算符(+、-、*、/、())
4. 提取表达式中使用的所有数字,排序后与题目提供的数字列表比较
5. 调用Python的eval函数,在安全沙箱中计算表达式的值,与目标值比较
"""
# 步骤1:提取<answer>标签内的内容
answer_regex = r"<answer>(.*?)</answer>"
answer_match = re.search(answer_regex, response, re.DOTALL)
if not answer_match:
return 0.0
answer_content = answer_match.group(1)
# 步骤2:检查提取的内容是否为空
if not answer_content:
return 0.0
# 步骤3:字符合法性验证(只允许数字、运算符、括号和空格)
# 这里用allowed_chars对非法字符进行过滤
allowed_chars = r"^[0-9+\-*/() ]+$"
if not re.match(allowed_chars, answer_content):
return 0.0
# 步骤4:数字使用情况校验(每个数字必须用且只用一次)
# 正则表达式r"\d+"匹配所有连续的数字序列
used_numbers = [int(n) for n in re.findall(r"\d+", answer_content)]
if sorted(used_numbers) != sorted(numbers):
return 0.0
# 步骤5:表达式求值验证
# 在eval中关闭__builtins__是为了防止表达式包含恶意代码
try:
result = eval(answer_content, {"__builtins__": None}, {})
# 浮点数比较允许1e-5的误差范围
if abs(float(result) - float(target)) < 1e-5:
return 1.0
except:
pass
return 0.0
5.3 Rollout阶段:并行采样生成回答
Rollout是强化学习生命周期中的第一步,也是最耗时的一步。它承担的任务是:给定一批问题,让当前的模型为每个问题生成G条不同的回答,并计算每条回答的奖励。 并行处理多条序列时,需要解决三个工程挑战:
-
问题长度不一
:有的问题短、有的问题长,无法直接放在同一个矩阵里。解决方案是:用input_text_mask记录每个位置原本就有token的是哪些序列,生成新token时只对需要填充的位置做采样。
-
序列结束时间不同
:有的回答很快遇到<im_end>结束,有的回答要生成几百个token。解决方案是:维护is_finished标志位,已结束的序列后续全部填充pad_token_id。
-
显存管理
:生成完成后释放KV Cache并手动调用垃圾回收。
这里是Rollout的核心实现,注释中详细说明每一步的工程考量:
@torch.no_grad()
def rollout(
model: Transformer, # 当前策略模型
batch: MiniBatch, # 批次中的N个问题
tokenizer: Tokenizer,
max_gen_len: int, # 最大生成长度
num_answer_per_question: int, # 每个问题生成G条回答
reward_function: Callable, # 奖励函数
device: torch.device,
dtype: torch.dtype,
) -> List[Episode]:
"""
并为每个问题生成G条回答
并行策略说明:
- 将所有问题的所有回答的token矩阵合并成一个形状为(N × G, total_len)的张量
- 逐token地并行采样:一轮迭代同时为所有N×G条序列预测下一个token
- 每个序列独立管理结束状态,互不影响
"""
end_token = tokenizer.eos_token
end_token_id = tokenizer.eos_token_id
pad_token_id = tokenizer.pad_token_id
# 获取每个问题的前缀token ID(问题的输入)
prefix_token_ids = batch.prefix_token_ids
# 批次中的总序列数 = N × G
bsz = len(batch.prefix) * num_answer_per_question
# 找出最短问题和最长问题的长度,用于后续填充和截断
min_prompt_len = min(len(t) for t in prefix_token_ids)
max_prompt_len = max(len(t) for t in prefix_token_ids)
total_len = max_gen_len + max_prompt_len
# 初始化KV Cache加速生成:后续每走一步只需要计算新token的注意力,
# 不需要重新计算整个序列的历史信息
model.init_kv_cache(
max_batch_size=bsz,
max_seq_len=total_len,
device=device,
dtype=dtype,
)
# 所有序列的token矩阵:初始全部填充为pad_token_id
tokens = torch.full(
(bsz, total_len), pad_token_id, dtype=torch.long, device=device
)
# 将前缀部分填入token矩阵
# 逻辑说明:第i个问题的G个回答(索引从i*G到(i+1)*G-1)共享同一个前缀
for k, t in enumerate(prefix_token_ids):
offset = k * num_answer_per_question
for i in range(num_answer_per_question):
tokens[offset + i, :len(t)] = torch.tensor(t, dtype=torch.long, device=device)
# 标记矩阵:哪些位置是前缀(已有token),用于后续判断是否需要采样新token
input_text_mask = tokens != pad_token_id
# 每条序列的结束状态标志
is_finished = torch.zeros((bsz,), dtype=torch.bool, device=device)
prev_pos = 0
# 逐token并行生成
for cur_pos in range(min_prompt_len, total_len):
# 并行采样:根据当前已生成的token预测下一个token
with torch.autocast(device_type=device.type, dtype=dtype):
logits = model.inference(tokens[:, prev_pos:cur_pos], prev_pos)
probs = torch.softmax(logits[:, -1], dim=-1)
# 从概率分布中采样下一个token(使用多项分布)
next_token = torch.multinomial(probs, num_samples=1).reshape(-1)
# 关键处理:如果cur_pos位置本来已有前缀token,则不使用采样的token
# 例如长问题在生成阶段遇到已填充的内容时应跳过,保持内容不变
next_token = torch.where(
input_text_mask[:, cur_pos],
tokens[:, cur_pos],
next_token
)
# 如果序列已经结束,继续填充pad_token_id
next_token = torch.where(
is_finished,
pad_token_id,
next_token
)
tokens[:, cur_pos] = next_token
# 检测结束符:如果新生成的是eos_token,标记该序列为已结束
is_end_token = next_token == end_token_id
if is_end_token.any():
is_generated_token = ~input_text_mask[:, cur_pos]
is_finished = is_finished | (is_end_token & is_generated_token)
prev_pos = cur_pos
# 全部序列都结束后提前退出循环
if is_finished.all():
break
# 生成完成,释放KV Cache
model.del_kv_cache()
gc.collect()
torch.cuda.empty_cache()
# 解码每条生成的回答并计算奖励
episodes = []
is_finished_list = is_finished.tolist()
tokens_list = tokens.tolist()
for i in range(bsz // num_answer_per_question):
for j in range(num_answer_per_question):
idx = i * num_answer_per_question + j
# 提取回答部分(去掉前缀)
generated_token_ids = tokens_list[idx][len(batch.prefix_token_ids[i]):]
# 去除末尾的pad_token(按第一个出现的pad_token_id截断)
if pad_token_id in generated_token_ids:
generated_token_ids = generated_token_ids[:generated_token_ids.index(pad_token_id)]
generated_text = tokenizer.detokenize(generated_token_ids)
# 调用奖励函数计算得分
rewards = reward_function(
response=generated_text,
numbers=batch.numbers[i],
target=batch.target[i],
end_token=end_token,
)
episodes.append(Episode(
prefix=batch.prefix[i],
text=batch.prefix[i] + generated_text,
prefix_token_ids=batch.prefix_token_ids[i],
generated_token_ids=generated_token_ids,
is_finished=is_finished_list[idx],
reward=rewards["reward"],
reward_info=rewards["reward_info"],
))
return episodes
5.4 优势计算:组内比较是DAPO的灵魂
这是GRPO/DAPO区别于传统强化学习的关键一步——优势不是绝对分数,而是“相对表现”。同样得80分,放在一组平均分90的组里是负优势,放在平均分70的组里是正优势,这取决于被比较的同组样本的表现。设计这个步骤,是因为不同问题的难度差异太大了,必须归一化到同一基准上才能正确评估。
优势计算公式:
优势 = (回答的原始奖励 - 同一问题的所有回答的平均奖励) / 同一问题的所有回答的奖励标准差
实现代码:
def normalize_rewards_per_group(episodes: List[Episode]) -> List[Episode]:
"""
组内优势归一化
为什么要这一步?
- 不同问题的难度不同,绝对奖励无法直接比较
- 归一化后将不同问题的奖励映射到标准正态分布,消除了问题难度差异
- 拿高分只是及格线,比组内大多数人好才叫确实优秀
"""
groups = defaultdict(list)
for episode in episodes:
groups[tuple(episode.prefix)].append(episode) # 按问题(prefix)分组
output = []
for group in groups.values():
group_rewards = [ep.reward for ep in group]
mean_reward = np.mean(group_rewards)
std_reward = np.std(group_rewards)
for episode in group:
normalized_reward = (episode.reward - mean_reward) / (std_reward + 1e-4)
output.append(dataclasses.replace(episode, reward=normalized_reward))
return output
这里有一个微小但必要的细节:分母加上1e-4。当组内所有回答的奖励相同时,标准差会接近0,不加这个修复因子会导致除零错误。
5.5 策略更新:DAPO损失函数的核心实现
DAPO与标准GRPO在代码层面的最大差异体现在两个地方:
-
Token级别损失聚合
:不再对每个回答内的token平均后再对所有回答平均,而是将batch内所有回答的所有token损失放在一起,计算总和的平均值。
-
无KL散度
:损失函数中不包含KL散度项,也不需要维护参考策略网络π_ref。
以下是完整的策略更新实现:
def update_policy(
model, optimizer, episodes: List[Episode],
micro_batch_size: int, pad_token_id: int,
max_grad_norm: float, device, dtype
):
"""
使用DAPO风格的策略梯度更新模型
关键差异说明:
- GRPO:先计算每个回答内的平均损失,再对所有回答取平均
- DAPO:将所有回答的所有token损失直接放在一起求平均
数学上,这意味着:
GRPO_objective = (1/G) * Σ_i [(1/len_i) * Σ_t obj_{i,t}]
DAPO_objective = (1/Σ_i len_i) * Σ_i Σ_t obj_{i,t}
效果上:长回答中的每个token获得与短回答中每个token相等的权重
"""
# 第一步:组内优势归一化(得到最终的A_{i,j})
episodes = normalize_rewards_per_group(episodes)
# 第二步:按总token数排序,便于批次处理时节省padding开销
episodes.sort(key=lambda x: len(x.prefix_token_ids) + len(x.generated_token_ids))
num_target_tokens = sum(len(ep.generated_token_ids) for ep in episodes)
entropy = 0.0
for i in range(0, len(episodes), micro_batch_size):
j = min(i + micro_batch_size, len(episodes))
batch_episodes = episodes[i:j]
# 计算当前微批次的最长序列长度,用于填充对齐
batch_lengths = [
len(ep.prefix_token_ids) + len(ep.generated_token_ids)
for ep in batch_episodes
]
batch_max_length = max(batch_lengths)
# 构建batch token矩阵:每一行是一个完整的序列(问题+回答+填充符)
batch_token_ids = [
ep.prefix_token_ids + ep.generated_token_ids +
[pad_token_id] * (batch_max_length - batch_lengths[k])
for k, ep in enumerate(batch_episodes)
]
# batch_masks:标记哪些位置属于回答部分(目标token部分)
# 前缀部分的mask为0(不参与梯度计算),回答部分的mask为1(参与计算)
batch_masks = [
[0] * len(ep.prefix_token_ids) +
[1] * len(ep.generated_token_ids) +
[0] * (batch_max_length - batch_lengths[k])
for k, ep in enumerate(batch_episodes)
]
# 取出归一化后的优势值(每个序列共享同一个优势值赋给其所有位置)
batch_advantages = [ep.reward for ep in batch_episodes]
# 转换为张量
batch_token_ids = torch.tensor(batch_token_ids, device=device, dtype=torch.long)
batch_masks = torch.tensor(batch_masks, device=device, dtype=torch.long)
batch_advantages = torch.tensor(batch_advantages, device=device, dtype=torch.float32)
with torch.autocast(device_type=device.type, dtype=dtype):
# 输入token_ids = 去掉最后一个token(模型根据前面的所有token预测下一个)
input_token_ids = batch_token_ids[:, :-1]
# 目标token_ids = 去掉第一个token(要预测的真实token)
target_token_ids = batch_token_ids[:, 1:]
target_masks = batch_masks[:, 1:] # 只关注回答部分的目标token
# 模型前向传播:输入每步的input_token,获取logits(未归一化的概率)
logits = model.forward(input_token_ids).float()
# 计算负对数似然(即交叉熵损失)
# ignore_index=pad_token_id:忽略填充位置,不参与损失计算
log_probs = -torch.nn.functional.cross_entropy(
logits.reshape(-1, logits.size(-1)),
target_token_ids.reshape(-1),
ignore_index=pad_token_id,
reduction="none",
).reshape(input_token_ids.shape[0], -1)
# 计算熵用于监控模型探索能力(不参与反向传播)
with torch.no_grad():
token_entropy = compute_entropy(logits)
entropy = (token_entropy * target_masks).sum() / num_target_tokens
# DAPO核心:token级别的策略梯度损失
# 每个token的损失 = log_probs × A_i(该token所在的回答的优势)
obj = log_probs * batch_advantages[:, None]
# 关键差异:不再对每个回答内的token取平均,而是所有token直接加权平均
# 这确保了长回答中的每个token不会因序列过长而被稀释
obj = (obj * target_masks).sum() / num_target_tokens
loss = -obj
# 反向传播(只累积梯度,暂不更新参数)
loss.backward()
# 梯度裁剪:防止梯度爆炸,将梯度范数限制在max_grad_norm以内
grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=max_grad_norm)
# 优化器更新参数
optimizer.step()
optimizer.zero_grad(set_to_none=True)
return {"loss": loss.item(), "grad_norm": grad_norm.item(), "entropy": entropy.item()}
5.6 完整的训练脚本
将所有组件串联起来,在Countdown数据集上对Qwen2.5-3B模型进行RL训练:
def main():
# 超参数配置
BATCH_SIZE = 256 # 整体批次大小
NUM_QUESTIONS_PER_BATCH = 32 # 每个批次32个问题
NUM_ANSWERS_PER_QUESTION = 8 # 每个问题生成8条回答
MAX_GEN_LEN = 1024
LR = 1.0e-5
# 初始化分词器、数据集、模型
tokenizer = Tokenizer("./Qwen2.5-3B-Instruct/tokenizer.json")
train_dataset = CountdownTasksDataset(
data_path="./Countdown-Tasks-3to4/",
tokenizer=tokenizer,
split="train",
test_size=128,
)
train_dataloader = DataLoader(
train_dataset,
shuffle=True,
collate_fn=CountdownTasksDataset.collate_fn,
batch_size=NUM_QUESTIONS_PER_BATCH,
)
model = Transformer.from_pretrained("./Qwen2.5-3B-Instruct/", device=device).train()
optimizer = torch.optim.AdamW(model.parameters(), lr=LR, betas=[0.9, 0.999])
for step, batch in enumerate(train_dataloader, start=1):
# 阶段一:Rollout——生成回答并计算奖励
episodes = rollout(
model=model,
batch=batch,
max_gen_len=MAX_GEN_LEN,
num_answer_per_question=NUM_ANSWERS_PER_QUESTION,
reward_function=reward_function,
)
# 阶段二:策略更新——使用DAPO损失更新模型
results = update_policy(
model=model,
optimizer=optimizer,
episodes=episodes,
micro_batch_size=2, # 微批次大小,显存小的话可以调得更小
pad_token_id=tokenizer.pad_token_id,
max_grad_norm=1.0,
)
# 每次迭代的输出监控指标
reward = [ep.reward for ep in episodes]
print(f"步骤 {step}, 平均奖励: {np.mean(reward):.2f}, "
f"解题正确率: {np.mean([ep.reward_info['answer_reward'] for ep in episodes]):.2f}")
# 每10步在测试集上评估
if step % 10 == 0:
eval_success_rate = evaluate(model, tokenizer, device, dtype)
print(f"测试集解题正确率: {eval_success_rate:.2%}")
# 每100步保存检查点
if step % 100 == 0:
torch.save(model.state_dict(), f"ckpt/ckpt_{step:06d}.pt")
5.7 训练过程中你应该关注的指标
-
解题正确率(Answer Reward Accuracy)
:训练集上模型给出正确回答的比例,应随步骤逐渐上升
-
测试集正确率
:train和test的差距过大说明过拟合,差距过小说明模型还没学会
-
策略熵(Policy Entropy)
:熵过大说明模型还在乱猜,熵突然骤降说明模型可能陷入局部最优
-
梯度范数(Gradient Norm)
:大幅度的异常波动是训练不稳定的危险信号
六、拓展应用:如何推广到医学推理等复杂任务
Countdown Task是一个规则明确、验证简单的任务,很适合作为RL+LLM的入门项目。但GRPO/DAPO的潜力远不止于此——在医学推理、SQL生成等复杂场景中,同样的方法同样有效。
6.1 将Countdown的经验迁移到医学推理
在临床问诊场景中,患者描述一段症状,模型需要推理出可能的诊断并给出解释。要用RL训练这样的模型,可以沿用相同的基本框架:
-
格式
:思维过程结论
-
正确性奖励
:答案是否正确(与标注的诊断结论对比)
-
附加监控模块
:调用外部评估API检查思维链的逻辑严谨性——推理过程中是否存在逻辑断层或未经验证的假设
在医学影像诊断等细分场景中,研究团队使用GRPO训练了MedReason-Embed模型(8B参数量),其宏F1分数比基线模型提升了18%,在跨病种的泛化能力上也超过了参数量更大的替代模型。
6.2 医疗数据集的多任务混合
构建医学推理模型时可以混合多种类型的数据集:
-
PubMedQA
(临床问答,答案限定yes/no/maybe)
-
GSM8K
(数学应用题,防止其他领域推理能力衰退)
-
Health Benchmarks
(50+类医学选择题,覆盖心脏病学、皮肤病学到内分泌等多个专科)
关键技巧:动态调整采样比例。 某些数据集(例如PubMedQA)的任务更细腻,可以将其采样频率提高到其他数据集的3倍,让模型在训练中更频繁地遇到这些有代表性的样本,从而在同一轮训练过程中实现更均衡的学习。
6.3 SQL生成任务:奖励函数的不同变形
对于将自然语言转换为SQL的Text-to-SQL任务,奖励函数的设计思路略有不同,但核心原则完全相同:
-
格式奖励
:检查SQL语句的结构是否符合官方语法规范
-
语法正确性奖励
:连接真实的sqlite3数据库执行生成的SQL,若执行无报错则给分
-
查询结果匹配奖励
:SQL的查询结果是否与预期结果一致
-
思维链质量评估
:调用GPT或DeepSeek API评估模型的推理过程质量(主要针对复杂任务)
一种有效的实践是构建一个多阶段奖励流水线:先检查格式,再测试语法,最后验证结果正确性——正确性权重最高,前置检查的权重稍低。这种分层设计比单一分数能提供更精细的训练信号。
6.4 规则奖励 vs 模型奖励:如何选择
基于规则的奖励函数有三大优势:计算速度快、完全可解释、不会有“奖励篡改”(Reward Hacking)的问题。对于Countdown Task和SQL语法验证这类有明确正确答案的任务,优先使用规则奖励。只有在规则无法判断的情况下(比如开放域推理中的“答案质量”),才考虑使用外部模型进行评判。
一个实用的决策原则:能用规则判断的,坚决不用模型;必须用模型判断的,要附带置信度阈值来过滤掉低质量的反馈信号。这能够保证训练信号的可靠性,是提高训练稳定性的重要工程策略。
七、总结
从GRPO到DAPO,LLM强化学习算法的演进,本质上是在回答一个核心问题:如何让大型语言模型通过自我探索获得推理能力,而不是简单地背诵标准答案。
GRPO的核心理念在于“组内比较替代价值网络” :每个问题生成多个回答,通过组内标准化计算相对优势,在避免维护庞大价值网络的同时,实现了稳定有效的策略优化。基于规则奖励的数理推理任务中,GRPO展现出了超越PPO的核心优势——训练成本降低约40%,推理效果则与顶尖模型持平。这种简化的设计让深度学习工程师更容易入门,也让更多中小团队有条件参与到前沿LLM训练中来。
DAPO则通过四项关键技术推动了GRPO的极限:Token级损失确保每个推理步都能获得均衡训练信号;Clip-Higher非对称裁剪赋予低概率token足够的探索空间,打破熵崩溃困境;动态采样过滤零梯度样本,确保每一次更新都有信息价值;长度感知的奖励噪声消除机制让训练过程更稳定。这些改进在相同的32B模型规模上,让AIME 2024基准评分从30分提升至50分——更大的价值是训练步数减少了50%。这种效率提升为更多团队进入大模型RL训练领域提供了可能。
从理论到实践,我们实施了一个完整的Countdown Task训练流程,从奖励函数设计、Rollout并行采样、组内优势计算到核心的Token级梯度更新,每一步都已转化为可执行的开源代码。Countdown Task具有规则清晰、验证简便的特点,很适合作为入门项目;而在此基础上,医学推理和SQL生成等更复杂的应用方向也展现出RL+LLM方法的普适性。
强化学习正在重塑大模型的能力边界——它使得模型不仅“知道”答案,更学会“如何一步步走向”答案,甚至能从自身的试错中完成认知顿悟。尽管完整的工业级RL训练仍有相当高的门槛,但GRPO和DAPO这些开源框架的涌现,正一步步降低这座技术高地的攀登难度。对于每一位希望将模型从“陪聊”层面推进到“能解决实际推理任务”的开发者而言,这正是最好的实践起点,也是通向更真实AGI征途中最坚实的台阶。