页边批注 · A Note in the Margin
我给 agent 建了个工具调用测评场,然后发现「通道」比「模型」更能决定成败
开源了一个小工具 Model Arena:受限工具集 + 18 题四档题库 + 自动判分 + Web UI。跑完 17 个模型之后,最有价值的结论不是排名,而是几件容易归错因的事——同一个模型换条路走分数能差一倍、「畸形工具调用」看着像模型不行其实不是,以及最要命的那个:我的评测沙箱漏了,它在按搜索风格而不是能力打分。
起因
我有个自建的制片 dashboard,里面有个自主 agent。它不走 Claude,而是经本地网关路由到 deepseek / gemini / kimi / minimax / hy3 / grok 一堆第三方后端。
这类 agent 的成败几乎不取决于模型「聪不聪明」,而取决于一件更朴素的事:它能不能把工具调对。参数漏一个必填字段、把该 Grep 的活儿拿去 Read、撞满轮次交白卷——这些失败跟基准测试里的推理分数关系不大。
于是我把「构造测试题 → 批量跑 → 判分 → 排名」固化成了一个可复现的程序,现在开源出来:
github.com/pyf-labrary/model-arena
它测什么
模拟生产 agent 的受限工具集:只放行 Read / Grep / Glob 加一个白名单 MCP 工具,用 disallowed_tools 拉黑 Bash / Write / Task / WebSearch。经 claude-agent-sdk 起子进程 agent,模型经 ANTHROPIC_BASE_URL 指向网关。
题库 18 题、四个难度档,跑在一个自包含的样例工作区上。关键设计是标准答案由生成器从数据结构实算、再回磁盘对账——不一致直接报错退出,所以判分可以全自动。
工作区里处处埋雷,每个雷对应一类真实的 agent 失败模式:
- 大小写变体的文件名(
component.md混在COMPONENT.md里)→ 考口径漂移 - 更长的近似标记(
ZETA9X混在ZETA9里)→ 考整词边界 .bak备份、草稿目录、归档区的假模块 → 考盲目全仓 grep- 一个工具故意返回过期缓存值(报 14,磁盘真值 12)→ 一道题考「有没有真读返回值」,另一道题禁用该工具考「敢不敢不信它」
难度分层符合预期:T1 基线四题人人满分,区分度落在 T2 工具保真和 T4 杀手级。

结果:满分梯队
17 个模型、每个 18 题。先说不确定性:我用同一套 harness 把全部模型重复跑了两轮,两轮之间平均差 1.0 分(最大 3 分,只有 5/19 个完全不变)。所以下面这张表只能分档看,名次没有意义——17 和 18 之间的差别完全落在噪声里。
满分档(18/18,按耗时排):
| 模型 | 通过 | 无瑕 | 均耗时 | out token |
|---|---|---|---|---|
| grok-composer-2.5-fast | 18/18 | 18/18 | 10.0s | 6938 |
| deepseek-v4-flash-free | 18/18 | 18/18 | 14.1s | 15828 |
| hy3-preview | 18/18 | 18/18 | 18.9s | 12931 |
| claude-opus-5 | 18/18 | 18/18 | 20.1s | 14488 |
| kimi-k2.7-code | 18/18 | 18/18 | 22.5s | 10466 |
| gpt-5.6-luna | 18/18 | 18/18 | 30.4s | 8208 |
17/18 档:grok-4.5(12.3s) · grok-4.3(14.4s) · gpt-5.6-terra(23.3s,4840 tok 最省) · gemini-pro-agent(28.3s) · claude-sonnet-5(34.4s) · gpt-5.4-mini(39.7s)。
15–16 档:deepseek-v4-pro 16 · minimax-m3-pay / gemini-3.6-flash / kimi-k2.6 / gemini-3.1-pro-low 各 15。
比通过数更能拉开差距的是畸形调用那一列:Gemini 三个变体分别 20/106、7/98、28/130,其余 14 个模型全为 0。gemini-3.1-pro-low 答对 15 题,但只有 12 题算「干净通过」。

