乐于分享
好东西不私藏

AgentScope 2.0 源码解析系列第 九 篇 Workspace 模块

AgentScope 2.0 源码解析系列第 九 篇 Workspace 模块

workspace 模块|三层抽象、四种后端、一个不暴露端口的网关

AgentScope 2.0 源码解析系列 · 第 9 篇(沙箱 / MCP 网关 / 多后端)

本文是 AgentScope 2.0 源码解析系列 第 9 篇(workspace 模块)。

已发:第 1-8 篇(agent / event / message / model / tool / permission / middleware / rag 等) | 下一篇:mcp 模块(workspace 的网关深度依赖它)。

关注追更,后续会持续更新 mcp / skill / state 等模块。

读完这篇你会带走这些源码层面的结论:
  • 整个 workspace 只对外暴露 WorkspaceBase 一个抽象基类,LocalWorkspace 直接落地,DockerWorkspace / E2BWorkspace / K8sWorkspace 都继承自中间层 SandboxedWorkspaceBase——定制差异被压进两个抽象方法 _provision_backend / _teardown_backend
  • 所有跨沙箱文件/进程操作最终收拢成 BackendBase 的 3 个原语exec_shell / read_file / write_file;Docker 的 get_archive、E2B 的 files.read、K8s 的 exec 全部翻译成这 3 个,上层内置工具(Bash/Read/Write/Edit/Grep/Glob)零感知
  • 沙箱后端里的 MCP 服务器不直接暴露给宿主机:容器内跑一个 FastAPI 网关(_mcp_gateway_app.py),宿主机用 python3 -c 拼一段 stdlib 脚本(_gateway_shim.py)走 exec_shell 代理每一次 HTTP 请求——不需要端口映射,不需要宿主机能连到沙箱网络
  • MCP 注册表持久化在 ${workdir}/.mcp(一个 JSON 数组),这是唯一跨重启的配置;网关启动时直接读它当配置文件
  • LocalWorkspace 多了一套基于 SKILL.md SHA-256 哈希的技能去重和 .skills 索引,沙箱后端走更朴素的 tar 打包路径
  • offload_context / offload_tool_result 把对话上下文和工具结果落到 ${workdir}/sessions/,inline base64 多模态载荷先抽到 data/ 再改写成 file:// 引用

如果只记一件事:workspace 把「在哪儿执行」(BackendBase 3 个原语)和「执行什么」(工具/MCP/技能)彻底解耦,再用一个只绑 loopback、靠 exec_shell 代理的进程内网关吸收掉所有沙箱的网络异质性。这套「抽象最小执行原语 + 把网络可达性难题关进网关」的思路,搬到任何「宿主要驱动一个网络不可达的隔离环境」的场景都成立。

适合谁读:在搭 Agent 工程化方案、需要给 Agent 一个安全执行环境(本地/容器/云沙箱)的工程师。预计阅读:主线约 22 分钟(字段表较多,附录另需 8 分钟)。


一、这个模块到底在解决什么问题?

一个能跑代码、调工具的 Agent,光有 LLM 是不够的。它得有一个地方放文件、跑 shell、装技能、挂 MCP 服务器——而且这个地方最好能被换掉:本地开发时用宿主机目录最快,上生产时换成 Docker 容器隔离,要做多租户或弹性时再换成 E2B 云沙箱或 K8s Pod。

workspace 模块就是来解决这件事的。一句话概括它的职责(来自 _base.py 的模块 docstring):

一个 workspace 提供 Resources(技能)、Tools(MCP 和内置工具)、Offload(上下文与工具结果的持久化)。

如果没有这个模块会怎样?每换一个执行环境,Agent、工具、MCP 调用方都得重写一遍 I/O 逻辑——Docker 要走 exec API、E2B 要走 commands.run、K8s 又是另一套。workspace 模块把这层差异整个吃掉了:上层只看到统一的 list_tools / add_mcp / offload_context,底下是本地目录还是云沙箱,对 Agent 透明。

二、这个模块在整个框架中的位置

