起因:我的 Agent 挂着 48 个工具,然后开始乱选

我把手边能用的工具都挂到一个 Agent 上了——文件、Git、数据库、HTTP、邮件日历、笔记、服务器运维、构建测试、图片文档处理,一共 48 个,分 9 个域。挂完之后问题来了:让它「帮我约个会,明天下午三点」,它去调 write_file;让它「查一下 orders 表上个月一共有多少单」,它给我列目录。

不是模型傻。是我在它面前摊了 48 个选项,还指望它每次都挑对。

我先去核对了官方口径(2026-10-05 查的 OpenAI tools 文档与 function calling 指南):Chat Completions 的 tools 参数硬上限是 128 个函数;但 OpenAI 自己的建议是单次调用不要超过 20 个,原话大意是工具数量到 10~20 个之后,模型选对工具的能力就开始下降。128 是能塞,20 是能选对。这两个数差了六倍。

所以问题就变成了怎么把这 48 个砍到个位数,还不能砍掉该用的那个。

先算笔账:9099 字符,每轮都要原样付一遍

我按真实格式把 48 个工具定义(name + description + parameters)序列化了一遍:

工具 48 个 / 域 9 个
全量工具定义 9099 字符,每轮都要原样付一遍

一个多轮任务跑 10 轮,光工具定义就是 9 万字符。这部分钱是纯损耗,跟任务本身一点关系都没有。这是我做路由的第一个动机,省 token 是顺带的,主要还是想让它别选错。

第一版跑出 100% 召回,我不敢信

我先手搓了一个 TF-IDF 打分器,中文按 bigram 切、英文按词切,给工具名 3 倍权重、描述 1 倍、参数名 0.5 倍。另外我给每个工具手写了一份别名表,比如 send_mail 底下挂「发邮件 / 通知一下 / 群发个通知」。

跑完 61 条我自己标的 query,召回 100%。

我当时就觉得不对劲。回头一看:那 61 条 query 是我出的,别名表也是我照着这 61 条写的,「通知一下」这种词直接就是 query 里的原话。答案被我提前塞进索引了。这个 100% 测的不是路由,测的是我的记忆力。

我把这版删了。

砍到只剩注册中心真实字段,掉到 79%

第二版我给自己立了条规矩:索引里只准用注册中心真实存在的字段——工具名、描述、参数名。别名表全部作废。另外把 61 条按固定种子洗牌切成 dev 30 / test 31,后面所有数字只看 test。

结果诚实多了:

A 全量 48 个全塞                  召回 100.0%  开销 9099字符(100%)  噪声 47.0
B1 单层打分(仅注册字段)            召回  79.3%  开销 1140字符( 13%)  噪声  5.2

79.3%,而且看漏掉的样本我发现了一个自己写出来的 bug:

MISS 「帮我约个会,明天下午三点」  应为 calendar_create
     候选=read_file,write_file,list_dir,search_files,move_file,delete_file

6 个漏掉里有 4 个候选长得一模一样,全是注册表最前面那 6 个文件工具。原因很蠢:query 跟所有工具的字面重合都是 0,全是 0 分,而 Array.sort 是稳定排序,0 分之间保持原序,slice(0,6) 就把注册顺序最前面的 6 个切出来了。

当打分器对所有选项都没意见时,它其实是在说「我不知道」,但我的代码把它翻译成了「用文件工具」。 这个 bug 比 79% 这个数字本身更有价值——返回一堆看着有道理的候选,比返回空集危险得多,因为模型会真的去调。

真正救命的不是路由,是给描述补一句「什么时候叫我」

我给每个工具补了一组触发句,写的是用户会怎么说话,不是工具在干什么:

calendar_create: ['约个会', '加个日程', '订个会议', '拉个会', '安排个时间'],
disk_usage:      ['磁盘还剩', '内存', '负载', '空间够不够', 'df'],
image_resize:    ['压缩图片', '改尺寸', '缩略图', '压到多宽', '图片太大'],

calendar_create 的描述原本是「创建一条新日程并可邀请参与者」。用户说「约个会」,字面上跟这句话一个字的重合都没有。这是词汇鸿沟,不是排序问题——再好的排序算法也救不了零交集。

补完之后:

B2 单层·仅注册字段·有0分保护   召回  75.9%  开销  476字符( 5%)  噪声 1.9  无解识别 2/2
B3 单层·+触发句·有0分保护      召回 100.0%  开销  968字符(11%)  噪声 4.2
C2 两级·+触发句 K=3           召回 100.0%  开销  542字符( 6%)  噪声 1.9

顺带把上面那个 0 分 bug 修了:得分不高于 0 的直接不进候选。B2 的召回反而比 B1 低了 3 个百分点,但这是好事——它变成了「宁可空手,也不给你错的」,而且 2 条本该没有工具的 query 它正确识别了。

两级路由我加了,但收益没我想的那么大

我的设计是第一层用域先验(哪个 MCP server 提供的,这个信息客户端本来就有),取 top-2 域;第二层在域内用重信号精排——工具名整串命中、以及一批只认「值长什么样」的通用槽位正则:

const SLOT = [
  { re: /\/[\w./-]{4,}/,                 dom: 'file',  w: 0.8 },
  { re: /\b(select|where|group by)\b/,   dom: 'db',    w: 1.2 },
  { re: /https?:\/\//,                   dom: 'net',   w: 1.2 },
  { re: /\b(ssh|docker|systemctl|nginx)\b/, dom: 'ops',w: 1.0 },
  { re: /\d{1,2}:\d{2}|明天|下午|日历/,   dom: 'mail', w: 0.8 },
];

但扫下来我得承认:域先验几乎没有独立贡献。top1 域、top2、top3、top4 在测试集上全是 100% 召回。加了触发句之后,字面信号已经够强,域这一层只是在开销上帮我从 11% 压到 6%。

真正省下开销的是把候选数从 6 压到 3(968 → 542 字符),跟两不两级没关系。

换个说法就崩了:41.7%

到这里我还是不踏实,因为触发句也是我写的。我做了两件事。

第一件是泄漏审计:算 query 和标准答案描述之间的最长公共子串,中文 ≥4 字就判定为字面泄漏。test 里 18 条属于泄漏、11 条不是,两边都是 100%——说明我这个测试集对我自己来说太简单了,这个数字只能证明流程没写错,不能证明别的。

第二件是我另写了 14 条同义改写,刻意避开标准答案描述里的任何字面说法:

「把这张图弄小一点,别占那么大地方」              → image_resize
「跟大家说一声今晚要停机」                        → send_mail
「看看这台机器还剩多少地方能放东西」              → disk_usage
「这个月到底卖出去多少台,拉个数给我」            → sql_query

这组跑出来:

C2 · 同义改写集 HARD    召回 41.7%   低置信 7/14

一夜回到解放前。字面匹配能搞定「说法规范」的请求,搞不定「换个说法」。我预期到了会掉,但没想到掉这么多。

兜底:宁可多花钱,也别把错的候选塞给模型

召回低本身不可怕,可怕的是给了模型一堆不含正确答案的候选——它会信心十足地选一个错的,而我无从察觉。我把这种叫「危险错选」。

所以我加了低置信兜底:第一名分数低于阈值,或者一二名分差太小,就判定不确定,把候选放宽。然后扫阈值看代价:

阈值(minScore/minGap)  兜底方式   危险错选   测试集开销   HARD开销   兜底率
    3/ 0.10           top3域       4/41      731( 8%)   1805(20%)   20%
    6/ 0.25           top3域       2/41      814( 9%)   2545(28%)   31%
    8/ 0.35           top3域       0/41     1133(12%)   2955(32%)   44%
    8/ 0.35           全量48       0/41     2487(27%)   8487(93%)   44%

两个结论:

  1. 想把危险错选压到 0,兜底率得到 44%——差不多一半的请求要走兜底。这是这套字面路由的真实能力上限,没什么好美化的。
  2. 兜底放宽到 top3 域就够了,别直接回落到全量 48。两者危险错选一样是 0,但全量兜底在改写集上要付 93% 的开销,top3 域只要 32%。兜底不是放弃治疗,是换一个便宜一档的搜索范围。

另外「今天天气怎么样」这种根本没有工具能干的请求,路由会返回空候选——这时候正确的行为是让 Agent 直接说没有这个能力,而不是硬挑一个。

我的判断

这套东西我最后留在了 Agent 里,但对它的定位很清楚:

它主要是在省钱,不是在变聪明。 从 9099 字符压到 542~1133 字符,测试流量上省掉 88%,这是实打实的。召回从 79% 到 100% 那一段,功劳在触发句不在算法——先去改你的工具描述,别急着写路由器。OpenAI 那句「10~20 个工具之后选对能力下降」我信,但解法首先是让描述说人话,不是加一层调度。

字面路由的天花板很低。 换个说法掉到 41.7%,这个数我不会拿去吹。要真解决语义歧义得上 embedding 或者一个小分类器,代价是要养一个模型和一批标注。我在自己的场景里没上,因为我的工具集小、调用模式固定,44% 的兜底率换来的成本还是比全量划算。

别信你自己出的 benchmark。 我第一版跑出 100%,差点就发出去了。query 是我出的、别名是我写的、标注是我给的,三个环节全是同一个人,这种数据天生就是好看的。我现在唯一信得过的做法是从真实调用日志里捞 (用户输入, 最终调用的工具) 做回归集,每周跑一次——那个数字才是真的。

最后补一条时效:OpenAI 在 Responses API 里已经内置了 tool_search 和 defer_loading——把工具放进 namespace、标上延迟加载,模型需要时自己检索,官方说只有 gpt-5.4 及以后支持(2026-10-05 核对)。如果你用的模型支持这个,我上面这套手搓路由基本可以扔了。 我留着它只是因为我的链路不全是 OpenAI,而且这层是纯本地的、不依赖任何厂商。

实验脚本在 router-lab.js,48 个工具定义在 toolkit-48.js,触发句在 triggers.js,零依赖,改一下就能拿你自己的工具集跑。


注:文中召回率都是在我自己写的 61 条 query 上测的,触发句也是我写的,结论不能直接外推到你的流量上。OpenAI 的工具数量建议与其 API 参数口径,我核对于 2026-10-05。

添加新评论

文章目录