摘要
在跨模型 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,但要拒绝 NaN、Infinity |
{...} / [...] |
尝试 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_CALL 或 IN_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