当前位置:首页>排行榜>本地工具调用评测先测到的是推理服务:0% 和 55 个百分点都不属于模型

本地工具调用评测先测到的是推理服务:0% 和 55 个百分点都不属于模型

  • 更新时间 2026-09-23 17:18:39
本地工具调用评测先测到的是推理服务:0% 和 55 个百分点都不属于模型

把 Agent 从云端模型换到自托管推理,团队通常会先跑一轮工具调用评测。跑完之后的结论往往很有戏剧性:某个 1.5B 模型"完全不会调工具",某个 3B 模型"加一句提示词就从 23% 涨到 82%",某个 4B 模型"在小样本上 100% 命中"。这些数字会直接进选型材料和回归门禁。

但同一次请求、同一份权重,换一个推理栈部署,结果可能完全不同。把模型输出解析成一次合法调用这件事发生在模型之外,由 chat template 和服务端启动参数共同决定。arXiv 2609.26693《Measuring the Serving Stack Instead of the Model: Hidden Confounds in Local Tool-Use Evaluation》(REALM @ EMNLP 2026,2026-09-22) 用四个推理栈、九个本地模型加一个云端对照模型,把这层混淆拆开测了一遍。

这篇论文给出的,是三种具体的失真方式和一套把服务端失败从模型失败里剥出来的顺序。

请求还没进模型,就可能已经被拒了

论文把同一个 tools= 请求打到四个栈上,得到四种不同的处理:

  • Ollama 0.30.8 在 dispatch 之前查一个 per-model 的 template flag。Qwen2.5-Coder 被接受但把调用写成文本,Llama-3.2 被接受并返回原生 tool_calls,Phi-3 和 Gemma-3 直接被拒,HTTP 400 does not support tools。这个 flag 通过 /api/show 的 capabilities 暴露出来。
  • vLLM 的检查发生在模型 dispatch 之前,返回的是 "auto" tool choice requires --enable-auto-tool-choice and --tool-call-parser to be set。这句话我在 vLLM 仓库的 vllm/renderers/online_renderer.py 里核过,是源码里的原字符串,触发条件是 tool_choice == "auto" 且没开 enable_auto_tools,跟具体是哪个模型无关。
  • SGLang 会返回 200,但没有用 --tool-call-parser 启动时,会把调用当普通文本返回。
  • llama.cpp 需要在 llama-server 上加 --jinja。

真正要紧的是第一点:拒绝发生在 dispatch 之前,模型一次推理都没跑。论文里 Phi-3 和 Gemma-3 在每一个 seed、每一个回合上都是被拒的请求,所以它们的原生工具调用保真度是未定义,而不是 0。这两个数字语义差得很远——0 说的是模型能力,未定义说的是这次测量根本没发生。

跨栈对照更直接。同一个模型同样的权重,在 Ollama 上是 400 被拒,在 llama.cpp 上是 200 但返回文本;Qwen-0.5B 在 Ollama、llama.cpp、SGLang 上是 200 文本,在 vLLM 上是 400。把 vLLM 的参数补齐、状态码变成 200 之后,两个被测模型仍然没有一个产出原生调用:Qwen 输出了一段被围栏包起来的 JSON,解析器没认出来,Phi-3 直接输出了散文。

被拒的请求最后被记成了「模型没调用工具」

这一段最该被运维同学抄走。

论文用的 harness 是一个基于 LOCA-bench 的 ReAct 编码 Agent。当服务端拒绝请求、或者客户端重试耗尽时,这个 harness 把错误折叠成一条普通的助手消息,内容是一句通用文案 Failed to get response after multiple retries.,并且不把错误类型写进保存下来的 trajectory。

下游分析拿到的就是一条普通回合,里面既没有 tool_calls,也不是合法的调用文本,于是被归类成"模型没有调用工具"。论文里 Phi-3 和 Gemma-3 因此被朴素地报成 0% 保真度,而真相是它们 100% 的回合都是被拒的请求。论文自己承认,要把这两类分开只能靠字符串匹配那句错误文案。

这是一个很典型的可观测性缺口:失败发生了,失败类型没有落库,指标就开始撒谎。放进生产系统更麻烦——第一眼看到的不是"评测分数低",而是"Agent 工具调用成功率掉了",这两件事的处置动作完全不同:前者要改评测口径,后者要查模型或提示词。

