摘要

在跨模型 coding agent 里,工具调用失败不一定是模型“不懂工具”,也不一定是工具实现本身坏了。Qwen3-Coder 一类模型在某些 chat template、推理服务或本地 OpenAI-compatible server 下,可能生成这样的 XML 式工具调用文本:

<tool_call>
<function=read_file>
<path>src/index.ts</path>
</function>
</tool_call>

但 harness 侧通常不是读取 assistant 消息里的普通文本来决定是否执行工具,而是读取协议层的结构化字段:OpenAI 风格的 tool_calls,或 Anthropic 风格的 tool_use content block。于是会出现一个很迷惑的现象:日志里明明看见模型“写出了工具调用”,但 tool_calls_count=0,agent 没有执行任何工具。

根因是格式层错位。模型输出的是内容文本,harness 期待的是 API 响应里的结构化工具调用对象。解决问题不能靠提示词里反复强调“请调用工具”,而要在兼容层增加一个严谨的 XML tool call parser:只在 assistant 输出边界内识别 Qwen XML 片段,解析函数名和参数,做 JSON 归一化,映射到统一的内部 ToolCallIR 结构,再进入现有工具执行状态机。同时,它必须处理流式输出被 chunk 切断、半截 XML、混合自然语言、非法工具名、参数注入和 schema 校验等边界。

格式差异

主流 tool use 协议看似都叫 function calling,但它们暴露给客户端的形态不同。

维度 OpenAI JSON tool_calls Anthropic tool_use blocks Qwen XML-style tool call
载体 assistant message 的结构化字段 assistant content 数组中的 block assistant content 文本片段
调用名位置 tool_calls[].function.name content[].name <function=NAME>
参数位置 tool_calls[].function.arguments,通常是 JSON 字符串 content[].input,通常是对象 <param>value</param> 或 XML 内嵌 JSON
调用 ID API / harness 生成或模型返回 tool_use.id 通常没有,需要兼容层生成
执行触发 SDK / harness 看到 tool_calls SDK / harness 看到 tool_use block 必须额外解析文本,否则只是普通回答
流式处理 delta 里有 tool call 增量 block start / delta / stop 标签可能跨 chunk,需要状态机重组

OpenAI-compatible harness 通常类似这样判断:

const toolCalls = response.choices[0]?.message?.tool_calls ?? [];
logger.info({ tool_calls_count: toolCalls.length });

for (const call of toolCalls) {
  await runTool(call.function.name, JSON.parse(call.function.arguments));
}

Anthropic harness 则会扫描 assistant content blocks:

for (const block of message.content) {
  if (block.type === "tool_use") {
    await runTool(block.name, block.input);
  }
}

这两段代码都不会把普通 content 字符串里的 <tool_call> 当成工具调用。对它们来说,下面这段响应只是 assistant 说出的一段文本:

{
  "role": "assistant",
  "content": "<tool_call>\n<function=read_file>\n<path>src/index.ts</path>\n</function>\n</tool_call>",
  "tool_calls": []
}

所以 tool_calls_count=0 并不和“文本里出现了 <tool_call>”矛盾。计数器统计的是结构化字段,不是自然语言内容。

根因:chat template、server parser 与 harness 状态机没有对齐

Qwen3-Coder 的问题经常出现在三层边界之间。

第一层是模型和 chat template。某些 Qwen 模板会把工具调用示例写成 XML-style 标签,例如外层 <tool_call>,内层 <function=...>,参数再用子标签表示。模型学到的是“把工具调用写成这段文本”。

第二层是推理服务。云厂商 API、Transformers、vLLM、llama.cpp、LM Studio、Ollama 或各种 OpenAI-compatible wrapper 对工具调用的支持并不完全一致。有的服务能把模型输出解析成 OpenAI tool_calls;有的服务只把原文放在 message.content;有的在非流式下勉强可用,流式下仍然只吐文本 delta。

