数据截至 (上游 commit 7803d562546a)
扩展性与 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 里跑代码 │
└────────────────┘ └────────────────┘
三条边界的共同套路(记住这个模式,后面全是它的变体):
- 主进程侧只有一个瘦门面——插件是
PluginRuntimeConnector,Box 是BoxService+BoxRuntimeConnector。 门面不干活,只负责「打包参数 → 发过去 → 收结果 → 反序列化」。 - 真正的实体和协议住在 sibling 仓
langbot-plugin(pyproject.toml:73里 pinnedlangbot-plugin==0.5.5)。 动作枚举、Handler 基类、BoxSpec、Profile 这些都从那个包 import。 - 连接方式按部署形态自动二选一:本机开发用 stdio(把子进程当管子), Docker/远程/Windows 用 WebSocket。这个选择贯穿插件和 Box 两侧,逻辑几乎对称。
3. 插件系统:跨仓、跨进程的扩展
3.1 它要解决的小问题
你想让社区能给 LangBot 写插件(加一个天气工具、一个 /help 命令、一个 RAG 检索器)。
但第三方代码你控制不了:它可能 pip install 一堆奇怪依赖、可能死循环、可能 import 崩掉。
如果直接在主进程里 import 它,一崩全崩。
LangBot 的答案:插件根本不在主进程里跑。它们跑在一个独立的 Plugin Runtime 进程里,
那个进程的代码来自 sibling 仓 langbot-plugin。主进程只持有一个连接器。
3.2 连接器:一根管子,两种材质
PluginRuntimeConnector(src/langbot/pkg/plugin/connector.py:154)在 initialize() 里根据平台选传输:
- Docker 或显式配了 WS → WebSocket,连
plugin.runtime_ws_url(connector.py:123,默认ws://langbot_plugin_runtime:5400/control/ws)。 - Windows → 因为 Windows 的 asyncio 不支持 stdio 子进程管道,所以「用 cmd 起进程、用 ws 通信」
(
connector.py:142起)。 - Unix/macOS → stdio:直接
python -m langbot_plugin.cli.__init__ rt -s拉起子进程当管子 (connector.py:170起)。
一个关键取舍写 在注释里:stdio 模式断了不自动重连,只能重启 LangBot(connector.py:96-100);
只有 WS 模式才走 runtime_disconnect_callback 自动重连。这也是为什么本机调试插件时进程一乱就得重启。
3.3 handler:双向的动作总线
连上之后,真正干活的是 RuntimeConnectionHandler(src/langbot/pkg/plugin/handler.py:123)。
它是双向的,这点最容易看漏——一根连接上跑着两个方向的调用:
方向一:运行时/插件 → 主进程(插件要用主进程的能力)。
handler 用 @self.action(...) 注册了一堆回调,把 LangBot 的动作暴露给运行时。例如:
reply_message(handler.py:582)——插件想回消息,主进程替它调平台适配器发出去。send_message(handler.py:758)、invoke_llm(handler.py:815)——插件借主进程的 bot 和模型。- 一整套 RAG 能力(
invoke_embedding/vector_search…,handler.py:1155起)——插件当 RAG 引擎时反向调主进程。
方向二:主进程 → 运行时(主进程驱动插件)。
handler 上一批 call_action 方法,把 LangBot 的意图发给运行时执行:
install_plugin(handler.py:1721)、list_tools(handler.py:1840)、call_tool(handler.py:1993)。emit_event(handler.py:1811)——把一个 SDK 事件派发给运行时里所有监听的插件。
PluginRuntimeConnector 上层就是对这些 handler 方法再包一层。看 emit_event
(connector.py:732):它把 EventContext 序列化、发过去、把结果反序列化回 EventContext,
并回填 _emitted_plugins。call_tool(connector.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 操作的应用层门面
BoxService(src/langbot/pkg/box/service.py:102)是唯一对 LangBot 其余部分暴露 Box 的入口。
它把「配置解析、会话、托管进程、技能 CRUD、配额、挂载、profile」全收在一个类里。几个关键点:
| 关注点 | 方法 / 属性 | 在干 嘛 |
|---|---|---|
| 可用性开关 | available(service.py:153) | 后端没起来就 False,所有消费方据此优雅降级 |
| 跑一次代码 | execute_tool(service.py:715) | agent 的 exec 工具落点:注入 session_id、挂技能、跑 spec |
| 沙箱作用域 | resolve_box_session_id(service.py:226) | 决定「谁和谁共用一个容器」,SaaS 下可被强制模板锁死 |
| 附件进沙箱 | materialize_inbound_attachments(service.py:608) | 把用户发的图/文件写进 /workspace/inbox/<query_id>/ |
| 给 LLM 的说明 | get_system_guidance(service.py:2035) | 生成 exec 工具的 system prompt 文本(含 outbox 路径) |
| Profile 应用 | _apply_profile(service.py:1204) | 把 profile 默认值合进 spec,锁定字段强制覆盖、超时钳制 |
| 挂技能 | build_skill_extra_mounts(service.py:261) | 把流水线绑定的技能包挂到 /workspace/.skills/<name> |
native 工具加载器就是靠它:exec 工具最终调 self.ap.box_service.execute_tool(...)
(src/langbot/pkg/provider/tools/loaders/native.py:353),read/write/edit 也走 Box。
Box 不可用时,native.py 直接不注册这些工具——这就是「据 available 降级」的具体样子。