跳到主要内容

工具从哪来:四来源聚合与调用路由

30 秒导读: LangBot 的本地 Agent 每一轮都要拿到"我现在能用哪些工具",还要在模型说"调用 exec"时把这次调用送到真正干活的代码。这两件事全归 ToolManager 管:它把四个来源不同的工具聚合成一份清单,再按工具名把每次调用路由回对应来源执行。本章讲这套聚合与路由,不讲工具循环本身(见 03-local-agent-loop)和 Box 沙箱内部实现(见 05-extensibility-and-surfaces)。

1. 这是什么(零基础也能懂)

一句话定义: ToolManager 是一个工具中枢——把散落在四个不同子系统里的工具,收拢成一份 LLM 看得懂的统一清单,并在调用发生时把它送回该去的地方。

它解决什么问题。 一个 Agent 能用的"工具"其实来路很杂:

  • 有的是 LangBot 内建、跑在沙箱里的 exec/read/write(自带的手脚);
  • 有的来自用户装的插件
  • 有的来自 LangBot 作为客户端连出去的外部 MCP 服务器;
  • 还有一类是"制作技能"用的 activate/register_skill

模型不关心这些区别——它只想要一份"函数列表",然后说"我要调这个函数、参数是这些"。ToolManager 就站在中间抹平来源差异:对上游(模型)给一份统一清单,对下游(各子系统)按来源分发。

一句话直觉: 把它想成公司总机。四个部门(native/plugin/mcp/skill)各有自己的电话,但对外只公布一个总机号码;来电(一次工具调用)报上分机名,总机负责转接到正确的部门。

一个容易混淆点,先说清楚(重要)。 本章的 mcp.pyLangBot 作为 MCP 客户端,连接出去用的外部工具服务器("我去调别人的工具")。这和 05 章里 LangBot 自己对外暴露的 /mcp 服务器("别人来调我的能力")方向完全相反。仓库自己的 AGENTS.md 也专门提醒过别把这两者搞混。本章只讲"连出去"的那一侧。

2. 顶层全景(它大概怎么转)

ToolManagerpkg/provider/tools/toolmgr.py)持有四个 loader,全流程围绕它们展开。

┌───────────────────────────────────────────┐
模型/流水线 ───► │ ToolManager │
"给我工具清单" │ (pkg/provider/tools/toolmgr.py) │
"执行 exec" │ │
│ ┌── native_tool_loader ──► 沙箱手脚 │
│ ├── plugin_tool_loader ──► 插件系统 │
│ ├── mcp_tool_loader ──► 外部 MCP 服务器│
│ └── skill_tool_loader ──► 技能制作 │
└───────────────────────────────────────────┘
│ ▲
聚合(get_all_tools) 路由(execute_func_call)
▼ │
一份 LLMTool 列表 ───► 转成 OpenAI function schema

四个 loader 各管一类来源:

loader文件管什么工具谁在背后干活
native_tool_loaderloaders/native.py沙箱里的 exec/read/write/edit/glob/grepBox 沙箱服务
plugin_tool_loaderloaders/plugin.py用户插件暴露的 Tool 组件插件运行时(plugin_connector)
mcp_tool_loaderloaders/mcp.pyLangBot 连出去的外部 MCP 服务器上的工具远端 MCP server
skill_tool_loaderloaders/skill_authoring.pyactivate / register_skill技能管理器 + 沙箱

主线走一遍(高层,不进代码)。 一次带工具的对话是这样转的:

  1. 装配:启动时 ToolManager.initialize 把四个 loader 一一 initializetoolmgr.py:35)。
  2. 聚合:流水线预处理阶段调 get_all_tools,按当前会话绑定的插件/MCP 服务器过滤,拿到一份 LLMTool 清单,塞进 query.use_funcspkg/pipeline/preproc/preproc.py:116)。
  3. 翻译:请求模型前,generate_tools_for_openaiLLMTool 转成 OpenAI 的 function schema(toolmgr.py:87,由 requesters/litellmchat.py:517 调用)。
  4. 路由:模型回了个 tool call,本地 Agent 循环调 execute_func_call(name, params, query)ToolManager 按名字找到对应 loader 执行(toolmgr.py:103,由 runners/localagent.py:502 调用)。

3. 核心机制一:统一的 loader 抽象

要解决的小问题: 四类来源实现天差地别(沙箱进程、插件 IPC、MCP 网络会话、技能扫描),但 ToolManager 想用同一套方法对待它们。

