首页 / 文章

在本地 OpenSandbox 后端运行 Deep Agents

如何通过 BaseSandbox 将 OpenSandbox 与 Deep Agents 相连,桥接同步与异步代码,并在本地 Docker 沙箱中安全地运行数据分析代理。

发布者:REACTAPP.TOP阅读约 15 分钟3403 词

来源: Upgrade Your Deep Agent With a Local Open-Source Sandbox

为何智能体需要安全的代码运行环境

ChatGPT、Claude、Gemini和Qwen等聊天助手能够接收图片、PDF和电子表格等文件上传。对于这些文件的各种操作,比如描述图片内容、总结PDF内容或提取文档片段,都在语言模型的能力范围之内。但如果交给模型一个.csv文件并要求其进行包含图表的完整统计分析,仅靠逻辑推理就不够了,因为这需要实际的数据处理、计算以及图表生成功能。

大型语言模型本身无法完成这些数学运算或绘图工作,但它可以编写使用pandas、NumPy和Matplotlib库的Python脚本来实现这些功能。脚本运行完成后,模型的任务就是将结果整理成易于阅读的报告。正是智能体工具使得执行这些脚本成为可能。

不过,直接在自家机器上运行人工智能编写的代码并非明智之举。无监督执行会带来严重的安全与稳定性风险:它可能会更改系统配置、删除关键文件或泄露个人数据。因此才有了沙箱——这种安全的隔离环境能让智能体运行代码而不影响底层操作系统。本文重点介绍 Deep Agents Python 库所使用的沙箱技术。

什么是沙箱以及为什么智能体需要它