第三层是 agent harness。Claude Code、Roo Code、Qwen Code、Cursor 类工具执行器通常只信任结构化 tool call,因为它们要维护调用 ID、工具结果配对、并发执行、错误恢复和安全校验。它们不会默认执行 assistant 文本中看起来像命令的内容。

于是失败路径是:

1、用户请求需要读文件、搜索或执行命令。
2、Qwen3-Coder 生成 <tool_call><function=...>...</function></tool_call>
3、推理服务没有把这段文本提升为 tool_calls
4、harness 只检查 message.tool_calls 或 Anthropic tool_use block。
5、结构化调用为空,记录 tool_calls_count=0
6、agent 把 XML 当作普通 assistant 文本显示,工具没有执行。

从工程角度看,这不是单点 bug,而是协议适配缺口。修复应放在模型适配层:当目标模型或后端可能输出 Qwen XML-style tool call 时,兼容层要在进入 harness 前完成再解析和归一化。

解析器设计

推荐把 XML 再解析做成一个独立的 adapter,而不是散落在 prompt、日志处理或工具执行器里。

以下是兼容层的安全实现建议,并非 Qwen 官方 XML tool call 规范。官方文档和社区 issue 能支撑“存在 XML-style tool call 与 harness 结构化解析不匹配”的问题;具体正则、拒绝策略、流式缓冲和审计字段应由兼容层按安全边界自行定义。

type ToolCallIR = {
  id: string;
  type: "function";
  function: {
    name: string;
    arguments: string; // canonical JSON string
  };
  parsedInput: Record<string, unknown>;
  source: {
    provider: "qwen-xml";
    raw: string;
    startOffset: number;
    endOffset: number;
  };
};

整体管线如下:

raw assistant content
  -> qwenXmlToolCallScanner
  -> xmlToolCallParser
  -> argumentNormalizer
  -> tool allow-list + JSON Schema validator
  -> OpenAI tool_calls or internal ToolCall[]
  -> existing harness executor

解析器只负责“从 assistant 文本中提取候选工具调用并归一化”。它不应该直接执行工具,也不应该绕过 harness 原有的权限、并发、审计和 tool_result 回填逻辑。

1. 识别外层边界

最小可接受格式是:

<tool_call>
  <function=TOOL_NAME>
    ...
  </function>
</tool_call>

扫描器应只在 assistant 输出中查找 <tool_call></tool_call> 的闭合片段。不要在 user 消息、tool result、系统提示或历史日志里扫描,否则模型可能通过引用旧文本诱导重复执行工具。

多工具调用可以顺序出现:

<tool_call>
<function=read_file>
<path>package.json</path>
</function>
</tool_call>
<tool_call>
<function=grep>
<pattern>tool_calls_count</pattern>
</function>
</tool_call>

解析结果应保持顺序,因为 agent 常常依赖工具调用顺序做回填和调试。

2. 解析函数名

函数名来自 <function=NAME>。建议使用严格白名单:

NAME := [A-Za-z_][A-Za-z0-9_.:-]{0,127}

解析后还要检查 NAME 是否存在于当前请求声明的工具列表中。没有声明的工具一律拒绝,而不是“按名字猜一个相近工具”。工具名大小写也不建议自动纠正,因为大小写纠正可能把模型错误输出变成真实执行。

3. 解析参数

Qwen XML-style 常见参数形式是子标签:

<function=run_command>
<cmd>npm test -- --runInBand</cmd>
<timeout_ms>120000</timeout_ms>
</function>

可以归一化为:

{
  "cmd": "npm test -- --runInBand",
  "timeout_ms": 120000
}

参数解析建议采用“保守强类型”:

输入 归一化
true / false boolean
null null
整数 / 浮点数 number,但要拒绝 NaNInfinity
{...} / [...] 尝试 JSON.parse
其他文本 string,保留内部换行

如果工具 schema 要求字符串,即使文本看起来像数字,也应在 schema 层按声明类型处理。更稳的做法是先得到原始候选值,再交给 JSON Schema validator 做类型约束和默认值处理。

