跳到主要内容

扩展性与 Agent 面向的对外表面:插件运行时 / Box 沙箱 / MCP 服务器

30 秒导读: 前四章讲的都是 LangBot 主进程内部怎么把一条消息跑完。这一章讲两件外向的事: 一是 LangBot 怎么把「别人写的扩展代码」和「要跑的不可信代码」踢到独立进程/独立仓去跑(插件运行时、 Box 沙箱),主进程只留一层薄薄的连接器;二是它怎么把自己的管理能力对 agent 暴露成一套表面 (HTTP API + /mcp + skills),并把「这三者必须同步」当成硬约束。

本章不重复 04 章讲的「LangBot 作为 MCP 客户端去连别人的 MCP 服务器」——那是进来的工具。 本章讲的 /mcp出去的:LangBot 自己当 MCP 服务器,让外部 agent 来管它。


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

先建立一个心智模型:LangBot 主进程刻意把两类危险活儿揽在自己身上。

  • 别人写的插件代码——第三方插件质量参差、依赖各异,崩了不能把主进程带崩,所以放到一个 独立的 Plugin Runtime 进程里跑,主进程通过一根管子(stdio 或 WebSocket)指挥它。
  • 要执行的不可信代码——当 agent 需要真的跑一段 Python、读写文件时,这段代码绝不能碰到宿主机, 所以放进 Box 沙箱(Docker / nsjail / E2B 三选一的容器)里跑。

再看第三件事,方向反过来:LangBot 想让外部 agent(比如另一个 Claude、一个自动化脚本)能来 「管理这台 LangBot」——建 bot、列流水线、查系统信息。于是它把一批管理接口同时用三种形态摆出去: HTTP API、/mcp(MCP 服务器)、skills(技能包)。

一句话类比:

  • 插件运行时 = 把不放心的租客请到隔壁楼,留一部电话(connector)联系。
  • Box 沙箱 = 给 agent 一间带门禁的实验室,进出的东西都过安检(quota / mount / profile)。
  • 对外表面 = 给整栋楼开三个一模一样功能的服务窗口(网页、MCP、技能),谁来都能办同样的事。

三块各一句职责:

模块一句话职责应用层门面
插件系统跨进程/跨仓加载并驱动第三方扩展(工具/命令/事件/RAG)PluginRuntimeConnector
Box 沙箱给 agent 一个受控的代码执行 + 文件读写环境BoxService
对外表面把 LangBot 的管理能力暴露给外部 agent(HTTP + MCP + skills)LangBotMCPServer / HTTPController

2. 顶层全景:三种进程,三条边界

这一章最重要的图,是进程边界。LangBot 不是一个单体——它运行时最多是三类进程在协作:

怎么读这张图:中间是 LangBot 主进程,它对外(上)开服务窗口,对内(左右)通过两个 connector 连出两个独立进程。每根连线上标了传输方式(stdio 或 WebSocket)。

外部 agent / 浏览器 / 脚本

┌────────────┼────────────┐
HTTP API /mcp skills
(Quart) (FastMCP) (技能包)
└────────────┼────────────┘
│ 都靠一把 API key 进门
┌─────────────────▼──────────────────┐
│ LangBot 主进程 │
│ ┌────────────┐ ┌──────────────┐ │
│ │ Plugin │ │ Box │ │
│ │ Connector │ │ Service + │ │
│ │ (门面) │ │ Connector │ │
│ └─────┬──────┘ └──────┬───────┘ │
└────────┼─────────────────┼──────────┘
stdio ‖ WS │ │ stdio ‖ WS
┌────────▼───────┐ ┌──────▼─────────┐
│ Plugin Runtime │ │ Box Runtime │
│ 进程(独立仓 │ │ 进程 → 容器 │
│ langbot-plugin)│ │ Docker/nsjail/ │
│ 跑第三方插件 │ │ E2B 里跑代码 │
└────────────────┘ └────────────────┘

