Agentic RL中Dressage的Staleness控制深度解析

【Agentic RL】Dressage Staleness 控制深度分析
Dressage是一款具有代表性的Agent RL系统,本文将深度解析其针对Staleness问题的实现逻辑。
Staleness问题的核心背景
问题本质
在异步强化学习流程中,数据采集(Rollout)与模型更新(Training)并行执行:训练端用当前策略更新权重,而采集端持续用旧权重生成样本。当采集的批次最终送入训练端时,生产这批样本所用的权重可能已完成多次更新,这种样本与当前策略的版本差异就是Staleness(陈旧性)。
时间轴:
t1: 权重v1 → Rollout开始用v1采样
t2: 权重v2 ← Trainer完成一次更新
t3: 权重v3 ← Trainer又完成一次更新
t4: Rollout采样结束 → 样本送入Trainer
但样本基于v1生成,当前权重已是v3
这就是staleness gap
两类不同来源的Staleness
Staleness在异步链路中有两个独立来源,需分别处理:
| 来源 | 粒度 | 成因 | 后果 |
|---|---|---|---|
| 单条长轨迹内部 | Trajectory级 | Partial rollout下轨迹被反复中断续写,前后token来自不同权重版本 | 陈旧前缀决定状态分布,即使仅在最新token计算loss,样本仍从陈旧上下文续写 |
| Batch组装时 | Batch级 | 异步采样与训练并行,已完成轨迹在队列等待时权重已更新多次 | Train-Inference Gap扩大,梯度方向偏置,极端情况训练发散 |
核心矛盾
异步架构带来了吞吐能力,但吞吐越高,Staleness问题越严重。Dressage的目标是在不降低吞吐的前提下,从两个粒度分别控制版本偏移。
总体设计思路
从"防"到"治"的多层次防御
Dressage未采用单一开关解决Staleness,而是构建了多层次防御体系,按两个独立粒度设计正交控制逻辑,兼顾吞吐与版本新鲜度。
Trajectory粒度:约束单条轨迹的版本跨度
Partial Rollout的Proxy层透明中断续写
该级控制基于Partial Rollout机制:当长生成遇到权重更新时,直接作废轨迹太浪费,用旧权重跑完又会混入过时输出。Dressage将中断逻辑下沉到Proxy层,所有模型调用都经过Proxy收口,实现透明中断。
- Proxy按request-id中止在途生成,接住已产出的partial token
- Agent的HTTP调用原地挂起,对后台权重切换毫不知情
- 新权重就绪后,Proxy从原有前缀+已生成部分续写,拼成完整响应交还给Agent
- 整个过程对Agent完全透明,适配任意白盒/黑盒Agent
反复中断带来的Trajectory级Staleness问题
Partial Rollout避免了轨迹浪费,但反复中断会导致单条轨迹横跨过多版本,且轨迹的前缀、工具调用历史等由旧策略决定,即便训练时仅在最新token计算loss,样本仍从陈旧前缀续写,给策略更新引入偏差。
这里有两个层面分工明确:训练样本构造阶段通过mask_nonlast_version_tokens(),仅保留最后一个真实版本的token参与loss,过滤偏移影响;Rollout阶段直接限制轨迹的版本跨度,从源头控制偏移幅度。
控制逻辑:版本跨度上限
Dressage为单条Partial轨迹设定版本跨度上限(max_partial_rollout_preempts),例如最多切换2次版本,意味着单条轨迹最多横跨3个版本。
def _raise_if_partial_version_span_exceeded(*, session, candidate_versions, partial_rollout, max_partial_rollout_preempts):
if not partial_rollout or max_partial_rollout_preempts is None:
return
# 收集session历史+本次候选版本,去重后按观测顺序排列
versions = _ordered_real_versions([*_session_response_versions(session), *candidate_versions])
version_span = len(versions)
version_switches = max(0, version_span - 1)
if version_switches <= max_partial_rollout_preempts:
return
# 超限 → HTTP 502,当场终止
raise HTTPException(status_code=502, detail={"error": "partial_rollout_staleness_exceeded", ...})
超限后的处理机制
超限轨迹走统一失败重试路径:
- Proxy返回HTTP 502,错误标识为partial_rollout_staleness_exceeded
- 黑盒服务器识别错误后返回合成空响应给Agent,避免崩溃
- Rollout收集器将该组标记为失败,默认重试指定次数
- 重试耗尽后整组丢弃
- 日志持续汇报版本跨度分布与拒绝比例,判断控制触发频率
"软拒绝"机制的核心价值:Agent永远不会因陈旧性崩溃,仅当前轨迹被丢弃重试。
Batch粒度:约束进入训练批次的版本窗口
对外接口
该级控制仅暴露一个参数:训练批次中最多保留的最近版本数(dressage_staleness_keep_versions)。配置为0时关闭控制,N>0时仅保留最近N个观测版本。
| 取值 | 含义 |
|---|---|
| 0 | 关闭batch级控制(默认) |
| N>0 | 仅保留最近N个版本,如keep_versions=2则批次可包含当前与上一版本,更早版本的轨迹被丢弃 |
最核心的权衡:窗口越窄数据越新鲜,但被丢弃的样本也越多。
版本判定规则
Dressage记录token级版本信息,但在批次判定时,以单条轨迹最后一个可训练token的版本作为该轨迹的新鲜度代表,避免因轨迹早期片段陈旧误判丢弃。
def trajectory_version_infos(group):
latest_by_key = {}
for order, sample in enumerate(group):
version = real_version(metadata.get("dressage_end_token_version"))
key = trajectory_key(sample)
# 只保留同一条轨迹中segment_index最大的segment的版本
position = _segment_index(sample, order)
if key not in latest_by_key or latest_by_key[key][0] <= position:
latest_by_key[key] = (position, version)
return [TrajectoryVersionInfo(key=k, version=v) for k, (_, v) in latest_by_key.items()]
核心机制:推进窗口 + 回溯复查
Batch级控制由两个核心动作配合实现:
动作一:推进窗口。新完成的组带来未观测过的新版本时,StalenessTracker将版本追加到观测列表,current_version_index和cutoff_version_index随之前移。
def observe_group(self, group):
previous_count = len(self.versions)
for info in trajectory_version_infos(group):
if info.version not in self.versions:
self.versions.append(info.version)
return len(self.versions) != previous_count # 是否推进了窗口
动作二:回溯复查。批次收集采用"先缓存、触发新版本时再重过滤"的惰性策略,窗口前移后,已进入候选批次但超出窗口的组会被批量重检移除;新组加入前也会对照窗口检查,只要存在陈旧轨迹则整组丢弃。
关键设计选择
版本号按观测顺序排列
版本号按被观测到的顺序排列,而非字典序或数值序,避免出现"v10 < v2"这类反直觉的排序错误,保证版本窗口正确性。
整GRPO Group丢弃
GRPO的advantage normalization依赖完整Group结构,若仅删除部分轨迹,Group的reward均值和标准差会偏移,破坏advantage计算,因此丢弃粒度为整组。
不完整元数据的容错
当parent_traj_id缺失或版本号为unknown/-1/none时,该样本既不推进版本追踪器,也不触发丢弃,确保控制仅作用于可靠版本信息。
def real_version(value):
if value is None or str(value).strip().lower() in _NON_REAL_VERSIONS:
return None
return str(value).strip()
两个粒度的关系与互补
| 维度 | Trajectory级控制 | Batch级控制 |
|---|---|---|
| 作用位置 | Proxy侧(生成时) | Rollout侧(收集时) |
| 控制对象 | 单条轨迹内部 | 整批轨迹集合 |
| 触发时机 | 每次生成续写前检查 | Batch组装循环中 |
| 超限后果 | 502 → 整组重试 | 整组丢弃 |
两个粒度可同时启用:Trajectory控制约束单条轨迹的版本偏移,Batch控制约束进入训练端的整体版本新鲜度。此外,还有配套机制互补:
训练侧的Token-Level Loss Mask
当轨迹跨越多个版本时,Dressage在构造训练样本时仅保留最后一个真实版本对应token的loss,将非最终版本token的loss mask置为0,确保梯度仅来自最新策略版本,过滤偏移影响,与Rollout阶段的"防"形成互补。
配套机制
- Proxy暂停/恢复机制:训练端更新权重前暂停活跃生成,更新后恢复,是Partial Rollout的基础设施
- 黑盒Agent的Staleness错误处理:Proxy返回502时,黑盒服务器返回空响应避免崩溃
- 版本号全链路记录:从Proxy到Rollout到样本构建,记录token_version确保可追溯
目前缺点与优化方向
当前缺点
| 缺点 | 说明 |
|---|---|
| N/M是静态超参数 | keep_versions和max_preempts训练启动时固定,无法自适应不同阶段 |
| Group级丢弃代价高 | 丢弃整组会丢失所有轨迹,尤其版本分布不均时浪费大 |
| 二元丢弃策略 | 要么全留要么全扔,无平滑过渡机制 |
| 版本号语义仅止于标签 | 版本号不含策略差异信息,差异极小也会被丢弃 |
优化方向
| 方向 | 具体建议 |
|---|---|
| 自适应版本窗口 | 根据KL散度动态调整keep_versions,变化大时缩小窗口 |
| Trajectory级选择性过滤 | 标记陈旧轨迹为no_grad而非丢弃整组,改造GRPO advantage计算 |
| Soft Staleness Weighting | 引入0-1连续权重,版本越旧loss权重越低 |
总结
Dressage对Staleness问题的处理体现了"两个粒度、正交约束"的设计思想:
- Trajectory粒度:基于Proxy层透明的Partial Rollout机制,限制单条轨迹的版本跨度上限,从源头控制偏移幅度
- Batch粒度:通过"推进窗口 + 回溯复查",将进入训练端的轨迹约束在最近版本内,丢弃粒度为整组
- 配套机制:Token级Loss Mask过滤偏移影响,Proxy暂停/恢复提供基础设施,黑盒合成空响应保证容错
- 可观测:通过监控指标实时掌握Staleness状况,便于训练调试
实验表明,控制版本偏移后,reward学习更快,rollout与当前策略的logprob差距显著缩小。当前局限在于静态配置和二元丢弃策略,未来将向自适应窗口、软权重衰减等方向优化。

