Prhub

#1916 (1/2) refactor(rollout): drop --generate-multi-samples and its per-turn sample semantics

原始 PR 作者 guapisolo 合并时间 2026-07-30 02:44 文件变更 10 提交数 4 评论 5 代码增减 +104 / -246

执行摘要

移除 --generate-multi-samples,多轮轨迹统一返回标量 Sample

PR body 明确指出:Sample boundaries belong to trajectory topology, not a CLI switch; treating turns as siblings gives them one group_index and pollutes group baselines. 也就是说,--generate-multi-samples 让每个 turn 跳过 TITO merge、成为独立的 full-context sample,这些轮次级 sample 被视为同组兄弟,共享一个 group_index,从而污染基于 group 的 reward baseline 统计。移除 flag 同时不收缩输出契约:GenerateFnOutput.samples 仍保持 Sample | list[Sample],内置线性生成器返回合并后的标量 sample,自定义生成器(multi-agent、tree 轨迹)可继续返回 list,未来还计划补充 tree 型轨迹支持。

值得精读。这是 rollout 输出契约规范化的代表性 PR,展示了「把语义决策从 CLI 开关迁移到生成器类型契约」的设计思路。重点阅读 miles/rollout/generate_hub/multi_turn.pymiles/rollout/generate_hub/agentic_tool_call.py 的 head 版本,对比 merge 链路的统一;再看 dynamic_sampling_filters.py 的类型收窄,理解混合 group 过滤的正确姿势。若团队有自定义 reward model 或依赖旧 flag 的脚本,需按此 PR 同步迁移。

讨论亮点

Review 主要由作者 guapisolo 自己在文档 diff 上发起讨论,核心交锋集中在「输出契约的文档表述」上:

  • guapisolo 对文档初稿 Returning a list[Sample] from a generate function is supported natively; no flag is needed. 提出意见:输出形状不应表述为「需要 flag 或 native support」,而应直接描述契约本身;随后在 eae3bdf 中改为 GenerateFnOutput.samples 接受 Sample | list[Sample]
  • guapisolo 对 <Warning> Agentic output is a list[Sample] 的警告评论 "Not always actually."——因为最终实现把 agentic 生成结果 merge 为标量 Sample,仅在成功与 abort 路径都返回标量;最终移除该 list-only 警告。
  • 审阅者 Shi-Dong 直接 APPROVED("LGTM!"),说明整体设计方向无分歧。

实现拆解

整个改造按以下步骤推进:

  1. multi_turn.generate 移除 flag 分支:在 miles/rollout/generate_hub/multi_turn.py 中删除 multi_samples 累积列表、每轮 sample = deepcopy(input.sample) 的重置逻辑、payload is None 时对 multi_samples[-1] 置 halt 状态的分支以及末尾 GenerateFnOutput(samples=multi_samples if args.generate_multi_samples else sample) 的条件返回;现在多轮轨迹始终累积在同一个 sample 对象上,TITO merge 天然生效,统一返回标量 Sample。同时从 _add_arguments 删除 --generate-multi-samples 参数注册。

  2. agentic_tool_call.generate 同步收敛:在 miles/rollout/generate_hub/agentic_tool_call.py 中删除 if not input.args.generate_multi_samples: ... else: samples[-1].metadata.update(session_metadata) 的条件分支,改为无条件 merge_samples(samples, input.state.tokenizer) 后把 session_metadata 合并到标量 sample 上返回;aborted 路径同样返回标量。--generate-multi-samples 参数一并移除。这一步保证同一 batch 内不会出现标量与 list 形状混合。

  3. 动态过滤器类型契约修正:在 miles/rollout/filter_hub/dynamic_sampling_filters.py 中把 _flatten_samplescheck_reward_nonzero_stdcheck_no_aborted 的参数注解统一为 list[Sample | list[Sample]]check_reward_nonzero_std 原先直接对 samples 取 reward(遇到嵌套 list 会报错),现在改为经 _flatten_samples 展平后再计算方差,消除了对混合 group 的隐性假设。

  4. 测试与 fixture 全面简化tests/fast/fixtures/generation_fixtures.pyVARIANT_TO_GENERATE_FN_PATH 从 4 个条目收敛为 multi_turnagentic_tool_call 两个,extra_argv_for_variant 不再注入 --generate-multi-samplestests/fast/rollout/generate_hub/test_multi_turn.pytests/fast/rollout/inference_rollout/integration/test_multi_turn.py 删除 multi_samples 形状断言分支(train 的 list[list[Sample]] 与 eval 展平 list[Sample] 两种形态),统一断言每次 generate 返回标量 Sampletests/fast/rollout/generate_hub/test_single_turn.pytests/fast/rollout/inference_rollout/integration/test_agent_metadata.pytests/manual/session/bench_session_server_overhead.py 同步移除对应变体与参数。

  5. 文档更新docs/user-guide/rollout-endpoints.md 将 "For multi-sample outputs, set --generate-multi-samples and return a list" 改为 "A generate function can set GenerateFnOutput.samples to a Sample or list[Sample]",把输出形状契约从 CLI 开关解绑到生成器返回值类型上。

