Prhub

#43243 fix: parse Qwen3 XML JSON arguments first

原始 PR 作者 he-yufeng 合并时间 2026-05-28 11:35 文件变更 2 提交数 2 评论 7 代码增减 +66 / -2

执行摘要

修复 Qwen3 XML 参数解析中 JSON 布尔 /null 失败

Issue #43238 报告 qwen3xml_tool_parser 在解析包含 JSON 布尔值(false)和 null 的数组参数时,ast.literal_eval 转换失败并退化为字符串输出,导致下游工具调用无法得到正确的原生数组。PR 的解决方案是在 deferred 参数分支中优先使用 json.loads,仅在 JSON 解析失败时回退到 ast.literal_eval。

值得精读该 PR 的处理方式:它展示了一种在不破坏向后兼容的前提下修复非标准输入解析问题的实用技巧——优先使用更严格/标准的解析器,再 fallback 到宽松的解析器。对于其他 tool parser 的类似问题(如 DeepSeek 或 Mistral 解析器)可参考此模式。

讨论亮点

gemini-code-assist[bot] 指出当前代码新增的 .strip() 是冗余的,因为 json.loads 原生处理前后空白。作者接受了该建议,并在后续提交中移除了 .strip() 调用。此外,sfeng33 在 review 中简单批准了该修复。

实现拆解

  1. 修改核心解析逻辑vllm/tool_parsers/qwen3xml_tool_parser.py):在 StreamingXMLToolCallParser._end_element 方法的 deferred 参数解析分支中,将原单次 ast.literal_eval(raw_for_parse) 替换为双重尝试:先执行 json.loads(raw_for_parse),若抛出 json.JSONDecodeError 则再执行 ast.literal_eval(raw_for_parse)。这一调整确保 JSON 格式的字面量(如 falsenull)能被准确解析,同时保留了对 Python 字面量格式的向后兼容。外层 except Exception 仍然作为最终 fallback 输出原始字符串。
  2. 新增测试夹具与回归测试tests/tool_parsers/test_qwen3coder_tool_parser.py):
    • 新增 QUESTION_PARAMS 夹具,定义包含布尔字段 multiSelect 和可能为 nullanswer 字段的数组参数结构。
    • 新增导入 StreamingXMLToolCallParser
    • 新增测试函数 test_qwen3xml_deferred_array_parses_json_literals,构造一个包含 falsenull 的数组参数 XML 输入,直接调用 parser.parse_single_streaming_chunks 并断言解析后的 DeltaMessage.tool_calls[0].function.arguments 正确反序列化为原生 Python 对象(FalseNone)。
  3. 测试修复:首次 CI 失败后,作者将测试断言从内部 buffer 改为 parse_single_streaming_chunks 返回的 DeltaMessage 对象,避免因内部状态误解导致错误。
文件 模块 状态 重要度
vllm/tool_parsers/qwen3xml_tool_parser.py 工具解析器 modified 5.87
tests/tool_parsers/test_qwen3coder_tool_parser.py 测试 modified 6.08

关键符号

_end_element

关键源码片段

vllm/tool_parsers/qwen3xml_tool_parser.py core-logic

核心修复:在 deferred 参数解析中优先使用 json.loads,仅在其失败时回退至 ast.literal_eval。

# vllm/tool_parsers/qwen3xml_tool_parser.py 第 817-832 行
try:
    # 若之前延迟了尾部换行,先补回
    if self.should_emit_end_newline:
        raw_for_parse = raw_text + "\n"
    else:
        raw_for_parse = raw_text
    try:
        # 优先使用 JSON 解析,以正确处理 false、null 等字面量
        parsed_value = json.loads(raw_for_parse)
    except json.JSONDecodeError:
        # 若 JSON 失败,回退至 Python 字面量解析(兼容旧模型输出)
        parsed_value = ast.literal_eval(raw_for_parse)
    output_arguments = json.dumps(parsed_value, ensure_ascii=False)
except Exception:
    # 最终 fallback:将原始文本作为字符串输出
    output_arguments = json.dumps(raw_text, ensure_ascii=False)
    parsed_value = raw_text
tests/tool_parsers/test_qwen3coder_tool_parser.py test-coverage

新增回归测试,覆盖数组参数包含 JSON false/null 的场景,并验证公开 API 返回值的正确性。

# tests/tool_parsers/test_qwen3coder_tool_parser.py 新增部分
QUESTION_PARAMS = {
    "type": "object",
    "properties": {
        "questions": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "question": {"type": "string"},
                    "multiSelect": {"type": "boolean"},
                    "answer": {"type": "string"}, # 可为 null
                },
            },
        },
    },
}def test_qwen3xml_deferred_array_parses_json_literals():
    parser = StreamingXMLToolCallParser()
    parser.set_tools(
        [
            ChatCompletionToolsParam(
                type="function",
                function={"name": "AskUserQuestion", "parameters": QUESTION_PARAMS},
            )
        ]
    )
​
    # 模拟包含 JSON false 和 null 的模型输出
    delta = parser.parse_single_streaming_chunks(
        """<tool_call>
<function=AskUserQuestion>
<parameter=questions>
[{"question": "Pick a color", "multiSelect": false, "answer": null}]
</parameter>
</function>
</tool_call>"""
    )
​
    # 提取 arguments 字符串并验证 JSON 反序列化结果
    arguments = "".join(
        tool_call.function.arguments or ""
        for tool_call in delta.tool_calls or []
        if tool_call.function and tool_call.function.arguments is not None
    )
    assert json.loads(arguments) == {
        "questions": [
            {"question": "Pick a color", "multiSelect": False, "answer": None}
        ]
    }

评论区精华

冗余 .strip() 调用 style

gemini-code-assist[bot] 指出新增的 `.strip()` 是冗余的,因为 json.loads 原生处理前后空白。

结论:作者采纳建议,在后续提交中移除了 .strip()。 · 已解决

风险与影响

风险极低:变更仅影响 deferred 参数解析分支,且为双重尝试(先 JSON 再 Python 字面量),外层 Exception 兜底保留。若模型输出来本是合法的 Python 字面量且被意外当作 JSON 解析成功,理论上可能改变语义(如 true 被解析为 True 而非字符串 "true"),但考虑到工具调用场景中参数值本就是结构化 JSON,此类冲突几乎不会发生。新增的测试覆盖了关键路径,进一步降低回归风险。

直接修复了 Qwen3 XML 工具调用解析器在处理包含 JSON 布尔值或 null 的参数时的功能性 bug,影响所有使用 --tool-call-parser qwen3-xml 标志且工具参数包含 booleannull 类型字段的用户。变更范围小(仅一个源文件 + 一个测试文件),对系统性能无显著影响。

最小变更

关联 Issue

#43238 [Bug]: qwen3xml_tool_parser: ast.literal_eval fails on JSON booleans/null, causing complex array arguments to be string-encoded instead of native arrays

完整报告

参与讨论