一个 Agent 只有三五个工具时,最简单的做法是把所有工具名称、描述和参数 Schema 一次性交给模型。
接入更多 MCP Server 后,问题会变得明显。GitHub、Slack、日历、邮件、数据库和监控平台可能带来几十甚至几百个工具。每次模型调用都携带全部定义,不但消耗 token,相似工具还会相互干扰。
Anthropic 的 Tool Search 文档给出了一个多服务器示例:GitHub、Slack、Sentry、Grafana 和 Splunk 的工具定义合计约 5.5 万 token;其文档还指出,工具数量超过 30---50 个后,工具选择能力会下降。官方方案是延迟加载工具,只在需要时搜索并展开少量定义。Anthropic Tool Search 文档
这篇文章不绑定特定模型 API,而是用纯 Python 实现一个本地版本:
text
用户请求
↓
BM25 工具检索
↓
风险等级过滤
↓
依赖工具递归补全
↓
只把候选工具 Schema 交给模型
↓
执行层校验并调用真实工具
重点不只是"召回一个最相似的工具"。真实任务经常需要一组工具,其中一个工具的输出还是另一个工具的输入。
1. 为什么不能只做 Top-K 语义搜索
假设用户说:
给张三发一封会议邀请,并安排到他的当地时间。
Agent 可能需要:
find_contact:找到张三的邮箱;get_timezone:确认当地时区;create_calendar_event:创建会议;send_email:发送邀请。
向量检索或 BM25 可能命中 create_calendar_event 和 send_email,却漏掉两个前置工具。候选集中只要缺一个必要工具,后续模型再聪明也无法完成整个流程。
2026 年 10 月 30 日发布的一项多工具检索研究,用 completeness@k 衡量 Top-K 中是否包含任务所需的全部 工具。作者在 AppWorld 的下游检查中报告:候选工具集完整时,Agent 21/21 次成功;候选不完整时只有 2/39 次成功。论文原文
所以,一个实用的工具发现层至少要完成三件事:
- 从大目录中找到相关工具;
- 补齐工具之间的前置依赖;
- 在模型看到工具之前限制风险边界。
2. 先定义一个带依赖与风险的工具注册表
工具 Schema 除了名称、说明和参数,还增加 dependencies 与 risk:
python
from dataclasses import dataclass
from enum import IntEnum
from typing import Any
class Risk(IntEnum):
LOW = 1
MEDIUM = 2
HIGH = 3
@dataclass(frozen=True)
class ToolSpec:
name: str
description: str
input_schema: dict[str, Any]
dependencies: tuple[str, ...] = ()
risk: Risk = Risk.LOW
@property
def searchable_text(self) -> str:
fields = " ".join(
self.input_schema.get("properties", {}).keys()
)
return f"{self.name} {self.description} {fields}"
dependencies 描述能力依赖,不负责传递运行时参数。例如 send_email 依赖 find_contact,代表发送前可能需要查联系人;邮箱如何从前一步映射到后一步,仍由工作流或模型处理。
risk 表示工具暴露等级:
| 等级 | 示例 | 默认策略 |
|---|---|---|
| LOW | 查询联系人、读取文档 | 可以按需暴露 |
| MEDIUM | 发邮件、创建日程 | 需要明确任务意图 |
| HIGH | 删除数据、付款、改权限 | 默认不进入候选集,单独审批 |
风险等级不是模型的提示词建议,而是检索层的硬过滤条件。高风险工具没有进入候选集,模型就不能通过普通工具调用选中它。
3. 用纯 Python 实现 BM25 工具检索
BM25 的优势是便宜、可解释,并且适合工具名称、参数名和描述中的精确术语。下面是一个最小实现:
python
import math
import re
from collections import Counter
TOKEN_PATTERN = re.compile(
r"[a-z0-9_]+|[\u4e00-\u9fff]", re.IGNORECASE
)
def tokenize(text: str) -> list[str]:
return TOKEN_PATTERN.findall(text.lower())
class BM25ToolIndex:
def __init__(
self,
tools: list[ToolSpec],
k1: float = 1.5,
b: float = 0.75,
):
self.tools = tools
self.k1 = k1
self.b = b
self.docs = [tokenize(t.searchable_text) for t in tools]
self.counts = [Counter(doc) for doc in self.docs]
self.avg_len = sum(map(len, self.docs)) / max(len(self.docs), 1)
self.df = Counter()
for doc in self.docs:
self.df.update(set(doc))
def search(
self,
query: str,
limit: int = 5,
max_risk: Risk = Risk.MEDIUM,
) -> list[tuple[ToolSpec, float]]:
terms = tokenize(query)
total = len(self.docs)
scored = []
for tool, doc, counts in zip(
self.tools, self.docs, self.counts
):
if tool.risk > max_risk:
continue
score = 0.0
for term in terms:
frequency = counts[term]
if not frequency:
continue
document_frequency = self.df[term]
idf = math.log(
1
+ (total - document_frequency + 0.5)
/ (document_frequency + 0.5)
)
length_norm = self.k1 * (
1 - self.b + self.b * len(doc) / self.avg_len
)
score += idf * (
frequency * (self.k1 + 1)
/ (frequency + length_norm)
)
if score > 0:
scored.append((tool, score))
return sorted(
scored, key=lambda item: item[1], reverse=True
)[:limit]
为了保持示例不依赖第三方分词库,这里把中文按单字切分。它能演示流程,却不适合作为中文生产检索的最终方案。真实项目可以换成结巴分词、领域词典,或者 BM25 与 BGE-M3 向量召回的混合检索。
工具描述也要面向检索编写。只写"处理对象"不够,应包含动作、对象、使用条件和常见别称:
python
ToolSpec(
name="send_email",
description="发送邮件或会议邀请;发邮件,邀请,通知联系人",
input_schema={
"type": "object",
"properties": {
"email": {"type": "string"},
"subject": {"type": "string"},
"body": {"type": "string"},
},
},
dependencies=("find_contact",),
risk=Risk.MEDIUM,
)
4. 检索后递归补全依赖
BM25 只负责选出种子工具。接下来沿依赖图进行深度优先遍历,并把前置工具排在使用者之前:
python
def resolve_dependencies(
seed_names: list[str],
catalog: dict[str, ToolSpec],
max_risk: Risk,
) -> list[ToolSpec]:
ordered: list[ToolSpec] = []
resolved: set[str] = set()
visiting: set[str] = set()
def visit(name: str) -> None:
if name in resolved:
return
if name in visiting:
raise ValueError(f"检测到工具依赖环: {name}")
if name not in catalog:
raise KeyError(f"缺少依赖工具: {name}")
tool = catalog[name]
if tool.risk > max_risk:
raise PermissionError(
f"工具 {name} 超过当前风险等级"
)
visiting.add(name)
for dependency in tool.dependencies:
visit(dependency)
visiting.remove(name)
resolved.add(name)
ordered.append(tool)
for seed_name in seed_names:
visit(seed_name)
return ordered
这段代码还处理两个容易被忽略的问题:
- 依赖不存在时立即报错,避免模型拿到一套不完整能力;
- 工具依赖形成环时终止,避免无限递归。
如果一个中风险工具依赖高风险工具,整个依赖解析会失败。不能因为顶层工具风险较低,就绕过其前置操作的风险限制。
5. 把检索和依赖补全组合起来
python
def discover_tools(
query: str,
index: BM25ToolIndex,
catalog: dict[str, ToolSpec],
top_k: int = 3,
max_risk: Risk = Risk.MEDIUM,
) -> list[ToolSpec]:
seeds = [
tool.name
for tool, _ in index.search(query, top_k, max_risk)
]
return resolve_dependencies(seeds, catalog, max_risk)
我使用 Python 3.13 运行了本文示例,请求为:
text
给张三发一封会议邀请,并安排到他的当地时间
输出候选集为:
text
[
'find_contact',
'send_email',
'get_timezone',
'create_calendar_event'
]
注册表中同时存在高风险的 delete_calendar_event,但它没有进入候选集。这个结果只验证示例代码的行为,不代表真实业务上的召回率。
6. "模型可见工具"与"系统可执行工具"必须分开
系统内部可以注册全部工具,模型每一轮只看到检索后的子集:
text
完整注册表:128 个工具
↓ 检索、权限、依赖
本轮模型可见:6 个工具
↓ 结构化调用
执行器再次校验:身份、参数、审批、幂等键
按需加载只是减少上下文和误选,并不代替执行安全。模型发出工具调用后,执行层仍要验证:
- 当前用户是否有权限;
- 参数是否符合 Schema 和业务规则;
- 高影响操作是否获得审批;
- 重试是否会造成重复写入;
- 调用的工具是否属于本轮允许集合。
对于支持原生 Tool Search 的模型,可以直接使用服务商提供的延迟加载能力。Anthropic 当前提供正则和 BM25 两种搜索方式,并通过 defer_loading: true 标记延迟加载工具;默认搜索返回最多 5 个匹配工具,再展开完整定义。
自建检索层的价值在于:可以跨模型使用,也可以加入本地权限、工具依赖、租户隔离和风险策略。
7. 多工具任务应该评测 completeness@k
普通检索经常使用 Recall@K:需要的工具中有多少出现在候选集。多工具任务还应增加 Completeness@K:
text
需要工具 = {A, B, C, D}
候选工具 = {A, B, C}
Recall@K = 3 / 4 = 75%
Completeness@K = 0
75% 的召回看起来不差,但 Agent 仍然无法完成任务。
建议记录以下指标:
| 指标 | 说明 |
|---|---|
| Tool Recall@K | 必要工具被召回的比例 |
| Completeness@K | 完整必要工具集被召回的任务比例 |
| Risk Exposure@K | 候选集中不必要高风险工具的比例 |
| Schema Tokens | 暴露给模型的工具定义 token 数 |
| Tool Selection Accuracy | 模型最终是否选对工具 |
| End-to-End Success | 任务是否真正完成 |
依赖补全只能补回注册表中已经声明的依赖。如果依赖元数据写错,Completeness@K 仍可能很低。因此,依赖图也需要从真实执行轨迹和失败案例中持续校正。
8. 什么时候不值得做工具检索
如果 Agent 只有五六个短小工具,而且每次任务几乎都要用到,把所有工具直接提供给模型通常更简单。
Anthropic 的工程文章也把工具搜索定位在工具定义超过约 1 万 token、工具数量达到 10 个以上或已经出现选择错误的场景。Advanced Tool Use
工具检索会新增一次搜索,并引入"必要工具未召回"的失败模式。小工具集为了节省几百 token 增加整套检索层,收益可能抵不过复杂度。
9. 生产环境还需要补哪些能力
本文代码是最小实现,生产化至少还需要:
- 混合召回:BM25 处理精确名称,向量检索处理语义表达;
- 查询拆解:把"查客户、生成报告并邮件发送"拆成多个能力需求;
- 租户与角色过滤:检索前先限制用户能够访问的工具集合;
- 依赖参数映射:声明前序输出怎样进入后序参数;
- 版本管理:工具 Schema 或依赖变化后重建索引;
- 评测与追踪:保存候选集、最终调用、遗漏工具和执行结果。
还要防止把外部 MCP Server 的描述直接视为可信内容。工具名称、Schema 和描述进入注册表前应经过允许列表、签名或人工审核,不能让一个不可信服务器通过描述文本诱导 Agent 暴露其他能力。
写在最后
工具数量变多后,Agent 面临的已经不是单纯的 Function Calling,而是一个检索与权限问题。
BM25 或向量检索负责找到相关能力,依赖图负责补齐完整工具链,风险策略负责限制模型能够看到的动作,执行层再进行最终授权。四层缺一不可。
真正应该优化的也不是"每次只给模型五个工具",而是:在尽量小的候选集中,稳定提供完成当前任务所需的全部能力,同时不暴露无关的高风险操作。