三条边界的共同套路(记住这个模式,后面全是它的变体):

  1. 主进程侧只有一个瘦门面——插件是 PluginRuntimeConnector,Box 是 BoxService+BoxRuntimeConnector。 门面不干活,只负责「打包参数 → 发过去 → 收结果 → 反序列化」。
  2. 真正的实体和协议住在 sibling 仓 langbot-pluginpyproject.toml:73 里 pinned langbot-plugin==0.4.6)。 动作枚举、Handler 基类、BoxSpec、Profile 这些都从那个包 import。
  3. 连接方式按部署形态自动二选一:本机开发用 stdio(把子进程当管子), Docker/远程/Windows 用 WebSocket。这个选择贯穿插件和 Box 两侧,逻辑几乎对称。

3. 插件系统:跨仓、跨进程的扩展

3.1 它要解决的小问题

你想让社区能给 LangBot 写插件(加一个天气工具、一个 /help 命令、一个 RAG 检索器)。 但第三方代码你控制不了:它可能 pip install 一堆奇怪依赖、可能死循环、可能 import 崩掉。 如果直接在主进程里 import 它,一崩全崩。

LangBot 的答案:插件根本不在主进程里跑。它们跑在一个独立的 Plugin Runtime 进程里, 那个进程的代码来自 sibling 仓 langbot-plugin。主进程只持有一个连接器。

3.2 连接器:一根管子,两种材质

PluginRuntimeConnectorsrc/langbot/pkg/plugin/connector.py:43)在 initialize() 里根据平台选传输:

  • Docker 或显式配了 WS → WebSocket,连 plugin.runtime_ws_urlconnector.py:123,默认 ws://langbot_plugin_runtime:5400/control/ws)。
  • Windows → 因为 Windows 的 asyncio 不支持 stdio 子进程管道,所以「用 cmd 起进程、用 ws 通信」 (connector.py:142 起)。
  • Unix/macOSstdio:直接 python -m langbot_plugin.cli.__init__ rt -s 拉起子进程当管子 (connector.py:170 起)。

一个关键取舍写在注释里:stdio 模式断了不自动重连,只能重启 LangBot(connector.py:96-100); 只有 WS 模式才走 runtime_disconnect_callback 自动重连。这也是为什么本机调试插件时进程一乱就得重启。

3.3 handler:双向的动作总线

连上之后,真正干活的是 RuntimeConnectionHandlersrc/langbot/pkg/plugin/handler.py:52)。 它是双向的,这点最容易看漏——一根连接上跑着两个方向的调用:

方向一:运行时/插件 → 主进程(插件要用主进程的能力)。 handler 用 @self.action(...) 注册了一堆回调,把 LangBot 的动作暴露给运行时。例如:

  • reply_messagehandler.py:149)——插件想回消息,主进程替它调平台适配器发出去。
  • send_messagehandler.py:297)、invoke_llmhandler.py:334)——插件借主进程的 bot 和模型。
  • 一整套 RAG 能力(invoke_embedding / vector_search …,handler.py:509 起)——插件当 RAG 引擎时反向调主进程。

方向二:主进程 → 运行时(主进程驱动插件)。 handler 上一批 call_action 方法,把 LangBot 的意图发给运行时执行

  • install_pluginhandler.py:830)、list_toolshandler.py:947)、call_toolhandler.py:1097)。
  • emit_eventhandler.py:918)——把一个 SDK 事件派发给运行时里所有监听的插件。

PluginRuntimeConnector 上层就是对这些 handler 方法再包一层。看 emit_eventconnector.py:732):它把 EventContext 序列化、发过去、把结果反序列化回 EventContext, 并回填 _emitted_pluginscall_toolconnector.py:776)同理,还带 bound_plugins 做插件级过滤——这正是 04 章讲的四来源聚合里「插件工具」那一路的落点。

下面这段**示意代码(非源码)**把「门面只是转发」这件事演出来:

# 示意,非源码:connector 上的方法几乎都是这个形状
async def emit_event(self, event):
ctx = EventContext.from_event(event) # 打包
if not self.is_enable_plugin: # 插件关了就短路返回
return ctx
result = await self.handler.emit_event( # 发给独立进程
ctx.model_dump(), include_plugins=None,
)
return EventContext.model_validate( # 收结果、反序列化
result['event_context'])

