工具表是怎么装满的 -- 函数、协议与聚合

本文基于 AgentScope 2.0.6 源码,示例项目为 myagent(AgentScope + FastAPI + Postgres + WebSocket)。

一、承接第四篇:函数,你注册的第一批货

工具表里的每件商品,要么是你注册的函数,要么是 server 递来的契约 -- 本文讲这张表是怎么装满的。

第四篇把第一条来路讲透了:FunctionTool 包装一个 Python 函数,schema 从类型注解自动推导,注册进 Toolkit。函数来路的特点一句话说尽:契约和实现都在你手里 。你写 calculate(expression: str) -> str,schema 是编译器替你从签名里读出来的,调用就是一次进程内的函数跳转。

它还有个常被低估的身份:万能适配器 。任何远端能力都能用函数包进来--myagent 的天气工具(tools/weather.py)内部就是一次 httpx 请求高德 API,外面套一层 FunctionTool。远端 API 不需要任何协议就能进门,代价是契约(schema 和参数说明)得你手写、你维护。

手写契约是条活路,但不是唯一的路。当远端能力多起来、跨项目复用起来,"每个 API 手写一层包装"就开始发臭--于是有了第二条来路:让 server 自己把契约递过来。

二、协议来路:契约长在 server 身上

协议来路和函数来路的根本差异只有一处:契约的归属--函数的契约写在你代码里,协议的契约由 server 在运行时递给你。

先看 AgentScope 里一份真实的 MCP 配置(agentscope/mcp/_config.py):

python 复制代码
from agentscope.mcp import MCPClient, StdioMCPConfig, HttpMCPConfig

# 本地 MCP server:拉起一个子进程,走 stdio 通信
fs_client = MCPClient(
    name="file_system",
    is_stateful=True,
    mcp_config=StdioMCPConfig(command="mcp-server-filesystem", args=["/data"]),
)

# 远程 MCP server:一个 HTTP 端点
weather_client = MCPClient(
    name="weather",
    is_stateful=False,                       # 无状态:按调用临时建连
    mcp_config=HttpMCPConfig(url="https://api.example.com/mcp"),
    enable_tools=["get_weather"],            # 只要这一件货
    disable_tools=None,
    execution_timeout=10.0,
)

注意你写了什么:command/urlargsheaders -- 全是连接信息。你一个工具的名字都没提。那模型看到的工具清单从哪来?运行时动态拉的:

text 复制代码
  配置(你写的)                运行时(自动发生的)
  ┌──────────────────┐        ① 按 config 建立 session(connect)
  │ MCPClient(        │        ② session.list_tools() 拉取工具清单
  │   name="weather", │ ────▶  ③ enable/disable_tools 过滤
  │   url="...",      │        ④ 包装成 MCPTool,前缀化命名
  │   enable_tools=[...] │        ⑤ 合并进 Toolkit 的扁平工具表
  │ )                 │        ⑥ 序列化进模型请求
  └──────────────────┘

这就是"进货渠道 vs 货架商品":配置声明的是渠道(连哪个 server),工具清单是开张后动态上架的。直接后果:server 升级新增了工具,你的 Agent 重启就能看到,配置一个字不用改--契约的维护权外包给了 server。

三个值得停下来看的细节:

细节一:过滤内建在连接里。 enable_tools / disable_tools 就长在 MCPClient 上(_mcp_client.py:89-96)。而且过滤发生在拉取之后list_raw_tools 先拉全量缓存,再按名单筛),所以被过滤掉的工具仍然留在缓存里,get_tool 依然能按名字取到。这是"给模型的清单"和"实际可用的清单"刻意分离。

细节二:连接分有状态和无状态。 is_stateful=True 意味着要显式 await client.connect() 建立常驻会话、用完 close();stdio 传输必须有状态(子进程要一直活着)。HTTP 端点则可以无状态:每次调用工具时临时建一次会话、用完即散。

细节三:传输选择藏在 URL 里。 AgentScope 怎么知道一个 HTTP 端点该用 SSE 还是 Streamable HTTP?看 URL 后缀(_mcp_client.py:189-213):以 /sse/messages/ 结尾走 SSE,否则走 Streamable HTTP。协议演化史被压缩成了一个路径后缀的 if-else。

这个"连接 + 过滤"的槽位结构不是 AgentScope 独有的。Claude Code 的 .mcp.json、opencode 的 mcp 配置段、Coze 里勾选一个 MCP 插件 -- 剥掉语法外壳,写的都是同样的东西:连哪、怎么鉴权、要哪些工具。槽位收敛,变的只是载体。