论文给的口径是:被拒请求标成 rej,非响应从保真度分母里拿掉单独上报,剩下的有效回合才进分母。比如 Gemma-3-4B 在 text-tools 条件下每个 seed 只产出一个有效调用,之后就不再响应;把它的 100% 直接拿去和其他模型比,等于拿一个非响应回合当能力样本。

通道不是关键,提示词和解析路径的匹配才是

论文对比了三个条件:native 只发 tools=;native+hint 仍然发 tools=,另外在提示里注入一段纯文本工具清单和 JSON 调用格式说明;text-tools 不发 tools=,全部走文本解析。

按 seed 均值(括号内为轮次池化值):Qwen-0.5B 从 0 涨到 35;Qwen-1.5B 从 38 涨到 58;Qwen-3B 从 23 涨到 82;Qwen-7B 从 59 涨到 89;Qwen-14B 从 60 涨到 80。对每一个服务端接受的模型,native+hint 都明显高于 native。

也就是说,在固定服务栈和固定解析路径的前提下,涨的这部分是提示词带来的,不是通道带来的。这条结论对"我们上了原生 function calling,所以不用管提示词"的想法是一记直接的否定。

只有 Llama-3.2-3B 是反例:native 60,native+hint 82,但 text-tools 掉到 44。原因是它本来就有真实的原生工具调用支持,统一换成文本协议等于把已经跑通的通道扔掉。

对运维的含义很具体:评测配置里必须显式记录 tools 模式是 native 还是 text,以及文本提示有没有注入。同一个模型名在两个模式下不是同一个被测对象。

把每一轮倒进同一个池子,结论会反过来

论文报了两个口径:turn-pooled 把所有回合倒进一个池子算总比例;per-seed mean 先按 seed 算再取均值,配 seed 级 bootstrap 区间,10000 次重采样。这里的 seed 指任务实例,不是采样随机种子。

Qwen-0.5B 在 text-tools 条件下,池化是 85%,按 seed 是 34%。差的这 51 个点来自一段 41 回合的长会话——它在一个 seed 里连续产出合法调用,把池子整体拉高,而另外七个 seed 大多在一两个回合内就失败。论文给出的最大差距约为 55 个百分点。

论文还提到一个细节:他们最早的 3-seed 试点画出过一条"很干净的曲线",扩到 8 个 seed 之后曲线就消失了。弱模型在 8 个 seed 里只产出 8 到 16 个回合,分母本来就很薄:Qwen-0.5B text-tools 是 34% [9,59],Phi-3 是 38% [12,75]。

一条可以直接写进评测规范的结论是:工具调用保真度的分母是任务或会话,不是轮次。轮次池化会被长会话主导,而 Agent 恰恰擅长产出长会话。如果你用轮次池化数做回归门禁,一次多跑几轮的运行就能让指标自己涨上去。

约束解码治好了解析失败,换来了 869 轮停不下来

论文还测了一个很多团队会自然想到的方案:用结构化输出把解码约束到一个 JSON schema,schema 里的工具名字段是可用工具名的 enum。

结果是九个本地模型——包括 text-tools 条件下得分最低的三个:Llama-3.2、Phi-3、Gemma-3-270m——全部做到 8/8 次试验产出合法 in-schema 调用。解析失败被彻底消灭了。

代价是约束同时拿掉了"停下来"的能力。被强制每轮都必须调用工具之后,弱模型陷入 600 到 900 个回合的循环,Qwen-0.5B 在其中一个 seed 里跑到 869 个助手回合才被切断。

这是一条成本线上的风险:一次评测运行的 token 消耗和轨迹写入量级,会因为一个解码配置从几十轮变成几百轮。约束解码让"格式正确"变成必然,也让"该停的时候停"变成不可能。要用约束解码做工具调用,就得在 harness 层同时加硬性轮次上限和终止判定,而不是指望模型自己收敛。

落地:把服务端失败做成一等失败类型

把论文里可操作的部分抄进自己的链路,大概是四件事。

第一,固定并上报推理栈。比较模型能力时服务接口必须钉死;做系统级评估时,报的是"模型 + 服务端配置"这个组合。至少要记录栈名、版本、parser 名、模型 tag 和量化档位。论文只钉住了 Ollama 0.30.8 一个版本,并明确说 per-model gating 和 HTTP 400 的契约是这个 release 的属性。

第二,上评测前先做能力预检。/api/show 的 capabilities 能告诉你服务端认不认 tools,但更可靠的是直接发一次真实请求看状态码:

formin qwen2.5-coder:7b llama3.2:latest phi3:latest gemma3:4b;docaps=$(curl-s http://127.0.0.1:11434/api/show \-d"{\"model\":\"$m\"}"| jq -c'.capabilities')code=$(curl-s-o /tmp/tc.json -w'%{http_code}' http://127.0.0.1:11434/api/chat \-d"{\"model\":\"$m\",\"stream\":false, \"messages\":[{\"role\":\"user\",\"content\":\"查一下 pod api-1 的状态\"}], \"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"pod_status\", \"parameters\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}}, \"required\":[\"name\"]}}}]}")calls=$(jq -r'(.message.tool_calls // []) | length' /tmp/tc.json 2>/dev/null)printf'%-20s caps=%-26s http=%s calls=%s\n'"$m""$caps""$code""$calls"done

HTTP 400 意味着这次请求根本没跑到模型,它的结果不能进模型能力的分子分母;HTTP 200 但 calls=0 且 content 里出现被围栏包起来的 JSON,说明解析路径没接上。vLLM 侧对应的启动参数是 --enable-auto-tool-choice 加 --tool-call-parser(论文对 Qwen2.5 用的是 hermes),SGLang 用 --tool-call-parser,llama.cpp 用 --jinja。

第三,给每个回合落一个显式的 outcome 字段:

{"turn":7,"model":"qwen2.5-coder:7b","stack":"ollama","stack_version":"0.30.8","tools_mode":"native+hint","http_status":200,"parser":"native","outcome":"valid_call"}

outcome 的取值集合是 valid_call、hallucinated_call、unparseable_text、no_call_prose、rejected_request、non_response。rejected_request 和 non_response 永远不进分母,单独上报。这一条比任何阈值都重要:只要它们被并进"模型没调用工具",后面所有分析都是错的。这些字段还要能落到 trace 上,否则事后无法复盘。

第四,报数用 seed 或任务级均值加区间,不用轮次池化。8 个 seed 是论文用的下限,3 个 seed 会画出会被推翻的"干净曲线"。同时把工具清单长度、提示词模板、解码参数一并绑定到这次运行。

遇到 0% 的工具调用率,论文给的排查顺序是固定的:先看服务端有没有拒绝或返回空(HTTP 4xx、重试耗尽),再看这个栈这个模型有没有配 tool-call parser,两条都排除了,才把失败归给模型。

什么结论能用,什么结论不能外推

论文自己划的边界写得很清楚,照抄过来:

  • 覆盖九个本地模型(Qwen2.5-Coder 0.5B/1.5B/3B/7B/14B、Llama-3.2-3B、Phi-3-mini、Gemma-3-4B、Gemma-3-270m)加一个云端对照模型,跑的是基于 LOCA-bench 的 ReAct harness 上的聚合任务,另有 dependency-chain 任务与 HumanEval 复现作为补充;解码参数为 temperature 1.0、top-p 1.0。
  • 只钉住了一个 Ollama 版本,vLLM 和 SGLang 的默认行为只在 Qwen 和 Phi-3 上确认过。
  • 作者明确不主张规模律、家族效应或与推理能力分离之类的结论。

所以"某个模型不会调工具"这种结论在这篇论文的语境下不能成立;能成立的是"这个模型跑在这个栈这套配置下,产出合法调用的比例是多少,其中多少回合请求根本没到模型"。

对托管 API,这层混淆小很多——论文里云端对照模型在 native 和 text-tools 下都是 100%。但网关、代理、SDK 对 tool_choice 的处理同样不在你的观测范围内。这类评测报数时,至少要把这种可能性写进结论的限定条件。

先在你自己的栈上跑一遍这三个探针

不用等完整评测,三个探针几十分钟能跑完:

  1. 对每个待测模型发一次单轮 tools= 请求,记录 HTTP 状态码和 tool_calls 数量。任何 400、任何"200 但返回文本"的组合,先修服务端配置,再谈模型能力。
  2. 把现有的评测 trajectory 拿回来,在离线日志里搜 harness 的通用错误文案,数一数有多少被标成"无工具调用"的回合其实是请求被拒。这个数字往往会动摇你已经发布过的结论。
  3. 用同一批 seed 分别按轮次池化和按会话算两个数,如果差距超过 10 个百分点,就把轮次池化从门禁指标里去掉。

三步做完再决定要不要换模型。多数情况下,先被修好的不是模型。

随机文章