文件 模块 状态 重要度
miles/rollout/generate_hub/multi_turn.py 多轮生成 modified 6.33
miles/rollout/generate_hub/agentic_tool_call.py 智能体生成 modified 6.05
miles/rollout/filter_hub/dynamic_sampling_filters.py 动态过滤 modified 7.05
tests/fast/rollout/generate_hub/test_multi_turn.py 多轮生成 modified 5.85
tests/fast/rollout/inference_rollout/integration/test_multi_turn.py 集成测试 modified 5.6
tests/fast/rollout/generate_hub/test_single_turn.py 单轮生成 modified 4.82
tests/fast/fixtures/generation_fixtures.py 测试夹具 modified 4.55
tests/fast/rollout/inference_rollout/integration/test_agent_metadata.py 智能体元数据 modified 3.62
tests/manual/session/bench_session_server_overhead.py 基准测试 modified 3.28
docs/user-guide/rollout-endpoints.md 用户文档 modified 1.9

关键符号

multi_turn.generate multi_turn._add_arguments agentic_tool_call.generate agentic_tool_call._add_arguments dynamic_sampling_filters._flatten_samples dynamic_sampling_filters.check_reward_nonzero_std dynamic_sampling_filters.check_no_aborted tests/.../test_multi_turn.py::_verify_samples tests/.../test_multi_turn.py::_verify_group_samples tests/.../test_multi_turn.py::_simple_reward_function

关键源码片段

miles/rollout/generate_hub/multi_turn.py core-logic

核心改造对象:移除 multi_samples 累积与按轮深拷贝分支,多轮轨迹统一在单一 sample 上累积并以标量 Sample 返回;同时删除 --generate-multi-samples 参数注册。

async def generate(input: GenerateFnInput) -> GenerateFnOutput:
    # ----------------------- Setup -------------------------
    args = input.args
    sample = deepcopy(input.sample)
    tokenizer = input.state.tokenizer
    assert not args.partial_rollout, 'Partial rollout is not supported'
​
    url = f'http://{args.sglang_router_ip}:{args.sglang_router_port}/generate'
​
    execute_tool_function = load_function(args.generate_execute_tool_function_path)
    tool_specs = load_function(args.generate_tool_specs_path)
    tool_call_parser = create_tool_call_parser(tool_specs, args.generate_tool_call_parser)
​
    # 调试轨迹开关:把多轮 messages 按快照写回 sample.metadata,
    # 后续 turn 继续追加,因此快照是不断增长的轨迹而不是逐轮独立副本
    record_trajectory = args.save_debug_trajectory_data is not None
    trajectory = (list(sample.prompt) if isinstance(sample.prompt, list) else []) if record_trajectory else None
​
    # ----------------------- Initial prompts -------------------------
    prompt_tokens_ids = compute_prompt_ids_from_sample(input.state, sample, tools=tool_specs)
    sample.tokens = prompt_tokens_ids.copy()
​
    for _turn in range(args.generate_max_turns):
        # 调用推理端点;payload 为 None 表示上下文超限,置 halt 状态直接退出
        payload, halt_status = compute_request_payload(args, sample.tokens, input.sampling_params)
        if payload is None:
            sample.status = halt_status
            break
​
        gen_t0 = time.time()
        output = await post(url, payload, headers=compute_routing_headers(args, sample))
        sink = None if input.evaluation else TrajectoryLifecycle().sink
        if sink is not None:
            tokens = output.get('meta_info', {}).get('completion_tokens', '')
            sink.gen_span(sample, gen_t0, time.time(), turn=_turn + 1, detail=str(tokens))
        await update_sample_from_response(args, sample, payload=payload, output=output, update_loss_mask=True)
        if record_trajectory:
            trajectory.append({'role': 'assistant', 'content': output['text']})
            sample.metadata['messages'] = list(trajectory) # 快照:后续 turn 继续增长
​
        # abort / length 终止(tokens 仍保留在 sample 上,便于截断恢复)
        if output['meta_info']['finish_reason']['type'] in ('abort', 'length'):
            break
​
        # ----------------------- Execute tools -------------------------
        _, tool_calls = tool_call_parser.parse_non_stream(output['text'])
        if len(tool_calls) == 0:
            break
​
        tool_t0 = time.time()
        tool_messages = await execute_tool_calls(tool_calls, execute_tool_function)
        if sink is not None:
            sink.tool_span(sample, tool_t0, time.time(), turn=_turn + 1, detail=f'{len(tool_calls)} calls')
        update_sample_with_tool_responses(sample, tool_messages, tokenizer=tokenizer)
        if record_trajectory:
            trajectory.extend(tool_messages)
            sample.metadata['messages'] = list(trajectory)
​
    # 整个多轮轨迹累积在同一个 sample 上,只返回一个标量 Sample;
    # --generate-multi-samples 时代按 turn 深拷贝出 list[Sample] 的分支已移除
    return GenerateFnOutput(samples=sample)