补一句座位与门牌的区分:这条来路的名字叫协议 ,MCP 只是这个座位上的现任标准化代表。它的前身是 OpenAPI/REST 工具(ChatGPT 插件时代):开发者把 OpenAPI 文档贴进配置,平台把函数调用翻译成 HTTP 请求。两者的实质差异在契约从哪来--MCP 是运行时动态发现(tools/list,server 自己维护清单,上文"重启即得"的由来),OpenAPI 是静态贴配、人肉维护,server 加了工具得重贴文档。而上一节说的"函数包 HTTP",就是这两条来路之间的侧门:不套协议也能进,契约自己扛。

三、直连与 Hub:连接由谁管

契约多了,连接就成了资产 -- Hub 回答的不是"工具从哪来",而是"这条来路的连接由谁管"。

聊 MCP 时最容易被一个词搅浑:MCPHub。它听起来像是和"MCP tool"并列的另一种东西,需要"二选一"。实际上两者根本不在同一层:

text 复制代码
  MCP 的三层结构
  ┌──────────────────────────────────────────────┐
  │ 你配置的    ->  MCP 连接(MCPClient:端点+鉴权) │  配置层
  │                │                              │
  │                ▼  一个 server 暴露 N 个能力     │
  │ 模型看到的  ->  MCP tool(mcp__xx__yy:函数)    │  消费层
  └──────────────────────────────────────────────┘

  Hub 插在哪?插在"连接"和"server"之间:
  ┌────────┐      直连拓扑(AgentScope 默认)           ┌────────┐
  │ client │──────┬────▶ [server A]                    │ client │
  └────────┘      ├────▶ [server B]                    └───┬────┘
                  └────▶ [server C]                        ▼
                                              ┌─────────────────┐
  配置里写 3 个连接                            │      HUB        │
                                              │  鉴权/过滤/审计   │──▶ server A
                                              └────────┬────────┘──▶ server B
  配置里只写 1 个连接 ─────────────────────────────────┘           └─▶ server C

Hub 是一个聚合网关:把 N 个 server 挂在一个端点后面。对你的 Agent 来说,Hub "长得就像一个大号 server" -- 配置里只是少写几个连接而已。

Hub 的关键性质在两个"面"上完全不同:

  • 数据面(工具调用的流量):协议透明。client 说 MCP、Hub 说 MCP、上游 server 还是说 MCP。Hub 必须说标准 MCP,否则没有一个客户端愿意接它 -- 它的全部价值就是"零改造接入"。
  • 控制面(注册 server、配租户、管凭据):私有 REST API,各家自造,互不兼容。这很正常:控制面是 Hub 厂商的差异化所在,也是换 Hub 时真正的迁移成本所在(数据面换个 URL 就走,控制面的策略全得重配)。

AgentScope 走的是直连拓扑:Toolkit 构造器里的 mcps=[...] 列表,就是你的"进程内聚合器" -- 每个 MCPClient 一个连接,聚合逻辑全在本地。什么时候值得上 Hub?当"谁来管连接"变成团队问题的时候:多个 Agent、多个用户共享同一批 server,鉴权和过滤策略需要集中管理 -- 那是 Hub 的主场。个人项目直连就够了。

四、聚合:前缀 + 路由表 + 一份预算

两条来路的货最终汇进同一张表 -- 聚合的全部机密,是一个前缀约定加一张路由表。

函数和协议两条来路的货,怎么汇进 Toolkit 的同一张扁平表?答案朴素得让人失望 -- 命名空间拼接:

python 复制代码
# agentscope/tool/_adapters.py:219(MCPTool.__init__)
self.name = f"mcp__{mcp_name}__{sanitized_tool}"

file_system server 的 read_file 工具,上架时改名为 mcp__file_system__read_fileweather server 的 get_weather 变成 mcp__weather__get_weather。两家的工具汇进 Toolkit 的同一张扁平表:

text 复制代码
  agent 进程内(Toolkit)
  ┌─────────────────────────────────────────────┐
  │ session A (file_system) ──list_tools──▶ [read_file, write_file]
  │ session B (weather)     ──list_tools──▶ [get_weather]
  │         │                    │
  │         ▼ 前缀化 + 去重         ▼
  │ ┌─────────────────────────────────────┐   │
  │ │ 扁平工具表(本质是个 dict):           │   │
  │ │   "mcp__file_system__read_file" -> A │   │
  │ │   "mcp__file_system__write_file" -> A │   │
  │ │   "mcp__weather__get_weather"   -> B │   │
  │ │   "calculator"(进程内函数)      -> 本地│   │
  │ └─────────────────────────────────────┘   │
  │         │                                  │
  │         ▼                                  │
  │   统一清单序列化进模型请求(tools 参数)       │
  │         │                                  │
  │   模型: tool_call("mcp__weather__get_weather")│
  │         │                                  │
  │   查表 -> 剥前缀 -> 转发到 session B           │
  │         -> call_tool("get_weather")          │
  └────────────────────────────────────────────┘

注意最后一行:进程内的 calculator(函数来路)和两个 MCP server 的工具(协议来路),躺在同一张表里。模型根本不知道、也不需要知道某个工具是本地函数还是远程服务。

两个源码里值得敬佩的细节:

前缀消解的严谨版。 前缀用 __ 分隔,那 server 传来的工具名里如果恰好有非法字符怎么办?_adapters.py:213-218 的做法:非法字符替换成 x不是 _ -- 因为换成 _ 可能造出新的 __ 分隔符,让 a.ba_b 撞名。原名字保留在内部字段,转发调用时用原名。一个字符的选择,背后是命名空间污染的防御。

容错的哲学。 Toolkit 拉取工具清单时,某个 server 连不上怎么办?源码注释写得很直白(_toolkit.py:519-529):"One unreachable MCP must not take the reply down with it" -- 一个失联的 server 只是它的工具暂时下架,记一条 warning,整轮对话照常进行。聚合器对上游故障的默认姿态是降级,不是崩溃。

但表是有预算的。 装满一张表不等于免费的盛宴:

text 复制代码
  上下文预算:
  ┌────────────────────────────────┐
  │ ████████ 系统提示 + 人设         │
  │ ████████████ 工具定义(常驻!)   │  ← 每接一个 server 胀一截,
  │ ████ 对话历史                   │     且每一轮推理都带着
  │ ██ 留给真正干活的空间            │  ← 被持续挤压
  └────────────────────────────────┘

工具定义是常驻成本 :不像对话历史可以压缩,它每轮都在。接十个 server、每个八件工具,光工具 schema 就可能吃掉几千 token -- 这是工程经验法则,量级取决于工具描述的详尽程度。所以判据只有一句话:你的任务需要模型在一个注意力窗口里同时看见这几件工具吗? 需要(改代码 = 读写 + 搜索交替进行)就聚合同框;不需要(翻译的归翻译、数据库的归数据库)就别往一张表里塞--塞不下的能力怎么办,本文结尾给答案。

五、元工具:表里会自己长货的那件商品

表里还有一件特殊的货:它自己会造工具 -- 元工具是"会使用工具"和"会制造工具"的分界。

_builtin/_bash.py:25

python 复制代码
class Bash(ToolBase):
    name: str = "Bash"
    description: str = """Executes a bash command and returns its output.
The working directory persists between commands, but shell state
does not. ...

IMPORTANT: Avoid using this tool to run `find`, `grep`, `cat`,
`head`, `tail`, `sed`, `awk`, or `echo` commands ... Instead, use
the appropriate dedicated tool ..."""

Bash 在机制上就是个普通工具--入参一条命令,出参执行结果。但它和 calculator 有本质区别:calculator 能算数,Bash 能造出 calculator。写一段脚本、跑起来、拿到结果--agent 缺什么工具,就能现场写一个。前几节的来路都是"给定的能力",这件商品是"生成能力的入口"。

它的 description 里藏着全篇最妙的一行:这个元工具的说明书,用一整段 IMPORTANT 劝模型别用它--优先用 Glob/Grep/Read 这些专职工具。连元工具自己都在给元工具降温:能造一切的东西,也最该被节制使用。

六、收束:表满了,装不下怎么办

表内的故事到头了:函数注册、协议递契、前缀聚合、元工具兜底 -- 但预算是刚性的,装不下就得换思路。

text 复制代码
  工具表(表内全家福)
  ┌────────────────────────────────────┐
  │ 函数     契约和实现都在你手里(万能   │
  │          适配器,远端 API 也能包)    │
  │ 协议     契约由 server 运行时递给     │
  │          你(直连或经 Hub)          │
  │ 元工具   会造工具的工具(Bash)       │
  └────────────────────────────────────┘
              │
              ▼ 预算见底时
  ┌────────────────────────────────────┐
  │ 表内收窄:过滤清单、上 Hub、按需激活  │  ← 本文 §二/§三/§四
  │ 表外隔离:让能力绕过工具表,          │  ← 下一篇
  │          直接进入(或发生在)上下文   │
  └────────────────────────────────────┘

