《Deep Agents 实战笔记》第 9 篇 | 基于 deepagents (Python) 官方文档 原文:https://docs.langchain.com/oss/python/deepagents/sandboxes 原文:https://docs.langchain.com/oss/python/deepagents/interpreters

上一篇我们给 Agent 装上了记忆和技能,它已经有模有样了。但离"真正能干活"还差最后一块拼图——执行。让 Agent 写代码、跑分析、执行测试,就必须先回答一个尖锐的问题:你敢让一个行为不可预测的程序,在你的机器上执行任意 shell 命令吗?

一、"能跑代码"是能力分水岭,也是风险分水岭

官方沙箱文档的开篇说得很直白:代理会生成代码、操作文件系统并运行 shell 命令。由于你无法预测代理可能做什么,它的环境必须被隔离,以免其访问凭据、文件或网络。

这不是杞人忧天。一个被网页里的恶意提示注入的编码代理,完全可能执行 cat ~/.ssh/id_rsa 或者把你的环境变量发到某个陌生地址。沙箱的作用,就是在代理的执行环境与你的宿主系统之间建立一条边界——代理在里面可以为所欲为,但出不了这个圈子。

哪些场景值得上沙箱?官方列了两类典型:一是编码代理,自主运行的代理需要 shell、git(许多提供商提供原生 git API,例如 Daytona 的 git 操作),甚至用 Docker-in-Docker 跑构建与测试流水线;二是数据分析代理,在隔离环境里加载数据文件、安装 pandas / numpy 这类分析库、跑统计计算,最后生成一份 PowerPoint 演示文稿交回。共同点是"要装依赖、要跑命令、产物留在环境里"——这正是沙箱的定义域。

Deep Agents 里最优雅的一点是:沙箱就是 Backend。还记得第 4 篇讲的 Backend 抽象吗?StateBackend、FilesystemBackend、StoreBackend 都只暴露文件操作,而沙箱 Backend 在标准文件系统工具(ls、read_file、write_file、edit_file、delete、glob、grep)之外,还额外给代理一个 execute 工具,用于在沙箱里运行任意 shell 命令。对 create_deep_agent 来说,切换到沙箱只是换一个 backend 参数,代理侧的使用方式完全不变——这就是第 4 篇说的"同一个 Agent API,随便换后端"的威力。

二、一个 execute() 撬动整个文件系统

沙箱 Backend 的架构值得单独讲一下,因为它决定了"接入一家新的沙箱提供商有多难"。

提供商唯一必须实现的方法是 execute()——运行一条 shell 命令,返回输出。其余所有文件操作(read、write、edit、delete、ls、glob、grep)都由 BaseSandbox 基类构建在 execute() 之上:基类负责把"读取文件"翻译成一个脚本,然后通过 execute() 在沙箱里跑这个脚本。

flowchart TB
    PROVIDER["提供商 SDK"]
    EXEC_METHOD["execute 方法<br/>唯一必须实现的方法"]
    BASE["BaseSandbox 基类<br/>用 execute 构建其余文件操作"]
    TOOLS["Agent 文件工具<br/>ls / read_file / write_file / glob / grep"]
    EXEC_TOOL["execute 工具<br/>运行任意 shell 命令"]

    PROVIDER --> EXEC_METHOD
    EXEC_METHOD --> BASE
    BASE --> TOOLS
    EXEC_METHOD --> EXEC_TOOL
    style EXEC_METHOD fill:#fdeef0,color:#701d2a,stroke:#c94a5e
    style PROVIDER fill:#f0f7f0,color:#1d4a1d,stroke:#4a9e4a

正在渲染图表…

这个设计带来两个结果:

  1. 接新提供商极简:实现一个 execute(),其余全部白嫖基类。所以 Deep Agents 的沙箱生态才能铺得这么快。
  2. execute 工具有条件出现:运行框架每次模型调用前会检查 backend 是否实现了沙箱协议,没实现就把 execute 从工具列表里过滤掉——代理永远看不到它。你不需要手动开关。

