起因:我的 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.279.3%,而且看漏掉的样本我发现了一个自己写出来的 bug:
MISS 「帮我约个会,明天下午三点」 应为 calendar_create
候选=read_file,write_file,list_dir,search_files,move_file,delete_file6 个漏掉里有 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%两个结论:
- 想把危险错选压到 0,兜底率得到 44%——差不多一半的请求要走兜底。这是这套字面路由的真实能力上限,没什么好美化的。
- 兜底放宽到 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。