DeepAgents(十五):上千个 MCP 工具怎么接进 harness

2026-10-21 05:220 条评论5 次阅读

上千个 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=。

MCP server 上的 N 个工具

get_tools 拿回 N 个对象

整批塞进 tools=

内置 8 个原样保留

MCP 新增 N 个

模型侧 8 加 N 个

拿回来的东西是 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

同一个 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 对工具只有一个态度,你给多少它绑多少。挑工具这件事,得在它之前做完。