Prhub

#7300 [algo] fix: carry running_return through observation spans in REINFORCE++ (#7278)

原始 PR 作者 shotsan 合并时间 2026-08-10 14:23 文件变更 2 提交数 1 评论 2 代码增减 +153 / -4

执行摘要

修复 REINFORCE++ 多轮观察段奖励丢失

issue #7278 给出了精确复现:在相同 4 个 assistant token 中间插入 2 个 observation token(mask=0)后,gamma=1 时 valid-token return 从 [1.0, 1.0, 1.0, 1.0] 退化为 [0.0, 0.0, 1.0, 1.0]。issue 指出 AgentLoopManager.generate_sequences 的 response_mask=0 同时表示工具 observation 与尾部 padding,而 REINFORCE++ 反向扫描对每个位置直接乘 mask,等于把 observation 当作 episode 终点;GAE 已有正确的 carry-through 处理(254-256 行),REINFORCE++ 应复用同一模式。

值得精读。这是一个小而精准的正确性修复:展示了如何用与 GAE 一致的 carry-through 模式统一处理 response_mask 的双重语义,避免在同一代码库中出现两套不一致的 mask 行为。测试设计覆盖了核心回归、gamma 折扣、padding、batch 和单轮兼容性,可作为 RL 算法修改的测试范例。建议重点关注 running_return 选择逻辑与测试中对 observation/padding 的区分。

讨论亮点

PR 本身没有实质 review 评论,wuxibin89 直接 APPROVED;评论仅 CLAassistant 的签署检查(作者已签)。真正的技术讨论发生在 issue #7278 中:社区详细分析了 mask 双重语义(observation 与 padding 均为 0),对比了 GAE 的正确实现,并给出了可重复的最小复现脚本——这些直接决定了修复路径的选择。

实现拆解

  1. 根因定位verl/trainer/ppo/core_algos.pycompute_reinforce_plus_plus_outcome_advantage(约 718 行)在反向扫描中,每步先计算 running_return = token_level_rewards[:, t] + gamma * running_return,随后立即执行 running_return = running_return * response_mask[:, t]AgentLoopManager.generate_sequences 中工具观察与尾部 padding 的 response_mask 均为 0,因此 observation 段会把 running_return 清零,等价于在此处提前结束轨迹,使 observation 之前的 action token 无法拿到最终 outcome reward。
  2. 修复方案:将“更新后清零”改为 GAE(同文件 254-256 行)已有的 carry-through 模式。新增中间变量 new_running_returnreturns[:, t] 仅对 mask=1 的位置写入,running_return 则按 mask 选择:mask=1 时推进为 new_running_returnmask=0 时保持旧值。这样 observation 段既不会终结轨迹,也不会消耗一步 gamma 折扣。
  3. 兼容性论证:当 response_mask 全为 1 时,新公式 running_return = new_running_return * 1 + running_return * 0 退化为原逻辑,单轮训练行为完全不变。
  4. 测试配套:新增 tests/trainer/ppo/test_reinforce_pp_multiturn_on_cpu.py,共 6 个用例,分别验证:跨 observation 段奖励传播、observation 位置返回 0、尾部 padding 保持 0、gamma 折扣跨段仅按有效 token 计算、batch 维度独立处理、无 observation 时结果不变。既有 tests/trainer/ppo/test_core_algos_on_cpu.py 23 个用例全部通过,未触发回归。
文件 模块 状态 重要度
verl/trainer/ppo/core_algos.py 训练核心 modified 5.95
tests/trainer/ppo/test_reinforce_pp_multiturn_on_cpu.py 单测 added 7.28

关键符号

compute_reinforce_plus_plus_outcome_advantage

关键源码片段

verl/trainer/ppo/core_algos.py core-logic

核心修复文件:compute_reinforce_plus_plus_outcome_advantage 的反向 return 扫描从“每步乘 mask 清零”改为 GAE 式 carry-through,observation 位置不再阻断奖励传播。

# verl/trainer/ppo/core_algos.py
# REINFORCE++ 优势估计:反向扫描计算 return
@register_adv_est(AdvantageEstimator.REINFORCE_PLUS_PLUS)
def compute_reinforce_plus_plus_outcome_advantage(
    token_level_rewards: torch.Tensor,
    response_mask: torch.Tensor,
    config: Optional[AlgoConfig] = None,
    **kwargs,
) -> tuple[torch.Tensor, torch.Tensor]:
    assert config is not None
    gamma = config.gamma
    with torch.no_grad():
        returns = torch.zeros_like(token_level_rewards)
        running_return = 0
