Skip to content

沙箱系统架构

本章目标:

  1. 讲清楚为什么 super agent 需要一层可插拔的隔离沙箱抽象,以及 Sandbox / SandboxProvider 两层接口如何分工。
  2. 拆解 LocalSandboxProvider(per-thread LocalSandbox + LRU 缓存)与 AioSandboxProvider(Docker 隔离 + 温池)两套实现的生命周期差异。
  3. 给出实现自定义 SandboxProvider 的最小模板与必须遵守的抽象约束清单。

TL;DR

DeerFlow 把"agent 执行命令/读写文件"的能力抽象成 Sandbox 接口(7 个抽象方法),由 SandboxProvider 负责 acquire/get/release 生命周期管理。通过 config.sandbox.use 配置类路径,运行时用反射动态加载具体 provider 单例。内置 LocalSandboxProvider(直接在宿主机文件系统执行,per-thread 路径映射 + 256 容量 LRU 缓存)与 community 的 AioSandboxProvider(每线程一个 Docker 容器,真隔离 + 温池复用)。SandboxMiddleware 在 agent 生命周期中负责按需 acquire,把 sandbox_id 写进 ThreadState;security.py 默认禁止 LocalSandboxProvider 的宿主 bash 以收敛攻击面。

Overview

agent harness 的核心风险点是让 LLM 自由执行命令和读写文件。这天然要求一个可替换的隔离层:本地开发时希望直接用宿主机文件系统(零依赖、快、便于调试),生产/不可信场景则必须把执行关进容器(防止 LLM 误删宿主文件或越权访问)。如果把执行逻辑硬编码进工具层,就无法在不改工具代码的前提下切换隔离强度。

DeerFlow 的解法是两层抽象 + 反射加载:Sandbox 抽象"执行原语"(命令、文件、搜索),SandboxProvider 抽象"沙箱实例的生命周期"(谁创建、谁缓存、谁销毁)。工具层只面向接口编程,具体用哪种隔离完全由 config.sandbox.use 一行配置决定,运行时通过 resolve_class 反射实例化为单例 sandbox_provider.py:48-62。这样同一套工具代码在"本地直跑"与"Docker 隔离"两种模式下行为一致——两个实现都对外承诺统一的 /mnt/user-data/... 虚拟路径契约。

Architecture

整个沙箱系统由"抽象基类 + provider 单例工厂 + 中间件生命周期管理 + 安全门控"四块构成:

  • Sandbox(ABC):执行原语的契约,7 个 @abstractmethod:execute_command / read_file / list_dir / write_file / glob / grep / update_file,每个沙箱实例带一个 id sandbox.py:6-93
  • SandboxProvider(ABC):生命周期契约,3 个 @abstractmethod:acquire(thread_id) / get(sandbox_id) / release(sandbox_id),外加可选 reset();类属性 uses_thread_data_mounts 标记是否已通过挂载暴露线程数据目录 sandbox_provider.py:8-42
  • 单例工厂:get_sandbox_provider()resolve_class(config.sandbox.use, SandboxProvider) 动态加载并缓存进程级单例;reset_sandbox_provider() / shutdown_sandbox_provider() / set_sandbox_provider() 分别用于配置变更、优雅关闭、测试注入 sandbox_provider.py:45-109
  • SandboxMiddleware:把 provider 接入 agent 中间件链,负责按需 acquire 并把 sandbox_id 落进 ThreadState middleware.py:21-83
  • security.py:沙箱能力门控,默认禁止 LocalSandboxProvider 执行宿主 bash security.py:23-45

Source 列表:

文件角色
sandbox/sandbox.pySandbox 抽象基类(执行原语契约)
sandbox/sandbox_provider.pySandboxProvider 抽象基类 + 单例工厂
sandbox/local/local_sandbox_provider.py本地文件系统 provider(LRU 缓存)
sandbox/local/local_sandbox.pyLocalSandbox + PathMapping 路径映射
community/aio_sandbox/aio_sandbox_provider.pyDocker 隔离 provider(温池 + 空闲回收)
community/aio_sandbox/aio_sandbox.pyAioSandbox(HTTP 调容器 API)
sandbox/middleware.pySandboxMiddleware 生命周期管理
sandbox/security.py宿主 bash 能力门控
sandbox/exceptions.py结构化异常类型

Components / Subsystems

Sandbox 接口

职责:定义沙箱内可执行的最小操作集,屏蔽"本地直跑"与"远程容器"差异。所有方法都是 @abstractmethod,实现类必须全部实现。关键定义在 sandbox.py:18-93:execute_command(command) 返回 stdout/stderr 文本;read_file/write_file/list_dir/update_file 处理文件;glob/grep 返回 (结果, 是否截断) 二元组。基类只保存一个 _id 并暴露只读 id 属性 sandbox.py:9-16

