Prhub

#48922 [Bugfix] Guard tool call argument JSON parsing in chat message postprocessing

原始 PR 作者 VBS2004 合并时间 2026-08-27 04:26 文件变更 2 提交数 4 评论 6 代码增减 +154 / -3

执行摘要

修复 tool call 参数 JSON 解析失败导致会话不可恢复

issue #47761 报告 Qwen3.6-35B-A3B-FP8 在生成代码时返回 400 'Unterminated string starting at'。PR body 指出:两个独立触发点(Generation 撞 max_tokens 中途截断、Responses 路径 EOS 时字符串未闭合)汇聚到同一个无保护的 json.loads;坏值位于对话历史中,客户端每轮重放同一条 assistant 消息都会复现同一错误,会话只能丢弃。PR 作者排除了 tool parser、客户端转义、chat template 配置和版本回归等假设,并验证 payload 与 SSE delta 重建结果字节一致(md5 相同),因此修复必须落在 _postprocess_messages 这个唯一汇聚点。

值得精读。核心看两处:一是在历史消息重放场景下 coercion 与 400 的权衡论证(PR body 的三点理由与 bbrowning 的最终裁决);二是以 gemma4 jinja 为例说明 chat template 对 mapping 的硬约束如何反过来决定入口层的容错策略。测试对边界的系统覆盖(畸形 / 非对象 / 缺失 / 多条调用独立收敛)也值得参考。

讨论亮点

bbrowning 先给出 CHANGES_REQUESTED:不能把非 dict JSON 透传给 chat template,模板普遍期望 JSON dict,并以 examples/tool_chat_template_gemma4.jinja 显式 raise 为例。VBS2004 确认该模板的 raise_exception 后删除透传分支,只允许 dict 到达模板。随后 bbrowning 在 APPROVED 中明确:考虑到多轮场景,coercion 优于 400——若 vLLM 自己产出的坏参数在下一轮导致 400,这是不可恢复的问题;coercion 至少让模型有机会反应并纠正。

实现拆解

  1. 变更入口:vllm/entrypoints/chat_utils.py 的 _postprocess_messages,它是 Chat Completions 与 Responses 两条路由对 assistant 消息后处理的汇聚点;本 PR 只改它内部 tool_calls 循环中对 function["arguments"] 的处理分支(+37 / -3)。
  2. 核心逻辑重构:把原来的单分支 if not isinstance(content, (dict, list)): parsed = json.loads(content) 拆成类型分派——dict 直接透传;str 用 json.loads 解析,捕获 json.JSONDecodeError 后记录 warning(含工具名与 payload 长度,不记录 payload 内容)并将 parsed 置为 None;其他类型(如 list)原样保留到统一校验。随后统一校验:parsed 非 dict 时,若非 None 再发一条告警说明解码结果类型,最终都置为 {};null 解析为 None,按既有语义直接变 {} 且不告警。缺失、空字符串、"null" 依旧走 function["arguments"] = {} 的旧路径。
  3. 设计约束:所有值只允许 dict 进入 chat template。PR body 和 commit 历史显示早期版本曾把原始字符串透传,review 中发现 examples/tool_chat_template_gemma4.jinja 会对字符串显式 raise_exception、toolace 与 pythonic 模板抛 TypeError、gemma3_pythonic 逐字符迭代,因此移除透传。
  4. 测试配套:tests/entrypoints/unit_tests/test_chat_utils.py 新增 117 行,辅助函数 _assistant_tool_call / _assistant_tool_calls 构造单条/多条 tool call 消息;7 组测试覆盖 dict 直通、合法对象字符串、小型畸形 JSON(复刻 @iMMIQ 的 54 字节 payload)、大型畸形 JSON(复刻 18k 字符 payload)、合法非对象 JSON(参数化 []/42/true/"hello"/null)、缺失或空参数、多 tool call 相互独立;用 caplog 断言警告内容。
  5. 协作与演进:4 个 commit 包含两次 main 合并;评审阶段从「透传原始字符串」改为「仅 dict 进入模板」;最终由 bbrowning 批准合并。
