Prhub

#53824 [Bugfix] Detect OpenAI content format when message.content is passed through macro parameters

原始 PR 作者 wuhangxian 合并时间 2026-09-10 04:10 文件变更 2 提交数 7 评论 32 代码增减 +118 / -3

执行摘要

修复 MiMo-V2.5 等模板通过宏参数传递 content 时,内容格式检测错误为 string 的问题。

修复 Issue #53820 中报告的 MiMo-V2.5 聊天模板被错误检测为 'string' 格式的问题。模板通过 {% macro render_content(message_content) %} ... {% for content in message_content %} 的形式处理内容,但旧的检测逻辑只识别直接遍历 message.content 或变量名为 'content' 的循环,导致多模态内容顺序(如 text A -> image -> text B)被重排为 image -> text A -> text B,用户必须手动指定 --chat-template-content-format openai 来绕过此问题。

本 PR 是一个高质量的 bugfix,值得精读。核心修复逻辑(AST 分析追踪宏参数)设计巧妙,保持了良好的向后兼容性。建议关注以下几点:

1) _iter_nodes_assign_content_item 函数中新增的宏参数绑定分析逻辑;
2) 如何通过 id(loop_ast) 建立宏参数与循环的映射关系;
3) 测试用例的设计,特别是如何使用参数化测试覆盖多种模板模式。对于维护聊天模板格式检测逻辑的工程师,此 PR 提供了处理宏传递内容的解决方案模式。

讨论亮点

核心讨论围绕测试策略展开。维护者 @DarkLight1337 建议将针对 MiMo-V2.5 的测试直接集成到已有的 test_resolve_content_format_hf_defined 参数化测试中,而非新增独立的测试类,认为这样更简洁且能端到端验证。作者在后续提交中采纳了此建议,移除了独立的 TestDetectContentFormatMacros 类。

此外,在最终审查中,维护者 @DarkLight1337 指出 facebook/chameleon-7b 模型已被移除,要求从测试参数化列表中删除。CodeRabbit AI 机器人也提出了多个代码质量建议,包括缓存键应包含修订版本、移除不支持的 tokenizer 参数等,但这些建议与本 PR 的核心修复无关,且部分可能基于对变更前代码的误解。

实现拆解

  1. 扩展 AST 分析以追踪宏参数:在 vllm/renderers/hf.py 的 _iter_nodes_assign_content_item 函数中,新增逻辑遍历所有宏定义 (jinja2.nodes.Macro) 和宏调用 (jinja2.nodes.Call),建立宏参数与 message.content 的绑定映射 (macro_content_params_by_loop)。
  2. 在循环检测中增加宏参数匹配:修改主循环逻辑,除了原有的直接属性访问和字面量 'content' 变量匹配外,新增对已记录的宏参数的匹配检查,从而正确识别通过宏传递的内容循环。
  3. 添加全面的单元测试:在 tests/renderers/test_hf.py 中新增 test_detect_content_format 测试函数,使用参数化测试覆盖了宏参数(位置参数和关键字参数)、直接循环、纯字符串等五种场景,并新增 XiaomiMiMo/MiMo-V2.5-Pro 到端到端模型测试中。
  4. 保持向后兼容:所有现有检测路径均保持不变,变更纯粹是新增检测能力,不影响已有模板的格式识别结果。
文件 模块 状态 重要度
vllm/renderers/hf.py 渲染器 modified 6.78
tests/renderers/test_hf.py 渲染器 modified 5.88

关键符号

_iter_nodes_assign_content_item

关键源码片段

vllm/renderers/hf.py core-logic

核心修复文件,包含内容格式检测逻辑的扩展,新增宏参数追踪能力。

def _iter_nodes_assign_content_item(root: jinja2.nodes.Node):
    """Yield loops that iterate over message content or macro-bound content."""
    message_varnames = [
        varname for _, varname in _iter_nodes_assign_messages_item(root)
    ]
