Agent 工具太多怎么办:BM25 检索、依赖补全与按需加载实战

一个 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 可能需要:

  1. find_contact:找到张三的邮箱;
  2. get_timezone:确认当地时区;
  3. create_calendar_event:创建会议;
  4. 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. 生产环境还需要补哪些能力

本文代码是最小实现,生产化至少还需要:

  1. 混合召回:BM25 处理精确名称,向量检索处理语义表达;
  2. 查询拆解:把"查客户、生成报告并邮件发送"拆成多个能力需求;
  3. 租户与角色过滤:检索前先限制用户能够访问的工具集合;
  4. 依赖参数映射:声明前序输出怎样进入后序参数;
  5. 版本管理:工具 Schema 或依赖变化后重建索引;
  6. 评测与追踪:保存候选集、最终调用、遗漏工具和执行结果。

还要防止把外部 MCP Server 的描述直接视为可信内容。工具名称、Schema 和描述进入注册表前应经过允许列表、签名或人工审核,不能让一个不可信服务器通过描述文本诱导 Agent 暴露其他能力。

写在最后

工具数量变多后,Agent 面临的已经不是单纯的 Function Calling,而是一个检索与权限问题。

BM25 或向量检索负责找到相关能力,依赖图负责补齐完整工具链,风险策略负责限制模型能够看到的动作,执行层再进行最终授权。四层缺一不可。

真正应该优化的也不是"每次只给模型五个工具",而是:在尽量小的候选集中,稳定提供完成当前任务所需的全部能力,同时不暴露无关的高风险操作。

相关推荐
小林ixn10 分钟前
别再全量塞历史了:用 Redis 给 Agent 装上「短期记忆」
redis·agent
用户66026761187914 分钟前
AI 代码上线前的第 24 道门禁:上线失败时,怎样在 10 分钟内退回?
后端·架构
NishikiAsumi072117 分钟前
一次170万行的导出:sql优化远没有加索引这么简单
后端
发现你走远了1 小时前
05-权限不是开关:工具调用如何在放行、拦截与追问之间做决定
人工智能·agent·claude
xjz18421 小时前
Rust 注释:从 // 到 rustdoc
后端
泯泷1 小时前
RL 训练真正难的不是算法:环境、奖励与样本效率
llm·agent
syyjhchou3 小时前
自己搭企业内部中转站,怎么让请求更容易命中缓存
人工智能·后端·语言模型·aigc
特立独行的猫A3 小时前
Ionic + 仓颉语言的 cj-tauri 开发框架实战:开发精美移动端 APP
java·后端·python
威哥爱编程4 小时前
模拟面试智能体开发实战 07:答完一题就点评一题,结构化输出给分建议,让每道题都变成复盘
python·agent·harmonyos