SandboxProvider

职责:管理沙箱实例的创建、查找与释放生命周期,并作为反射加载入口。acquire(thread_id) 返回 sandbox_id(字符串,非实例),get(sandbox_id) 才返回 Sandbox 实例——这种"id 与实例分离"设计让 id 可以安全地序列化进 ThreadState 并跨进程/跨中间件传递 sandbox_provider.py:13-38reset() 是非抽象的可选钩子,供 provider 清理跨实例存活的模块级状态 sandbox_provider.py:40-42

LocalSandboxProvider

职责:在宿主机文件系统直接执行,适合本地开发。关键类 local_sandbox_provider.py:34-329。要点:

  • per-thread 沙箱:acquire("abc") 返回 id 为 local:abcLocalSandbox,其 path_mappings/mnt/user-data/{workspace,uploads,outputs}/mnt/acp-workspace 解析到该线程的宿主目录;acquire(None) 保留 id 为 local 的遗留泛用单例 local_sandbox_provider.py:218-263
  • LRU 缓存:per-thread 实例存于 OrderedDict,默认上限 256,超出时淘汰最久未用条目;get()/acquire() 命中时 move_to_end 提升热度 local_sandbox_provider.py:265-297
  • 路径映射:LocalSandbox 通过 PathMapping 把容器虚拟路径双向翻译成宿主真实路径,_resolve_path_with_mapping 还会校验解析后路径未逃逸出挂载根目录(否则抛 PermissionError)local_sandbox.py:123-148
  • release 是空操作:LocalSandbox 无外部资源,缓存实例跨轮次保留以维持 _agent_written_paths 反解提示,清理只走 LRU 淘汰或 reset()/shutdown() local_sandbox_provider.py:299-328

AioSandboxProvider

职责:每线程一个 Docker 容器,提供真隔离。关键类 aio_sandbox_provider.py:69-708。要点:

  • 确定性 id:sandbox_idsha256(thread_id)[:8] 推导,任何进程对同一线程得出同一容器名,无需共享内存即可跨进程发现 aio_sandbox_provider.py:239-246
  • 多层 acquire:进程内缓存 → 温池(容器仍在跑,免冷启动)→ 跨进程文件锁下 backend 发现/创建 aio_sandbox_provider.py:443-535
  • 温池语义:release() 不销毁容器而是放进 _warm_pool 供同线程下轮快速复用;真正销毁由 replicas 容量驱逐、空闲超时检查器或 shutdown() 触发 aio_sandbox_provider.py:623-708
  • 挂载注入:线程数据目录与 skills 目录在容器创建时 bind-mount 进同名虚拟路径,所以 AioSandbox 本身不需要路径翻译;AioSandbox.execute_commandthreading.Lock 串行化对容器单一持久 shell 会话的调用 aio_sandbox.py:57-87

SandboxMiddleware

职责:把 provider 接入 LangGraph agent 生命周期 middleware.py:21-83。默认 lazy_init=True:before_agent 不 acquire,推迟到首次工具调用时由 ensure_sandbox_initialized() 懒加载;lazy_init=False 时在 before_agent 提前 acquire 并写入 state["sandbox"]。沙箱在同一线程多轮间复用,不在每次 agent 调用后释放以避免重建浪费,清理依赖应用关闭时的 SandboxProvider.shutdown()

security.py 与 exceptions.py

security.py 职责:能力门控。uses_local_sandbox_provider() 通过类路径标记判断当前是否本地 provider;is_host_bash_allowed() 规定:非本地 provider 一律放行,本地 provider 仅当 sandbox.allow_host_bash=true 才放行,否则返回禁用提示文案 security.py:23-45is_local_sandbox(runtime) 从 ThreadState 读 sandbox_id,同时接受遗留 "local" 和 per-thread "local:..." 两种格式 tools.py:1006-1023

exceptions.py 职责:结构化异常体系,SandboxError 为基类(带 message + details),派生 SandboxNotFoundError / SandboxRuntimeError / SandboxCommandError / SandboxFileError(及其子类 SandboxPermissionError / SandboxFileNotFoundError)exceptions.py:4-72

Data Flow

下面是 lazy_init=True(默认)下一次完整的沙箱获取-执行-释放生命周期。注意:SandboxMiddleware.before_agent 在懒加载模式下不 acquire,真正的 acquire 发生在首个工具调用 ensure_sandbox_initialized() 内。

沙箱实例自身的生命周期状态机如下:

Implementation Details

LocalSandboxProvider.acquire 的双重检查 + LRU 关键片段(已精简)local_sandbox_provider.py:241-263:

python
# 锁内快路径:命中缓存即提升 LRU 热度并返回
with self._lock:
    cached = self._thread_sandboxes.get(thread_id)
    if cached is not None:
        self._thread_sandboxes.move_to_end(thread_id)
        return cached.id

# 释放锁做文件 I/O(ensure_thread_dirs)
new_mappings = list(self._path_mappings) + self._build_thread_path_mappings(thread_id)

with self._lock:
    # 锁外 I/O 期间可能被其他线程抢先,重新检查
    cached = self._thread_sandboxes.get(thread_id)
    if cached is None:
        cached = LocalSandbox(f"local:{thread_id}", path_mappings=new_mappings)
        self._thread_sandboxes[thread_id] = cached
        self._evict_until_within_cap_locked()   # 超 256 则淘汰最旧
    else:
        self._thread_sandboxes.move_to_end(thread_id)
    return cached.id

解读:这是经典的"锁外做慢 I/O、锁内做快缓存操作"双重检查模式。_build_thread_path_mappings 会调 ensure_thread_dirs 触碰文件系统,必须在锁外执行避免阻塞其他线程;锁外 I/O 后重新检查是因为可能有并发 caller 已为同一 thread_id 建好沙箱,保证"同 thread_id 永远观察到同一实例"。_evict_until_within_cap_locked 在持锁状态下用 popitem(last=False) 弹出最久未用条目实现 LRU local_sandbox_provider.py:265-276

速查表

LocalSandbox 与 AioSandbox 能力/隔离对比矩阵:

维度LocalSandbox / LocalSandboxProviderAioSandbox / AioSandboxProviderSource
执行位置宿主机进程(subprocess)Docker 容器内 HTTP APIlocal_sandbox.py:307-352 / aio_sandbox.py:57-87
隔离强度无进程/文件系统隔离容器级隔离security.py:10-20
sandbox_id 格式locallocal:{thread_id}sha256(thread_id)[:8]local_sandbox_provider.py:218-258 / aio_sandbox_provider.py:239-246
路径处理PathMapping 双向翻译 + 逃逸校验容器内 bind-mount,无需翻译local_sandbox.py:123-148 / aio_sandbox_provider.py:265-284
缓存/复用256 容量 LRU OrderedDict进程内缓存 + 温池 + 跨进程发现local_sandbox_provider.py:265-297 / aio_sandbox_provider.py:443-535
release 行为空操作(保留缓存)放进温池,容器继续运行local_sandbox_provider.py:299-308 / aio_sandbox_provider.py:623-647
空闲回收无(仅 LRU 淘汰)后台 idle checker,默认 600saio_sandbox_provider.py:318-374
宿主 bash默认禁止(需 allow_host_bash)默认放行(容器内安全)security.py:35-45
uses_thread_data_mountsTrue(类属性)仅本地容器后端为 Truelocal_sandbox_provider.py:65 / aio_sandbox_provider.py:123-131

扩展指南

要接入自定义隔离后端(例如 gVisor、远程 VM、WebContainer),实现一个 SandboxProvider 子类与一个 Sandbox 子类,再把类路径写进 config.sandbox.use 即可,无需改动任何工具代码。

最小模板:

python
from deerflow.sandbox.sandbox import Sandbox
from deerflow.sandbox.sandbox_provider import SandboxProvider
from deerflow.sandbox.search import GrepMatch


class MySandbox(Sandbox):
    def execute_command(self, command: str) -> str: ...
    def read_file(self, path: str) -> str: ...
    def list_dir(self, path: str, max_depth=2) -> list[str]: ...
    def write_file(self, path: str, content: str, append: bool = False) -> None: ...
    def glob(self, path: str, pattern: str, *, include_dirs: bool = False,
             max_results: int = 200) -> tuple[list[str], bool]: ...
    def grep(self, path: str, pattern: str, *, glob: str | None = None,
             literal: bool = False, case_sensitive: bool = False,
             max_results: int = 100) -> tuple[list[GrepMatch], bool]: ...
    def update_file(self, path: str, content: bytes) -> None: ...


class MySandboxProvider(SandboxProvider):
    uses_thread_data_mounts = False  # 若已挂载线程数据目录则设 True

    def acquire(self, thread_id: str | None = None) -> str: ...   # 返回 sandbox_id 字符串
    def get(self, sandbox_id: str) -> Sandbox | None: ...         # 返回 Sandbox 实例
    def release(self, sandbox_id: str) -> None: ...
    # 可选:def reset(self) -> None / def shutdown(self) -> None

