Prhub

#6937 [rollout] fix: probe list-of-parts content in initialize_turn_separator for multimodal processors

原始 PR 作者 abtonmoy 合并时间 2026-07-06 17:52 文件变更 2 提交数 1 评论 2 代码增减 +65 / -10

执行摘要

修复 VLM 处理器回合分隔符探测崩溃

PR#6921 合并后 3 个 CI 任务(e2e_ppo_trainer_veomni_vllme2e_grpo_trainer_megatron-vlmsgl)因 initialize_turn_separatorTypeError: string indices must be integers 而失败。根本原因是多模态处理器期望 content[{"type": "text", "text": "..."}] 形式,但代码传递了纯字符串 "x"

建议合并。该 PR 精准修复了由上游 PR#6921 引入的回归问题,设计巧妙(通过循环尝试两种格式),测试充分(包含模拟处理器桩和回归验证),且作者已对真实模型进行了额外验证。值得关注的设计决策:使用 for...break 形式而非 if-else 能保持两种探针形式对称,确保推导逻辑一致。

讨论亮点

Review 中 gemini-code-assist[bot] 指出使用空字符串作为基础探针可能导致错误分离符推导,建议改为非空单字符 "y" vs "x"。作者 abtonmoy 回应:已在真实 tokenizer/processor 上验证空与非空基础探针产生相同分隔符,且尾部的 eos 分割已丢弃可能的多余前缀,因此保持原方案不变。该讨论未引发代码变更,作者给出了充分理由。