​
    # Track macro parameters that receive message.content as an argument.
    # Some templates pass message.content through a macro parameter whose
    # name is not literally "content".
    macro_content_params_by_loop: dict[int, set[str]] = {}
    loops_in_macros: set[int] = set()
    for macro_node in root.find_all(jinja2.nodes.Macro):
        macro_param_names = {arg.name for arg in macro_node.args}
        macro_content_params: set[str] = set()
        for call_node in root.find_all(jinja2.nodes.Call):
            if (
                isinstance(call_node.node, jinja2.nodes.Name)
                and call_node.node.name == macro_node.name
            ):
                # Check positional arguments
                for i, arg in enumerate(call_node.args):
                    if i < len(macro_node.args) and any(
                        _is_var_or_elems_access(arg, varname, "content")
                        for varname in message_varnames
                    ):
                        macro_content_params.add(macro_node.args[i].name)
                # Check keyword arguments
                for kwarg in call_node.kwargs:
                    if (
                        isinstance(kwarg, jinja2.nodes.Keyword)
                        and kwarg.key in macro_param_names
                        and any(
                            _is_var_or_elems_access(kwarg.value, varname, "content")
                            for varname in message_varnames
                        )
                    ):
                        macro_content_params.add(kwarg.key)
​
        # Record loops inside this macro and their associated content parameters
        for loop_ast in macro_node.find_all(jinja2.nodes.For):
            loops_in_macros.add(id(loop_ast))
            if macro_content_params:
                macro_content_params_by_loop[id(loop_ast)] = macro_content_params
​
    # Search for {%- for content in message['content'] -%} loops
    # or {%- for item in content -%} loops
    for loop_ast in root.find_all(jinja2.nodes.For):
        loop_iter = loop_ast.iter
        loop_target = loop_ast.target
​
        # Original detection: direct access to message['content']
        for varname in message_varnames:
            if _is_var_or_elems_access(loop_iter, varname, "content"):
                assert isinstance(loop_target, jinja2.nodes.Name)
                yield loop_ast, loop_target.name
                break
        else:
            # New detection: loop over a macro parameter bound to message.content
            macro_content_params_for_loop = macro_content_params_by_loop.get(
                id(loop_ast)
            )
            if (
                isinstance(loop_iter, jinja2.nodes.Name)
                and macro_content_params_for_loop is not None
                and loop_iter.name in macro_content_params_for_loop
            ):
                assert isinstance(loop_target, jinja2.nodes.Name)
                yield loop_ast, loop_target.name
                continue
​
            # Fallback detection: loop over a variable literally named "content"
            # but only if it's not inside a macro (to avoid false positives)
            if (
                id(loop_ast) not in loops_in_macros
                and isinstance(loop_iter, jinja2.nodes.Name)
                and loop_iter.name == "content"
            ):
                assert isinstance(loop_target, jinja2.nodes.Name)
                yield loop_ast, loop_target.name

评论区精华

测试策略:独立测试类 vs 集成到现有参数化测试 设计

维护者 @DarkLight1337 建议将 MiMo-V2.5 的测试直接添加到已有的 `test_resolve_content_format_hf_defined` 参数化测试中,而非新增独立的 `TestDetectContentFormatMacros` 测试类,认为这样更简洁且能端到端验证。

结论:采纳建议,移除了独立测试类,将 `XiaomiMiMo/MiMo-V2.5-Pro` 添加到现有参数化测试列表中。 · 已解决

模型移除:`facebook/chameleon-7b` 已不在仓库中 正确性

维护者 @DarkLight1337 指出 `facebook/chameleon-7b` 模型已被移除,要求从测试参数化列表中删除。

结论:已移除该模型测试用例。 · 已解决

风险与影响

风险较低。变更集中在 Jinja AST 静态分析逻辑,不涉及运行时模板执行。

  1. 正确性风险:宏参数绑定分析依赖于精确的 AST 模式匹配,如果模板使用更复杂的元编程(如动态变量名),可能无法检测。但当前实现已覆盖常见场景(位置参数和关键字参数)。
  2. 回归风险:所有现有检测路径保持不变,新逻辑在原有 else 分支中添加,不影响已有模板的识别。测试覆盖了主要路径和边界情况。
  3. 性能影响:新增了宏遍历和参数绑定分析,但仅在模板首次解析时执行一次(结果被 @lru_cache 缓存),对实际运行无影响。
  1. 用户体验:直接影响使用 MiMo-V2.5 等模板的多模态用户,修复后多模态内容顺序将正确保持,无需手动指定内容格式。
  2. 系统兼容性:增强了 vLLM 对多样化聊天模板的兼容性,为其他使用类似宏传递模式的模板提供了正确的格式检测。
  3. 开发团队:为模板格式检测逻辑提供了更健壮的实现模式,可作为未来类似问题的参考。测试覆盖的增加提升了该模块的可靠性。
模板兼容性边界 AST 静态分析局限性

关联 Issue

#53820 [Bug]: MiMo-V2.5 chat template is auto-detected as string, reordering multimodal content

完整报告

参与讨论