先看它在框架里和谁打交道。

  • 上游(谁用它)Agent 是主消费者,调 list_tools / list_mcps / offload_context / offload_tool_result;用户侧动态增删走 add_mcp / remove_mcp / add_skill / remove_skill;开发者管生命周期 initialize / close
  • 下游(它依赖谁)tool.BackendBase(执行后端抽象)、mcp.MCPClient(MCP 客户端)、skill.Skill(技能对象)、message.Msg(上下文持久化的消息类型)。
  • 谁依赖它:Agent 实例持有它;mcp 模块后续文章中会展开,沙箱后端里网关就是 agentscope.mcp 的直接用户。

三、为什么这样设计?

这是理解这个模块的关键。我读到一半的时候,最强烈的一个感受是:作者在用「抽象层数」换「新增后端的成本」。下面把几个关键设计决策拆开讲。

决策 1:为什么是三层抽象,而不是一个大基类 + 四个子类?

workspace 的继承树是这样的:基类 WorkspaceBase 下分两支——LocalWorkspace 直接落地,SandboxedWorkspaceBase 作为中间层,下面再挂 DockerWorkspace / E2BWorkspace / K8sWorkspace 三个沙箱后端。

为什么中间要插一层 SandboxedWorkspaceBase?因为三个沙箱后端共享的东西太多了:都要在沙箱内启动一个 MCP 网关、都要轮询 /health、都要从 .mcp 恢复注册表、reset / add_mcp / remove_mcp 的网关路由逻辑完全一样。如果只有两层,这三段逻辑要在三个子类里各抄一遍。

图 1 · workspace 三层抽象:差异压进两个钩子,所有 I/O 收拢成 3 个原语

中间层用模板方法模式收口:initialize / close / reset 是定稿的模板,子类只填两个钩子——

  • _provision_backend:把沙箱拉起来,绑好 self._backend
  • _teardown_backend:把沙箱拆掉

外加一个可选的 _bootstrap_commands:首次启动时要在沙箱里跑的 shell 命令(装 uv、建 venv、装 agentscope)。Docker 因为镜像构建时已经装好,返回空列表走快速路径;E2B / K8s 返回真实的 apt + curl 命令。

这个设计的好处很直接:新增一个后端,只要实现三个方法,其余全部白拿。

决策 2:为什么所有 I/O 最终只有 3 个原语?

这是整个模块最值得品味的一处。BackendBase(在 tool 模块)定义了一堆文件系统方法:file_exists / is_dir / list_dir / stat_mtime / delete_path……但只有 3 个是抽象方法,其余全是基于这 3 个的默认实现:exec_shellread_filewrite_file

为什么这样切?因为这 3 个是任何能跑 shell 的环境都必然支持的。file_exists 可以用 exec_shell(["test", "-f", path]) 实现,list_dir 可以 lsdelete_path 可以 rm -rf。于是 DockerBackend 只实现这 3 个(分别映射到 container.exec / get_archive / put_archive),E2BBackend 也只实现这 3 个,其余全部继承。

这套切法的代价是:is_dir 这种操作要多走一次 exec_shell 往返。但换来的是新增一个后端只要写 3 个方法——极度降低了抽象的「实现税」。

决策 3:为什么沙箱里的 MCP 不直接暴露端口给宿主机?

这是读这个模块时最容易困惑的一点。常规思路是:容器里跑个 FastAPI,宿主机映射个端口,直接 HTTP 调。但 workspace 偏不这么干。原因写在 _gateway_client.py 开头:

每个请求都在沙箱内执行:宿主机把请求体写进沙箱的临时文件,通过 backend.exec_shell 拉起一段小 Python 脚本,解析脚本打到 stdout 的 JSON 信封。不需要宿主机→沙箱的网络可达性——网关只绑沙箱的 loopback。

为什么这么设计?因为三种沙箱的「宿主机能不能连到沙箱网络」差异极大:Docker 可以 publish 端口但要改配置且多租户下端口冲突;E2B 云沙箱宿主根本不在同一个网络;K8s Pod 端口要 Service / port-forward,运维负担重。作者选了对所有后端都成立的那条最小公倍数:沙箱里总能跑 python3,那就用 exec_shell 跑一段 stdlib 脚本去访问 loopback 上的网关。

