Prhub

#7643 [rollout] feat: continuous_token support Assistant Role in Merge and Encode Context

原始 PR 作者 gxlvera 合并时间 2026-09-07 13:18 文件变更 7 提交数 154 评论 3 代码增减 +975 / -153

执行摘要

为 Continuous Token 构建器增加 rollback 风格的 assistant 角色上下文合并支持。

此变更旨在支持某些 agent harness 的 rollback 行为,即它们可能会重写之前的 assistant turn。在这种情况下,返回给 CT 的上下文增量(context delta)可能包含 role == "assistant" 的消息。旧的 API (merge_non_assistant_tokens) 假定增量消息仅包含 tool、user 或 system 角色,因此无法处理这种情况。此外,直接对整个增量消息列表进行全量编码在某些模型(如 Qwen3)上会导致模板行为不一致(例如,模板会丢弃历史 assistant thinking),因此需要按角色分组进行增量编码以确保稳定性和正确性。

此 PR 是一个具有重要设计决策的功能增强。建议相关工程师精读,特别关注:

  1. merge_context_tokens 和分组编码的新逻辑,以及 MergeResult 语义的变化。
  2. 模型特定子类(尤其是 DeepSeek)如何通过钩子适配新逻辑。
  3. scripts/chat_template_checker.py 中的更新,以理解 CT 的新行为如何被验证。
  4. 新增的测试用例,特别是那些模拟 rollback 场景的,以理解边界条件。

该 PR 的洞察价值较高,因为它展示了如何通过引入中间层(钩子)和约束来解决一个复杂的、与模型模板紧密相关的工程问题,其设计模式值得借鉴。

讨论亮点

审查者 wuxibin89 提出了一个关键问题:Gemma4 和 DeepSeek 为什么需要专门的 tokenize_context_incremental_messages?是否可以将其合并到基类中?

作者 gxlvera 回应称,最初为这两个子类重写整个方法是因为它们在合并上下文消息组内(例如,[tool_response, user_prompt])时有特殊的边界行为。但随后作者发现,这种特殊行为可以通过更小的钩子函数来实现,而不是重写整个方法。因此,作者重构了代码:

  1. 在基类中新增了一个 _merge_context_group() 钩子函数,用于处理上下文消息列表中合并不同消息组时的特殊边界行为。
  2. 让 Gemma4 和 DeepSeek 子类复用基类的 tokenize_context_incremental_messages 和 merge_context_tokens,只重写更小的钩子函数,如 _merge_context_group 和 _merge_context_token_ids。这样减少了重复代码,并提高了代码复用性。

这个讨论和随后的重构显著提升了代码设计的清晰度和可维护性。

实现拆解

  1. API 重命名与角色扩展:将 ContinuousTokenBuilder 基类中的 merge_non_assistant_tokens 和 tokenize_non_assistant_incremental_messages 方法分别重命名为 merge_context_tokens 和 tokenize_context_incremental_messages。同时,将 MergeKind 类型从 "non_assistant" 更新为 "context",并将 MergeResult 的默认 kind 从 "non_assistant" 更新为 "context"。_SUPPORTED_APPEND_ROLES 集合中增加了 "assistant" 角色。
  2. 分组增量编码逻辑:在 tokenize_context_incremental_messages 中引入 processed_messages 列表来维护处理上下文。该方法不再一次性编码所有追加消息,而是调用 _iter_append_groups 将消息按角色分组,并对每一组独立进行 delta 编码。通过新增的 _merge_context_group 钩子方法,将每个组的编码结果追加到总增量 token 列表中。
  3. 上下文约束与子类适配:新增规则:上下文增量消息的最后一条消息不能是 assistant 角色,否则抛出 ValueError。针对模型特定的子类(如 DeepSeekV4ContinuousTokenBuilder、Gemma4ContinuousTokenBuilder)进行了适配:DeepSeek 子类重写了 _merge_context_token_ids,仅在 previous_role == "assistant" 时插入 EOS token,并在 tokenize_context_incremental_messages 中禁用了 drop_thinking 以保留历史推理内容。Gemma4 子类则可能通过 _merge_context_group 钩子处理其特定的 turn separator 逻辑。
  4. Agent Loop 调用点更新:更新 verl/experimental/agent_loop/agent_loop.py 中的 ct_merge_non_assistant_msg 方法,将其重命名为 ct_merge_context_msg,并调用新的 merge_context_tokens。同步更新 verl/experimental/agent_loop/tool_agent_loop.py 中的调用点。
  5. 测试与配套更新:扩展了 tests/utils/test_continuous_token_on_cpu.py 测试文件,增加了新的 mock tokenizer(如 _GptOssRollbackTokenizer, _DeepSeekToolAppendTokenizer)和测试用例,以覆盖 rollback 场景、分组编码行为以及模型特定的边界处理。更新了 scripts/chat_template_checker.py 以调用新的 merge_context_tokens API。