思路: 定义一个抽象基类 ToolLoader,规定每个 loader 都必须会做四件事——列工具、查有没有、执行、关闭。ToolManager 只依赖这个契约,不关心背后是什么。

ToolLoader 的抽象接口(pkg/provider/tools/loader.py:31):

方法干什么
get_tools(...)返回本来源的 LLMTool 列表
get_tool(name)按名字取单个工具(基类默认遍历 get_tools
has_tool(name)本来源认不认识这个名字
invoke_tool(name, params, query)真正执行
initialize / shutdown生命周期

统一的返回类型是 resource_tool.LLMTool——一个带 name/description/parameters/func 的数据结构,来自 plugin SDK。四个来源各自把自己的东西包装成 LLMTool,于是对 ToolManager 而言它们长得一模一样。

关键细节: loader.py:14 定义了 ToolLookupResult = LLMTool | ComponentManifest——因为插件来源查单个工具时返回的是插件的 ComponentManifest(组件清单),不是 LLMTool,所以查找结果类型要放宽成联合类型。

4. 核心机制二:聚合(get_all_tools)

要解决的小问题: 把四个来源的清单拼成一份,同时按当前会话的绑定关系过滤——不是所有插件、所有 MCP 服务器都对每条流水线开放。

思路: 顺序拼接四个 loader 的 get_tools(),其中 plugin 和 mcp 接收"绑定白名单"做过滤,skill 制作类工具默认不给、按开关加。

真实实现(toolmgr.py:57get_all_tools):

# 真实源码节选 toolmgr.py:65-69
all_functions.extend(await self.native_tool_loader.get_tools())
if include_skill_authoring:
all_functions.extend(await self.skill_tool_loader.get_tools())
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
all_functions.extend(await self.mcp_tool_loader.get_tools(bound_mcp_servers))

四类来源的"给不给"规则各不相同:

来源何时纳入清单
native总是尝试;但只有沙箱后端就绪才真返回工具(否则空列表)
skill 制作只有 include_skill_authoring=True 才纳入
pluginbound_plugins 白名单过滤
mcpbound_mcp_servers 白名单过滤

这些参数从哪来。 预处理阶段从 query.variables 读出本条流水线绑定的插件和 MCP 服务器,传进来(preproc.py:114-119):

# 真实源码节选 preproc.py:114-119
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
bound_plugins, bound_mcp_servers,
include_skill_authoring=include_skill_authoring,
)

include_skill_authoring 只在 runner 是 local-agent 且装了技能服务时才为真(preproc.py:35)——技能制作工具是 Agent 专属能力。

过滤的粒度差异(值得注意的坑)。 插件按名字(bound_plugins 是插件名列表)过滤;MCP 却按 server_uuid 过滤(mcp.py:583)——白名单里放的是服务器 UUID,不是工具名。这也意味着 MCP 是整台服务器级别的授权,一旦绑定,该服务器上的所有工具一并可见。

5. 核心机制三:翻译成 OpenAI function schema

要解决的小问题: LLMTool 是 LangBot 内部结构,模型 API 要的是 OpenAI 约定的 {type, function:{name, description, parameters}} 格式。

这一步是纯粹的格式转换,无过滤、无副作用(toolmgr.py:87generate_tools_for_openai):

# 真实源码节选 toolmgr.py:91-98
function_schema = {
'type': 'function',
'function': {
'name': function.name,
'description': function.description, # 给模型看的 prompt 描述
'parameters': function.parameters, # JSON Schema
},
}

注意 LLMTool 里有两个描述字段:description喂给模型的(例如 native exec 的那段"Run shell commands…",见 native.py:667),human_desc 是给人/后台看的短说明。这里只取 description。参数 parameters 直接就是 JSON Schema,各 loader 构造工具时已经写好(例如 exec 的 schema 在 native.py:674)。

6. 核心机制四:路由与打点(execute_func_call)

要解决的小问题: 模型只给了一个工具名和一坨参数。ToolManager 得判断"这名字属于哪个来源",把调用送过去,顺带记一笔遥测。

思路: 依次问四个 loader "这名字是你的吗"(has_tool),第一个认领的就负责执行;认领的同时按来源打 tool_calls 标签。

真实实现(toolmgr.py:103execute_func_call),路由顺序是 native → plugin → mcp → skill:

# 真实源码节选 toolmgr.py:106-118
if await self.native_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'native')
return await self.native_tool_loader.invoke_tool(name, parameters, query)
if await self.plugin_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'plugin')
return await self.plugin_tool_loader.invoke_tool(name, parameters, query)
# ... mcp 打 'mcp'、skill 打 'skill' ,同样模式
raise ToolNotFoundError(name)

遥测打点。 每次成功路由前调 telemetry_features.increment(query, 'tool_calls', <来源>),把该来源的计数 +1(pkg/telemetry/features.py:31)。四个来源分别打 native/plugin/mcp/skill 四种标签,于是后台能看出"这次对话各类工具各调了多少次"。

顺序即优先级(潜在的坑)。 路由是"谁先认领谁执行",顺序写死为 native 优先。若不同来源出现同名工具,native 会先接管。各 loader 的 has_tool 实现也各异:native 是查一个固定名字集合 _ALL_TOOL_NAMESnative.py:22,73),plugin/mcp 则要去遍历实时工具列表(plugin.py:36mcp.py:594)。

按名字取工具。 另有 get_tool_by_nametoolmgr.py:73)用同一优先级顺序遍历四个 loader,返回第一个命中的工具定义——供需要工具元信息(而非执行)的场景使用。

7. 四个 loader 各自的实现要点

前面讲的是"聚合与路由"这条主干。下面是每个来源的具体实现,供要读源码的人快速定位。

7.1 native:沙箱里的手脚

NativeToolLoaderpkg/provider/tools/loaders/native.py)暴露 6 个工具,名字是常量:exec/read/write/edit/glob/grepnative.py:15-22)。其中最核心的是 exec——在隔离环境里跑 shell 命令(_build_exec_toolnative.py:663)。

这些工具依赖沙箱后端get_tools 一开头就检查 _is_sandbox_available(),没有可用后端就返回空列表(native.py:60655)。可用性由 availability.py:6is_box_backend_available 判定——它不只看 box 进程在不在,还要 get_status() 确认后端真的 ready。启动时若不可用会打一条 warning,明说"模型将没有代码执行能力"(native.py:49)。

exec 的真正执行落到 self.ap.box_service.execute_tool(...)native.py:141)——沙箱本身的实现属于 05 章,本章不展开。值得一提的是 exec 会先做技能路径校验:命令里引用 /workspace/.skills/<name> 的,必须是已激活的技能包,且一次只能引用一个(native.py:104-125)。

7.2 plugin:暴露插件的 Tool 组件

PluginToolLoaderpkg/provider/tools/loaders/plugin.py自己不存工具,纯转发——每次都去问插件系统 self.ap.plugin_connector.list_tools(bound_plugins)plugin.py:24)。它把插件返回的组件包装成 LLMTool,其中 description 取插件声明的 llm_promptparameters 取插件的 spec['parameters']

有个细节:包装时 func=lambda parameters: {}plugin.py:30)是个空壳——真正执行不走这个 func,而是 invoke_tool 里回调 plugin_connector.call_tool(...)plugin.py:51),带上会话与 query_id。执行出错会捕获并返回一段 error 字符串而非抛出(plugin.py:54-57)。

7.3 mcp:LangBot 作为客户端连出去

MCPLoaderpkg/provider/tools/loaders/mcp.py:482)维护一堆 RuntimeMCPSession——每个对应一台 LangBot 连接出去的外部 MCP 服务器。启动时从数据库读出所有配置好的 MCP 服务器,各起一个后台任务去连(load_mcp_servers_from_dbmcp.py:503)。

连接支持多种传输:stdio、SSE、streamable HTTP(_init_*_servermcp.py:95 起),底层用官方 mcp 客户端库(ClientSessionstdio_clientsse_client…,mcp.py:13-16)。连上后 refresh 调远端的 session.list_tools(),把每个远端工具包装成 LLMTool,其 func 是一个闭包,调用时透传到 session.call_tool(...) 并把 MCP 返回的 text/image 内容转成 LangBot 的 ContentElementmcp.py:346-392)。执行路由 invoke_tool 会遍历所有会话找到该工具并 await function.func(**parameters)mcp.py:609)。

mcp_stdio.py 是 stdio 传输的一个特殊分支:当配置要求把 MCP 服务器跑在 Box 沙箱里(而非直接在宿主机起进程)时,由 BoxStdioSessionRuntimemcp_stdio.py:77)负责在沙箱内拉起进程、装依赖、建 relay 连接。它还定义了 MCPSessionErrorPhasemcp_stdio.py:43)把连接失败细分成阶段(如 BOX_UNAVAILABLE/DEP_INSTALL/PROCESS_START),好让前端渲染可操作的报错。