图 2 · 网关请求流:宿主机靠 exec_shell 跑 shim 脚本,访问沙箱 loopback 上的网关

决策 4:为什么 .mcp 是唯一的持久化配置?

workspace 的标准目录布局:.mcp(持久化的 MCP 客户端配置,JSON 数组)、data/(卸载的多模态载荷)、skills/(技能子目录)、sessions/(每会话的上下文和工具结果文件)。

其中 .mcp 是唯一一份「配置」级别的持久化,因为 MCP 注册表是跨重启要恢复的核心状态。它的格式就是 [MCPClient.model_dump(mode="json"), ...] 的 JSON 数组,add_mcp / remove_mcp 改完内存就重写它;网关启动时直接拿它当 --config。这个设计让「配置即文件」:没有数据库、没有额外的配置服务。

持久化是有条件的:_save_mcp_file 在 is_persistent == False 时直接 return。Docker 如果没挂 host_workdir,容器是临时的,写 .mcp 没意义,就跳过——这个判断在 DockerWorkspace.is_persistent 里(return self.host_workdir is not None)。

四、跟着我阅读源码

23 个文件不用全读。按重要度排,真正必看的是这几个。

必读文件 1:_base.py(734 行)—— 整个模块的脊梁

这个文件定义了 WorkspaceBase,它不是个空壳抽象类,而是带着大量共享实现的基类。读它要抓住三条主线:

  1. 派生路径_data_dir / _skills_dir / _sessions_dir / _mcp_file 这几个 property):全部通过 self.get_backend().join_path(self.workdir, ...) 拼出来。注意它们都依赖 workdir——子类必须在调用任何基类方法前设好 self.workdir
  2. offload 家族offload_context / offload_tool_result / _offload_data_block):这是 Agent 长上下文管理的落地实现。
  3. 技能管理的朴素实现list_skills / add_skill / remove_skill):基类版本走「tar 打包 → 写到沙箱 tmp → 容器内 python3 -c 解压」的通用路径;LocalWorkspace 会覆盖成更精细的哈希去重版本。

有个细节值得停下来看:add_skill 里内嵌了一段 _EXTRACT_TAR_SHIM 字符串(_base.py:81),它是在沙箱内跑的解压脚本,里面手动做了 path traversal 防护(检查每个 tar member 解压后是不是落在 dst 目录内)。为什么不用 Python 的 tarfile.extractall(filter='data')?因为沙箱里的 Python 版本不一定够新(3.12+ 才有 filter 参数),手动检查更可控。

必读文件 2:_sandboxed_base.py(455 行)—— 沙箱后端的共享大脑

如果说 _base.py 是「对所有 workspace 都成立」的逻辑,那 _sandboxed_base.py 就是「对所有沙箱 workspace 都成立」的逻辑。核心是 _setup_mcp_gateway_sandboxed_base.py:365),它干四件事:

  1. 首次启动 bootstrap:如果 _gateway_script 不存在,跑 _bootstrap_commands() 装 venv 和脚本;Docker 镜像构建时已装好,走快速路径跳过
  2. 清残留pkill -f _mcp_gateway_app.py || true 杀掉上次 resume 残留的网关进程,让新进程能干净绑定端口
  3. 拉起网关nohup {venv_python} -u {gateway_script} --config {mcp_file} --port {port} > {log} 2>&1 &
  4. 轮询 /health:带指数退避(delay = min(delay * 1.5, 1.0))轮询最多 30 秒,超时就 tail 网关日志报错

第 4 步的失败处理很值得学。超时后它不是简单抛「timeout」,而是 read_file(self._gateway_log) 读最后 2000 字节日志一起塞进异常消息——因为「网关没起来」的原因(依赖装失败、端口冲突、.mcp 损坏)全在日志里,不带上日志调用方根本没法排查。

必读文件 3:_gateway_client.py(668 行)—— 网关代理的三件套

这个文件有三个类:GatewayClient(宿主机侧的门面)、GatewayMCPClient(继承自 MCPClient,但把 connect / close / list_raw_tools / get_tool 全部改成走网关 HTTP)、GatewayMCPTool__call__ 就是 POST /mcps/{name}/tools/{tool} 一次代理)。