代理调用 execute 时传入一个 command 字符串,拿回合并的 stdout/stderr、退出码。这里有个贴心的细节:如果命令输出过大,结果会自动保存成文件,并提示代理用 read_file 分块读取——防止上下文窗口被一次 cat 大文件 打爆。官方连这个坑都替你踩掉了。

三、沙箱全家桶:六家提供商怎么选

目前官方集成的提供商:LangSmith、Daytona、E2B、Modal、Runloop、Vercel(另有 AWS AgentCore、NVIDIA OpenShell 等扩展)。它们的接入方式高度一致,以 LangSmith 为例:

from deepagents import create_deep_agent
from deepagents.backends import LangSmithSandbox
from langchain_anthropic import ChatAnthropic
from langsmith.sandbox import SandboxClient

client = SandboxClient()
ls_sandbox = client.create_sandbox()
backend = LangSmithSandbox(sandbox=ls_sandbox)

agent = create_deep_agent(
    model=ChatAnthropic(model="claude-sonnet-4-6"),
    system_prompt="You are a Python coding assistant with sandbox access.",
    backend=backend,
)
try:
    result = agent.invoke(
        {
            "messages": [
                {
                    "role": "user",
                    "content": "Create a small Python package and run pytest",
                }
            ]
        }
    )
finally:
    client.delete_sandbox(ls_sandbox.name)

换一家提供商,本质上只是换前两行:E2B 用 Sandbox.create() + E2BSandbox,Daytona 用 Daytona().create() + DaytonaSandbox,Modal 用 modal.Sandbox.create(app=app) + ModalSandbox,Runloop 用 RunloopSDK 创建 devbox + RunloopSandbox,Vercel 用 Sandbox.create(runtime="python3.13") + VercelSandbox。清理动作各不相同(delete_sandbox / kill / stop / terminate / shutdown),都放在 finally 里。

选型上我的看法:

  • LangSmith:第一方托管沙箱,不用注册第三方账号,和 LangSmith 追踪天然打通(trace 里直接能看到沙箱里跑了哪些命令),是这个系列默认推荐——尤其你本来就要开 LangSmith 调试。
  • E2B / Daytona:老牌代码沙箱服务商,生态成熟。Daytona 还提供原生 git 操作 API,编码代理克隆仓库很方便。
  • Modal:适合已经很重地用 Modal 跑计算任务的团队,沙箱和你的 GPU 作业在同一个账户体系里;它还支持 blockNetwork: true 阻断网络访问(后面讲安全时会用到)。
  • Runloop:devbox 概念,面向编码代理场景。
  • Vercel:如果您的应用本身跑在 Vercel 生态里,顺手。

具体到注册、认证和生命周期细节,各家有差异,动手前查一下官方的沙箱集成页即可。

四、生命周期:沙箱是持续烧钱的

这是文档里用加粗语气强调的事:沙箱在被关闭之前会持续消耗资源并产生费用。 所以生命周期管理不是可选项,是必修课。

主流做法是线程作用域(默认):每个对话一个沙箱,第一次运行创建,同一线程后续轮次复用,线程结束或 TTL 过期后销毁。实现方式是在 graph 工厂里按 thread_id 解析沙箱:

from deepagents import create_deep_agent
from deepagents.backends.langsmith import LangSmithSandbox
from langchain_core.runnables import RunnableConfig
from langsmith.sandbox import SandboxClient

client = SandboxClient()


async def agent(config: RunnableConfig):
    thread_id = config["configurable"]["thread_id"]
    sandbox_name = f"thread-{thread_id}"
    existing = [
        sb
        for sb in client.list_sandboxes()
        if getattr(sb, "name", None) == sandbox_name
    ]
    if existing:
        ls_sandbox = existing[0]
    else:
        ls_sandbox = client.create_sandbox(
            name=sandbox_name,
            idle_ttl_seconds=3600,  # TTL: clean up when idle
        )
    return create_deep_agent(
        model="google_genai:gemini-3.6-flash",
        backend=LangSmithSandbox(sandbox=ls_sandbox),
    )

