多轮 Agent 的强化学习:一条轨迹一个优势为什么会失效,分组粒度又该怎么选
多轮 Agent 的 RL 训练,起点通常长这样:采样一批轨迹,每批同一个任务、N 条 rollout,跑完拿最终奖励做组内归一化,得到每条轨迹一个标量优势,然后把它乘到这条轨迹上每一个 token 的 log-prob 上。
这套东西在 GSM8K 那种单轮任务里工作得很好,因为整条回答就是一次决策,最终奖励就该摊给所有 token。搬到 Agent 上,一条轨迹变成了「调工具、看观测、再决策」的 T 轮序列,模型在每一轮都做了一个独立决策,而奖励只有一个。这时候「整条轨迹共享一个优势」就有了两个隐含假设:每一轮的贡献大致相同,且各轮的决策彼此独立。多轮场景里这两条都不成立。
这篇文章讲三件事:轨迹级优势在多轮里到底错在哪(不是有偏,是方差)、turn-level 分组要付什么代价、以及不同回合数下该选哪种粒度。中间有一次我自己跑的对照实验,用的是纯标准库写的合成环境,代码可复现,数据在后面。
一、先把数学摆正:无偏,但方差错配
设一条轨迹由 T 个动作组成,最终奖励 R 只在一个回合结束时给出。策略梯度里的 baseline 只要与动作无关就可以任意选,不影响无偏性。所以轨迹级优势
A_i = (R_i - mean(R_1..R_G)) / std(R_1..R_G)
对每一轮的动作梯度都是无偏的估计。这一点很重要,很多文章说「轨迹级优势有偏」,这是错的。
真正的问题是方差。对第 t 轮的动作,梯度的方差里包含两部分:这一轮动作本身的不确定性,以及后面 T−t 轮采样带来的噪声。轨迹级 baseline 只减掉了「任务难度」这一项共同方差,减不掉后续轮次的噪声。而 turn-level 的 baseline 是条件在同一个 anchor 状态(同一个 turn、同一段历史)上的组内均值:
A_{i,t} = (R_i - mean(R | anchor_t)) / std(R | anchor_t)
这是一个更强的控制变量:它把「这个前缀已经把路走成什么样了」解释掉的方差全部扣除,剩下的才是这一轮动作的边际贡献。理论上它必然不差于轨迹级 baseline,前提只有一个——同一个 anchor 上要有足够多的兄弟样本。
多轮 Agent 的麻烦就在这里。单轮任务里,同一个 prompt 的所有 rollout 共享第一个 token 的 anchor,兄弟姐妹永远够用。多轮任务里,做完第一轮之后轨迹就分叉了:G 条 rollout 在第二轮会被拆到各个不同的历史分支上去。分支一多,每个 anchor 上剩几个样本就是运气问题了。
二、组大小为 1 的 anchor,优势恒等于 0
我第一版实现踩的就是这个坑。代码看着没问题:按 (turn, 历史观测) 建桶,桶内算均值和标准差,桶内每条轨迹拿自己的相对优势。跑出来的曲线却比轨迹级还差。
原因很朴素:当一个桶里只有 1 条样本时,它减去自己的均值等于 0,标准差也退化成 0,优势恒为 0,这一轮的梯度被彻底屏蔽。而桶里只剩 2 条样本时,标准差是这两点距离的一半,归一化之后优势被强行拉成 ±1,梯度噪声被放大。
更糟的是这种退化不是均匀发生的,它偏向轨迹末端。第一轮的 anchor 是所有 rollout 共享的,桶大小恒等于 G;第二轮分叉成几个分支,桶大小约等于 G 除以分支数;到了最后一轮,每个 anchor 上平均只剩一个多一点的样本。也就是说,被屏蔽的恰好是离奖励最近、信用分配最该精确的那一步。
这不是我编的理论推演,下面有实测:在 G=16、T=3 的任务里,53.6% 的 anchor 只有单个样本;T=5 时这个比例涨到 79.7%。
三、三种粒度,代价各不相同
工程上其实只有三种可选:
| 粒度 | 优势来源 | 需要什么 | 主要问题 |
|---|---|---|---|
| 轨迹级(traj) | 整组 G 条轨迹的最终奖励 | 无额外成本 | 后续轮次噪声稀释信号,T 越大越严重 |
| anchor 分组(turn-level) | 同一 anchor 上兄弟样本的奖励 | 兄弟样本要有共享前缀 | 末级 anchor 退化成组大小 1 |
| 前缀分叉(tree) | 同一前缀下分叉 m 个动作的奖励 | 环境可重置、可复制状态 | 环境调用成本上升 m 倍量级 |
anchor 分组的核心代码就这几行,关键在于「桶的 key 必须是 agent 真实可见的历史」,不能把隐藏状态塞进去,否则就是给模型偷看答案:
def anchor_advantages(trajs, R):
"""trajs[i] = [{'key': (turn, hist), 'a': .., 'pr': ..}, ...]"""
buckets = {}
for i, st in enumerate(trajs):
for step in st:
buckets.setdefault(step["key"], []).append(i)
adv = {}
for key, idxs in buckets.items():
rs = [R[i] for i in idxs]
mu = sum(rs) / len(rs)
sd = (sum((x - mu) ** 2 for x in rs) / len(rs)) ** 0.5 if len(rs) > 1 else 0.0
if len(rs) < 2 or sd < 1e-6:
for i in idxs:
adv[(i, key)] = 0.0 # 组大小为 1:信号直接丢,别硬编
else:
for i in idxs:
adv[(i, key)] = (R[i] - mu) / sd
return adv
tree 则是把「兄弟样本」从「碰巧共享前缀」变成「主动制造」:采样到第 t 轮时,复制当前状态,从同一个 anchor 分叉 m 个不同动作,各自跑完剩余轮次,组内做相对优势。这就是 GiGPO 的 anchor state grouping 和 ARPO 的熵驱动分叉在工程上的共同内核,区别只在「分叉点怎么选」:均匀随机便宜,按动作熵选更准(熵高的地方才是真正需要探索的地方)。
四、对照实验:T 从 2 到 5,三种粒度怎么分化
合成任务设计成「取线索 + 作答」:目标是 8 个候选之一,三轮里前几轮各调一个探测工具,返回目标的一个 bit,最后一轮从 8 个候选里作答。答对得 1 分。重复调同一个工具等于浪费一轮,所以核心技能是每轮探不同的 bit——这正是多轮规划里最典型的「状态依赖决策」。
指标有三个:作答准确率、greedy 轨迹里用到的不同探测工具数(衡量有没有学会避免重复观测)、以及每轮迭代的环境调用次数。每个配置跑 5 个随机种子取均值,G=16,m=4,lr=0.1,250 轮迭代。
| 回合数 T | 最优准确率 | traj | anchor | tree | anchor 退化率 |
|---|---|---|---|---|---|
| 2 | 0.25 | 0.251 | 0.257 | 0.257 | 15.1% |
| 3 | 0.50 | 0.451 | 0.430 | 0.395 | 53.6% |
| 4 | 1.00 | 0.556 | 0.394 | 0.603 | 70.9% |
| 5 | 1.00 | 0.691 | 0.168 | 0.778 | 79.7% |
三个结论,都不是我先想好再去凑的:
第一,两步任务里三种粒度没有区别。 T=2 时前两个方案都到 0.25 附近,就是最优值。原因很简单:两步任务里「第一个动作的 anchor」就是初始状态,所有 rollout 共享,轨迹级分组和 anchor 分组是同一个东西。所以你在 toy 环境上验证「turn-level 优势更好」大概率验证不出来,必须把回合数拉到 3 以上。
第二,独立 rollout 下的 anchor 分组会崩。 T=5 时它掉到 0.168,比随机猜(0.125)高不了多少,还比它自己在 T=3 时的成绩差。机制就是上面说的:79.7% 的 anchor 组大小为 1,梯度被屏蔽;剩下的桶大多是 2 到 3 个样本,重心和方差估计全是噪声,归一化把噪声放大成 ±1 的假信号。这条曲线不是「学得慢」,是训练信号被自己的分组方式吃掉了。
第三,前缀分叉在回合数上来之后反超。 T=5 时 tree 到 0.778,traj 0.691,而 tree 每轮迭代的环境调用是 70 次,比 traj 的 80 次还少。原因是分叉复用了前缀:5 条基轨迹各分叉 4 个动作,等于用 14 倍的样本量覆盖了 5 个 anchor,而不是把预算平摊到 16 条完整轨迹上。同时它的种子间方差也小:T=5 时 tree 是 0.647 到 0.882,traj 是 0.480 到 0.755。
还有一个容易被忽略的指标:T=5 时最优要探 3 个不同 bit,tree 学到 2.55 个,traj 2.46 个,anchor 只有 2.21 个。也就是说,粒度选错不只是「收敛慢」,它会学不到正确的行为模式——模型学会了「作答要自信」,但没学会「每轮换一个工具」。
五、真实训练里,粒度只是第一层问题
上面的实验是表格策略、无噪声环境,只用来说明信用分配的机制。真实的多轮 RL 训练(verl、OpenRLHF 这一类)还会遇到几层更硬的问题。
5.1 token 级 mask 是硬性要求
多轮训练里,一条序列由「模型生成」和「工具返回」交替拼成。工具返回的内容绝对不能算进 loss,否则模型会去学怎么生成工具输出。同时每一轮生成结束都要重新计算 log-prob,不能拿拼完的整条序列一次性算——中间插入了外部文本,位置编码和 attention 都对不上。
# 伪代码:一轮生成 → 一次前向,只对 assistant token 计 loss
loss_mask = []
for turn in range(T):
out = model.generate(state, stop=["</tool_call>"])
logp_old = model.logprob(state, out.tokens) # 本轮生成时重算
loss_mask += [1] * len(out.tokens) # 只有模型自己吐的 token 计 loss
obs = sandbox.call(parse_tool_call(out))
state = state + out.tokens + obs.tokens
loss_mask += [0] * len(obs.tokens) # 工具观测全部屏蔽
# 优势按 token 广播:同一条轨迹内所有 assistant token 共享同一个 adv
token_adv = [adv * mk for mk, _ in zip(loss_mask, state)]
注意最后那行:adv 在 token 维度上是广播的,但在轮次维度上可以不同——这就是 turn-level 优势落到实现上的样子,也是为什么它和 token mask 是两件独立的事。
5.2 长度爆炸与 Echo Trap
多轮 RL 最常见的一条失效曲线:训练前期成功率上升,中后期开始输出越来越长、工具调用越来越频繁、熵持续下降,最后奖励方差归零。RAGEN 在多轮 Agent 训练里报告并命名了这个现象(Echo Trap):模型把「多调几次工具」当成了安全策略,rollout 在组内高度同质,组内归一化的分母趋近于 0,优势被噪声主导。
我上面实验里 anchor 分组在 T=5 的崩溃,机制上就是它的近亲:当一个组的样本彼此太像(或太少)时,组内归一化不再提供信息,只提供噪声。所以在生产里,组内标准差要加下限保护,或者在方差低于阈值时直接跳过这一批,比让它训练下去强。
5.3 奖励可验证,但 side effect 不可验证
多轮 Agent 的奖励通常来自规则校验(答案对不对、测试过没过、JSON 结构合不合法),这比 RM 便宜可靠得多。危险在于模型会优化你没校验的部分:任务要求「查完再答」,模型直接答(跳步),奖励照样给 1;任务要求「调搜索工具」,模型把工具名硬编码进回答里骗过校验。
可行的做法是把「过程约束」做成硬门禁而不是加分项:必调工具缺失、探索轮次低于下限、工具调用参数非法,直接判 0,不进优势计算。指望通过给过程加 0.1 的正向奖励来约束行为,在多轮里基本都会被打穿。
5.4 沙箱是真正的成本中心
一次 rollout 要跑 T 轮工具调用,每轮可能要起一个容器、等一次网络、跑一次代码。G=16、T=5、m=4 的配置下一次迭代就是几百次工具执行,训练吞吐完全被沙箱吞吐卡住。工程上的常规做法:预热沙箱池、按 anchor 复用容器(分叉时直接 clone 容器快照,而不是重新起)、给每轮设超时并把超时作为终止状态而不是异常、把部分轨迹的复用建立在「环境可重置」这个前提上。
最后一条要特别强调:只有幂等的环境才能复用来做分叉。真实工具里有发邮件、下单、写数据库,这类环境不能 clone、也不能重放,分叉只能发生在纯读工具上。这也是为什么生产里的多轮 RL 往往先把工具集裁成只读集合。
六、怎么选:一份可操作的判断标准
把上面压成几条:
- T 等于 2:三种粒度随便选,别在 toy 环境上做粒度对比实验,结论没有外推性。
- T 在 3 到 4、预算有限:先上轨迹级 + 组内标准差下限保护,把力气花在奖励设计和长度控制上,收益比换粒度大。
- T 大于等于 5 或存在明显的长尾决策:上前缀分叉。分叉点优先按动作熵选(工具名、参数边界这些高熵 token),不要均匀随机。
- 任何情况下都不要直接上「独立 rollout 的 anchor 分组」。它是最容易写、最容易看起来对、也最容易在末级把梯度清零的方案,除非你的环境里状态本来就会大量收敛复用(比如 ALFWorld 那种有限房间)。
- 监控一个指标:anchor 组的平均大小和组大小为 1 的比例。这个数字超过 30% 就该换方案了,别等曲线崩了再回头查。
结语
多轮 Agent 的强化学习,难点不在算法公式,在于「把哪一段轨迹当成一个决策单元」这个建模选择,以及为这个选择付出的采样成本。轨迹级优势是免费的,代价是方差随回合数增长;turn-level 分组看起来更精确,代价是兄弟样本,而兄弟样本要么靠环境收敛白捡,要么得用前缀分叉真金白银去换。
我这次实验里最值得记住的一个数字不是准确率,是 79.7%:在 G=16、T=5 的独立 rollout 配置下,将近八成的 anchor 只剩下一个样本,它们的优势是 0、梯度是 0,而它们在代码里看起来完全正常。指标不骗人,只有曲线会。