最值得读的是 exec_request_gateway_client.py:474)——这是「代理」二字的全部实现。它把请求体写到沙箱临时文件,调 exec_shell(["python3", "-c", SHIM_SCRIPT, method, url, body_file, ...]),然后解析 shim 打到 stdout 的 JSON 信封。大响应(>4MB)会溢出到沙箱临时文件再读回来,避免 stdout 通道塞爆。

还有个细节:_diagnose_failure_gateway_client.py:587)。任何请求失败,它会先探一下 /health,如果网关死了,就把网关日志 tail 出来打到 ERROR。注意它特意排除了 /health 路径本身——否则一个死网关的 health 探测失败会递归触发对 health 的诊断。

必读文件 4:_gateway_shim.py + _mcp_gateway_app.py —— 两端的两半

这两个文件是一对:_gateway_shim.py(93 行)是宿主机侧的一段字符串,通过 python3 -c 在沙箱里跑,用纯 stdlib(urllib.request)发一次 HTTP 请求。为什么不用 curl?因为不能假设每个后端镜像都有 curl(K8s 的 python:3.11-slim 就没有),但网关 venv 必然有 python3

_mcp_gateway_app.py(191 行)是沙箱内的 FastAPI 应用,6 个端点。它直接 from agentscope.mcp import MCPClient,给每个 MCP 配置项建一个真实 client。注意它用绝对导入——是为了避免触发 agentscope.workspace.__init__ 的重导入图(那会拉进 skill / tool 一大堆沙箱不需要的东西)。

字段表

下面这些是后续调试/扩展时要回头查的索引。第一次读只需精读表 1(BackendBase 三个抽象原语)一张,其余按需查阅,不影响理解主线。

表 1:BackendBase 三个抽象原语(核心,必读)

方法
签名
返回
含义
exec_shell(command: list[str], *, cwd, timeout)ExecResult
在后端里跑一条 argv(不经 shell;要 shell 特性得包 ["sh","-c",line]
read_file(path: str)bytes
读文件;不存在抛 FileNotFoundError
write_file(path: str, data: bytes)None
写文件;父目录不存在会先建

ExecResult 的字段:exit_code: int(传输错误为 -1)、stdout: bytesstderr: bytes。这三个原语就是所有沙箱 I/O 的最终落点。

表 2:WorkspaceBase.__init__ 构造参数(按需查阅)

参数
类型
默认
含义
workspace_idstr \| NoneNone
(自动生成 UUID)
工作区实例标识;E2B 还会写进沙箱 metadata 用于重连
default_mcpslist[MCPClient] \| NoneNone
首次启动、.mcp 不存在时的种子 MCP
skill_pathslist[str] \| NoneNone
首次启动复制进 skills/ 的本地技能目录

注意 default_mcps 和 skill_paths 都是种子:只有「全新工作区」才会用,一旦 .mcp / skills/ 已有内容就以已有内容为准。

表 3:WorkspaceBase 关键实例属性(按需查阅)

属性
类型
含义
workdirstr
Agent 可见的根目录;子类自己设(本地是宿主路径,沙箱是 /workspace
is_alivebool
是否已 initialize、未 close
_backendBackendBase \| None
当前执行后端;get_backend() 取
_mcpslist[MCPClient]
当前注册的 MCP(内存即权威副本)
_mcp_lock
 / _skill_lock
asyncio.Lock
保护 .mcp 文件 / skills/ 目录的并发改

表 4:DockerWorkspace.__init__ 参数(按需查阅)

参数
类型
默认
含义
base_imagestr"python:3.11-slim"
基础镜像,必须 PATH 里有 python3
host_workdirstr \| NoneNone
挂载到 /workspace 的宿主目录;None 则容器临时
node_versionstr \| NoneNone
给定(如 "20")则从官方 Node 镜像拷 node/npm
extra_piplist[str] \| NoneNone
镜像构建时装进网关 venv 的额外包
gateway_portint5600
网关监听的容器内端口(不做宿主映射)
envdict[str,str] \| NoneNone
容器环境变量

表 5:SandboxedWorkspaceBase 子类要填的钩子(核心,扩展时看)

钩子
必填
含义
_provision_backend
拉起沙箱并绑 self._backend;返回前必须保证 backend 可 exec_shell
_teardown_backend
拆掉沙箱;必须幂等、吞异常
_bootstrap_commands
首次启动在沙箱里跑的 shell 命令列表;镜像已装好则返回 []

类属性 _gateway_home(网关 venv/脚本/日志的目录)、gateway_port_bootstrap_cmd_timeout(Docker 1800s / E2B 600s / K8s 继承 1800s)也由子类设。


五、代码到底是怎么运行起来的?

用一个具体场景串起来:用户 async with DockerWorkspace(host_workdir="/tmp/ws", default_mcps=[...], skill_paths=[...]) as ws:,然后 await ws.list_mcps()。跟着这条线索走全程。

第 1 步:__aenter__ → initialize()

SandboxedWorkspaceBase.initialize 是模板方法,依次执行四步:

  1. _provision_backend()(Docker 子类填的钩子):aiodocker.Docker() 连 daemon → _build_or_reuse_image() 用 prepare_build_context 渲染 Dockerfile + 算内容哈希 tag(agentscope-workspace:<12hex>),先 images.inspect(tag) 探缓存命中,没命中才 images.build 走 tar 流构建 → _create_and_start_container()Cmd=["sleep","infinity"] 让容器常驻,可选 bind-mount host_workdir:/workspace,最后 self._backend = DockerBackend(container, "/workspace")
  2. _ensure_workspace_layout()mkdir -p 出 workdir / data / skills / sessions / _gateway_home;然后处理 .mcp:存在就校验它是合法 JSON 数组(损坏就重播种 defaults),不存在就写 defaults
  3. _setup_mcp_gateway():Docker 镜像已含脚本跳过 bootstrap;pkill 清残留 → nohup 拉起网关 → 轮询 /health → 成功后 self._gateway = GatewayClient(...),再 self._mcps = await self._gateway.list_mcps() 把种子 spec 换成网关侧的 live proxy
  4. _setup_skills():如果 skills/ 已有内容就跳过(尊重已有状态);否则遍历 skill_paths 逐个 add_skill

第 2 步:await ws.list_mcps()

直接返回 list(self._mcps)——这些是第 1 步从网关拉回来的 GatewayMCPClient 实例。注意它们和本地 MCP 的区别:GatewayMCPClient.connect 不建本地 stdio/HTTP,而是 POST /mcps 在网关上注册。

第 3 步:Agent 调某个 MCP 工具

走到 GatewayMCPTool.__call___gateway_client.py:129):POST /mcps/{mcp_name}/tools/{tool_name},body 是 {"arguments": kwargs}。这个请求通过 GatewayClient.exec_request 走 shim 进沙箱,网关收到后 _call_tool 找到对应 MCPClientget_tool(tool) 拿到真实工具对象再执行,结果打包成 {"chunk": ...} 回传。

整条链路的精妙之处在于:Agent 完全不知道工具其实在另一个隔离环境里跑。它拿到的 ToolBase 接口和本地 MCP 工具一模一样,差异全被 GatewayMCPTool 吸收。

第 4 步:长上下文 offload_context

offload_context_base.py:398):deepcopy 一份 → 遍历每条消息,把里面的 inline base64 DataBlock 抽到 data/(用 base64 文本的 SHA-256 当文件名,同块第二次卸载直接命中已存在文件短路)并改写成 URLSource(file://...) → 每条消息 model_dump_json() 一行 → 追加写到 sessions/{session_id}/context.jsonloffload_tool_result 类似,但落成 tool_result-{id}.txt 纯文本。

第 5 步:close()

先 self._gateway.aclose()(其实是 no-op),再 _teardown_backend()(Docker:Linux 下先 chown 还原 bind-mount 文件属主给宿主用户,再 kill + delete(force=True) 容器,最后关 aiodocker client)。整个 teardown 吞异常,保证 close 永远安全可调。


六、如何开始调试源码

按这个顺序下断点,每一步观察什么:

  1. 第一站:SandboxedWorkspaceBase.initialize_sandboxed_base.py:165)。观察四个步骤的执行顺序。如果启动卡住,多半卡在第 3 步的 /health 轮询。关键变量:self._backend 在第 1 步后必须非 None;self._gateway 在第 3 步后必须非 None。
  2. 第二站:health 轮询_sandboxed_base.py:432-449)。网关起不来时,断点设在 raise RuntimeError 那行,看 tail 变量——它是网关日志最后 2000 字节,错误原因都在里面。常见死因:.mcp 文件损坏、bootstrap 命令失败、端口被残留进程占用。
  3. 第三站:GatewayClient.exec_request_gateway_client.py:474)。调 MCP 工具报错时看这里。观察 result.exit_code 和 result.stdout——shim 的 JSON 信封解析失败说明 shim 脚本本身崩了,看 result.stderr
  4. 第四站:_offload_data_block_base.py:513)。调试上下文持久化时看这里。观察 hash_str(base64 文本的 SHA-256,不是解码后字节的)——理解为什么用 base64 文本哈希(同块第二次卸载能短路)。
  5. 第五站:LocalWorkspace._reconcile_skills_dir_local_workspace.py:539)。调试技能索引问题时看这里。观察 current_mtime vs skills_file["skills_dir_mtime"]——不等才会触发调和。