另一个选择是助手作用域:同一助手的所有线程共享一个沙箱,文件、已安装的软件包、克隆过的仓库跨对话持久。听着很美好,但官方给了明确警告:助手作用域的沙箱会随时间累积状态,请配置 TTL、用快照定期重置,或实现清理逻辑,以免磁盘和内存无限制增长。

我的建议:默认线程作用域 + TTL。用户可能闲置几小时后回来,TTL 让提供商自动回收环境,下次访问再冷启动——比你手动对账沙箱账单体面得多。

五、文件的两个通道:别混用

文件进出沙箱有两条完全不同的路,理解它们的分工很重要:

  1. 代理文件系统工具(read_file、write_file、execute 等):LLM 在执行过程中调用的,在沙箱内部通过 execute() 实现,是任务执行的一部分。
  2. 文件传输 API(upload_files / download_files):你的应用代码调用的,走提供商的原生文件传输接口,专用于在宿主与沙箱边界上移动文件。
flowchart LR
    subgraph APP["你的应用"]
        CODE["应用代码"]
    end
    subgraph AG["Agent"]
        LLM["LLM"] --> TOOLS["read_file / write_file / execute"]
        TOOLS --> LLM
    end
    subgraph SB["沙箱"]
        FS["沙箱文件系统"]
    end
    CODE -->|"文件传输 API<br/>upload_files / download_files"| FS
    TOOLS -->|"execute"| FS
    style CODE fill:#f0f7f0,color:#1d4a1d,stroke:#4a9e4a
    style FS fill:#f4effb,color:#4a2170,stroke:#8b52c9

正在渲染图表…

典型流程是三段式:预置(代理运行前放入源码、配置、数据)→ 代理干活 → 取回(拿走生成的报告、构建产物):

backend.upload_files(
    [
        ("/src/index.py", b"print('Hello')\n"),
        ("/pyproject.toml", b"[project]\nname = 'my-app'\n"),
    ]
)

# ... 代理运行 ...

results = backend.download_files(["/src/index.py", "/output.txt"])
for result in results:
    if result.content is not None:
        print(f"{result.path}: {result.content.decode()}")
    else:
        print(f"Failed to download {result.path}: {result.error}")

记住这条纪律:沙箱内部,代理用自己的文件工具,不走 upload_files。那些方法是给你——应用开发者——在边界上搬文件用的。

六、两种集成模式:沙箱中的代理 vs 沙箱即工具

官方总结了两种把代理和沙箱组合的架构,本文示例全部是第二种:

flowchart TB
    subgraph MODE2["沙箱即工具(推荐起点)"]
        A2["代理运行在宿主 / 服务器"]
        S2["远程沙箱"]
        A2 -->|"execute / read_file<br/>走提供商 API"| S2
        K2["API 密钥留在沙箱外"]
    end
    subgraph MODE1["沙箱中的代理"]
        IMG["Docker / VM 镜像<br/>预装 deepagents"]
        S1["沙箱内运行代理"]
        IMG --> S1
        U1["应用"] -. "WebSocket / HTTP 通信" .-> S1
    end
    style MODE2 fill:#f0f7f0,color:#1d4a1d,stroke:#4a9e4a
    style MODE1 fill:#fbf3e6,color:#6b4a12,stroke:#c99a3d

正在渲染图表…

沙箱中的代理:把代理本身打进镜像、跑在沙箱里,从外部通过网络与它通信。优点是与本地开发高度一致、代理与环境紧密耦合;代价是 API 密钥必须放进沙箱(安全风险)、更新要重新构建镜像、还得自己搭通信层。

沙箱即工具:代理跑在你的机器上,需要执行代码时通过工具调用远程沙箱的 API。优点很实在:改代理逻辑不用重建镜像、密钥留在沙箱外、沙箱挂了不丢代理状态、还能在多个沙箱里并行跑任务,并且只为执行时间付费。唯一代价是每次执行有网络延迟。

