Prhub

#7628 [rollout] fix: continuous token fuse generation prompt with the final append group

原始 PR 作者 gxlvera 合并时间 2026-08-31 13:09 文件变更 2 提交数 2 评论 4 代码增减 +103 / -6

执行摘要

连续 token 将 generation prompt 融合进末组渲染,消除 O(N^2) 重复 tokenize

Issue #7617 定位了完整的低效链路:AgentLoopBase.ct_merge_non_assistant_msg()merge_non_assistant_tokens()tokenize_non_assistant_incremental_messages()_tokenize_generation_prompt_delta()render_delta_token_id()render_delta_token_id() 对同一份完整历史渲染两遍(add_generation_prompt=FalseTrue),只为取回下一轮 assistant 开头的一小段脚手架 token。Issue 原文指出“every turn repeats work over all previous turns, creating approximately quadratic cumulative tokenization work for long agent trajectories”,并建议用有界伪消息推导 generation-prompt delta(Continuous Token 追加消息本身已采用该做法)。PR body 进一步明确设计红线:AgentLoop 运行时 API 不变、仅扩展 developer 扩展 API,且 GPT-OSS 与 Gemma 4 保留各自模型特定路径,避免模板语义被默认优化破坏。

值得精读。三个看点:① 用能力钩子把默认优化路径与模板特定回退解耦,钩子语义(“generation scaffold 是否只由末组决定”)单一清晰;② 用录制型 tokenizer 单测同时锁定渲染次数与 add_generation_prompt 参数序列,把性能修复固化为可回归的行为契约;③ review 阶段在真实 tokenizer 上做 8 builder × 24 用例的逐 token 对比矩阵,是同类增量渲染改动最可靠的验证范式。合并前由 wuxibin89 一次 approve,说明方案在讨论阶段已收敛;阅读时建议结合 #7617 的调用链分析和 #7630 的 DeepSeek 覆写一起看。

讨论亮点

核心讨论集中在 Issue #7617 评论区(PR 自身无 review 评论,仅 wuxibin89 一次 approve):

  • 作者 gxlvera 主动提出兼容性疑问:“Can anyone check for deepseek model family, which has special behavior.”——这一问暴露了默认融合路径对模板特殊性的依赖,ruiling-smartbear 随后确认 DeepSeek 需单独覆写,开出 #7630 并附 CPU 测试。

  • ruiling-smartbear 给出可复制的回归验证方法:在 merge commit 3dab856 与其父提交 8e4a572 之间,用 transformers 5.16.1 真实 tokenizer 构造 24 组用例(system prompt 开关 × 0/10/50 轮历史 × 1/2 并行调用 × enable_thinking 默认/False),对 qwen/qwen25/qwen3/qwen35/minimaxm2/glm47/gemma4/gptoss/default 逐 token 对比,全部 24/24 前后一致;gemma4 与 gptoss 因显式退出两边路径相同,naive full-history diff 作为第三方参照也一致(Qwen3-8B 模板 prefix 不稳定、gpt-oss 已知 _format_tool_response 差异除外)。

实现拆解

变更入口是 verl/utils/tokenizer/continuous_token.pyContinuousTokenBuilder.tokenize_non_assistant_incremental_messages(),配套测试在 tests/utils/test_continuous_token_on_cpu.py。实现分 5 步:

  1. 入口改造:先用 _iter_append_groups(appended_messages) 物化 append 分组列表,再查询 _should_fuse_generation_prompt_with_last_group() 决定是否融合。遍历时计算 add_generation_prompt = fuse_generation_prompt and index == len(groups) - 1,默认仅最后一个分组带 add_generation_prompt=True;融合开启时跳过结尾的 _tokenize_generation_prompt_delta() 调用,从源头消除完整历史的双渲染。

  2. 钩子透传_tokenize_tool_group()_tokenize_single_non_tool() 新增 add_generation_prompt: bool = False 关键字参数并透传给 render_delta_token_id()。默认值 False 使既有子类调用不传参时行为不变;render_delta_token_id() 内部的前缀一致性校验继续兜底,融合失败会抛 ValueError 而非静默产出错误 token。

  3. 能力钩子与模型退出:基类 _should_fuse_generation_prompt_with_last_group() 返回 True;GptOssContinuousTokenBuilder 返回 False(工具响应走 tokenizer.encode 直接编码,无法承载模板级 generation prompt);Gemma4ContinuousTokenBuilder 返回 False(generation scaffold 依赖前一条消息类型,保留模型特定钩子)。退出时 _tokenize_generation_prompt_delta(updated_messages) 作为唯一回退路径继续做全量历史 false/true 差分。

  4. 测试配套:新增 3 个 CPU 用例——test_default_builder_fuses_generation_prompt_into_last_append_group 验证多消息 tool 分组仅 2 次有界渲染且第二次 add_generation_prompt=Truetest_default_builder_only_fuses_generation_prompt_into_final_append_group 验证多分组时只有末组带 prompt(渲染 4 次,参数序列 False/False/False/True);test_special_builder_can_keep_separate_full_history_generation_prompt 用本地 FullHistoryGenerationPromptBuilder(返回 False)验证回退路径仍对完整历史渲染两遍。

  5. 验证配套:PR body 声明有 e2e 试验验证性能收益;review 阶段完成 8 个 builder × 24 组用例的真实 tokenizer 逐 token 回归(见评论区精华);DeepSeek 家族经确认后由 follow-up PR #7630 单独补上覆写与 CPU 测试。