内嵌 JSON 也要支持:

<tool_call>
<function=search>
{"query":"Qwen tool_calls_count=0","limit":5}
</function>
</tool_call>

如果 <function=...> 内部去掉空白后整体是 JSON object,就直接作为参数对象;否则按子标签解析。两种格式不要混用。混用时应拒绝,避免出现“JSON 里一个 query,XML 子标签里另一个 query”的歧义。

4. 输出 OpenAI 或 Anthropic 兼容结构

如果上游 harness 是 OpenAI 风格,可以生成:

{
  "id": "call_qwenxml_01HX...",
  "type": "function",
  "function": {
    "name": "read_file",
    "arguments": "{\"path\":\"src/index.ts\"}"
  }
}

如果 harness 是 Anthropic 风格,可以生成:

{
  "type": "tool_use",
  "id": "toolu_qwenxml_01HX...",
  "name": "read_file",
  "input": {
    "path": "src/index.ts"
  }
}

ID 必须由兼容层生成并保持唯一。不要把函数名或参数 hash 直接当 ID,因为同一轮里可能重复调用同一个工具。一个实用策略是:

call_qwenxml_${messageIndex}_${toolIndex}_${shortRandom}

这样既方便追踪来源,也不会因为参数相同而冲突。

容错规则

XML 再解析不能过度宽松。它的目标是弥合格式差异,不是把任意文本都解释成工具调用。

推荐规则如下。

情况 建议处理
<tool_call> 未闭合 不执行,保留为普通文本或返回解析错误
<function=...> 未闭合 不执行
一个 <tool_call> 内多个 <function=...> 拒绝;一个 tool call 只能有一个函数
函数名不在白名单 拒绝
参数标签未闭合 拒绝
重复参数名 默认拒绝;如果业务确需数组,用 schema 显式声明数组
XML 子标签与 JSON object 混用 拒绝
参数超过大小限制 拒绝或截断后拒绝执行
自然语言 + 一个完整 tool call 可解析 tool call,自然语言作为 assistant preamble 保留
code fence 内出现 tool_call 默认不执行,除非整个 assistant 输出只是一段被错误包裹的 tool call

最后一条很重要。模型可能解释格式时写出:

你可以这样调用:

```xml
<tool_call>
<function=read_file>
<path>package.json</path>
</function>
</tool_call>
```

这不应该触发真实工具执行。一个安全的判断是:如果 <tool_call> 出现在解释性文本、列表、引用或 fenced code block 里,默认当作普通文本。只有在当前轮 assistant 的意图明确是工具调用,且 XML 片段不在代码围栏里,才进入解析。

流式输出边界处理

流式是最容易出错的地方。Qwen XML 标签可能被拆成任意 chunk:

chunk 1: "<tool_"
chunk 2: "call>\n<function=re"
chunk 3: "ad_file>\n<path>src/"
chunk 4: "index.ts</path>\n</function>\n</tool_call>"

如果每个 chunk 单独正则匹配,一定会漏掉。正确做法是增量状态机。

TEXT
  看到完整 <tool_call> -> IN_TOOL_CALL
IN_TOOL_CALL
  看到完整 <function=NAME> -> IN_FUNCTION
IN_FUNCTION
  收集参数文本,直到 </function>
AFTER_FUNCTION
  等待 </tool_call>
COMPLETE
  解析、校验、发出 normalized tool call
ERROR
  放弃执行,按普通文本或错误事件处理

实现上有几个关键点。

第一,保留尾部缓冲。即使当前状态是 TEXT,也不要立刻把 chunk 全部下发给 UI。至少要保留 "<tool_call>".length - 1 个字符,防止 <tool_call> 被拆开后已经把前半段当普通文本发出。

第二,工具调用未闭合前不要执行。流式过程中看到 <function=run_command> 不能立即执行,因为参数可能还没完整输出,后续 chunk 也可能把它变成非法 XML。

