文章目录
-
-
- [1. 问题:MCP 工具一股脑全注册,代价是什么](#1. 问题:MCP 工具一股脑全注册,代价是什么)
- [2. 思路:把"拥有的工具"和"该暴露的工具"拆开](#2. 思路:把"拥有的工具"和"该暴露的工具"拆开)
- [3. 实现:双池 + 三个元工具](#3. 实现:双池 + 三个元工具)
- [4. 兜底:模型没激活就调用怎么办](#4. 兜底:模型没激活就调用怎么办)
- [5. 完整流程演示](#5. 完整流程演示)
- [6. 什么时候该用、什么时候不必](#6. 什么时候该用、什么时候不必)
-
1. 问题:MCP 工具一股脑全注册,代价是什么
给 Agent 接 MCP(Model Context Protocol)的标准动作是:连上 server,把它返回的所有工具一次性注册进可调用列表。接一两个 server 没感觉,接到三五个、每个再带十几个工具,问题就来了。
代价一:上下文被工具 schema 撑大。 每个工具暴露给大模型,是一段包含名字、描述、参数定义的 JSON。一个中等复杂度的工具,schema 摊开大约两三百个 token(经验量级,随参数多少浮动)。二十个工具就是好几千 token,而且每一轮请求都要带着,不管这轮用不用得上。
代价二:工具一多,模型就开始选串。 工具列表越长,大模型挑错工具、调错参数的概率越高。尤其当工具名相近时(get_row_by_id、get_rows_by_conditions、get_row_by_key_value),模型很容易混。工具不是越多越强,过了某个临界点反而掉准确率。
这两个问题在 MCP 生态爆发后特别突出------能接的 server 越来越多,全量注册的负担也越来越重。
2. 思路:把"拥有的工具"和"该暴露的工具"拆开
根因在于我们把两件事当成了一件:"Agent 能用的全部工具" 和 "这一刻该让模型看见的工具"。
把它俩拆开,方案就出来了:工具都注册进来,但默认不暴露给模型;模型需要时,自己去发现、激活。下面用我的开源框架 milu 的实现来讲具体怎么落地。
3. 实现:双池 + 三个元工具
第一步,工具分两个池。 注册表里维护两个字典:活跃池(注入模型,可直接调用)和休眠池(已注册但不注入):
python
class ToolRegistry:
def __init__(self):
self._tools: dict[str, ToolWrapper] = {} # 活跃池:schema 进上下文
self._dormant: dict[str, ToolWrapper] = {} # 休眠池:不进上下文
关键在生成给大模型的 schema 时,只遍历活跃池:
python
def get_schemas(self) -> list[dict]:
schemas = []
for wrapper in self._tools.values(): # 只有活跃池,休眠工具一概不出现
schemas.append({
"type": "function",
"function": {
"name": wrapper.name,
"description": wrapper.description,
"parameters": wrapper.parameters_schema,
},
})
return schemas
MCP 工具默认进哪个池?由一个开关决定,默认让它们休眠:
python
mcp_tools_active_by_default: bool = False # 默认 False:MCP 工具进休眠池
第二步,给模型三个"元工具"去自助。 既然工具休眠了,就得让模型能查、能激活。常驻三个轻量元工具在活跃池:
python
# list_catalog ------ 列出所有休眠工具(按分类分组)
# search_tools ------ 关键词搜索休眠工具
# activate_tools ------ 激活指定工具,使其下一轮出现在工具列表里
休眠工具暴露给模型时只给摘要(名字 + 截断到 100 字的描述 + 分类),不给完整 schema,省得多:
python
def list_dormant_tools(self) -> list[dict]:
return [
{"name": w.name, "description": w.description[:100], "category": w.category}
for w in self._dormant.values()
]
分类是注册时从工具名前缀自动切的,fetch__html 归 fetch,mysql__query 归 mysql,列目录时自然分组。
第三步,激活就是搬家。 把工具从休眠池字典挪到活跃池字典,O(1):
python
def activate(self, name: str) -> ToolWrapper | None:
wrapper = self._dormant.pop(name, None)
if wrapper:
self._tools[name] = wrapper # 进活跃池,下一轮 get_schemas 就带上它
return wrapper
4. 兜底:模型没激活就调用怎么办
模型有时会"抢跑"------在目录里看到 mysql__query,没激活就直接调。执行器拦一道,回一句友好提示:
python
if self._registry.is_dormant(func_name):
return ToolExecutionResult(
output=f"工具 {func_name} 尚未激活。请先调用 activate_tools 激活它。",
is_error=True,
)
模型读到这条,补一步 activate_tools 就接上了,不会卡死。
5. 完整流程演示
用户说"查下数据库里有多少用户",内部是这样跑的:
- 模型没看到 mysql 工具,但它知道有
search_tools; - 调
search_tools("mysql user")→ 返回mysql__query等候选的摘要; - 调
activate_tools(["mysql__query"])→ 回"已激活: mysql__query"; - 下一轮
mysql__query的完整 schema 进了工具列表,模型直接调它执行 SQL。
整个过程,上下文里始终只有 3 个元工具 + 真正被激活的那一两个,其余几十个工具全程休眠、不占上下文。
6. 什么时候该用、什么时候不必
这套方案不是无脑全上:
- 工具就三五个:没必要,直接全激活(把开关设成
True)更省事。 - 工具几十上百个、或上下文窗口吃紧:按需激活的收益明显,代价是首次用到某工具时多一两轮"搜索 → 激活"的往返(延迟换上下文)。
- 它和"压缩历史消息"是两条互补的线:一个砍工具列表的常驻开销,一个砍对话历史的堆积,长对话两个一起上才稳。
完整实现在 milu 仓库的 src/milu/tools/registry.py 和 catalog.py,几百行,想抄思路或直接看代码都行。
项目地址:GitHub 搜
stephonGAO/milu(中文 LLM 一等公民的多用户 Agent 框架)
你在接 MCP 时遇到过工具太多导致模型选错、或上下文被 schema 撑爆的情况吗?评论区说说你的处理办法,我会一条条回。