表内收窄是本文讲过的所有手段的合流:enable_tools 过滤、Hub 集中裁剪、工具组按需激活。而表外隔离是一条全新的路--下一篇的主角:有一种能力不进工具表、不能被 tool_call,模型却靠它平白多出本事;还有一种办法,把"一整个 agent"当工具用,让脏活发生在别人的上下文里。工具表外面,还有两条通道。

七、常见陷阱

陷阱 1:手写 schema 与实际 API 漂移

症状:函数包装的远端工具,模型传参老出错,或调用成功但结果解析失败。

原因:函数侧门的契约是你手写的。上游 API 改了参数或返回结构,你的 schema 和适配代码不会跟着变--这正是协议来路用动态发现消灭掉的问题,在函数侧门原样复发。

解决:包装层加契约测试(定期用 schema 里的示例参数打一次真实 API);高频变动的 API 考虑改走 MCP。

陷阱 2:无状态 HTTP client 用在需要会话的 server 上

症状:工具能列出来,调用却随机失败或状态丢失。

原因is_stateful=False 的 client 每次调用都新建会话,server 端如果有会话状态(登录态、订阅),每次都是新的。

解决 :有会话语义的 server 用 is_stateful=True 常驻连接;纯无状态查询类才用 False。stdio 传输没有选择,必须常驻。

陷阱 3:给 Bash 配了免确认权限

症状:危险命令(rm、curl 外发)不经确认直接执行。

原因:把元工具当普通工具放行。Bash 能做任何事,包括你不想让它做的事;它的 description 里那段"劝退"只是软约束。

解决:保留 ASK 默认值;真要放行也只对白名单命令模式放行,而不是对整个工具放行。

八、收尾:一张表的经济学

第四篇造了一件商品(FunctionTool),这一篇讲清了整张表的进货与装配:

  • 函数来路:契约和实现都在你手里,万能适配器什么都能包
  • 协议来路:契约长在 server 身上,配置写连接、运行时拉清单(MCP 动态发现 / OpenAPI 静态贴配)
  • 直连与 Hub:连接是资产,个人直连、团队上 Hub
  • 聚合:前缀约定 + 一张路由表 + 容错降级,两路货汇成一张表
  • 预算:工具定义是常驻成本,判据是"是否需要同框"
  • 元工具:会造工具的工具,表内自我扩展的极限

下一篇离开工具表:《工具表之外 -- 派生、注入与两条原语》。你会看到能力如何绕过工具表直接进入上下文,以及"把另一个 agent 当工具用"时,上下文预算怎么被彻底绕开。

延伸思考:契约的维护权、前缀的边界、元工具的白名单

  • 函数侧门"契约自己扛"和协议侧"契约外包给 server",各自最坏的情况是什么?(提示:一边是漂移,一边是 server 偷偷改描述)
  • 两个 MCPClient 起了相同的 name,扁平表会怎样?(提示:_toolkit.py 对重名的处理是 warning + overwrite -- 那模型的调用会路由到哪一家?)
  • 如果给 Bash 做一个"技能目录式"的命令白名单--目录注入、调用取件--算不算用下一篇的机制给元工具套上缰绳?
相关推荐
HIT_Weston3 小时前
183、【Agent】【OpenCode】TuiThreadCmd(JS&TS 历史)
人工智能·agent·opencode
Flynt4 小时前
从 Claude Code 切到 Pi 跑了一阵,聊聊真实体感
agent·ai编程·claude
番茄不是西红柿kk5 小时前
deepseek-harness跨平台桌面端二开项目(附git仓库地址+安装包)
git·agent·codex·deepseek·deepseekharness
张忠琳6 小时前
【deepseek-harness】DSH 文档合辑 · 篇一:核心架构与概览
ai·agent·deepseek·harness
海兰6 小时前
【插件】OpenClaw 上下文引擎指南
人工智能·agent·openclaw
云烟成雨TD7 小时前
LlamaIndex 系列【5】智能体开发:大语言模型接入与基础调用
ai·agent·rag·llamaindex
海兰7 小时前
【原理】OpenClaw Agent 运行时回顾一文清
人工智能·agent
新知图书7 小时前
11.4 基于扣子编程的实现过程(AI 数据质检工作流)
人工智能·agent·ai agent·智能体
weixin_471383037 小时前
22 多 Agent 架构
agent