另外 minimax-m3-pay 有个别人没有的毛病:越权调用被拉黑的 Bash 共 13 次(SDK 会拒绝执行,但这是个明确的失败信号,我后来给它加了单独的计数指标)。
发现一:通道 > 模型
榜上最低的是 kimi-k2.7,11/18。但同一个模型换条路进来,叫 kimi-k2.7-code,拿了 18/18。
差别在哪?前者是经订阅转发的通道,后者是厂商官方 OAuth 直连。转发层的症状长这样:
- 「找不到 registry 目录」
- 「Read 的 file_path 与 cwd 不一致」
- 卡满 150 秒,空答
这三个症状,每一个都长得像「这模型不行」。 如果不做通道对照,我就会得出「kimi 工具调用能力差」的结论,然后把它从选型里划掉——而真相是这个模型在满分档。7 分的差距远超前面量的 1.0 分噪声,这个结论站得住。
但同样的对照在 deepseek 上不成立:转发版 16/18、官方直连版 18/18,差 2 分——落在噪声范围内,不能声称有差异。我最早那版文章里写过「转发无质量损耗、但有速度与 token 代价」,那是基于一批后来被推翻的数据(见文末后记),现在这个方向性结论我撤回。
所以准确的说法是:转发层可能引入严重故障(kimi 是实锤),但不是必然(deepseek 上看不出来)。 结论仍然是那句:做模型评测必须把「通道」当成自变量——否则你测的是通道,报出来的却是模型。
(顺带:这两个对照组我之后不再跑了——对照价值已经取到,继续测只是重复烧 token。所以上面这组数字是历史快照,日期 2026-07-25。)
发现二:畸形调用的归因,要靠换执行器做单变量对照
全场 17 个模型里,「畸形工具调用」(Grep/Glob 丢了必填的 pattern 字段)一共出现 55 次——全部来自 Gemini 的三个变体(20 / 7 / 28),其余 14 个模型合计零次。这个分布在两轮独立重跑里都稳定复现,不是噪声。
看起来结论很清楚:Gemini 不适应 Claude 形状的工具 schema。
我差点就这么写了。但这里有个第二解释没排除:所有非 Anthropic 模型都要经过协议翻译,会不会是翻译层把 pattern 弄丢了,而不是模型没发?
判别方法很朴素——同一道题、同一个模型,只换上游执行器:
| 路径 | 畸形调用 |
|---|---|
| 订阅通道(Gemini 原生协议) | 2/20 |
| API key 通道(同为 Gemini 原生协议,另一个 executor) | 0/20 |
畸形归零了。所以不是模型能力问题。
但故事没完。我又做了配对补测——另外 3 道题、两条路各跑一遍——两边都是零畸形。也就是说它是间歇性的,6 次配对一次都没复现。
所以诚实的结论只能到这里:不是模型的锅,但也没有足够证据说这是某条通道的确定性缺陷。 样本量不支持我给出一个发生率。我很想把它写成一句干净的「就是 XX 的问题」,但那会是编的。
点通过矩阵里任意一格,能看到那一题的完整工具调用序列。下面这张里,红框标出的两次就是缺 pattern 的畸形调用——而它们旁边还有十几次形状完全正常的 Grep。这也是为什么我后来把「畸形」单独成列,而不是折叠进通过率:

(另外还有个反直觉的细节:那道让 Gemini 反复出错的题,换了干净通道之后照样撞满轮次交白卷。所以这道题的失分不能全算到通道头上——它对这个模型本来就是难题。归因的时候很容易一激动把所有失分都归给刚发现的那个原因。)
一个被证伪的优化想法
既然怀疑 Anthropic→Gemini 的协议翻译有问题,那 Gemini 不是有 OpenAI 兼容端点吗?绕开这层翻译走成熟的 Anthropic→OpenAI 路径不就行了?
我真去接了一条对照路由。结果第一次工具调用后立刻 400:
Function call is missing a thought_signature in function...
Gemini 的 OpenAI 兼容端点在多轮 function calling 上要求把 thought_signature 原样回传,而 OpenAI 协议里根本没有这个字段的位置。这条链是断的——换协议不但不能提效,是直接不可用。
好在这类想法验证起来很便宜:加一条路由、跑一道题、看报错。比想很久要快。
顺便修了判分器
原本的判分规则要求「零畸形」才算通过。这导致「答案其实对了、只是某次调用形状有瑕疵」和「答错了」在榜上长得一模一样——恰恰把我最想区分的两种失败压成了同一档。
改成并列:pass 只管能不能把活干对,另设 clean 管干不干净,榜上「通过 / 无瑕通过 / 答对」三列并排。综合分照旧扣畸形率(并列不等于免罚)。改完用 --rescore 重判历史数据——原始回答和工具调用序列都存着,不用重烧一遍 token,而且重判产物写新文件,绝不覆盖原件(原始判定是证据)。
重判之后,两个 Gemini 变体各自涨了 1 分和 2 分。
后记:这张榜差点是错的
上面那些数字,是推倒重来过两轮的。
起因是一句质疑。我把新加进来的两个模型(Claude 的 Opus 5 / Sonnet 5,走本机订阅而非网关)跑完,报出 16/18 和 15/18,位置在中游。用户看了一眼说:这是当前最强的模型,跑分不该落后。
我当时手边有两句现成的解释——「n=1 有噪声」和「这题库测的是工具调用不是智力」。两句都成立。但它们都是在看证据之前就准备好的话。所以我去复跑了它失分的那道题,把工具调用和返回全打出来:
Grep {"pattern":"\\bOMEGA7\\b", "path":"/home/user", "output_mode":"files_with_matches"}
→ Found 3 files
……/工作日志/model-arena题库v2.md
……/模型横评v2-资料/questions.json ← 题库本身
……/模型横评v2-资料/gen_workspace.py
→ 答 3
题目问「整个工作区里有多少个文件包含整词 OMEGA7」,标准答案 0(工作区里只有更长的 OMEGA71)。而它搜到的三个文件,是这套题库自己的设计文档——其中 questions.json 里躺着全部标准答案。
它答 3,对它实际看到的东西而言完全正确。
缺陷一:cwd 不是沙箱
我一直以为给 agent 设了 cwd=WORKSPACE 就等于把它关在工作区里。不是——那只是起始目录。Read/Grep/Glob 的 path 参数照样能指向机器上任何位置。另一个模型更干脆,path 直接填 /。
危害有两层。答案泄漏是显眼的那层。更隐蔽的是第二层:这个缺陷是有方向性的。老老实实用相对路径在工作区里干活的模型毫发无伤;用绝对路径做彻底检索的模型撞雷。于是榜单实际上在按搜索风格打分,而不是按能力——而「更彻底地检索」恰恰是我希望 agent 具备的品质。
修法是加一道路径闸:can_use_tool 里把 path/file_path resolve 后检查是否在工作区内,不在就拒绝。
缺陷二:装了闸,闸没响
改完跑一遍,越界计数 0,而模型照样搜出了工作区外的文件。
原因是 allowed_tools。我一直把它理解成「可见工具白名单」,它实际上是自动批准名单——名单里的工具直接绕过 can_use_tool。我把 Read/Grep/Glob 留在里面,于是权限回调一次都没被调用过。SDK 其实打了个 _warn_if_can_use_tool_shadowed 警告,混在输出里,我第一遍没当回事。
(工具的可见性由 disallowed_tools 决定,与 allowed_tools 无关。把只读工具移出自动批准名单不影响模型使用它们,只是每次调用要过闸。)
缺陷三:闸响了,但响错了
第二版闸生效,然后分数掉了:一个模型从 18 掉到 16,另一个掉到 14。
我差点又要去解释「沙箱变严了所以更难」。真实原因是我的回调返回了 dict,而 SDK 要求返回 PermissionResult 对象。回调直接抛异常 → MCP 工具全线挂掉 → 模型在那几道题里回答「工具调用失败,无法给出数字」。
看着像模型变笨了,其实是 harness 在自残。
缺陷四:拒绝语本身是一种提示
闸终于对了。但翻越界记录时我发现,拦下的 296 次里只有 18 次是真往外搜(path=/ 那种),其余全是模型在猜工作区的绝对路径——猜错了而已。
这类调用在原环境里只会得到一句「文件不存在」,模型自己改用相对路径。而我的拒绝语写的是「越界,请只用相对路径检索」——比自然的 ENOENT 多给了一条指导。等于我在修 bug 的同时给所有模型塞了新帮助,那么修复前后的分数变化,究竟是「污染被堵住」还是「提示帮了忙」,就分不清了。
改成中性的「路径不存在或不可访问」(语义与 chroot 一致:外面的东西就是不存在),再全部重跑一遍。
值多少
四个缺陷,每一个单独出现都足以把排名弄反,而它们的表现全都是「某个模型分低」——一个我早就准备好用「模型能力」去解释的现象。
代价是两轮全量重跑。收获是这条判据:
当结果与强先验冲突时,先查 harness,再查被测对象。
「最强的模型不该落后」是个强先验。我本可以用「n=1 噪声」把它打发过去——那句话甚至是对的,前面量到的噪声就有 ±1 分。但用一个成立的通用解释去覆盖一个具体的异常,是归因里最容易犯的懒。
还有个附带收获:修完之后我才有条件去量噪声——同一 harness 跑两轮,平均差 1.0 分。这个数字反过来说明,我原先那张榜上「第 1 名和第 5 名」的排序,本来就没有意义。先修偏差,再量方差,然后才谈名次。
最后一句实践建议:这次能查下去,全靠每道题的原始回答和完整工具调用序列都存在结果文件里。如果当初只存了分数,这四个缺陷一个都查不出来——重跑只会得到同样可疑的数字,而没有任何线索。评测系统存原始过程,比存结论重要。
拿去跑
git clone https://github.com/pyf-labrary/model-arena.git && cd model-arena
pip install claude-agent-sdk
cp config.example.json config.json # 填网关地址和 token
python3 server.py --port 8799
不限某一种网关——任何暴露 Anthropic Messages 协议 /v1/messages 的网关都能接。
仓库里带了 17 模型评测的全部原始数据(含修沙箱前后的各轮,可对比),每道题保留了模型的原始回答和完整工具调用序列,可以自己 --rescore 用别的判据复判,不用重跑。
最后
这事让我改了一个习惯:以前跑完评测,写完报告就算交付了。但那次收尾时我顺手对了一下账——把榜单和实际生效的配置逐行比一遍——发现 5 个满分模型在我自己的 dashboard 里全都是关着的,用户根本选不到;而榜末那两个反倒开着。
评测跑完了、报告写完了、结论也记下来了,兑现率却是 0。
所以最有价值的一步往往不在报告里,而在「榜与现状的差集」里。