DeepAgents(十五):上千个 MCP 工具怎么接进 harness
上千个 MCP 工具怎么接进 harness
tools= 是 harness 唯一一个能收 MCP 的口子。它只往里加,不往外拿。
harness 自己不认识 MCP,它只认一个工具列表。上千个工具接进来,它一个都不筛,全部绑给模型,这笔固定开销在第一轮对话之前就付掉了。
这篇把这条链接一遍:MCP 从哪儿进、进来之后工具面变成什么样、一个工具值多少钱、上千个的时候该怎么办。
MCP 从 tools= 这个口子进来
先确认一件事。deepagents 包里,跟 MCP 有关的字节一个都没有。
实测扫包内 53 个 .py 文件、1150678 字节,搜 mcp 这个字符串,不分大小写,命中 0 处。create_deep_agent 一共 18 个参数,里面有 tools,没有 profile。
所以 MCP 不是 harness 的一种能力,是你从外面接进来的东西。接法还是 langchain 那套:起客户端、get_tools() 拿回一批工具对象、整批喂给 tools=。
拿回来的东西是 StructuredTool,跟本地手写的工具是同一个类。有个细节值得看一眼:它挂的 args_schema 是 1 个 dict,不是 pydantic 类。
这个差别不影响模型看到的价钱。实测用同样的名字、同样的描述、同样的 2 个参数各造 1 批,MCP 来的和本地写的,绑定时单个都是 99 token。
模型不关心工具从哪来。
只加不减,有个例外
官方 docstring 把这件事写得很直白:Passing tools here is additive,it never removes a built-in。
实测对得上。默认装配模型侧 8 个工具,喂 10 个 MCP 工具进去,变成 18 个。前 8 个位置还是原来那 8 个,接进来的排在后面,一个内置工具都没少。
不过这句话管不到另一种情况。
| 情况 | 模型侧工具面 | 留下的那一份 |
|---|---|---|
| 名字不撞 | 8 个变 18 个 | 内置 8 个全在,新增 10 个 |
名字撞上 read_file / grep / task |
还是 8 个 | 3 个名字都归接进来那份 |
第二行的做法是让 MCP server 上直接摆 3 个跟内置同名的工具。装配不报错,模型侧还是 8 个名字、没有重复,但那 3 个名字底下躺的已经是 MCP 版:描述 26 字符,不是内置那份的 1220、557、1419。
去重发生在 langgraph/prebuilt/tool_node.py 第 784 行,self._tools_by_name[tool_.name] = tool_,按顺序赋值。内置在前、tools= 在后,所以后进的那份赢。
接进来的工具可以顶掉内置的,只是工具面不会因此变多。 想让 MCP 接管某个内置能力,这是个现成的口子;反过来,如果名字撞上了,你等于悄悄换掉了一个内置工具。
一个工具值多少钱
先量默认那 8 个,口径是单个工具单独绑定时的 token:
| 工具 | token | 描述字符 |
|---|---|---|
grep |
597 | 557 |
task |
499 | 1419 |
read_file |
456 | 1220 |
glob |
414 | 809 |
edit_file |
293 | 357 |
write_file |
191 | 321 |
delete |
161 | 362 |
ls |
118 | 206 |
| 合计 | 2729 |
接回来的 MCP 工具便宜得多,单个 99 token,10 个求和 990,是这 8 个的 0.36 倍。
但这 99 绑着一套形状:名字 15 字符、描述 26 字符、2 个参数。描述一换长就不一样了。
同样 1000 个工具,只把描述从 26 字符换成 126 字符:
| 描述长度 | 单个 | 1000 个 |
|---|---|---|
| 26 字符 | 99 | 99000 |
| 126 字符 | 249 | 249000 |
所以「一个工具多少钱」这句话没有通用答案。报价必须连描述长度一起说,这也是为什么平台的 API 目录里那种两三行说明的工具,比一句话的工具贵得多。
1000 个工具就是 10 万 token
工具面随数量的账,实测这一串:
| 接进来多少个 | 模型侧工具面 | token |
|---|---|---|
| 0 | 8 | 2729 |
| 12 个(先筛过) | 20 | 3917 |
| 1000 个 | 1008 | 101729 |
| 2000 个 | 2008 | 200729 |
中间几档也量了。接进来 10、100、500、1000、2000 个,工具那一侧的 token 分别是 990、9900、49500、99000、198000,单个均值稳稳停在 99。这是加法,没有规模效应。
1000 个工具就等于 10 万 token 的固定开销,在你说第一句话之前就付掉了。这些数字还只是工具本身,把 1000 个工具的 schema 摊开写是 269496 字符。同一轮的 system 提示词是 0 字符。
顺带说下口径差别:上面用的是「单个工具各自算再相加」。如果换成「整批一次算」,1000 个是 93728,比逐个求和少 5% 左右。2 个数都对,只是量的东西不一样,别混着引。
先筛再接,差 26 倍
1000 个工具直接接进去,这条路走不通。真正能做的是在进 harness 之前挑一遍。
同一个 server、同一批工具对象,只换喂进去的数量:喂 1000 个,模型绑到 1008 个工具、101729 token;挑 12 个喂进去,模型绑到 20 个、3917 token。
筛掉 988 个,省下 97812 token,26 倍。
注意筛的位置。tools= 之前那道筛选不属于 harness,它得由你的服务来做:先把工具按任务路由出一个小集合,再把小集合交给 tools=。这一步放在 harness 外面,harness 里没有任何参数能替你做。
想删工具,出口只有一个
tools= 是加法的,那减法在哪?
不在 tools= 里。你传 8 个工具进去,内置的还是 8 个都在。但 harness 有个统一的减法口,是 profile 上的 excluded_tools。
实测这个口对内外一视同仁。排掉内置的 grep 和 glob,工具面从 20 掉到 18;排掉 1 个 MCP 工具名和内置的 task,也同样生效。模型侧看不到,调用边界上也拦着:模型硬发一个被排掉的名字,回来的是一条错误回执,原文 Error: grep is not available.
有个坑要记住:注册是叠加的。同一个模型键注册 2 次,第 1 次排 grep、第 2 次排 glob,结果是 2 个都没了,不是后来者替换。想换一套排除,得换一个模型键。
另外 create_deep_agent 的 18 个参数里没有 profile,收窄只能按模型键注册进去。
工具描述也能压,走 profile 的 tool_description_overrides。实测把一个 126 字符描述的 MCP 工具改成 4 个字符,那个工具从 249 token 掉到 69。
代价是它按名字一条条写,1000 个工具就得写 1000 条。压描述这件事,放在工具源头做才划算。
一段能跑起来的代码
from typing import ClassVar
import deepagents as da
from langchain_core import (
language_models as lm,
messages as msg,
outputs as ops,
tools as lt,
)
class Spy(lm.BaseChatModel):
"""只记工具清单,不发请求。"""
bound: ClassVar[list] = []
@property
def _llm_type(self) -> str:
return "spy"
def _get_ls_params(self, **kw):
return {"ls_provider": "lab"}
def bind_tools(self, tools, **kw):
ns = [t.name for t in tools]
Spy.bound = ns
return self
def _generate(self, *a, **kw):
ai = msg.AIMessage("好")
g = ops.ChatGeneration(
message=ai
)
return ops.ChatResult(
generations=[g]
)
def mk(n: int) -> list:
res = []
for i in range(n):
@lt.tool("svc_%04d" % i)
def fn(q: str) -> str:
"""占位。"""
return "ok"
res.append(fn)
return res
def face(**kw) -> list:
Spy.bound = []
a = da.create_deep_agent(
model=Spy(), **kw
)
m = msg.HumanMessage("跑")
a.invoke({"messages": [m]})
return Spy.bound
assert len(face()) == 8
tools = mk(12)
assert len(face(tools=tools)) == 20
这段不用 key,3 条断言都能过。Spy 是个只记工具清单的假模型,bind_tools 收到什么它就记什么,所以它量的是模型侧,不看源码也不看文档。
第 1 条断言是默认装配的 8 个。第 2 条把 12 个工具喂进去,工具面变 20 个,加法成立。
减法在那个基础上再加 2 行:
from deepagents import (
HarnessProfile,
register_harness_profile,
)
register_harness_profile(
"lab",
HarnessProfile(
excluded_tools=frozenset(
{"grep", "glob"}
)
),
)
assert len(face(tools=tools)) == 18
"lab" 是假模型声明的 provider,真实的写法是 provider:model。注册完再量,18 个。
把货架摆在 harness 前面
平台的 API 目录动辄上千个接口,全量接进 harness 的结果上面已经量出来了:10 万 token 打底,而且这还只是工具那一块。
可用的做法是把筛选做成架构里的一层,而不是靠 harness 兜。工具目录照旧全量维护,进 harness 之前按任务路由出一小撮,再交给 tools=。
一句话收口:harness 对工具只有一个态度,你给多少它绑多少。挑工具这件事,得在它之前做完。