我的判断:默认选沙箱即工具。除非你的提供商 SDK 把通信层处理得很好、且你刻意追求"环境即镜像"的强一致性,否则沙箱中的代理模式的那三个代价(密钥、重建镜像、通信基建)每一个都够你喝一壶。

七、解释器:另一种"跑代码"

讲完沙箱,必须澄清一个容易混淆的概念。Deep Agents 有两种"代码执行",定位完全不同:

  • 沙箱:以代码优先的方式操作一个环境——跑 shell 命令、装依赖、跑测试、编辑文件系统。
  • 解释器:以代码优先的方式组合工具、保留状态——在代理循环内部提供一个内存中的可编程工作区,代理写代码完成任务,运行时只把相关结果返回给模型,中间结果不进上下文。

解释器目前处于 beta 阶段(API 可能随版本变化),依赖 langchain-quickjs>=0.3.0 和 Python 3.11+。用法是挂一个中间件:

from deepagents import create_deep_agent
from langchain_quickjs import CodeInterpreterMiddleware

agent = create_deep_agent(
    model="google_genai:gemini-3.6-flash",
    middleware=[CodeInterpreterMiddleware()],
)

挂上之后代理多了一个 eval 工具。当任务适合用代码表达时,代理会编写 JavaScript 并调用 eval,代码跑在 QuickJS(编译为 WebAssembly)的内存沙箱里,console.log 输出和最后一个表达式的值会返回给模型。

为什么需要它?大多数代理在"模型推理"和"工具调用"之间交替,模型一轮可以发出多个工具调用,但这批调用发出的那一刻就固定了——没法循环、没法根据结果分支、没法重试、没法把上一个调用的输出喂给下一个,除非再过一轮模型调用,而且每个结果都会灌进上下文。解释器把这种编排移进代码:模型只推理"做什么",中间的每一步操作交给代码。

它的默认权限边界极窄:无文件系统、无网络、无 shell、无真实时钟,只有计算、状态和 console 输出。要扩大能力范围只有两座显式的桥:

  • PTC(程序化工具调用):把允许列表中的工具暴露为解释器里的异步函数。默认关闭,需显式开启:
agent = create_deep_agent(
    model="google_genai:gemini-3.6-flash",
    middleware=[CodeInterpreterMiddleware(ptc=["web_search"])],
)

开启后,代理就能在解释器代码里批量编排工具(工具名转 camelCase,web_search 变成 tools.webSearch):

const topics = ["retrieval", "memory", "evaluation"];

const results = await Promise.all(
  topics.map((topic) =>
    tools.webSearch({ query: `${topic} best practices 2025` }),
  ),
);

results.join("\n\n");

三个主题并行搜索、合并结果,模型只看到最终合并文本——这就是 PTC 省 token 的方式。

  • 动态子代理:代理配置了子代理时,解释器暴露 task() 全局函数,可以从代码里扇出子代理(这部分第 6 篇已经详细讲过)。

解释器状态默认跨轮次持久化(mode="thread",每轮结束做快照、下轮恢复),也可设为 "turn"(单轮内)或 "call"(每次 eval 全新 REPL)。资源上默认 64 MB 堆内存、单次 eval 5 秒超时、结果截断 4000 字符、每次 eval 最多 256 次 PTC 调用。

把两者放进一张表,边界就清楚了:

维度沙箱解释器
作用对象一个环境(文件系统 + shell + 依赖)代理循环内部的内存工作区
代码形式任意 shell 命令(execute 工具)QuickJS 里的 JavaScript(eval 工具)
能力边界接近完整操作系统默认无文件、无网络、无 shell、无时钟
中间结果留在环境里不进模型上下文,只回传过滤后的结果
状态持久沙箱实例(按 TTL / 作用域)快照存进 graph 状态(thread / turn / call)
典型场景编码代理、数据分析、跑测试批量编排工具、确定性数据变换、扇出子代理

