工具太多时,怎么让模型还能选对?

深入性能优化场景题约 7 分钟读完

一句话回答

工具定义每次请求都要发给模型,工具一多,会占用大量上下文 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 是什么:

TypeScript
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 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。