文件 模块 状态 重要度
verl/utils/tokenizer/continuous_token.py Token 构建器 modified 8.93
verl/utils/tokenizer/deepseek.py DeepSeek 构建器 modified 7.97
tests/utils/test_continuous_token_on_cpu.py CT 单元测试 modified 7.82

关键符号

tokenize_context_incremental_messages merge_context_tokens _merge_context_group _merge_context_token_ids ct_merge_context_msg

关键源码片段

verl/utils/tokenizer/continuous_token.py dependency-wiring

Continuous Token 构建器的核心实现文件,包含所有主要 API 重命名、分组编码逻辑引入和 MergeKind 变更。

# continuous_token.py 中 token 增量编码的核心逻辑
# 修改后支持按角色分组编码,并处理上下文中的 assistant 消息
​
def tokenize_context_incremental_messages(
    self,
    previous_messages: list[dict[str, Any]],
    updated_messages: list[dict[str, Any]],
    *,
    tools: list[dict[str, Any]] | None = None,
) -> list[int]:
    # ... 前置检查 ...
    incremental_ids: list[int] = []
    # 用于维护逐步处理的消息历史
    processed_messages = list(previous_messages)
​
    # 按角色分组追加消息
    groups = self._iter_append_groups(appended_messages)
    fuse_generation_prompt = self._should_fuse_generation_prompt_with_last_group()
​
    for index, group in enumerate(groups):
        # ... 根据 group 角色进行编码 (tool, user, system, assistant) ...
        role = group[0].get("role")
        if role == "tool":
            # ... token 化工具组 ...
        elif role in {"user", "system", "assistant"}:
            # 新规则:上下文增量不能以 assistant 结尾
            if role == "assistant" and index == len(groups) - 1:
                raise ValueError("Continuous Token context incremental messages cannot end with assistant")
            # ... token 化单条消息 ...
        # ... 错误处理 ...
​
        # 使用钩子将编码结果合并到增量列表中
        self._merge_context_group(
            incremental_ids,
            group_token_ids,
            group=group,
            processed_messages=processed_messages,
            tools=tools,
        )
        # 更新已处理的消息历史,为下一组提供上下文
        processed_messages.extend(group)
​
    # ... 处理 generation prompt delta ...
    return incremental_ids
​
# 新增的钩子方法,子类可用于自定义合并行为
def _merge_context_group(
    self,
    incremental_ids: list[int],
    group_token_ids: list[int],
    *,
    group: list[dict[str, Any]],
    processed_messages: list[dict[str, Any]],
    tools: list[dict[str, Any]] | None = None,
) -> None:
    """Merge one encoded context group into the incremental token stream."""
    incremental_ids.extend(group_token_ids)
verl/utils/tokenizer/deepseek.py core-logic

DeepSeek V4 构建器的子类适配,展示了如何通过重写钩子来适配新的上下文合并逻辑,特别是处理 EOS 插入和 drop_thinking 行为。