七、如何扩展这个模块

应该改(推荐路径)

  • 新增一个沙箱后端:继承 SandboxedWorkspaceBase,实现 _provision_backend / _teardown_backend,按需覆盖 _bootstrap_commands。这是设计好的官方扩展点,参考 E2BWorkspace(最简洁)和 K8sWorkspace(最复杂,带 PVC 生命周期)。
  • 自定义网关行为:改 _mcp_gateway_app.py。它是独立脚本,宿主侧通过 _read_gateway_script_bytes() 打包进镜像/沙箱。注意改完要同步更新 Docker 的内容哈希。
  • 自定义本地技能管理:覆盖 LocalWorkspace 的 list_skills / add_skill / remove_skill。基类也提供了通用版本(tar 打包路径),如果你的后端支持更高效的同步方式可以覆盖。

不应该改(有更优替代)

  • 不要直接改 self._mcps 绕过 add_mcp / remove_mcp:这两个方法管着 .mcp 文件持久化和网关注册/注销。直接改内存列表会导致 .mcp 和网关状态不一致。
  • 不要在 WorkspaceBase 层加后端特有逻辑:本地特有的放 LocalWorkspace,沙箱共有的放 SandboxedWorkspaceBase,某后端特有的放对应子类。

千万不要改(动了会破坏不变量)

  • 不要改 BackendBase 的 3 个抽象原语契约exec_shell 返回 ExecResultexit_code 为 -1 表传输错误)、read_file 不存在抛 FileNotFoundErrorwrite_file 自动建父目录——这三个契约是上层所有内置工具的共同假设。
  • 不要改 get_backend() 的「实时取」语义:它每次都读 self._backend 而不是让调用方缓存——因为 Docker/E2B 在重连时会替换 backend,缓存会拿到 stale 引用。
  • 不要删 _ensure_workspace_layout 里对 .mcp 的合法性校验:那段 try/except 不是多余的——_save_mcp_file 写到一半 crash 会留下半个 JSON,下次启动网关直接 brick。校验 + 重播种是容灾关键。
⚠️ 扩展铁律

新增后端 → 填两个钩子;改 MCP → 走 add/remove;3 个原语契约和 get_backend 实时语义 → 别碰。这三条守住,扩展就不会破坏既有不变量。


八、本模块最值得学习的设计

读完整块代码,我带走这几个判断。

1.「抽象最小执行原语」的切法,值得反复揣摩。BackendBase 有十几个方法,但只把 3 个定为抽象。这个切法的依据不是「哪些方法常用」,而是「哪些方法在任何能跑 shell 的环境里都必然可实现」。file_exists 能用 test -f 实现,所以不抽象;exec_shell 没法用别的东西实现,所以抽象。判断标准是「实现的最小性」,不是「调用的频率」。