再强调这条边界: 这里的一切都是"LangBot 去当客户端、调别人的 MCP 工具"。反方向——LangBot 把自己的 HTTP 能力包成 MCP 工具对外供别人调用——是 pkg/api/mcp/ 那套,属于 05 章。两者只是恰好都叫"MCP",职责相反。

7.4 skill:技能制作工具

SkillToolLoaderpkg/provider/tools/loaders/skill_authoring.py:25)暴露两个工具,对齐 Claude Code 的技能设计:

工具干什么
activateskill_authoring.py:16按名激活一个流水线可见的技能,把它的 SKILL.md 内容作为 tool result 注入上下文(_invoke_activate_skill:79
register_skillskill_authoring.py:17把沙箱 /workspace 下准备好的技能目录注册进 LangBot 技能库(_invoke_register_skill:123

它同样依赖沙箱后端 + 技能管理器两者都在才可用(_is_availableskill_authoring.py:59)。activate 激活后会把技能登记到 query.variables,供 native 工具解析 /workspace/.skills/<name> 挂载路径——这套技能路径逻辑集中在 loaders/skill.py(辅助函数库,如 resolve_virtual_skill_pathskill.py:133register_activated_skillskill.py:65),native 的 exec/read/write 都会调它做路径改写与越界校验。

8. 边界与局限(诚实)

  • native/skill 强依赖沙箱:没有可用的 Box 后端(Docker/nsjail/E2B),exec/read/… 和 activate/register_skill 全部返回空、不进清单(native.py:60skill_authoring.py:52)。此时模型只剩 plugin 与 mcp 工具。
  • native 工具集写死:就 6 个固定名字(native.py:22),不可扩展;要加新的沙箱能力得改这个 loader。
  • 同名冲突按顺序裁决execute_func_call 的 native→plugin→mcp→skill 顺序是隐式优先级,代码里没有显式去重或告警(toolmgr.py:106)。
  • MCP 授权是服务器级bound_mcp_servers 过滤到服务器 UUID,不能只放行某台服务器上的部分工具(mcp.py:583)。
  • plugin 的 LLMTool.func 是空壳:别被 get_toolsfunc=lambda: {} 误导,真执行走 invoke_toolplugin_connector.call_toolplugin.py:30,51)。

9. 代码地图(导航索引)

主题文件路径符号名
工具中枢:装配四 loadersrc/langbot/pkg/provider/tools/toolmgr.pyToolManager.initialize
聚合+过滤成统一清单src/langbot/pkg/provider/tools/toolmgr.pyToolManager.get_all_tools
转 OpenAI function schemasrc/langbot/pkg/provider/tools/toolmgr.pyToolManager.generate_tools_for_openai
按名路由+打点 tool_callssrc/langbot/pkg/provider/tools/toolmgr.pyToolManager.execute_func_call
按名取工具定义src/langbot/pkg/provider/tools/toolmgr.pyToolManager.get_tool_by_name
loader 抽象契约src/langbot/pkg/provider/tools/loader.pyToolLoader
沙箱手脚(6 工具)src/langbot/pkg/provider/tools/loaders/native.pyNativeToolLoader, EXEC_TOOL_NAME, _build_exec_tool
沙箱可用性判定src/langbot/pkg/provider/tools/loaders/availability.pyis_box_backend_available
插件工具转发src/langbot/pkg/provider/tools/loaders/plugin.pyPluginToolLoader
外部 MCP 客户端会话src/langbot/pkg/provider/tools/loaders/mcp.pyMCPLoader, RuntimeMCPSession.refresh
Box 内 stdio MCP 运行时src/langbot/pkg/provider/tools/loaders/mcp_stdio.pyBoxStdioSessionRuntime, MCPSessionErrorPhase
技能制作工具src/langbot/pkg/provider/tools/loaders/skill_authoring.pySkillToolLoader, ACTIVATE_SKILL_TOOL_NAME
技能路径解析辅助src/langbot/pkg/provider/tools/loaders/skill.pyresolve_virtual_skill_path, register_activated_skill
聚合调用点(预处理)src/langbot/pkg/pipeline/preproc/preproc.pyPreProcessor.processget_all_tools
路由调用点(Agent 循环)src/langbot/pkg/provider/runners/localagent.pyexecute_func_call 调用处
tool_calls 遥测计数src/langbot/pkg/telemetry/features.pyincrement