执行摘要
- 一句话:修复 VLM 处理器回合分隔符探测崩溃
- 推荐动作:建议合并。该 PR 精准修复了由上游 PR#6921 引入的回归问题,设计巧妙(通过循环尝试两种格式),测试充分(包含模拟处理器桩和回归验证),且作者已对真实模型进行了额外验证。值得关注的设计决策:使用
for...break 形式而非 if-else 能保持两种探针形式对称,确保推导逻辑一致。
功能与动机
PR#6921 合并后 3 个 CI 任务(e2e_ppo_trainer_veomni_vllm、e2e_grpo_trainer_megatron-vlm、sgl)因 initialize_turn_separator 中 TypeError: string indices must be integers 而失败。根本原因是多模态处理器期望 content 为 [{"type": "text", "text": "..."}] 形式,但代码传递了纯字符串 "x"。
实现拆解
- 修改
initialize_turn_separator(verl/utils/tokenizer/chat_template.py):将原来的直接字符串探针改为 for as_parts in (False, True) 循环。先尝试字符串格式 content="x",若抛出异常则改用列表格式 [{"type": "text", "text": "x"}]。两种探针必须使用相同形式以确保仅 body 内容不同。若两种形式均失败,返回 [] 而非传播异常。
- 添加
MultimodalChatMLProcessor 测试桩(tests/utils/test_turn_separator_on_cpu.py):继承 ChatMLTokenizer,重写 apply_chat_template 以在 content 为字符串时抛出 TypeError,模拟真实多模态处理器的行为。
- 添加回归测试
test_separator_derived_when_processor_requires_list_content:验证当字符串探针失败时,列表格式探针仍能正确推导分隔符。
- 本地验证:在 Qwen3-8B、Qwen3-VL-2B-Instruct、Qwen2.5-VL-3B-Instruct 上测试,均返回
\n 分隔符(id 198)且无崩溃。
关键文件:
verl/utils/tokenizer/chat_template.py(模块 工具层;类别 source;类型 core-logic;符号 initialize_turn_separator): 核心修复文件,修改 initialize_turn_separator 以支持多模态处理器的列表格式 content。
tests/utils/test_turn_separator_on_cpu.py(模块 单元测试;类别 test;类型 test-coverage;符号 MultimodalChatMLProcessor, test_separator_derived_when_processor_requires_list_content): 添加回归测试,包含模拟多模态处理器的桩类和新测试函数。
关键符号:initialize_turn_separator, MultimodalChatMLProcessor.apply_chat_template, test_separator_derived_when_processor_requires_list_content
关键源码片段
verl/utils/tokenizer/chat_template.py
核心修复文件,修改 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
添加回归测试,包含模拟多模态处理器的桩类和新测试函数。
# 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]
评论区精华
Review 中 gemini-code-assist[bot] 指出使用空字符串作为基础探针可能导致错误分离符推导,建议改为非空单字符 "y" vs "x"。作者 abtonmoy 回应:已在真实 tokenizer/processor 上验证空与非空基础探针产生相同分隔符,且尾部的 eos 分割已丢弃可能的多余前缀,因此保持原方案不变。该讨论未引发代码变更,作者给出了充分理由。
- 使用空字符串作为基础探针的风险 (correctness): 作者 abtonmoy 回应:已在真实 tokenizer/processor 上验证,空与非空基础产生相同分隔符;且后续的 eos 分割逻辑会丢弃任何过度匹配的前缀 token,因此保持原方案不变。
风险与影响
- 风险:风险较低。核心修改是添加 try-except fallback,若字符串探针失败则静默回退至列表格式;若两种均失败则返回空列表。这确保了旧行为(非多模态处理器)不受影响,同时修复多模态路径的崩溃。但需注意:
except Exception 可能捕获非预期的异常(如网络错误),但此函数无 I/O,仅调用 apply_chat_template,风险可控。返回 [] 时调用方需自行处理缺失分隔符的情况。
- 影响:直接影响:修复 VLM(视觉语言模型)AgentLoop 初始化阶段的崩溃,影响使用多模态处理器的全线 CI 和用户。间接影响:无,因为 fallback 仅影响先前崩溃的路径。不影响纯文本 tokenizer。
- 风险标记:核心工具函数变更, 异常捕获范围较宽
关联脉络
- PR #6921 [rollout] fix: restore turn separator dropped at multi-turn tool agent loop boundaries: 本 PR 修复了 PR#6921 引入的回归问题(多模态处理器中字符串 content 崩溃)。PR#6921 新增了
initialize_turn_separator,但未考虑多模态处理器的 content 格式差异。
参与讨论