# deepseek.py 中 DeepSeekV4ContinuousTokenBuilder 的适配
​
def tokenize_context_incremental_messages(self, previous_messages, updated_messages, *, tools=None):
    # ... 前置检查 ...
    if appended_messages[-1].get("role") == "assistant":
        raise ValueError("Continuous Token context incremental messages cannot end with assistant")
    self._assert_prefix_is_stable(previous_messages, tools=tools)
    # 关键适配:编码上下文时禁用 drop_thinking,以保留历史推理内容
    return self._encode(
        appended_messages,
        tools=None,
        add_bos_token=False,
        drop_thinking=False,
    )
​
def _merge_context_token_ids(
    self,
    runtime_token_ids: list[int],
    appended_token_ids: list[int],
    *,
    previous_messages: list[dict[str, Any]] | None = None,
    **kwargs: Any,
) -> MergeResult:
    prefix = list(runtime_token_ids)
    inserted_token_ids: list[int] = []
    # 根据前一条消息角色决定是否插入 EOS
    previous_role = previous_messages[-1].get("role") if previous_messages else None
    # 只有前一条是 assistant 且当前没有 EOS 时才插入
    if appended_token_ids and previous_role == "assistant" and prefix[-1:] != [self._eos_id]:
        prefix.append(self._eos_id)
        inserted_token_ids.append(self._eos_id)
    return MergeResult(
        token_ids=prefix + list(appended_token_ids),
        appended_token_count=len(appended_token_ids),
        kind="context",
        inserted_token_ids=inserted_token_ids,
    )

评论区精华

子类钩子化重构 设计

审查者 `wuxibin89` 质疑 Gemma4 和 DeepSeek 子类为何需要完全重写 `tokenize_context_incremental_messages`。作者 `gxlvera` 解释了最初原因(特殊边界行为),并随后重构代码,引入更细粒度的钩子 `_merge_context_group` 来替代整个方法的重写,减少了重复代码。

结论:采用钩子化重构,提高代码复用性和可维护性。子类仅需重写必要的钩子。 · 已解决

风险与影响

  1. API 兼容性风险:merge_non_assistant_tokens 等 API 被重命名为 merge_context_tokens,MergeKind 的枚举值从 "non_assistant" 变为 "context"。这是一个 breaking change。任何直接调用这些 API 或依赖 MergeResult.kind 值为 "non_assistant" 的外部代码或集成都会受到影响。需要确保所有内部调用点(如 agent loop)已全部更新。
  2. 行为变更风险:对于不支持或不预期 assistant 角色出现在上下文增量中的下游逻辑,此变更可能引入意外行为。虽然 merge_context_tokens 会确保上下文中的 assistant token 的 loss mask 和 logprob 为 0,但调用方需要理解这一语义变化。
  3. 模型模板兼容性:分组编码策略旨在解决特定模型(如 Qwen3)的模板不一致性,但引入的“不允许上下文以 assistant 结束”规则是一个强约束。需要确保所有支持的 agent harness 和模型组合都满足此约束,否则会引发 ValueError。
  4. 回归风险:对 ContinuousTokenBuilder 核心类和多个子类(DeepSeek, Gemma4)的修改,以及 agent loop 调用点的更改,涉及关键的多轮对话 token 构建路径。尽管有大量测试,但仍需关注在复杂、长对话或特殊模型配置下的回归风险。

用户影响:对于使用支持 rollback 风格 agent harness 的用户,此 PR 是必要的功能补充,使得 CT 能够正确处理被重写的 assistant turn。对于其他用户,此 PR 是一个透明的内部重构,但需要注意 API 重命名带来的升级适配。
系统影响:此 PR 增强了 CT 模块的灵活性和健壮性,使其能够更准确地模拟全量 apply_chat_template 的行为,尤其是在处理复杂、多变的对话上下文时。这对于提升基于 CT 的多轮强化学习训练的稳定性和性能至关重要。
团队影响:此 PR 是 CT 功能演进的重要一步,标志着其从仅支持单向追加到支持更复杂上下文管理的转变。相关的代码重构(引入更小的钩子)也为未来的模型适配工作提供了更好的扩展性。

核心路径变更 API Breaking Change

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论