重点看:门面不实现业务,只做「序列化 → 过管子 → 反序列化」,业务全在另一个进程/仓里。


4. Box 沙箱:给 agent 一间带门禁的实验室

4.1 它要解决的小问题

当 agent 决定「我需要跑段代码算一下」或「把用户发来的图存到磁盘上处理」,这段行为必须隔离: 不能碰宿主机、不能无限占磁盘、不能跑太久。Box 就是这层隔离。它后端可以是 Docker、nsjail 或 E2B, 但 agent 和 LangBot 都不直接面对后端——中间隔着 BoxService

4.2 BoxService:一切 Box 操作的应用层门面

BoxServicesrc/langbot/pkg/box/service.py:42)是唯一对 LangBot 其余部分暴露 Box 的入口。 它把「配置解析、会话、托管进程、技能 CRUD、配额、挂载、profile」全收在一个类里。几个关键点:

关注点方法 / 属性在干嘛
可用性开关availableservice.py:153后端没起来就 False,所有消费方据此优雅降级
跑一次代码execute_toolservice.py:317agent 的 exec 工具落点:注入 session_id、挂技能、跑 spec
沙箱作用域resolve_box_session_idservice.py:226决定「谁和谁共用一个容器」,SaaS 下可被强制模板锁死
附件进沙箱materialize_inbound_attachmentsservice.py:608把用户发的图/文件写进 /workspace/inbox/<query_id>/
给 LLM 的说明get_system_guidanceservice.py:1308生成 exec 工具的 system prompt 文本(含 outbox 路径)
Profile 应用_apply_profileservice.py:1204把 profile 默认值合进 spec,锁定字段强制覆盖、超时钳制
挂技能build_skill_extra_mountsservice.py:261把流水线绑定的技能包挂到 /workspace/.skills/<name>

native 工具加载器就是靠它:exec 工具最终调 self.ap.box_service.execute_tool(...)src/langbot/pkg/provider/tools/loaders/native.py:141),read/write/edit 也走 Box。 Box 不可用时,native.py 直接不注册这些工具——这就是「据 available 降级」的具体样子。

4.3 一个巧妙细节:filesystem 拓扑决定行为

Box 的连接器 BoxRuntimeConnectorsrc/langbot/pkg/box/connector.py:89)和插件那侧几乎对称: stdio 起本地子进程(_start_local_stdioconnector.py:179),或 WS 连远程/Docker/Windows。 但 Box 多了一个语义判断——uses_websocket()connector.py:123)不只决定传输,还决定 LangBot 和 Box 是否共享同一个文件系统

shares_filesystem_with_boxservice.py:157)就建立在它之上:

  • stdio 本地模式 → 共享文件系统。Box 报的路径(如技能 package_root)在 LangBot 侧一样能解析, 于是 LangBot 可以本地校验这些路径、可以直接写 bind-mount 的 workspace(无大小限制)。
  • WS 分离模式(Docker Compose / k8s sidecar / --standalone-box / 远程 endpoint)→ 不共享。 Box 报的路径在 LangBot 侧解析不了,本地校验会误删所有技能,所以 LangBot 只能信任它、 文件传输退化到「base64 过 exec 通道」的小文件回环。

这个区分贯穿附件传输(service.py:339 那段长注释)、技能挂载、workspace 创建,是 Box 代码里 最需要理解的一条暗线:同样一段逻辑,在两种部署拓扑下走完全不同的分支

4.4 相关配置键

Box 的行为由 box.* 一组键驱动(在 data/config.yaml):box.enabled(总开关,默认 True, 关掉则连都不连,见 service.py:1111 _load_enabled)、box.backendbox.runtime.endpoint (显式远程地址,见 connector.py),Docker 下默认连 langbot_box:5410connector.py:27-28)。


5. 对外表面:一套能力,三个入口

前两块是 LangBot 连出去用别人的进程。这一块方向相反:LangBot 把自己的管理能力摆出去给 agent 用。 设计约束一句话:API + MCP + skills 是一套,任何一处漂移都是 bug。

5.1 三个入口,一把钥匙

它们全挂在同一个 Quart 应用上,共享同一套 API key 鉴权:

入口是什么给谁源码
HTTP APIQuart 路由组 + 内建 SPA前端 Web UI、脚本HTTPControllerapi/http/controller/main.py:30
/mcpFastMCP 把精选服务包成 MCP 工具外部 agent(MCP 客户端)LangBotMCPServerapi/mcp/server.py:49
skills技能包(含面向 agent 的说明)沙箱里的 agentskills/ 目录

「一把钥匙」= api.global_api_keyconfig.yaml)。它是 agent 的免登录入口verify_api_keysrc/langbot/pkg/api/http/service/apikey.py:53)先拿传入 key 和 api.global_api_keysecrets.compare_digest 比对(apikey.py:67),命中就放行、无需登录会话、无需查库; 否则才回退去查 web UI 建的 lbk_ key。

5.2 /mcp:直接调 service,不回环 HTTP

LangBotMCPServerapi/mcp/server.py:49)用 FastMCP 把一批操作注册成 MCP 工具: get_system_infoserver.py:74)、list_botsserver.py:89)、create_botserver.py:103)、 list_pipelinesserver.py:118)等。

关键设计(写在文件头注释里):这些工具直接调 service 类,不是自己去回环打一遍 HTTP。 例如 list_bots 就是 await ap.bot_service.get_bots(...)。好处是「MCP 表面和 API 表面共用同一个 service 层,天然对齐」——同一个 bot_service.get_bots 既服务 HTTP 又服务 MCP,不可能各说各话。

它刻意只暴露约 25 个 HTTP 路由组里的一小撮server.py:11-15 注释),把 agent 表面保持 「小、安全、可维护」。扩展是刻意的动作,不是自动全暴露。

5.3 主控制器:一个 Quart 应用被 MCP dispatcher 套住

HTTPControllerapi/http/controller/main.py:30)搭起整个 HTTP 层:

  • register_routesmain.py:113)建各路由组,并内建 SPA——非 API、非静态的路径全部回退到 index.html,交给前端 React Router 处理(main.py:134 起的 static_file)。
  • 真正对外服务时,_run_taskmain.py:91)不是裸跑 Quart,而是先用 self.mcp_mount.wrap(self.quart_app)main.py:109)把 Quart 的 ASGI 应用包一层 MCP dispatcher

这个 dispatcher(MCPMount.wrapapi/mcp/mount.py)逻辑很简单:请求路径落在 /mcp 下 就先用 verify_api_key 校验 API key,通过才交给 FastMCP 的 Starlette 应用;其余请求原样透给 Quart。 FastMCP 用 stateless HTTP + json_response(server.py:57),所以不需要粘性会话,好放在负载均衡后面。

┌──────────── dispatcher ASGI(MCPMount.wrap)─────────────┐
│ path 以 /mcp 开头? │
│ ├─ 是 → 校验 API key → FastMCP(Starlette) app │
│ └─ 否 → 原样交给 Quart(路由组 + SPA + /healthz) │
└──────────────────────────────────────────────────────────┘

5.4 为什么强调「漂移即 bug」

因为这三个入口暴露的是同一批能力的三个投影server.py 文件头和 AGENTS.md 都把它写成硬规则: 改了一个应当 agent 可用的 HTTP 端点,就必须在同一次改动里同步 api/mcp/server.py 的对应 MCP 工具 和 skills/ 下的技能。否则会出现「网页能建 bot、MCP 里却没有这个工具」这种表面不一致—— 对来管理 LangBot 的 agent 来说,那就是它看到的世界和真实能力对不上,即 bug。


6. 巧妙之处(可带走的技术)

  • 门面 + 独立进程 + sibling 仓,三段式解耦。 主进程只留连接器,实体/协议在 langbot-plugin==0.4.6pyproject.toml:73)里,插件代码在第三个进程里。崩溃、依赖、 版本演进都被隔离在边界之外。

  • stdio/WS 二选一贯穿两个子系统且几乎对称。 插件的 connector.py:121/170 和 Box 的 connector.py:147-154 是同一套「本机 stdio、部署 WS」模板,读懂一个就读懂另一个。

  • 用传输方式反推文件系统拓扑。 Box 把「是否 WebSocket」直接等价于「是否共享文件系统」 (service.py:157),从而在本地/分离两种部署下自动切换校验与传输策略,而不需要额外配置项。

  • MCP 工具直连 service 层。 server.py 不回环 HTTP 而是 await ap.xxx_service.yyy(), 让 API 与 MCP 两个表面结构性对齐,从根上消除漂移可能(server.py:88-119)。

  • global API key 做 agent 免登录门。 secrets.compare_digest 常量时间比对 (apikey.py:68),一把 key 同时开 HTTP 和 /mcp,无需会话——正是给自动化 agent 用的形态。