​
        for t in reversed(range(token_level_rewards.shape[1])):
            # 先计算本位置更新后的累计 return
            new_running_return = token_level_rewards[:, t] + gamma * running_return
​
            # 仅对 mask = 1 的有效 token 写入 return;mask = 0 的观察与填充位置保持 0
            returns[:, t] = new_running_return * response_mask[:, t]
​
            # Carry-through:mask = 1 时推进 running_return,mask = 0 时原样保留。
            # 观察段既不终结轨迹,也不消耗 gamma 折扣,与 GAE(同文件 254-256 行)保持一致
            running_return = (
                new_running_return * response_mask[:, t]
                + running_return * (1 - response_mask[:, t])
            )
​
        advantages = verl_F.masked_whiten(returns, response_mask)
        advantages = advantages * response_mask
​
    return advantages, returns
tests/trainer/ppo/test_reinforce_pp_multiturn_on_cpu.py test-coverage

新增 6 个回归测试,直接覆盖 issue 复现场景及 gamma、padding、batch、单轮兼容等边界,构成修复的验证支柱。

# tests/trainer/ppo/test_reinforce_pp_multiturn_on_cpu.py
# 核心回归:插入观察段后,valid token 的 return 应与无观察序列一致
def test_observation_span_does_not_block_returns(self):
    config = _config(gamma=1.0)
​
    compact_mask = torch.ones(1, 4)
    compact_rewards = torch.tensor([[0.0, 0.0, 0.0, 1.0]])
​
    expanded_mask = torch.tensor([[1.0, 1.0, 0.0, 0.0, 1.0, 1.0]])
    expanded_rewards = torch.tensor([[0.0, 0.0, 0.0, 0.0, 0.0, 1.0]])
​
    _, compact_returns = compute_reinforce_plus_plus_outcome_advantage(
        compact_rewards, compact_mask, config=config
    )
    _, expanded_returns = compute_reinforce_plus_plus_outcome_advantage(
        expanded_rewards, expanded_mask, config=config
    )
​
    torch.testing.assert_close(
        compact_returns[compact_mask.bool()],
        expanded_returns[expanded_mask.bool()],
    )

评论区精华

REINFORCE++ observation span 处 running_return 被清零导致奖励丢失 正确性

issue #7278 提供精确复现:相同 4 个 assistant token 中间插入 2 个 observation token(mask=0)后,gamma=1 时 valid-token return 从 [1.0, 1.0, 1.0, 1.0] 退化为 [0.0, 0.0, 1.0, 1.0]。分析指出 AgentLoopManager 中 response_mask=0 同时表示工具观察与尾部 padding,反向扫描每步乘 mask 会把 observation 当作 episode 终点;而 GAE 已经用 carry-through 正确处理同一场景。

结论:采用 GAE(core_algos.py 254-256 行)的 carry-through 模式:mask=0 时保留 running_return,observation 不终结轨迹也不消耗 gamma 折扣。修复后复现结果与紧凑序列一致,单轮行为不变。 · 已解决

风险与影响

变更位于 trainer 核心优势计算路径,所有使用 REINFORCE++ 的训练都会经过该函数。具体风险:

1) running_return 的语义从“每步清零”变为“按 mask 选择”,需确保尾部 padding(同样 mask=0)不会被错误地当作 observation 跳过——新公式下 padding 后 running_return 也不清零,但因为 padding 之后没有后续 token,return 不受影响;测试 test_trailing_padding_stays_zero 覆盖了该点。
2) 多轮训练中 gamma 折扣不再跨 observation 消耗,可能改变长序列的优势数值分布,需要对超参重新观察。
3) 当前只有 CPU 上的单元测试,缺少真实多轮 e2e(如工具调用)训练对比,建议补充。

影响所有使用 REINFORCE++ 做多轮 RL(Agent/工具调用)的用户:修复前 observation 之前的 action 因丢失 reward 而梯度被压低,可能造成训练不收敛或次优策略;修复后奖励正确传播,与 GAE 的行为语义对齐。单轮训练(无 observation)结果完全不变,因此对现有单轮实验无回归风险。仓库层面,core_algos.py 是共享算法文件,但改动向后兼容。新增测试文件为后续多轮 RL 回归提供了基础。

核心算法路径变更 多轮训练行为变化 缺少真实多轮 e2e 验证

关联 Issue

#7278 REINFORCE++ drops outcome rewards before multi-turn observation spans

完整报告

参与讨论