文件 模块 状态 重要度
vllm/entrypoints/chat_utils.py 请求入口 modified 6.82
tests/entrypoints/unit_tests/test_chat_utils.py 单元测试 modified 6.98

关键符号

_postprocess_messages _assistant_tool_call _assistant_tool_calls test_tool_call_arguments_dict_passthrough test_tool_call_arguments_valid_object_string test_tool_call_arguments_malformed_json_small test_tool_call_arguments_malformed_json_large test_tool_call_arguments_valid_json_non_object test_tool_call_arguments_missing_or_empty

关键源码片段

vllm/entrypoints/chat_utils.py core-logic

核心修复文件:`_postprocess_messages` 对 tool_calls[].function.arguments 增加防御式 JSON 解析,畸形或非对象的 JSON 收敛为 {} 并告警,仅 dict 进入 chat template。该函数是所有 Chat Completions / Responses 请求的后处理汇聚点。

# vllm/entrypoints/chat_utils.py
# _postprocess_messages 中处理 assistant tool_calls 的核心循环体。
# 背景:#47761 中 vLLM 生成被截断的 arguments 字符串后,客户端在后续
# 每一轮都会重放这条历史消息;若这里直接抛错,会话将永远不可恢复,
# 因此本分支的目标是:只有 dict 允许进入 chat template,其余一律收敛。
​
for item in tool_calls:
    if not isinstance(item, dict):
        raise VLLMValidationError(
            "assistant tool_calls entries must be objects.",
            parameter="tool_calls",
        )
​
    function = item.get("function")
    if item.get("type", "function") != "function" or not isinstance(
        function, dict
    ):
        raise VLLMValidationError(
            "chat completions only support assistant tool_calls "
            "of type 'function'.",
            parameter="tool_calls",
        )
​
    # arguments 缺失、空字符串或 None 时保持原有行为:置为 {}
    if content := function.get("arguments"):
        if isinstance(content, dict):
            # dict 直接透传,无需 JSON 解析
            parsed = content
        else:
            if isinstance(content, str):
                try:
                    parsed = json.loads(content)
                except json.JSONDecodeError:
                    # 畸形 JSON 不能抛错:该值在对话历史里,抛错会让
                    # 后续每一轮都失败。只记录工具名与 payload 长度,
                    # 不记录 payload 本身(可能包含用户源码)。
                    logger.warning(
                        "Tool call %r has arguments that are not valid "
                        "JSON (%d chars); coercing to an empty object "
                        "so the conversation can continue.",
                        function.get("name"),
                        len(content),
                    )
                    parsed = None
            else:
                # 非 str 非 dict(如 list),交给下方统一判断
                parsed = content
​
            if not isinstance(parsed, dict):
                if parsed is not None:
                    # 合法 JSON 但不是对象(如 "[]"、"42"、"true"),
                    # chat template 要求 mapping,同样收敛为 {}。
                    logger.warning(
                        "Tool call %r arguments decoded to %s, not a "
                        "JSON object; coercing to an empty object.",
                        function.get("name"),
                        type(parsed).__name__,
                    )
                parsed = {}
​
        function["arguments"] = parsed
    else:
        function["arguments"] = {}
tests/entrypoints/unit_tests/test_chat_utils.py test-coverage

新增 117 行测试,构造单条 / 多条 tool call 消息,覆盖 dict 直通、合法对象字符串、小 / 大畸形 JSON、合法非对象 JSON、缺失或空参数、多 tool call 独立收敛,用 caplog 断言告警内容。

# tests/entrypoints/unit_tests/test_chat_utils.py
# 辅助构造函数:生成一条带单个 function tool_call 的 assistant 消息,
# 让测试用例聚焦于 arguments 的解析行为。
​
def _assistant_tool_call(arguments, name="write"):
    return [
        {
            "role": "assistant",
            "content": "",
            "tool_calls": [
                {
                    "id": "call_0",
                    "type": "function",
                    "function": {"name": name, "arguments": arguments},
                }
            ],
        }
    ]