​
​
def _add_arguments(parser: argparse.ArgumentParser):
    parser.add_argument('--generate-max-turns', type=int, default=16)
    parser.add_argument('--generate-tool-specs-path', type=str)
    parser.add_argument('--generate-tool-call-parser', type=str)
    parser.add_argument('--generate-execute-tool-function-path', type=str)
miles/rollout/generate_hub/agentic_tool_call.py core-logic

agentic 生成路径同步移除 flag 分支,始终 TITO merge 返回标量 Sample;与 multi_turn 共同定义内置线性生成器的输出契约,并保证 batch 内不混入 list 形状。

    # 到这里 samples 已是 session server 按轮次组装的线性记录;
    # agentic 路径始终执行 TITO merge,把多轮记录折叠成一个标量 Sample,
    # 不再有 --generate-multi-samples 开启时返回 list[Sample] 的分支
    sample = merge_samples(samples, input.state.tokenizer)
    sample.metadata.update(session_metadata)
    return GenerateFnOutput(samples=sample)
​
​
def _add_arguments(parser: argparse.ArgumentParser):
    parser.add_argument('--custom-agent-function-path', type=str)
    parser.add_argument(
        '--max-seq-len',
        type=int,
        default=None,
        dest='max_seq_len',
        help='Max sequence length in tokens (prompt + completion, including env responses) '
        'per session. Truncates samples on the Miles side and is forwarded to the '
        'Harbor agent server (as max_seq_len) to abort the trial early.',
    )

评论区精华

文档措辞:输出形状不应绑定 flag documentation

guapisolo 在 docs/user-guide/rollout-endpoints.md 的 diff 上评论:Returning a `list[Sample]` from a generate function is supported natively; no flag is needed. 不应把输出形状描述成「原生支持」或与 flag 绑定。

结论:作者在 eae3bdf 更新文案为 "A generate function can set GenerateFnOutput.samples to a Sample or list[Sample]",把契约描述为返回值类型本身,不与任何 flag 绑定。 · 已解决

agentic 输出是否总是 list[Sample] 正确性

guapisolo 对文档新增的 `<Warning> Agentic output is a list[Sample]` 警告评论 "Not always actually."——因为最终实现中 agentic 路径在 merge 后返回标量 Sample,并非总是 list。

结论:在 eae3bdf 中移除 list-only 警告,文档改为描述 GenerateFnOutput.samples 同时支持 Sample | list[Sample];agentic 成功与 abort 路径均返回合并后的标量 Sample。 · 已解决

风险与影响

  1. 破坏性 CLI 变更--generate-multi-samples 被移除,任何仍在命令行或配置中传该参数的脚本会触发 argparse 报错(unrecognized arguments)。仓库内调用点(fixture、bench 脚本)已同步清理,但外部用户脚本需要自行迁移。
  2. 输出形状变化影响自定义 reward modelagentic_tool_call.generate 在 flag 开启时曾返回 list[Sample],现在总返回标量 Sample;若自定义 RM 按 list 形状编写(如旧版 _simple_reward_functiongetattr(args, "generate_multi_samples", False) 分支),不更新会在运行时把 Sample 当 list 遍历而报错。
  3. 多轮截断语义对 flag 用户的改变multi_turn.generate 首轮 payload 为 None 时,旧版 flag 路径会对 multi_samples[-1] 置 halt 状态并返回 list;新版统一对单一 samplehalt_status 并返回标量,行为等价于原先非 flag 路径,回归风险低,但 flag 用户看到的输出结构变化明显。
  4. 动态过滤器行为微调check_reward_nonzero_std 改为先展平再计算方差,对原纯标量 group 完全等价,对混入 list 元素的 group 从潜在 AttributeError 变为正常工作,属于安全性修复,但理论上可能改变此前「报错即失败」场景下的任务结果。
  5. 测试覆盖收缩:集成测试删除了多轮 multi_samples 的 train/eval 两种形状校验,list 输出路径仅保留在自定义生成器的单元测试层面,后续对 Sample | list[Sample] 契约的回归保护略弱于此前。
  • 对用户:依赖 --generate-multi-samples 的 rollout 命令必须移除该参数;内置生成器输出统一为标量 Sample,批量数据中不再出现标量与 list 混合形状,下游 sample 组装和 reward 计算更可预期。
  • 对系统:sample 边界回归到轨迹拓扑决定,turn 级样本不再共享 group_index,group baseline 统计不再被轮次兄弟污染,对 PPO/RLHF 训练数据质量是正向修正;GenerateFnOutput.samples 契约保持 Sample | list[Sample],为 multi-agent / tree 轨迹扩展预留空间。
  • 对团队:测试矩阵从 4 个 variant 收敛为 2 个,CI 运行时间略降;文档明确了 generate function 的输出契约;仓库内所有 flag 注入点已清理,后续维护成本降低。
  • 影响程度:中高——涉及 rollout 核心生成路径与输出形状契约,但对未使用该 flag 的默认路径行为不变。
破坏性 CLI 变更 生成输出形状变化 自定义 reward model 兼容性 外部脚本需同步更新

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论