7. 边界与局限

  • stdio 模式不自动重连。 插件(connector.py:96-100)和 Box(connector.py:330-334) 在 stdio 断开后都只能提示「请重启 LangBot」,只有 WS 模式走重连回调。本机调试的常见坑。

  • Box 分离部署时 LangBot 不校验路径、只能信任。service.py:261 build_skill_extra_mounts 的长注释——本地校验在分离拓扑下会误删技能,所以放弃校验。安全边界因此依赖 Box 运行时自身可信。

  • exec 回环通道只能搬小文件。 没有共享文件系统时,附件走 base64 过 exec,受 ARG_MAX 和 output_limit_chars 截断限制(service.py:369 _EXEC_FALLBACK_MAX_BYTES = 256KB), 大文件必须靠 host-shared workspace。

  • /mcp 表面是刻意最小集。 只覆盖 bots/pipelines/models/KB/MCP-servers/skills 的核心读 + 常见写 (server.py:11-15),不是全 API。要更多能力得手动加工具并同步 skills。

  • 三入口同步靠约定而非机制。 「漂移即 bug」是写在注释和 AGENTS.md 里的人工纪律, 代码层面没有强制检查——加了 HTTP 端点忘了同步 MCP/skills,不会有编译错误拦你。


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

主题文件路径符号名
插件连接器(门面)src/langbot/pkg/plugin/connector.pyPluginRuntimeConnector
插件传输选择(stdio/WS)src/langbot/pkg/plugin/connector.py:121PluginRuntimeConnector.initialize
派发 SDK 事件src/langbot/pkg/plugin/connector.py:732PluginRuntimeConnector.emit_event
调用插件工具src/langbot/pkg/plugin/connector.py:776PluginRuntimeConnector.call_tool
双向动作总线src/langbot/pkg/plugin/handler.py:52RuntimeConnectionHandler
暴露给运行时的动作src/langbot/pkg/plugin/handler.py:149reply_message / send_message / invoke_llm
发给运行时的调用src/langbot/pkg/plugin/handler.py:830install_plugin / emit_event / call_tool
Box 应用层门面src/langbot/pkg/box/service.py:42BoxService
agent exec 落点src/langbot/pkg/box/service.py:317BoxService.execute_tool
入站附件落盘src/langbot/pkg/box/service.py:608BoxService.materialize_inbound_attachments
exec 的 system 提示src/langbot/pkg/box/service.py:1308BoxService.get_system_guidance
文件系统拓扑判断src/langbot/pkg/box/service.py:157BoxService.shares_filesystem_with_box
Box 连接器(三种传输)src/langbot/pkg/box/connector.py:89BoxRuntimeConnector
传输⇔拓扑src/langbot/pkg/box/connector.py:123BoxRuntimeConnector.uses_websocket
MCP 服务器(工具注册)src/langbot/pkg/api/mcp/server.py:49LangBotMCPServer
MCP dispatcher + 鉴权src/langbot/pkg/api/mcp/mount.pyMCPMount.wrap
HTTP 控制器 + SPAsrc/langbot/pkg/api/http/controller/main.py:30HTTPController
ASGI 包 MCPsrc/langbot/pkg/api/http/controller/main.py:91HTTPController._run_task
API key 校验(含 global)src/langbot/pkg/api/http/service/apikey.py:53ApiKeyService.verify_api_key
exec 工具加载器(依赖 Box)src/langbot/pkg/provider/tools/loaders/native.py:141_invoke_exec
sibling 仓 pinpyproject.toml:73langbot-plugin==0.4.6