还有一个容易忽略的细节:解释器的快照只保留可序列化的数据——函数、类这类运行时对象恢复后不可访问,访问会直接报错。而且快照恢复的是解释器内存,不是对外部世界的影响:如果代码通过 PTC 调过工具,恢复快照不会撤销那次调用的副作用。所以"回滚"这个概念在解释器里只对变量成立,对真实世界不成立。

八、选型决策树

最后把本篇收进一张图。面对"让 Agent 跑代码"的需求,按这个顺序问自己:

flowchart TD
    START["Agent 需要执行代码"] --> Q1{"只是简单的一两次<br/>外部调用?"}
    Q1 -->|是| TOOL["普通工具调用"]
    Q1 -->|否| Q2{"纯内存中的数据变换<br/>循环 / 分支 / 重试 / 聚合?"}
    Q2 -->|是| EVAL["解释器 eval"]
    Q2 -->|否| Q3{"要从代码编排大量工具调用<br/>或扇出大量子代理?"}
    Q3 -->|是| PTC["解释器 PTC / 动态子代理"]
    Q3 -->|否| Q4{"需要 shell、装依赖、<br/>跑测试、完整文件系统?"}
    Q4 -->|是| SANDBOX["沙箱 Backend"]
    Q4 -->|否| TOOL2["回到普通工具调用"]
    style SANDBOX fill:#fdeef0,color:#701d2a,stroke:#c94a5e
    style EVAL fill:#f4effb,color:#4a2170,stroke:#8b52c9
    style PTC fill:#f4effb,color:#4a2170,stroke:#8b52c9
    style TOOL fill:#f0f7f0,color:#1d4a1d,stroke:#4a9e4a
    style TOOL2 fill:#f0f7f0,color:#1d4a1d,stroke:#4a9e4a

正在渲染图表…

一句话版本:要环境用沙箱,要编排用解释器,两者都要就组合使用。 解释器官方文档的安全章节也提醒:把它当作作用域受限的解释器运行时,而不是完整的生产级沙箱 Backend——需要操作系统级隔离时,老老实实上沙箱。

还有一点要提前埋个伏笔:PTC 调用目前通过解释器桥接执行,不经过正常的工具调用路径,因此 interrupt_on 审批工作流不会对 PTC 调用的工具强制生效。也就是说,你给 web_search 配了人工审批,但如果模型在解释器代码里调它,审批是拦不住的。这个坑的完整展开,正是下一篇的主题。

九、本篇踩坑点

  • 忘写 finally 清理:沙箱按时间计费,demo 跑完不删,月底账单教你做人。所有示例的 finally 不是装饰。
  • 助手作用域沙箱当传家宝:文件、依赖、克隆的仓库跨对话累积,不配 TTL 或快照重置,磁盘和内存迟早爆炸。
  • 把 upload_files 当成代理的写文件方式:代理在沙箱里只用自己的文件工具;upload_files / download_files 是应用代码在宿主与沙箱之间搬运文件用的,两条通道别混。
  • 高估了沙箱的防护范围:沙箱防的是代理碰你的宿主系统,防不了上下文注入(攻击者可以指示代理在沙箱内跑任意命令)和网络渗漏(除非像 Modal 那样 blockNetwork: true)。密钥仍然要最小化投放。
  • 解释器当沙箱用:解释器默认无网络无文件系统,是编排工具用的;要 shell、要装依赖、要跑测试,用沙箱 Backend。别拿 eval 硬凑。
  • PTC 绕过审批:给工具配了 interrupt_on 但开了 PTC?解释器里的调用不走审批路径。敏感工具别放进 PTC 允许列表。

能力越大越要装笼子。下一篇我们正式装笼子:声明式文件权限规则怎么写、组合 Backend 下的权限怎么落地,以及 HITL 审批的 interrupt-恢复全流程。


参考资料: