文章摘要
本文深度解析智能体强化学习(Agentic RL)代表性系统Dressage的陈旧性(Staleness)控制方案:异步强化学习中采样与训练并行,会产生轨迹级、批次级两类陈旧性问题,且吞吐提升会加剧该问题。Dressage构建多层次防御,从两个粒度分别约束版本偏移,兼顾吞吐与样本新鲜度,配套掩码机制过滤偏差,目前仍存在超参数静态等待优化点。

【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", ...})

超限后的处理机制

超限轨迹走统一失败重试路径:

  1. Proxy返回HTTP 502,错误标识为partial_rollout_staleness_exceeded
  2. 黑盒服务器识别错误后返回合成空响应给Agent,避免崩溃
  3. Rollout收集器将该组标记为失败,默认重试指定次数
  4. 重试耗尽后整组丢弃
  5. 日志持续汇报版本跨度分布与拒绝比例,判断控制触发频率

"软拒绝"机制的核心价值: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问题的处理体现了"两个粒度、正交约束"的设计思想:

  1. Trajectory粒度:基于Proxy层透明的Partial Rollout机制,限制单条轨迹的版本跨度上限,从源头控制偏移幅度
  2. Batch粒度:通过"推进窗口 + 回溯复查",将进入训练端的轨迹约束在最近版本内,丢弃粒度为整组
  3. 配套机制:Token级Loss Mask过滤偏移影响,Proxy暂停/恢复提供基础设施,黑盒合成空响应保证容错
  4. 可观测:通过监控指标实时掌握Staleness状况,便于训练调试

实验表明,控制版本偏移后,reward学习更快,rollout与当前策略的logprob差距显著缩小。当前局限在于静态配置和二元丢弃策略,未来将向自适应窗口、软权重衰减等方向优化。

以上内容不代表本平台立场,仅供读者参考