DeepSeek R1是怎么炼成的?一文看懂GRPO和DAPO,手把手教你用强化学习让大模型学会自己推理

1 阅读27分钟

大模型的推理能力到底是怎么来的?有人说是靠喂海量的训练数据“背”出来的,有人说是因为模型参数量足够大之后自己“涌现”出来的。但真正了解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在代码层面的最大差异体现在两个地方:

  1. Token级别损失聚合

    :不再对每个回答内的token平均后再对所有回答平均,而是将batch内所有回答的所有token损失放在一起,计算总和的平均值。

  2. 无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征途中最坚实的台阶。