在这里,沙箱指的是一种隔离环境,通常是一个容器,代码在其中运行时不会与机器的其他部分产生任何交互。它的文件系统与进程都是独立的,除非刻意允许,否则没有任何途径可以访问主机上的文件、凭证或网络。开发者可以将此视为一种便利,但对于自主代理而言,这几乎是一项必要条件,其原因十分实际:

  • 代码由代理选择,而非用户。 即使是行为良好的代理,有时也会误删文件或执行并非预期的命令,而如果没有隔离机制,这些错误就会影响到真实的机器。
  • 提示注入确实存在。如果智能体读取了不可信的内容,比如他人的网页或文件,一些措辞巧妙的隐藏指令就可能试图改变它的后续操作。沙箱机制可将损害限制在可丢弃的容器内。
  • 干净的初始环境很有帮助。当每个任务都在一个已知且为空的环境中开始,且不会继承之前运行的状态时,处理起来会更为容易。
  • 并行智能体。由于存在隔离机制,一个智能体的pip install操作不会与另一个智能体的冲突,而且某个沙箱崩溃也不会影响整个流程。
  • 资源限制。陷入无限循环的智能体,或是将整个数据集两次加载到内存中的智能体,应该只会耗尽可丢弃容器的资源,而不会占用你的电脑资源。
  • 正因如此,包括 Deep Agents 在内的那些用于开发智能体的框架都将沙箱视为核心概念。Deep Agents 提供了一个名为 BaseSandbox 的抽象接口,同时还与几家云沙箱服务提供商实现了官方集成。只需将智能体指向其中某一个服务,它就能获得一个真正隔离的代码运行环境。

    所有这些服务都托管在云端,按分钟或使用量计费。虽然这适合生产环境,但对于在本地构建、测试或演示智能体而言则显得过于复杂。

    本地选项:OpenSandbox

    OpenSandbox 是一种免费的开源沙箱运行时,可在用户自己的 Docker 环境中运行。它提供了与付费版本相同的核心功能:

    • 启动一个隔离的容器。
    • 在容器内执行命令。
    • 在容器中读写文件。
    • 使用完毕后销毁容器。

    在本地启动其控制平面服务器,将其指向 Docker,这样就会出现一个本地端点,可在需要时创建和管理沙箱。没有厂商将其作为托管产品出售。这是由你自己操作的基础设施,非常适合本地开发、测试以及像这样的演示。接下来要做的就是将其与 Deep Agents 相连接,不过该系统不支持 OpenSandbox 集成。

    BaseSandbox 合约

    BaseSandbox 是一个抽象类,其集成功能主要体现在四个成员函数上。

    class BaseSandbox(ABC):
        def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: ...
        @property
        def id(self) -> str: ...
        def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: ...
        def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: ...
    
    1. execute会在沙箱中运行命令,并返回一个ExecuteResponse。
    2. id是沙箱后端的唯一标识符。
    3. upload_files用于将多个文件上传到沙箱中。
    4. download_files用于从沙箱中下载多个文件。

    一旦这四个功能存在,Deep Agents就会在execute()的基础上自行构建ls、read_file、write_file、edit_file、glob和grep功能。无需再为它们编写额外代码。这就是完整的集成接口,意味着任何能够运行shell命令并处理文件上传下载的沙箱都符合要求。理论上路径看似简单,但一旦开始实际编码,问题就会出现。

    OpenSandbox的工作原理

    OpenSandbox利用Docker容器来创建和管理沙箱环境。其Python SDK能够创建及管理沙箱环境、执行shell命令、处理文件以及监控资源使用情况。使用前需先安装并运行Docker,然后再部署服务器本身:

    # Generate a starter config
    uvx opensandbox-server init-config ~/.sandbox.toml --example docker
    
    # Start the server
    uvx opensandbox-server
    

    之后,SDK会连接到正在运行的服务器,并展示OpenSandbox的所有功能。

    集成类

    这是个需要根据BaseSandbox抽象结构来填充的框架,各项方法需逐一实现。

    create()

    class MinimalOpenSandboxBackend(BaseSandbox):
        def __init__(self, sandbox: Sandbox, runner: AsyncRunner):
            self._sandbox = sandbox
            self._runner = runner
    

    __init__ 方法非常简单。它仅包含两个其他所有功能都依赖的对象:与服务器的实时 Sandbox 连接,以及驱动该连接的 AsyncRunner。这两个对象都不是手动创建的,而是通过工厂方法生成的。该类拥有的方法数量也超过了 BaseSandbox 所要求的四种,其中最显著的是 .create() 这一类方法。要了解其原因,只需看看沙箱对象究竟是如何生成的即可。

    在 __init__ 能够使用 Sandbox 连接和后台运行器之前,这两者必须已经存在,正因如此 create() 才被单独列为一个步骤。以下是相关代码。

    from opensandbox import Sandbox
    from opensandbox.config import ConnectionConfig
    
    IMAGE = "opensandbox/code-interpreter:v1.1.0"
    ENTRYPOINT = ["/opt/code-interpreter/code-interpreter.sh"]
    
    @classmethod
    def create(cls, api_key: str | None = None) -> "MinimalOpenSandboxBackend":
        runner = AsyncRunner()
        config = ConnectionConfig(domain="localhost:8080", api_key=api_key)
        sandbox = runner.run(
            Sandbox.create(IMAGE, entrypoint=ENTRYPOINT, connection_config=config, timeout=timedelta(minutes=30))
        )
        return cls(sandbox, runner)
    

    有两个要点值得注意。domain="localhost:8080"将此与之前通过uvx opensandbox-server启动的服务器关联起来。timeout=timedelta(minutes=30)起到了安全保障的作用:即便忘记调用kill(),沙箱也会自动销毁,而不会在后台持续运行。此外还需注意IMAGE和ENTRYPOINT的设置,它们必须与当前使用的OpenSandbox版本兼容。

    关于运行器:Sandbox.create 是一个协程,但 BaseSandbox.create 必须返回一个普通对象,而非需要等待的结果。在细节明确之前,可将 runner.run(coro) 理解为“等待此异步调用完成,然后返回其结果”。这也解释了为何 Sandbox.create(...) 被传递给 runner.run(...) 而非直接等待。AsyncRunner 的内部工作原理将在稍后介绍。

    id

    创建过程已经完成,后续方法将针对已运行的沙箱进行处理。

    @property
    def id(self) -> str:
        return self._sandbox.id
    

    它返回底层沙箱的标识符。BaseSandbox要求将其作为属性,同时在日志记录中也十分方便。

    execute()

    from deepagents.backends.protocol import ExecuteResponse
    
    def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
        execution = self._runner.run(self._sandbox.commands.run(command))
        stdout = "\n".join(c.text for c in execution.logs.stdout)
        stderr = "\n".join(c.text for c in execution.logs.stderr)
        output = "\n".join(p for p in (stdout, stderr) if p)
        return ExecuteResponse(output=output, exit_code=execution.exit_code or 0)
    

    这个其实简单得令人意外。OpenSandbox以分块列表的形式输出命令结果,每行一个块,且换行符早已被去除。因此必须重新构建响应格式,才能让后续的代理程序处理它。execute()也并非直接被调用——Deep Agents中的ls、glob和grep都依赖它,逐行读取输出。如果给它们提供格式错误的响应,它们将找不到匹配项,只会默默返回空结果。ls会返回[],不会有错误追踪指向有问题的join函数,而实际上沙箱里可能还有内容。

    这比看上去更重要,因为 execute() 并非仅用于直接调用。它是 Deep Agents 中 ls、glob 和 grep 的基础,而这些工具都是逐行解析输出内容的。格式错误的响应会导致它们无法匹配到任何结果,从而默默返回空结果。ls 会因此返回 [],且不会显示指出问题出在 join 的错误追踪信息,而实际上沙箱中可能还有内容。

    其余部分则更为简单:标准输出和标准错误会被合并,忽略为空的那部分,而缺失的退出码会被视为成功(0),而不会保持为 None。

    upload_files()

    from opensandbox.models import WriteEntry
    from deepagents.backends.protocol import FileUploadResponse
    
    def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
        entries = [WriteEntry(path=path, data=data, mode=644) for path, data in files]
        try:
            self._runner.run(self._sandbox.files.write_files(entries))
            return [FileUploadResponse(path=p) for p, _ in files]
        except Exception as exc:
            return [FileUploadResponse(path=p, error=str(exc)) for p, _ in files]
    

    每对 (路径, 字节数) 都会被封装在具有普通权限的 WriteEntry 中(所有者可读写,其他人仅可读取),整个批次通过一次 write_files() 调用发送。

    download_files()

    from deepagents.backends.protocol import FileDownloadResponse
    
    def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
        results = []
        for path in paths:
            try:
                content = self._runner.run(self._sandbox.files.read_bytes(path))
                results.append(FileDownloadResponse(path=path, content=content))
            except Exception as exc:
                results.append(FileDownloadResponse(path=path, error=str(exc)))
        return results
    

    关键在于应使用 read_bytes 而非 read_file。SDK 提供了这两种方式,但 read_file 假设文本为 UTF-8 格式。运行 LLM 代码的沙箱无法判断下一个请求的文件是 CSV 格式还是新生成的图表,因此 read_bytes 是处理这两种情况的唯一安全选择。

    kill()

    def kill(self) -> None:
        self._runner.run(self._sandbox.kill())
        self._runner.shutdown()
    

    销毁过程会执行两件重要的事:首先终止沙箱容器,然后明确关闭运行器的后台线程。如果跳过第二步,那么即使沙箱已消失,该线程及其内部的事件循环仍会存在。

    整个类结构就是这样:在《BaseSandbox》要求的四个方法(id、execute、upload_files、download_files)之外,还包含了两个生命周期方法(create、kill)。

    同步/异步桥接

    还有一部分内容尚未解释:即self._runner.run(...),正是它让所有的异步SDK调用在《BaseSandbox》看来都像是同步操作。

    BaseSandbox 是同步的。execute()、upload_files() 和 download_files() 都是普通的函数调用,不包含 await。不过 OpenSandbox SDK 是异步的:Sandbox.create()、sandbox.commands.run() 和 sandbox.files.write_files() 全部都是协程。AsyncRunner 的存在就是为了填补这一差距。

    实现方式很简单:创建一个事件循环,将其置于独立的后台线程中,并在沙箱存在期间一直保持运行状态。其他所有操作都仅使用 run(),该函数通过 run_coroutine_threadsafe 将协程提交到该事件循环中,同时阻塞调用代码直到协程执行完成并返回结果。对于 execute() 方法,self._runner.run(some_coroutine) 看起来就像一次普通的函数调用。

    使用一个长期运行的循环,而非每次调用都创建新的循环,可以让连接保持为连续的会话状态,而不会在每次execute()调用时都被拆解并重建。shutdown()通过停止循环并干净地终止线程来处理清理工作,因此kill()只需调用它即可。

    沙箱中的数据分析代理

    为测试集成功能,Jupyter笔记本首先会设置环境变量和常量,导入相关包,最重要的是创建一个MinimalOpenSandboxBackend对象。这需要你自行创建的API密钥,并将其设置在~/.sandbox.toml文件中。

    import asyncio
    import nest_asyncio
    import threading
    from datetime import timedelta
    from pathlib import Path
    
    from deepagents import create_deep_agent
    from deepagents.backends.protocol import ExecuteResponse, FileDownloadResponse, FileUploadResponse
    from deepagents.backends.sandbox import BaseSandbox
    from langchain.chat_models import init_chat_model
    
    from opensandbox import Sandbox
    from opensandbox.config import ConnectionConfig
    from opensandbox.models import WriteEntry
    
    # nest_asyncio for running async functions in Jupyter.
    nest_asyncio.apply()
    
    IMAGE = "opensandbox/code-interpreter:v1.1.0"
    ENTRYPOINT = ["/opt/code-interpreter/code-interpreter.sh"]
    
    backend = MinimalOpenSandboxBackend.create(api_key="SANDBOX_API_KEY")
    print("Sandbox ready:", backend.id)
    

    接下来是聊天模型。通过 Ollama 使用本地模型也可以,但该模型仍需处理代理任务。此次运行使用的是 Gemini Flash 模型。

    llm = init_chat_model(
        model="gemini-3.5-flash",
        model_provider="google_genai",
        api_key=os.environ["GOOGLE_API_KEY"],
        max_tokens=14750,
        max_retries=5,
    )
    

    然后是代理本身。

    agent = create_deep_agent(
        model=llm,
        system_prompt=(
            "You are a Python coding assistant with sandbox access. "
            "You specialize in performing data analysis and data visualization with python,"
            "you generate clear reports with good looking charts using seaborn."
        ),
        backend=backend,
    )
    

    迄今为止构建的所有内容都会在backend=backend参数中体现出来。Deep Agents以自定义的execute()函数为基础,从该单一对象中生成了ls、read_file、write_file、glob以及grep函数。

    接下来是通过upload_files()直接加载的一些真实数据。该数据集是包含1,000位客户的模拟数据。

    csv_bytes = Path("customers-1000.csv").read_bytes()
    results = backend.upload_files([("/workspace/customers-1000.csv", csv_bytes)])
    for r in results:
        if r.error:
            print(f"Upload failed for {r.path}: {r.error}")
        else:
            print(f"Uploaded {r.path}")
    

    然后,代理会接到实际任务。

    result = agent.invoke({
        "messages": "Perform a deep exploratory data analysis on the customers-1000.csv file "
                    "and summarize the findings in a markdown report with clear charts."
    })
    

    目前该代理仅能独立工作。借助其工具与基础设施,它可以编写 pandas 和 seaborn 代码,通过 execute() 在沙箱环境中运行这些代码,将图表存储在沙箱的文件系统中,并不断迭代直至生成完整的 Markdown 报告。

    仍存在一个问题:报告的部分内容仍留在沙箱内。其中的 Markdown 链接使用的是沙箱内的路径,而这些路径在主机上并不存在。要使其可读,需要分两步进行:首先用 download_files() 下载图表图像,然后重新编写 Markdown 中的图像路径,使其指向本地的副本。另一种更为稳健的方法是让代理直接返回结构化输出,例如包含报告字段以及明确的图像路径列表,而非在格式自由的 Markdown 中查找链接。这样一来,后续处理只需下载列表中的每个路径并替换即可。

    之后,这是沙箱从原始 CSV 文件中生成的输出。

    # Deep Exploratory Data Analysis: Customer Acquisition and Profiling
    **Dataset:** `customers-1000.csv`
    **Analysis Period:** Jan 2020 – May 2022
    
    ---
    
    ## 1. Executive Summary
    
    This report presents a comprehensive exploratory data analysis (EDA) of a customer database containing 1,000 unique records. The analysis delves into geographical distributions, sign-up temporal trends, domain & technical alignments, and name demographics to uncover actionable insights for strategic growth.
    
    ### Key Takeaways
    1. **Unprecedented Global Reach:** The customer base is extraordinarily decentralized, spanning **240 countries** across all **7 continents** (including Antarctica). No single country represents more than 1.2% of the customer base. Africa (24.7%) and Asia (22.6%) are the leading regions, followed by Europe (18.5%) and North America (16.1%).
    2. **Stable Acquisition Trends:** Customer subscriptions are remarkably stable, averaging roughly **34-35 new customers per month** across 2020 and 2021. This consistency is maintained across all continents year-over-year, indicating a highly standardized, globally distributed customer acquisition channel.
    3. **Mid-Week and Weekend Consistency:** Subscriptions are evenly spread across the days of the week, with a minor peak on Friday and Saturday, and a minor trough on Thursday.
    4. **B2B / Synthetic Profile Characteristics:** The dataset shows zero domain overlap between customer email domains and company websites (0.0% exact match across 923 unique domains). Combined with the near 1-to-1 ratio of customers to companies, this suggests a highly B2B-centric profile (one representative per enterprise) or synthetically generated profiles with randomized fields.
    5. **Standardized TLD Footprint:** The `.com` top-level domain (TLD) dominates both emails (61.2%) and corporate websites (58.8%). The remaining distribution is evenly split among `.org`, `.net`, `.biz`, and `.info`.
    
    ---
    
    ## 2. Dataset Structure & Data Integrity
    
    The initial dataset contains **1,000 rows** and **12 columns**. An inspection of data integrity reveals excellent completeness:
    - **Zero Missing Values:** Every column is 100% populated.
    - **Zero Duplicates:** There are no duplicate rows, and the `Customer Id` column contains 1,000 unique identifiers.
    - **Data Types:** All columns are stored as object/string types except for `Index` (integer).
    
    ### Data Preprocessing & Feature Engineering
    To enable deep exploratory analysis, several features were engineered:
    1. **Temporal Features:** `Subscription Date` was parsed as a datetime object, allowing the extraction of `Sub_Year`, `Sub_Month`, `Sub_Month_Name`, `Sub_Day_of_Week`, and `Sub_Year_Month` (period).
    2. **Geographical Mapping:** Using the `pycountry` and `pycountry-convert` libraries, coupled with a manual fallback dictionary for territories, each of the 240 countries was successfully mapped to its respective **Continent**.
    3. **Domain & Technical Profiles:** Email domains (`Email_Domain`), email TLDs (`Email_TLD`), and website TLDs (`Website_TLD`) were extracted to analyze the technical profiling of users.
    
    ---
    
    ## 3. Geographical Analysis
    
    ### Continent-Level Distribution
    The geographic reach of this customer base is truly global. Rather than being concentrated in a single dominant market like North America or Europe, customers are spread across all continents:
    
    | Continent | Customer Count | Percentage |
    | :--- | :---: | :---: |
    | **Africa** | 247 | 24.7% |
    | **Asia** | 226 | 22.6% |
    | **Europe** | 185 | 18.5% |
    | **North America** | 161 | 16.1% |
    | **Oceania** | 107 | 10.7% |
    | **South America** | 54 | 5.4% |
    | **Antarctica** | 20 | 2.0% |
    
    #### Chart 1: Customer Distribution by Continent
    ![Customer Distribution by Continent](downloads/customer_by_continent.png)
    
    ### Country-Level Distribution (Top 15 Countries)
    The country-level distribution exhibits a heavy tail, with the 1,000 customers distributed across **240 distinct nations**. This indicates that the average number of customers per country is only **4.17**.
    
    The top countries by customer density are:
    - **Liechtenstein:** 12 customers (1.2%)
    - **Gabon:** 10 customers (1.0%)
    - **China, Bangladesh, Reunion, Nigeria, Luxembourg:** 9 customers each (0.9%)
    
    This extreme dispersion suggests a borderless, digital-first product that appeals universally across jurisdictions without localized geographic bias.
    
    #### Chart 2: Top 15 Countries by Customer Count
    ![Top 15 Countries by Customer Count](downloads/top_15_countries.png)
    
    ---
    
    ## 4. Temporal Analysis (Subscription Trends)
    
    ... (Trimmed to keep blog (estimated read time short))
    

    渲染后报告的缩略视图:

    从笔记本到 PyPI 包

    笔记本可以展示各部分是如何组合的,但能够被任何人通过 pip 安装的包才更具实用价值。开发 deepagents_opensandbox_backend 意味着要在其他人使用作者从未尝试过的环境运行该代码时,修复所有可能出现的问题。公共接口保持不变,依旧使用相同的 BaseSandbox 方法以及相同的 create() 和 kill() 函数,变化发生在内部:

    • 单一共享循环。 后端现在共享同一个模块级的 _BackgroundLoop,不再各自运行独立的线程。
  • 默认的安全警告。由于该 API 未进行身份验证,现在当未提供 api_key 时,create() 方法会抛出 InsecureSandboxWarning 警告,而之前则是默默地建立连接。
  • Docker Desktop 的解决方案。在 Docker Desktop 环境中,直接连接到容器端口常常会出现超时问题。设置 use_server_proxy=True 可让请求通过服务器传输,从而避开此问题。
  • 路径验证。upload_files 和 download_files 方法会直接拒绝相对路径,这样服务器就无法悄悄地将它们解析到错误的目录中。
  • 更清晰的错误提示。客户端会检查文件信息,以区分“权限被拒”与“文件未找到”,并将每种情况对应到相应的错误代码,而非传递可能造成误解的原始错误信息。
  • 有效的超时机制。原先被忽略的timeout参数现在会被传递给服务器,用于终止运行过久的命令,而不会在客户端放弃后让这些命令无响应地继续运行。
  • 已移除手写的异步方法。诸如aexecute这样的真正异步版本会在LangGraph的ToolNode中产生无输出或静默运行的情况,因此已被删除,现在的代码会回退到在线程池中运行的常规同步方法。
  • 这一差距使得仅能在某台笔记本电脑上运行的演示版本与可供任何人使用 pip install 安装的包区分开来。该包附带了一套符合性测试套件,这些测试套件是依据 Deep Agents 自身的标准沙箱测试设计的,安装方式如下:

    !pip install deepagents-opensandbox-backend
    

    这与上文描述的集成方式相同,只是将尖锐的边缘磨平了。

    相关阅读

  • AgentSRE:基于可组合AI的AWS自主故障处理解决方案 —— 讲述AWS平台如何通过DevOps Agent、LangGraph故障处理Agent、人工审核以及ServiceNow工单,将CloudWatch警报中的故障转化为已解决的案例。
  • 架构设计无法告知Agent的信息:BigQuery上的开放知识格式 —— 通过BigQuery与ADK的实验展示,Git中的Markdown知识包如何让Agent的回答更加稳定,并将GMV误差率降低了29%。
  • CrewAI、AutoGen、LangGraph、OpenAI Agents SDK与smolagents对比分析 —— 一位工程师从任务编排、内存管理、工具支持、评估机制及可观测性等方面对这五种开源智能体框架进行了对比,并指出了常见的故障模式。