第三,完成一个 tool call 后再释放。只有看到 </tool_call> 且 schema 校验通过,才生成结构化调用。若流结束时仍处于 IN_TOOL_CALLIN_FUNCTION,该片段必须视为不完整,不能执行。

第四,UI 文本和工具调用要分流。工具 XML 不应原样显示给用户然后又执行一次;更好的方式是把 tool call span 从 assistant text 中移除,作为结构化工具事件进入 harness。如果解析失败,则可以把原文作为普通 assistant 文本显示,并记录诊断日志。

第五,支持一轮多个 tool call。状态机完成一个 </tool_call> 后回到 TEXT,继续扫描后续内容。每个 tool call 都生成独立 ID。

安全边界

XML 再解析器处在高风险位置:它把“文本”提升成“可执行工具调用”。所以安全策略应比普通格式转换更严格。

不使用通用 XML 解析器的危险功能

这类格式只是 XML-like,不是完整 XML 文档。不要启用 DTD、外部实体、XInclude、网络加载或实体扩展。最稳的是写一个专用 tokenizer,只识别有限标签:

<tool_call>
</tool_call>
<function=NAME>
</function>
<PARAM>
</PARAM>

参数值按文本处理,不做实体解析或路径展开。这样可以避开 XXE、实体膨胀和奇怪命名空间带来的攻击面。

只解析当前 assistant 消息

不要扫描整段 conversation,也不要扫描工具返回内容。否则一个恶意网页、README、测试日志或用户输入只要包含 <tool_call>,就可能被误提升为工具执行。

正确边界是:

model adapter receives assistant delta/content
  -> parse only this assistant output
  -> produce tool calls for this turn

工具白名单和 schema 是执行前硬门槛

解析成功不等于可以执行。执行前至少检查:

1、工具名必须在本轮 request 声明的 tool list 中。
2、参数必须通过该工具的 JSON Schema。
3、不允许 schema 之外的额外字段,除非工具明确声明 additionalProperties: true
4、字符串长度、数组长度、对象深度和总字节数要有限制。
5、高风险工具仍要走原有权限模型,例如 shell、文件写入、网络访问、浏览器自动提交表单。

不把自然语言解释成参数

如果模型输出:

<tool_call>
<function=run_command>
npm test should be enough, but if it fails maybe run npm install
</function>
</tool_call>

run_command 的 schema 需要 { "cmd": "string" },解析器不应把整段自然语言自动塞进 cmd。这类“猜参数名”的策略会把模型犹豫、解释和建议变成真实命令。缺字段就拒绝。

审计原始 span

每个归一化 tool call 都应记录原始 XML span、偏移、解析后的 JSON、校验结果和拒绝原因。这样当用户看到 tool_calls_count=0 或工具没有执行时,日志能区分:

日志状态 含义
xml_tool_calls_detected=0 没有完整 XML tool call
xml_tool_calls_detected=1, accepted=0 识别到 XML,但校验失败
accepted=1, emitted_tool_calls=1 已提升为结构化调用
accepted=1, executor_started=0 解析层成功,执行层被权限或状态机拦截

这比单一的 tool_calls_count=0 更能定位问题。

验证样例

下面这些样例适合作为 parser 的单元测试和流式集成测试。

样例 1:标准 XML 参数

输入:

<tool_call>
<function=read_file>
<path>src/index.ts</path>
</function>
</tool_call>

期望输出:

{
  "type": "function",
  "function": {
    "name": "read_file",
    "arguments": "{\"path\":\"src/index.ts\"}"
  },
  "parsedInput": {
    "path": "src/index.ts"
  }
}

样例 2:内嵌 JSON 参数

输入:

<tool_call>
<function=search>
{"query":"tool_calls_count=0 Qwen XML","limit":5}
</function>
</tool_call>

期望输出:

{
  "function": {
    "name": "search",
    "arguments": "{\"query\":\"tool_calls_count=0 Qwen XML\",\"limit\":5}"
  },
  "parsedInput": {
    "query": "tool_calls_count=0 Qwen XML",
    "limit": 5
  }
}

样例 3:多个工具调用保持顺序

输入:

<tool_call>
<function=read_file>
<path>package.json</path>
</function>
</tool_call>
<tool_call>
<function=read_file>
<path>tsconfig.json</path>
</function>
</tool_call>

期望:输出两个 tool call,toolIndex 分别为 0 和 1,ID 不相同,顺序不变。

样例 4:流式切分

输入 chunks:

[
  "<tool_",
  "call>\n<function=read_file>\n<pa",
  "th>src/index.ts</path>\n</function>",
  "\n</tool_call>"
]

期望:前三个 chunk 不触发执行;第四个 chunk 到达后生成一个结构化 tool call。

样例 5:代码围栏中的示例不执行

输入:

示例格式如下:

```xml
<tool_call>
<function=read_file>
<path>package.json</path>
</function>
</tool_call>
```

期望:detected_as_executable=false,不生成 tool call。

样例 6:未知工具拒绝

输入:

<tool_call>
<function=delete_everything>
<path>.</path>
</function>
</tool_call>

期望:解析器可记录 xml_tool_calls_detected=1,但因为工具不在白名单,accepted=0,不进入执行器。

样例 7:重复参数拒绝

输入:

<tool_call>
<function=read_file>
<path>safe.txt</path>
<path>secret.txt</path>
</function>
</tool_call>

期望:拒绝,原因是 duplicate_parameter:path

实现检查清单

上线 Qwen XML parser 前,至少应确认以下行为:

• 非流式和流式路径都经过同一套归一化和 schema 校验。
message.tool_calls 已存在时,不重复解析 message.content 中的 XML,避免双执行。
• XML 解析只作用于当前 assistant 输出,不作用于用户输入、工具结果和历史日志。
• 未闭合标签、未知工具、缺少必填参数、schema 不匹配都不会执行。
• 解析成功后生成的结构与原生 OpenAI / Anthropic tool call 走同一执行链路。
• 工具调用 ID 唯一,并能和后续 tool result / tool output 配对。
• 解析诊断日志能区分“未检测到 XML”“检测到但拒绝”“已提升为结构化调用”“执行层拦截”。
• 高风险工具仍然受原有 sandbox、approval、allow-list 和审计策略约束。

结论

Qwen3-Coder XML tool call 的核心问题不是模型不会调用工具,而是模型、推理服务和 harness 对“工具调用”的表示不同。模型生成 <tool_call><function=...> 只说明它在文本层表达了工具意图;如果服务端没有把它转换成 OpenAI tool_calls 或 Anthropic tool_use block,harness 看到的结构化调用仍然是空的,tool_calls_count=0 就是合理结果。

工程上最稳的修复方式是在模型适配层加入 Qwen XML 再解析:用有限状态机识别完整 XML tool call,用严格规则解析函数名和参数,用 JSON Schema 做归一化和校验,再映射到 harness 已有的结构化工具调用协议。解析器要足够容错以覆盖 Qwen 的真实输出,又要足够保守,不能把解释性文本、用户注入或不完整流式片段提升成可执行动作。

跨模型 agent 的可靠性,很多时候不取决于单个模型能力,而取决于这些协议边界是否被认真处理。Qwen XML parser 就是一个典型例子:少一个适配层,模型“看起来调用了工具”但系统完全不动;多一个严谨的适配层,文本意图才能安全地进入工具执行状态机。

参考链接

Qwen Function Calling
Alibaba Cloud Model Studio: Function calling
QwenLM/Qwen3-Coder #475
QwenLM/qwen-code #176
ggml-org/llama.cpp #15012
unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF discussion #10
Qwen/Qwen3.5-35B-A3B discussion #4
lmstudio-ai/lmstudio-bug-tracker #1071
RooCodeInc/Roo-Code #10780