文件 模块 状态 重要度
verl/utils/tokenizer/continuous_token.py 分词器 modified 7.13
tests/utils/test_continuous_token_on_cpu.py 单元测试 modified 6.74

关键符号

ContinuousTokenBuilder.tokenize_non_assistant_incremental_messages ContinuousTokenBuilder._should_fuse_generation_prompt_with_last_group ContinuousTokenBuilder._tokenize_tool_group ContinuousTokenBuilder._tokenize_single_non_tool ContinuousTokenBuilder._tokenize_generation_prompt_delta GptOssContinuousTokenBuilder._should_fuse_generation_prompt_with_last_group Gemma4ContinuousTokenBuilder._should_fuse_generation_prompt_with_last_group

关键源码片段

verl/utils/tokenizer/continuous_token.py core-logic

核心变更文件:重构 `tokenize_non_assistant_incremental_messages()` 将 generation prompt 融入最后一个 append 分组渲染,新增 `_should_fuse_generation_prompt_with_last_group()` 能力钩子,并为 GPT-OSS、Gemma 4 提供显式退出路径。这是消除 O(N^2) 全量 tokenize 的关键实现。

def tokenize_non_assistant_incremental_messages(
    self,
    previous_messages: list[dict[str, Any]],
    updated_messages: list[dict[str, Any]],
    *,
    tools: list[dict[str, Any]] | None = None,
) -> list[int]:
    # 只接受纯追加的消息变更;suffix-diff 依赖前缀完全一致,混入修改会静默出错
    self._assert_append_only(previous_messages, updated_messages)
    appended_messages = updated_messages[len(previous_messages):]
    if not appended_messages:
        return []
​
    # 先物化全部 append 分组,才能判断哪一组是“最后一组”:
    # 默认路径把 generation prompt 融进末组渲染,避免对完整历史做两遍全量渲染
    groups = self._iter_append_groups(appended_messages)
    fuse_generation_prompt = self._should_fuse_generation_prompt_with_last_group()
​
    incremental_ids: list[int] = []
    for index, group in enumerate(groups):
        # 只有最后一组带 add_generation_prompt=True,其 delta 同时包含
        # 追加消息本身与 generation prompt 脚手架,融合后不再单独渲染 prompt
        add_generation_prompt = fuse_generation_prompt and index == len(groups) - 1
        role = group[0].get("role")
        if role == "tool":
            incremental_ids.extend(
                self._tokenize_tool_group(
                    group,
                    previous_messages=previous_messages,
                    tools=tools,
                    add_generation_prompt=add_generation_prompt,
                )
            )
        elif role in {"user", "system"}:
            # system 追加通常是 retry/control 消息;模板不支持时会在 suffix-diff 中报错
            if len(group) != 1:
                raise ValueError(
                    f"Continuous Token expects one {role!r} message per append group, got {len(group)}"
                )
            incremental_ids.extend(
                self._tokenize_single_non_tool(
                    group[0],
                    tools=tools,
                    add_generation_prompt=add_generation_prompt,
                )
            )
        else:
            raise ValueError(f"Unsupported Continuous Token append role: {role!r}")
​
    # 选择不融合的 builder(如 GPT-OSS、Gemma 4)走回退路径:
    # 仍基于完整历史做 false/true 两次渲染的 suffix-diff,行为与旧版本一致
    if not fuse_generation_prompt:
        incremental_ids.extend(
            self._tokenize_generation_prompt_delta(updated_messages, tools=tools)
        )
    return incremental_ids
​
​
def _should_fuse_generation_prompt_with_last_group(self) -> bool:
    """是否把 generation prompt 融进最后一个 append 分组的渲染。    默认返回 True。某些 chat template 的 generation scaffold 依赖完整上下文
    (Gemma 4 依赖前一条消息类型;GPT-OSS 不走 suffix-diff 路径),
    这类 builder 应返回 False,并通过 _tokenize_generation_prompt_delta()
    保留原有的全量历史差分逻辑。
    """
    return True
tests/utils/test_continuous_token_on_cpu.py test-coverage

测试配套文件:新增 3 个 CPU 单测,用 `_RecordingTemplateTokenizer` 记录每次渲染的 messages 长度与 `add_generation_prompt` 参数,把“渲染次数”和“末组选择”固化为可回归的行为契约。