​
​
def test_tool_call_arguments_malformed_json_small(caplog):
    # 对应 #47761 中 EOS 截断的 54 字节 payload(未闭合引号),
    # 期望收敛为 {} 且只产生一条包含工具名与收敛提示的警告。
    bad = '{"cmd": "mkdir -p /tmp/mmadtest && cat > /tmp/mmadtest'
    messages = _assistant_tool_call(bad, name="exec")
    _postprocess_messages(messages)
    args = messages[0]["tool_calls"][0]["function"]["arguments"]
    assert args == {}
​
    assert len(caplog.records) == 1
    assert "exec" in caplog.records[0].message
    assert "coercing to an empty object" in caplog.records[0].message
​
​
@pytest.mark.parametrize("non_obj", ["[]", "42", "true", '"hello"', "null"])
def test_tool_call_arguments_valid_json_non_object(caplog, non_obj):
    # 合法 JSON 但不是对象:除 null(解析为 None)外都应告警并收敛
    messages = _assistant_tool_call(non_obj, name="bad_tool")
    _postprocess_messages(messages)
    args = messages[0]["tool_calls"][0]["function"]["arguments"]
    assert args == {}
​
    if non_obj == "null":
        # null 解析为 None,按需求不产生警告
        assert not caplog.records
    else:
        assert len(caplog.records) == 1
        assert "bad_tool" in caplog.records[0].message
        assert "not a JSON object" in caplog.records[0].message

评论区精华

非 dict JSON 值能否透传给 chat template 设计

bbrowning 要求变更:不能把非 dict JSON 值传给 chat template,模板普遍期望 dict,gemma4 jinja 会显式 raise_exception,部分模板会逐字符迭代导致奇怪行为。

结论:VBS2004 确认 gemma4 模板证据后删除透传分支,只允许 dict 到达模板;非 dict 一律告警并收敛为 {}。 · 已解决

畸形 JSON 时 coercion 与 400 的选择 设计

与 #50861 的分歧:畸形 JSON 时 #50861 抛 VLLMValidationError 返回 400,本 PR 收敛为 {} 并告警。VBS2004 给出三点理由(400 不解决历史消息重复失败;coercion 是既有模式 #43862/#47555/#50861;数据源自 vLLM 且已报告完成,客户端不应重新校验服务器已声明完成的 JSON)。

结论:bbrowning 最终同意 coercion:vLLM 自己产出的坏参数若导致下一轮 400 是不可恢复问题;coercion 至少给模型机会反应并纠正。 · 已解决

风险与影响

  1. 行为变更风险:畸形/非对象 arguments 从 400 变为静默 {},工具调用参数被丢弃,依赖 400 语义的客户端(如超时重试、熔断、前端错误提示)行为会变化,需在 release note 中说明。
  2. 数据质量风险:告警日志是唯一信号,但并非所有部署都会采集 warning;上游根因(finish_reason 应报 "length"、Responses Simple 路径发送前校验)未在本 PR 修复,坏参数仍可能继续产生,本修复只是兜底。
  3. 一致性风险:若 #50861 按 400 方案合并,两条路径对同一个畸形输入会给出不同行为(400 vs {}),可能造成调试困惑。
  4. 性能影响:正常路径仅多几次 isinstance 判断;异常路径的 json.loads 失败在超长字符串上代价可忽略。
  5. 兼容性:dict 直通与合法对象字符串解析为 dict 的行为不变,{"a": 1} 与 '{"a": 1}' 结果一致,原有 "null" → {} 语义保留。

所有使用 Chat Completions / Responses 且重放 assistant tool_calls 的多轮会话(工具调用、代码生成等场景)都会经过 _postprocess_messages 此分支,影响面广但分支逻辑简单。对受影响的用户,会话从「必须丢弃」变为「可继续并让模型纠错」;对团队,确立了「不可用 arguments 一律收敛 {}」的入口容错策略,为同函数系列 PR(#43862 / #47555 / #50861)提供统一的参照基线。

核心请求路径变更 静默数据收敛 行为与 #50861 潜在冲突 上游根因未修复

关联 Issue

#47761 [Bug]: vllm 0.23.0 and 0.24.0 - Qwen3.6-35B-A3B-FP8 - Fails generating code- "400 Unterminated string starting at"

完整报告

参与讨论