实现拆解

  1. 修改 initialize_turn_separatorverl/utils/tokenizer/chat_template.py:将原来的直接字符串探针改为 for as_parts in (False, True) 循环。先尝试字符串格式 content="x",若抛出异常则改用列表格式 [{"type": "text", "text": "x"}]。两种探针必须使用相同形式以确保仅 body 内容不同。若两种形式均失败,返回 [] 而非传播异常。
  2. 添加 MultimodalChatMLProcessor 测试桩(tests/utils/test_turn_separator_on_cpu.py:继承 ChatMLTokenizer,重写 apply_chat_template 以在 content 为字符串时抛出 TypeError,模拟真实多模态处理器的行为。
  3. 添加回归测试 test_separator_derived_when_processor_requires_list_content:验证当字符串探针失败时,列表格式探针仍能正确推导分隔符。
  4. 本地验证:在 Qwen3-8B、Qwen3-VL-2B-Instruct、Qwen2.5-VL-3B-Instruct 上测试,均返回 \n 分隔符(id 198)且无崩溃。
文件 模块 状态 重要度
verl/utils/tokenizer/chat_template.py 工具层 modified 7.13
tests/utils/test_turn_separator_on_cpu.py 单元测试 modified 6.61

关键符号

initialize_turn_separator MultimodalChatMLProcessor.apply_chat_template test_separator_derived_when_processor_requires_list_content

关键源码片段

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

核心修复文件,修改 `initialize_turn_separator` 以支持多模态处理器的列表格式 content。

# verl/utils/tokenizer/chat_template.py (modified)def initialize_turn_separator(tokenizer, **apply_chat_template_kwargs) -> list[int]:
    # ... docstring omitted ...
    # Render two user turns that differ only in body text; the shared trailing run is the separator.
    # A bare string ``content`` is rejected by some multimodal processors (they iterate ``content``
    # expecting a list of typed parts), so fall back to the list-of-parts form, and return ``[]`` if
    # neither renders. Both probes must use the same form so only the body differs.
    empty = filled = None
    for as_parts in (False, True):
        if as_parts:
            # 多模态处理器期望的列表格式
            body_empty, body_filled = [{"type": "text", "text": ""}], [{"type": "text", "text": "x"}]
        else:
            # 纯文本 tokenizer 的字符串格式(原始行为)
            body_empty, body_filled = "", "x"
        try:
            empty = normalize_token_ids(
                tokenizer.apply_chat_template(
                    [{"role": "user", "content": body_empty}],
                    add_generation_prompt=False,
                    tokenize=True,
                    **apply_chat_template_kwargs,
                )
            )
            filled = normalize_token_ids(
                tokenizer.apply_chat_template(
                    [{"role": "user", "content": body_filled}],
                    add_generation_prompt=False,
                    tokenize=True,
                    **apply_chat_template_kwargs,
                )
            )
            break # 成功则跳出循环
        except Exception:
            # 当前格式失败,尝试下一种;若两种均失败则最终返回 []
            empty = filled = None
    if empty is None or filled is None:
        return []
    # 后续逻辑不变:计算最长公共后缀,分割 eos 后返回分隔符 ...
    i = 0
    while i < len(empty) and i < len(filled) and empty[-1 - i] == filled[-1 - i]:
        i += 1
    suffix = empty[len(empty) - i :]
    if not suffix:
        return []
    eos_id = getattr(tokenizer, "eos_token_id", None)
    if eos_id is None:
        eos_id = getattr(getattr(tokenizer, "tokenizer", None), "eos_token_id", None)
    eos_ids = {eos_id} if isinstance(eos_id, int) else set(eos_id or [])
    last_close = max((i for i, tok_id in enumerate(suffix) if tok_id in eos_ids), default=None)
    if last_close is not None:
        return suffix[last_close + 1 :]
    return suffix[1:]
tests/utils/test_turn_separator_on_cpu.py test-coverage

添加回归测试,包含模拟多模态处理器的桩类和新测试函数。

# tests/utils/test_turn_separator_on_cpu.py (modified)class MultimodalChatMLProcessor(ChatMLTokenizer):
    """ChatML tokenizer that rejects bare-string ``content``, like a multimodal processor.    Multimodal processors iterate ``content`` expecting a list of typed parts
    (``{"type": "text", "text": ...}``), so a bare string is indexed as
    ``content["type"]`` and raises ``TypeError``.
    This reproduces the crash the string-content probe hit on real VLM processors in CI.
    """
​
    def apply_chat_template(self, messages, add_generation_prompt=False,
                            tokenize=True, tools=None, **kwargs):
        flattened = []
        for m in messages:
            content = m["content"]
            if isinstance(content, str):
                raise TypeError("string indices must be integers, not 'str'")
            # 从列表格式中提取纯文本
            text = "".join(part["text"] for part in content)
            flattened.append({"role": m["role"], "content": text})
        return super().apply_chat_template(flattened, add_generation_prompt,
                                           tokenize, tools, **kwargs)
​
​
def test_separator_derived_when_processor_requires_list_content():
    """Guard the multimodal path: string-content probe crashes, list-of-parts probe recovers it."""
    proc = MultimodalChatMLProcessor()
    # 验证字符串格式确实抛出异常
    try:
        proc.apply_chat_template([{"role": "user", "content": "x"}])
        raised = False
    except TypeError:
        raised = True
    assert raised
    # 验证 fallback 仍然能正确推导分隔符
    assert initialize_turn_separator(proc) == [_NL]

评论区精华

使用空字符串作为基础探针的风险 正确性

gemini-code-assist[bot] 指出使用空字符串 `""` 作为基础探针可能导致错误的分隔符推导:若空内容 tokenize 为空列表,尾部匹配可能意外匹配到模板前缀 token。建议改用两个不同的非空单字符(如 `"y"` 和 `"x"`)。

结论:作者 abtonmoy 回应:已在真实 tokenizer/processor 上验证,空与非空基础产生相同分隔符;且后续的 eos 分割逻辑会丢弃任何过度匹配的前缀 token,因此保持原方案不变。 · 已解决

风险与影响

风险较低。核心修改是添加 try-except fallback,若字符串探针失败则静默回退至列表格式;若两种均失败则返回空列表。这确保了旧行为(非多模态处理器)不受影响,同时修复多模态路径的崩溃。但需注意:except Exception 可能捕获非预期的异常(如网络错误),但此函数无 I/O,仅调用 apply_chat_template,风险可控。返回 [] 时调用方需自行处理缺失分隔符的情况。

直接影响:修复 VLM(视觉语言模型)AgentLoop 初始化阶段的崩溃,影响使用多模态处理器的全线 CI 和用户。间接影响:无,因为 fallback 仅影响先前崩溃的路径。不影响纯文本 tokenizer。

核心工具函数变更 异常捕获范围较宽

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论