2. 用「进程内网关 + exec_shell 代理」吸收网络异质性。 这是最能体现工程功底的一处。面对「三种沙箱的网络可达性差异巨大」这个问题,作者没有去给每个后端写一套端口暴露方案,而是选了「所有后端都支持 exec_shell」这条最小公倍数,把 HTTP 请求塞进一次 shell 调用里。代价是每次请求一次往返,换的是「同一份网关客户端代码、三种沙箱零改动」。这是个典型的「用确定的、小的运行时成本,换掉一整类跨后端的不确定性」的权衡。

3. 把「配置即文件」贯彻到底。.mcp 既是持久化、又是网关启动配置、又是 add/remove 的写入目标——三重身份一份文件。没有数据库,没有配置中心。前提是各角色对格式的需求一致,不一致时不能硬套。

4. 容灾细节藏在不起眼的地方。.mcp 写坏会校验重播种(防 crash brick);offload_context 的 base64 哈希用文本而不是字节(同块第二次短路);网关 /health 失败带日志 tail;Docker teardown 在 Linux 下 chown 还原属主;shim 用 stdlib urllib 而非 curl。每一个都是「踩过坑才会写」的代码。

5. 中间层 SandboxedWorkspaceBase 是教科书式的模板方法。initialize / close / reset 定稿成模板,子类只填 provision/teardown 两个钩子。新增后端的成本被压到了最低。

九、阅读建议

如果你打算自己读这 23 个文件,建议这个顺序:

  1. 先读 _base.py 的模块 docstring(前 44 行)——它用一段 ASCII 树把整个标准布局画清楚了,是理解所有路径的地图。
  2. 然后读 _base.py 的 WorkspaceBase——重点看派生路径 property 和 offload 家族。
  3. 跳到 _sandboxed_base.py——读 initialize / close 模板和 _setup_mcp_gateway。这是整个模块设计含量最高的地方。
  4. 挑一个沙箱后端细读,推荐 E2BWorkspace(最简洁)——看它怎么填三个钩子。读这个就懂了「新增一个后端要做什么」。
  5. 再读 _gateway_client.py 的三个类——理解宿主侧怎么驱动沙箱内网关。exec_request 是核心。
  6. _gateway_shim.py 和 _mcp_gateway_app.py 一起读——它们是网关的两半,对照着看请求怎么往返。
  7. _local_workspace.py 最后读——它覆盖了基类的技能管理,逻辑最繁(哈希去重 + mtime 调和)。
  8. _make_dockerfile.py 和三个 Backend 文件按需查——扩展时再细看。

可以跳过的:__init__.py(只是导出)、_offload_protocol.py(一个纯 Protocol)、各 _constants.py(路径常量)。

十、阅读完成以后

读完后你应该能回答:workspace 为什么要三层抽象?因为沙箱后端共享逻辑太多,中间层用模板方法收口能最大化复用。代码怎么运行?一条 initialize → provision backend → 建布局 → 起网关 → 种技能 的模板,之后所有调用走 exec_shell 代理的进程内网关。要改应该改哪里?新增后端填两个钩子,改 MCP 走 add/remove,不要碰 3 个原语契约和 get_backend 的实时语义。

真正应该带走的:把「在哪儿执行」收敛成最小原语、把「网络可达性」这类跨后端难题关进一个进程内网关——这两条思路在任何要驱动多种隔离环境的系统里都用得上。


💡 一句话带走

workspace 的精髓不在「支持了四种后端」,而在「用 3 个执行原语 + 1 个进程内网关,把这四种后端的差异整个吸收掉」——上层完全感知不到底下换了什么环境。

留两个问题

你做 Agent 工程化时,给 Agent 的执行环境是怎么选的——本地目录、Docker、还是云沙箱?踩过「换个环境 I/O 全得重写」或者「容器里跑的服务宿主连不上」的坑吗?

想深入的同学再想一个:如果让你加一个 Firecracker 微虚机后端,_bootstrap_commands 你会怎么写?网关启动失败的排查链路你会怎么设计?

觉得有用?点个「在看」或转发给同样在搞 Agent 工程化的朋友。系列持续更新,关注不迷路。

下一篇:Mcp 模块源码解析,敬请关注。