智能体接入更多工具,并不必然带来更高的任务完成率:相似描述会互相竞争,缺失参数也可能触发无效调用。本文实现一个可运行的 Node.js 工具路由器,并用命中率、误调用率和调用成本观察工具集扩张的真实影响。
工具数量为什么会影响路由
很多工具调用方案会把全部工具描述一次性交给模型,然后要求模型选择名称和参数。工具较少时,这种方式足够直接;工具增加后,问题通常不在于模型“完全不认识工具”,而在于多个工具都显得合理。
例如,查询城市天气、查询天气预警 和 规划城市出行 都可能包含“天气”“城市”“查询”等词。描述越长,共享词越多,候选之间越容易互相干扰。常见失败可以分为三类:
| 现象 | 示例 | 后果 |
|---|---|---|
| 误调用 | 用户闲聊“今天天气不错”,却调用天气接口 | 产生不必要的请求和费用 |
| 错工具 | “北京明天天气”被路由到天气预警 | 返回内容偏离用户目标 |
| 无效调用 | 选中天气工具,但没有提取到城市 | 工具报错或进入重试循环 |
因此,工具路由不应只有一次“选哪个”的判断。我更倾向于拆成四个可观测环节:
- 候选召回:从全部工具中找出少量相关候选,而不是把所有描述交给后续步骤。
- 参数校验:先提取并验证必填参数,无法构造合法调用的候选直接淘汰。
- 置信度门控:检查最高分是否足够高,以及它是否明显领先第二名。
- 拒绝调用:证据不足时返回
null,让上层追问、回复文本或转人工,而不是强行调用。
这四步不依赖特定模型。召回可以使用关键词、向量检索或分类模型,参数提取可以使用规则或 LLM;关键是每一步都有明确输入、输出和指标。
设计一个可解释的路由流程
下面的示例使用带权关键词做召回。它不试图替代语义模型,而是让路由行为可以在本地稳定复现。每个工具包含名称、简短描述、召回词、必填参数和一次调用的相对成本。
候选得分是命中词权重之和。召回后最多保留三个工具,再执行参数提取。通过校验的候选还要满足两个条件:最高分至少为 0.35,并且与第二名的分差至少为 0.15。后一个条件很重要:最高分看似不低,但两个天气工具得分接近时,路由器仍应拒绝。
参数校验放在置信度判断之前,是为了避免一个描述相关、实际却无法调用的工具占据第一名。例如用户说“今天天气不错”,天气工具能被召回,但没有城市参数,所以不会进入最终竞争。
生产环境可以用 Ajv 校验 JSON Schema,也可以用模型生成结构化参数;本文使用必填字段检查,避免引入依赖,并保持示例开箱可运行。
实现可运行的 Node.js 路由器
将下面内容保存为 router.mjs。示例只使用 Node.js 内置能力,Node.js 18 及以上版本可以直接运行。
const coreTools = [
{
name: 'weather', description: '查询指定城市的天气',
terms: [['天气', 0.55], ['气温', 0.55], ['下雨', 0.45]],
required: ['city'], cost: 2
},
{
name: 'calculator', description: '计算数学表达式',
terms: [['计算', 0.55], ['*', 0.15], ['/', 0.15]],
required: ['expression'], cost: 0.1
},
{
name: 'nodeDocs', description: '查找 Node.js API 文档',
terms: [['Node.js', 0.5], ['node', 0.35], ['API', 0.22], ['文档', 0.3]],
required: ['topic'], cost: 0.2
}
];
const addedTools = [
{
name: 'weatherAlert', description: '查询城市天气预警',
terms: [['天气', 0.48], ['气温', 0.42], ['预警', 0.55]],
required: ['city'], cost: 2.5
},
{
name: 'translate', description: '把文本翻译为中文或英文',
terms: [['翻译', 0.6]], required: ['text', 'target'], cost: 1
},
{
name: 'calendar', description: '查询日期和星期',
terms: [['日期', 0.5], ['星期', 0.5], ['今天', 0.2]],
required: [], cost: 0.1
},
{
name: 'travel', description: '规划两个城市之间的出行',
terms: [['从', 0.15], ['到', 0.15], ['怎么走', 0.5]],
required: ['from', 'to'], cost: 1.5
},
{
name: 'stock', description: '查询股票价格',
terms: [['股价', 0.55], ['股票', 0.5], ['查询', 0.1]],
required: ['symbol'], cost: 1
}
];
const cities = ['北京', '上海', '广州', '深圳', '杭州', '成都'];
function recall(text, tools, limit = 3) {
return tools
.map(tool => ({
tool,
score: tool.terms.reduce(
(sum, [term, weight]) => sum + (text.includes(term) ? weight : 0),
0
)
}))
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
function extractParams(name, text) {
if (name === 'weather' || name === 'weatherAlert') {
return { city: cities.find(city => text.includes(city)) };
}
if (name === 'calculator') {
const match = text.match(/[0-9][0-9+*/().\s-]*[0-9)]/);
return { expression: match?.[0].trim() };
}
if (name === 'nodeDocs') return { topic: text.trim() };
if (name === 'translate') {
const match = text.match(/把\s*(.+?)\s*翻译成(中文|英文)/);
return { text: match?.[1], target: match?.[2] };
}
if (name === 'travel') {
const match = text.match(/从(.+?)到(.+?)(?:怎么走|出行|$)/);
return { from: match?.[1], to: match?.[2] };
}
if (name === 'stock') {
const match = text.match(/\b[A-Z]{1,5}\b/);
return { symbol: match?.[0] };
}
if (name === 'calendar') return {};
return {};
}
function isValid(tool, params) {
return tool.required.every(key =>
typeof params[key] === 'string' && params[key].trim().length > 0
);
}
function route(text, tools) {
const recalled = recall(text, tools);
const valid = recalled
.map(item => ({ ...item, params: extractParams(item.tool.name, text) }))
.filter(item => isValid(item.tool, item.params));
if (valid.length === 0) {
return { selected: null, reason: '没有参数合法的候选', recalled };
}
const [first, second] = valid;
const margin = first.score - (second?.score ?? 0);
if (first.score < 0.35 || margin < 0.15) {
return { selected: null, reason: '置信度或候选分差不足', recalled };
}
return {
selected: first.tool.name,
params: first.params,
score: first.score,
cost: first.tool.cost,
recalled
};
}
const cases = [
['北京明天天气', 'weather'],
['上海气温怎么样', 'weather'],
['计算 12*(3+2)', 'calculator'],
['查 Node.js fs API', 'nodeDocs'],
['把 hello 翻译成中文', 'translate'],
['今天星期几', 'calendar'],
['从北京到上海怎么走', 'travel'],
['查询 AAPL 股价', 'stock'],
['帮我写一份周报', null],
['今天天气不错', null]
];
function evaluate(name, tools) {
let hits = 0, positives = 0, falseCalls = 0, negatives = 0;
let legacyHits = 0, legacyTotal = 0, totalCost = 0;
const coreNames = new Set(coreTools.map(tool => tool.name));
for (const [text, expected] of cases) {
const result = route(text, tools);
if (expected === null) {
negatives++;
if (result.selected !== null) falseCalls++;
} else {
positives++;
if (result.selected === expected) hits++;
if (coreNames.has(expected)) {
legacyTotal++;
if (result.selected === expected) legacyHits++;
}
}
if (result.selected !== null) totalCost += result.cost;
}
return {
toolSet: name,
hitRate: `${hits}/${positives}`,
legacyHitRate: `${legacyHits}/${legacyTotal}`,
falseCallRate: `${falseCalls}/${negatives}`,
totalCallCost: Number(totalCost.toFixed(2)),
costPerHit: hits ? Number((totalCost / hits).toFixed(2)) : null
};
}
if (process.argv[2] === '--eval') {
console.table([
evaluate('core', coreTools),
evaluate('expanded', [...coreTools, ...addedTools])
]);
} else {
const text = process.argv.slice(2).join(' ');
if (!text) throw new Error('请传入查询文本,或使用 --eval');
console.dir(route(text, [...coreTools, ...addedTools]), { depth: null });
}
执行单次路由和完整评测:
node router.mjs "北京明天天气"
node router.mjs --eval
这里没有真正请求天气或股票服务;cost 是便于比较的相对成本单位,不是虚构的供应商价格。接入真实工具时,只需在路由成功后调用对应处理函数,并把实际请求次数、费用或耗时写入日志。
用指标观察工具集扩张
评测集同时包含可调用请求和应拒绝请求。代码输出五项数据,其中三项最值得持续跟踪:
| 指标 | 计算方式 | 关注的问题 |
|---|---|---|
| 命中率 | 正确选择工具数 / 应调用请求数 | 新工具是否真的增加覆盖能力 |
| 误调用率 | 被调用的负样本数 / 应拒绝请求数 | 路由器是否对闲聊或信息不足的请求过度积极 |
| 调用成本 | 所有实际调用的成本之和 | 更高命中是否以不可接受的成本换取 |
示例还增加了 legacyHitRate,只统计原有三个工具的请求。这个指标能揭示总体命中率掩盖的问题:新增翻译、日历和股票工具后,总体覆盖可能上升,但 weatherAlert 会与原有 weather 竞争,使天气请求因分差不足而被拒绝。
这并不意味着应删除置信度门控。相反,拒绝比随机选中一个天气工具更安全。后续优化可以是:让天气预警只在用户明确提到“预警”时进入候选;按业务域分层召回;或者为相似工具增加一个二级分类器。修改规则后重新运行同一评测集,才能判断改动是在解决问题,还是把错误转移到了其他样本。
评测集也不应只包含成功案例。至少要覆盖参数缺失、闲聊、相似工具竞争、未支持意图和输入噪声。上线后还应记录召回候选、拒绝原因、最终参数和真实工具结果,但不要把敏感原文无条件写入日志。
总结
工具路由的目标不是“尽量调用”,而是在证据充分时调用正确工具,并在不确定时可靠地停下来。本文的要点包括:
- 将工具选择拆成候选召回、参数校验、置信度门控和拒绝调用,便于定位错误发生在哪一步。
- 工具集扩张需要同时检查总体命中率和原有工具命中率,避免新增能力掩盖回归。
- 误调用率衡量拒绝机制是否有效,调用成本则约束为了命中率而产生的额外请求。
- 示例中的关键词召回可以替换为向量检索或模型分类,但参数校验、分差门控和离线评测仍然适用。
- 工具描述应短而有区分度;如果两个工具只有细微差别,应通过路由层级或显式触发条件消除竞争,而不是继续堆叠描述。
评论