def test_default_builder_fuses_generation_prompt_into_last_append_group():
    tokenizer = _RecordingTemplateTokenizer()
    builder = ContinuousTokenBuilder(tokenizer, chat_template_kwargs={"enable_thinking": False})
    tools = [{"type": "function", "function": {"name": "lookup"}}]
    old_messages = [
        {"role": "user", "content": "first question"},
        {"role": "assistant", "content": "first answer"},
        {"role": "user", "content": "second question"},
    ]
    # 一次性追加两条连续 tool 消息,构成一个多消息 tool 分组
    new_messages = old_messages + [
        {"role": "tool", "content": "first result", "name": "lookup"},
        {"role": "tool", "content": "second result", "name": "lookup"},
    ]
​
    builder.tokenize_non_assistant_incremental_messages(old_messages, new_messages, tools=tools)
​
    # 核心断言:整段追加只触发 2 次渲染(融合前是 4 次且含 2 次全量历史渲染):
    # 第 1 次渲染合成前缀(3 条消息、无 generation prompt),
    # 第 2 次渲染前缀 + 末组(5 条消息、add_generation_prompt=True),
    # 由此证明 generation prompt 已融合进最后一个 append 分组
    assert len(tokenizer.calls) == 2
    assert [len(call["messages"]) for call in tokenizer.calls] == [3, 5]
    assert [call["add_generation_prompt"] for call in tokenizer.calls] == [False, True]
    assert all(call["tools"] is tools for call in tokenizer.calls)
    assert all(call["kwargs"] == {"enable_thinking": False} for call in tokenizer.calls)

评论区精华

DeepSeek 模型家族是否受末组融合影响 question

作者 gxlvera 在 Issue 评论区直接提问“Can anyone check for deepseek model family, which has special behavior.”,担心 DeepSeek 系列模板的 generation scaffold 不满足末组融合前提。

结论:ruiling-smartbear 检查后确认 DeepSeek 需要单独覆写,开出 #7630(DeepSeek override + CPU 测试),并在真实 R1 / V3.1 / V3.2-Exp tokenizer 上验证通过。 · 已解决

真实 tokenizer 逐 token 回归验证矩阵 测试

ruiling-smartbear 在 merge commit 3dab856 与其父提交 8e4a572 之间,用 transformers 5.16.1 真实 tokenizer 按 tool_agent_loop 轨迹形状构造 24 组用例(system prompt 开关 × 0/10/50 轮历史 × 1/2 并行调用 × enable_thinking 默认 /False),对 8 个 builder 前后对比 token ids。

结论:所有 builder 全部 24/24 一致;gemma4 与 gptoss 因显式退出而两边走相同路径;naive full-history diff 作为第三方参照(除 Qwen3-8B prefix 不稳定与 gpt-oss 已知的 _format_tool_response 差异外)也与两侧一致。 · 已解决

风险与影响

  • 默认路径行为变更:所有未覆写钩子的 builder 的增量 token 生成方式都被改变。验证矩阵虽覆盖主流模型家族(Qwen 系列、MiniMax-M2、GLM-4.7),但无法穷举所有 chat template;render_delta_token_id() 的前缀一致性校验是兜底,模板在末组渲染中前缀不一致会抛 ValueError 而非静默错误。
  • 模型特定模板依赖:若某模板的 generation scaffold 依赖完整历史且未被识别,融合后 token 会偏移。当前 GPT-OSS、Gemma 4 显式退出,DeepSeek 由 #7630 补充覆写;VL 多模态 builder 与用户自定义子类不在本次测试覆盖内,升级前需自查。
  • 兼容性:AgentLoop 运行时 API 无变化;_tokenize_tool_group() / _tokenize_single_non_tool() 新增参数默认 False,旧子类调用向后兼容;退出钩子为纯增量 API。
  • 性能收益前提:融合路径假设末组渲染 delta 包含完整 generation prompt。对 prefix 不稳定模板(如 Qwen3-8B)该假设不成立,需依赖回退或覆写。

对使用默认 ContinuousTokenBuilder 及继承基类的构建器,每次工具/系统/user 追加的 tokenization 从“两次全量历史渲染”降为“一次有界末组渲染”,累计复杂度从约 O(N^2) 降为 O(N);processor-backed 构建器收益更明显,因为每次渲染还伴随 processor/template 对完整消息列表的处理。对 GPT-OSS、Gemma 4 无行为变化(显式退出);DeepSeek 由 #7630 覆盖。对团队而言,该 PR 确立了“默认快速路径 + 能力钩子显式退出”的扩展模式,后续接入新模型 builder 时只需回答一个问题:generation scaffold 是否仅由最后一个 append 分组决定。影响范围集中在 verl/utils/tokenizer/continuous_token.py 与对应 CPU 单测,扩散面小但语义敏感。

默认路径行为变更 模型特定模板需逐一验证 VL 多模态 builder 未覆盖 依赖 prefix 稳定性校验兜底

关联 Issue

#7617 # [rollout] perf: `_tokenize_generation_prompt_delta` re-tokenizes the full conversation after every tool turn

完整报告

参与讨论