工具太多时,怎么让模型还能选对?
一句话回答
工具定义每次请求都要发给模型,工具一多,会占用大量上下文 token,相似的工具也更多,选择准确率会下降。常见做法:按场景或用户意图只提供相关的工具子集;用关键词或 Embedding 检索相关工具,或者给模型一个搜索工具让它按需加载;分层选择,先选类别再选工具;把一组工具交给专门的子 Agent;合并功能相近的工具。每次调整后,都要用评测集衡量工具选择的准确率。
详细解析
工具多了会出什么问题
- 占用上下文:每个工具的名称、描述和参数 Schema 都随请求发送。接上几个 MCP Server,工具定义就可能占掉上下文的一大块,成本和首 token 延迟都会上升
- 选择变难:候选越多、功能越相近,模型越容易选错工具或填错参数
- 干扰注意力:大量和当前任务无关的工具定义,本身就是噪声
几种做法
| 做法 | 思路 | 适合 | 代价 |
|---|---|---|---|
| 按场景划分 | 不同页面、角色、产品模块提供不同的工具集 | 场景边界清楚的应用 | 处理不了跨场景的问题 |
| 意图路由 | 先用小模型或分类器判断意图,再提供对应的工具集 | 意图类别有限,如客服 | 多一次调用,分错了后面都错 |
| 工具检索 | 按用户问题检索最相关的几个工具 | 工具多且持续增加 | 可能漏召回,需要兜底 |
| 按需搜索 | 只给模型一个 search_tools 工具,需要时搜索并加载完整定义 |
工具成百上千,如接了很多 MCP Server | 多一到两轮调用 |
| 分层选择 | 先选类别或 Server,再在类别里选工具 | 工具天然分组 | 多一轮决策 |
| 子 Agent | 主 Agent 只看到"交给某个子 Agent"的工具,具体工具由子 Agent 使用 | 领域独立,如代码仓库、工单系统 | 成本和延迟增加,交接时可能丢信息 |
| 合并工具 | 把只差一个条件的多个工具合并成一个带参数的工具 | 有大量相似的接口封装 | 单个工具的参数变复杂 |
有的模型平台已经内置了工具搜索:工具先声明但不加载,模型搜索到了才放进上下文。MCP 官方的客户端最佳实践也建议,在工具定义占用的上下文比例较高时,改用这种"渐进式发现"。
动态工具集的坑
- 提示缓存失效:工具定义通常位于 Prompt 的最前面,中途增删或重新排序,变动位置之后的缓存就全部失效。核心工具集保持稳定,新发现的工具追加在后面;也有方案用一个固定的
call_tool元工具转发所有调用,让工具列表本身不变。缓存原理见 Prompt Caching 是什么 - 用过的工具消失:对话前面用过的工具,后面的工具集里也要保留,否则模型会调用一个已经不存在的工具
- 漏召回:检索只是缩小范围,要始终保留一组常用的核心工具,并给模型留一个"找不到时再搜"的入口
怎么评估
准备一批真实问题,标注期望调用的工具,也包括"不需要调用工具"的问题,分两层统计:
- 检索层:期望的工具有没有进入候选(召回率)
- 模型层:最终选对工具、填对参数的比例,以及不该调用时有没有乱调用
同时记录工具定义占用的 token 数,和优化前对比。工具描述本身怎么写,见 工具的描述和参数怎么写。
代码示例
用 Embedding 检索相关工具。embed 是伪接口,可以换成任意 Embedding 模型,相似度的计算见 Embedding 是什么:
type ToolDef = { name: string; description: string; parameters: object }
type IndexedTool = { tool: ToolDef; vec: number[] }
function cosine(a: number[], b: number[]) {
let dot = 0
let na = 0
let nb = 0
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i]
na += a[i] * a[i]
nb += b[i] * b[i]
}
return dot / (Math.sqrt(na) * Math.sqrt(nb))
}
// 离线:为每个工具的名称和描述生成向量,工具列表变化时重建
async function buildToolIndex(tools: ToolDef[]): Promise<IndexedTool[]> {
return Promise.all(tools.map(async (tool) => ({ tool, vec: await embed(`${tool.name}:${tool.description}`) })))
}
// 在线:已加载的工具保持原样,只在后面追加与问题最相关、还没加载的工具
// current 第一次传始终提供的核心工具 CORE_TOOLS,开启新会话时再重置
async function selectTools(index: IndexedTool[], query: string, current: ToolDef[], k = 8) {
const q = await embed(query) // 多轮对话时,query 可以用最近几轮用户消息拼接
const loaded = new Set(current.map((t) => t.name))
const added = index
.map(({ tool, vec }) => ({ tool, score: cosine(q, vec) }))
.sort((a, b) => b.score - a.score)
.slice(0, k)
.map((r) => r.tool)
.filter((t) => !loaded.has(t.name))
// 只追加,不删除也不重排:变动只发生在列表末尾,之前的前缀仍能命中缓存,用过的工具也不会消失
return [...current, ...added]
}
面试官可能追问
工具多到什么程度需要做这些优化?
没有固定的数字,取决于模型能力、描述质量和工具之间的相似度。用评测集看选择准确率有没有下降,同时看工具定义占上下文的比例。MCP 的客户端最佳实践建议按"工具定义占上下文窗口的比例"设一个阈值,超过后再切换到按需加载。
工具检索用关键词还是向量?
各有优势。工具名和描述写得清楚时,关键词匹配(如 BM25)简单有效;向量检索能处理同义说法;用一个快速的小模型从候选里挑工具,效果通常更好,但成本更高。也可以混合使用,做法类似 混合检索。
子 Agent 方案什么时候合适?
一组工具属于独立的领域,而且使用过程中会产生大量中间结果时,比如在代码仓库里反复搜索和读文件,交给子 Agent 最合适,主 Agent 的上下文只收到结论。代价是多一次模型调用,信息在交接时可能丢失,见 多 Agent 系统有哪些协作模式。
工具结果占用的上下文也很多,怎么办?
一是让工具只返回精简的结果,长结果截断并提供翻页。二是"代码执行"的思路:让模型写一段调用多个工具的代码,在沙箱里执行,中间数据在沙箱内处理,只把最终结果返回给模型。代价是要实现一个安全的沙箱,见 让 AI 执行代码时,沙箱应该怎么做。
易错点
- 把所有 MCP Server 的全部工具一股脑挂上,指望模型自己选对
- 评估只看"该调用时选对没有",不看"不该调用时有没有乱调用"
- 每轮都重新检索、重新排序工具列表,提示缓存几乎命中不了
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。