执行摘要
- 一句话:连续 token 将 generation prompt 融合进末组渲染,消除 O(N^2) 重复 tokenize
- 推荐动作:值得精读。三个看点:① 用能力钩子把默认优化路径与模板特定回退解耦,钩子语义(“generation scaffold 是否只由末组决定”)单一清晰;② 用录制型 tokenizer 单测同时锁定渲染次数与
add_generation_prompt 参数序列,把性能修复固化为可回归的行为契约;③ review 阶段在真实 tokenizer 上做 8 builder × 24 用例的逐 token 对比矩阵,是同类增量渲染改动最可靠的验证范式。合并前由 wuxibin89 一次 approve,说明方案在讨论阶段已收敛;阅读时建议结合 #7617 的调用链分析和 #7630 的 DeepSeek 覆写一起看。
功能与动机
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=False 与 True),只为取回下一轮 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 保留各自模型特定路径,避免模板语义被默认优化破坏。
实现拆解
变更入口是 verl/utils/tokenizer/continuous_token.py 的 ContinuousTokenBuilder.tokenize_non_assistant_incremental_messages(),配套测试在 tests/utils/test_continuous_token_on_cpu.py。实现分 5 步:
-
入口改造:先用 _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() 调用,从源头消除完整历史的双渲染。
-
钩子透传:_tokenize_tool_group() 与 _tokenize_single_non_tool() 新增 add_generation_prompt: bool = False 关键字参数并透传给 render_delta_token_id()。默认值 False 使既有子类调用不传参时行为不变;render_delta_token_id() 内部的前缀一致性校验继续兜底,融合失败会抛 ValueError 而非静默产出错误 token。
-
能力钩子与模型退出:基类 _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 差分。
-
测试配套:新增 3 个 CPU 用例——test_default_builder_fuses_generation_prompt_into_last_append_group 验证多消息 tool 分组仅 2 次有界渲染且第二次 add_generation_prompt=True;test_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)验证回退路径仍对完整历史渲染两遍。
-
验证配套:PR body 声明有 e2e 试验验证性能收益;review 阶段完成 8 个 builder × 24 组用例的真实 tokenizer 逐 token 回归(见评论区精华);DeepSeek 家族经确认后由 follow-up PR #7630 单独补上覆写与 CPU 测试。
关键文件:
verl/utils/tokenizer/continuous_token.py(模块 分词器;类别 source;类型 core-logic;符号 tokenize_non_assistant_incremental_messages, _should_fuse_generation_prompt_with_last_group, _tokenize_tool_group, _tokenize_single_non_tool): 核心变更文件:重构 tokenize_non_assistant_incremental_messages() 将 generation prompt 融入最后一个 append 分组渲染,新增 _should_fuse_generation_prompt_with_last_group() 能力钩子,并为 GPT-OSS、Gemma 4 提供显式退出路径。这是消除 O(N^2) 全量 tokenize 的关键实现。
tests/utils/test_continuous_token_on_cpu.py(模块 单元测试;类别 test;类型 test-coverage;符号 test_default_builder_fuses_generation_prompt_into_last_append_group, test_default_builder_only_fuses_generation_prompt_into_final_append_group, test_special_builder_can_keep_separate_full_history_generation_prompt, FullHistoryGenerationPromptBuilder): 测试配套文件:新增 3 个 CPU 单测,用 _RecordingTemplateTokenizer 记录每次渲染的 messages 长度与 add_generation_prompt 参数,把“渲染次数”和“末组选择”固化为可回归的行为契约。
关键符号: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
核心变更文件:重构 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
测试配套文件:新增 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)
评论区精华
核心讨论集中在 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 差异除外)。
-
DeepSeek 模型家族是否受末组融合影响 (question): ruiling-smartbear 检查后确认 DeepSeek 需要单独覆写,开出 #7630(DeepSeek override + CPU 测试),并在真实 R1 / V3.1 / V3.2-Exp tokenizer 上验证通过。
- 真实 tokenizer 逐 token 回归验证矩阵 (testing): 所有 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 稳定性校验兜底
关联脉络
- PR #7630 [rollout] fix: DeepSeek continuous token builder cannot render tool appends: 本 PR 讨论中确认 DeepSeek 家族无法走默认融合路径,由 ruiling-smartbear 单独开出的覆写 PR,同样修改 continuous_token.py 与 test_continuous_token_on_cpu.py,两 PR 构成同一功能线的完整闭环。
- PR #7617 # [rollout] perf:
_tokenize_generation_prompt_delta re-tokenizes the full conversation after every tool turn: 本 PR 修复的关联 Issue,提供问题调用链分析、O(N^2) 定性结论与有界伪消息的建议方向。
参与讨论