约束清单(从抽象基类 @abstractmethod 读出,缺一个就无法实例化):

  • Sandbox 必须实现全部 7 个抽象方法:execute_command / read_file / list_dir / write_file / glob / grep / update_file,且通过 super().__init__(id) 设置 id sandbox.py:11-93
  • SandboxProvider 必须实现 3 个抽象方法:acquire / get / release;reset() 非抽象但建议覆写以清理跨实例存活状态 sandbox_provider.py:13-42
  • acquire 必须返回字符串 id而非实例;get(id) 必须对相同 id 返回同一(或等价)Sandbox 实例;get 找不到时返回 None(而非抛异常)。
  • acquire/get/release 可能被多线程并发调用(Gateway 工具分发、subagent 工作池、后台 memory updater),需自行加锁保证线程安全。
  • 若实现支持优雅关闭,提供 shutdown() 方法——shutdown_sandbox_provider() 通过 hasattr 探测并调用 sandbox_provider.py:86-97
  • 类必须可被 resolve_class 加载并通过 isinstance(cls, SandboxProvider) 校验,故必须直接或间接继承 SandboxProvider sandbox_provider.py:58-62

Configuration

沙箱配置位于 config.yamlsandbox 段,schema 见 sandbox_config.py:12-83:

配置项默认说明Source
use(必填)provider 类路径,反射加载sandbox_config.py:30-33
allow_host_bashfalse⚠️ 安全敏感:允许 LocalSandboxProvider 在宿主机直接执行 bash。开启等于把宿主机暴露给 LLM,仅限完全可信的本地环境,生产/不可信场景务必保持 false(默认禁用提示见 security.py:10-20)sandbox_config.py:34-37
imageAIO 默认镜像AioSandboxProvider 容器镜像sandbox_config.py:38-41
port8080AIO 容器基础端口sandbox_config.py:42-45
replicas3AIO 最大并发容器数,超限 LRU 驱逐温池sandbox_config.py:46-49
idle_timeout600AIO 空闲秒数后回收,0 关闭sandbox_config.py:54-57
mounts[]⚠️ 安全敏感:host↔container 卷挂载;LocalSandboxProvider 会拒绝与 skills//mnt/user-data//mnt/acp-workspace 等保留前缀冲突的挂载sandbox_config.py:58-61
environment{}注入容器的环境变量,$ 前缀从宿主 env 解析sandbox_config.py:62-65
bash_output_max_chars20000bash 工具输出上限(中段截断)sandbox_config.py:67-71

config.sandbox.use 变更后需调 reset_sandbox_provider()(触发 provider 的 reset())才能让新配置在下次 acquire() 生效 sandbox_provider.py:65-83

Common Pitfalls/Tips

  • 别把 LocalSandboxProvider 当安全边界:它直接在宿主机跑 subprocess,没有任何隔离。security.py 默认禁宿主 bash 就是这个原因;不可信场景一律用 AioSandboxProvider security.py:10-20
  • release 不等于销毁:两种内置 provider 的 release 都不真正回收(Local 空操作、Aio 进温池),SandboxMiddleware 也刻意不在每轮后释放——真正清理靠 shutdown() 或空闲/LRU 驱逐 middleware.py:24-29
  • acquire 返回 id 不是实例:务必再 get(sandbox_id) 拿实例;ensure_sandbox_initialized() 已封装这个两步流程,工具层直接用它即可 tools.py:1098-1107
  • LRU 淘汰会丢 _agent_written_paths:LocalSandbox 被驱逐后重建,只损失 read_file 的反向路径解析提示,行为退化为全新运行,不影响正确性 local_sandbox_provider.py:24-31
  • is_local_sandbox 要兼容两种 id:判断时既要认 "local" 也要认 "local:" 前缀,直接复用 is_local_sandbox(runtime) 不要自己拼字符串 tools.py:1006-1023
  • 改配置后记得 reset:仅改 config.yaml 不会自动换 provider 单例,需显式 reset_sandbox_provider() sandbox_provider.py:65-83

References

章节关系
./07-沙箱与工具配置.mdconfig.sandbox 配置在该章详述,本章聚焦运行时架构
./18-沙箱工具与路径映射.mdbash/read/write 等工具与虚拟路径翻译细节(本章留给 18 章)
./12-中间件链机制.mdSandboxMiddleware 在完整中间件链中的位置与执行顺序
./19-子代理委派系统.mdsubagent 工作池并发调用 provider,依赖本章的线程安全保证

公司内部参考 · 由 claude-wiki-gen 基于源码自动生成的二次分析