Cubesandbox 是腾讯开源的代码沙箱运行时,对外暴露 E2B 兼容的 REST API。其核心定位:
- API 协议层:与 E2B 完全兼容,应用层代码(包括
e2b-code-interpreterPython SDK)无需改动,只换E2B_API_URL - 执行隔离层:每个 sandbox 是一个独立的 KVM MicroVM(不是 Docker 容器),有自己的 kernel、CPU 配额、网络命名空间
- 典型用途:让 LLM Agent 安全地执行用户/模型生成的代码,避免容器逃逸或副作用
为了理解之后的步骤,我们先来梳理cudesandbox的组件依赖关系,其中包含两条关键链路:
- API 链:SDK → cube-api(:3000) → cubemaster(:8089) → cubelet(:9999) → 创建 MicroVM
- 数据链:SDK 拿到 sandbox-id 后,访问
https://<sandbox-id>-<port>.cube.app→ cube-proxy → MicroVM 内的服务
环境初始化
本次测试环境为AL2023操作系统(上游为Fedora系统),我们启动m5系列的裸金属实例完成本次测试。
首先,验证 KVM 与 CPU 虚拟化能力,如果/dev/kvm 缺失则需要停机检查环境,停止后续的步骤。
$ ls -la /dev/kvm
crw-rw-rw-. 1 root kvm 10, 232 ...$ lscpu | grep -iE "Architecture|Virtualization|CPU\(s\):"
Architecture: x86_64
CPU(s): 96
Virtualization: VT-x # 具备VT-x
/dev/kvm 不存在的环境跑不了 Cubesandbox bare-metal,需另寻支持嵌套,PVM或物理机的环境,具体的判断逻辑如下
安装 Docker
sudo dnf install -y docker
sudo systemctl enable --now docker
sudo docker version
安装 Cubesandbox
cubesandbox 安装脚本的 lib/common.sh 假设 yum 系发行版必有 EPEL 仓库。但是AL2023 是 Fedora 派生,但不预装 EPEL,且默认仓库不带 ripgrep 包。此外,安装脚本会检查是否存在ripgrep和docker compose命令,因此需要提前安装好
curl -fSL https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz \-o /tmp/rg.tar.gz
tar -xzf /tmp/rg.tar.gz -C /tmp/
sudo cp /tmp/ripgrep-14.1.1-x86_64-unknown-linux-musl/rg /usr/local/bin/rg
sudo chmod +x /usr/local/bin/rg# 2) docker compose v2 plugin
sudo mkdir -p /usr/local/lib/docker/cli-plugins
sudo curl -fSL \https://github.com/docker/compose/releases/download/v2.30.3/docker-compose-linux-x86_64 \-o /usr/local/lib/docker/cli-plugins/docker-compose
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
sudo ln -sf /usr/local/lib/docker/cli-plugins/docker-compose /usr/local/bin/docker-compose
在 root 用户下执行官方文档命令中的如下命令
curl -sL https://cnb.cool/CubeSandbox/CubeSandbox/-/git/raw/master/deploy/one-click/online-install.sh | MIRROR=cn bash
依赖装齐后重跑 install.sh,服务看起来都起来了
[one-click-runtime] started network-agent pid=43031
[one-click-runtime] started cubemaster pid=43032
[one-click-runtime] started cube-api pid=43033
[one-click-runtime] started cubelet pid=43034
但是此时创建模板会立即失败
status: FAILED
phase: DISTRIBUTING
error: artifact distribution failed on all 1 nodes:rpc error: code = Unimplementeddesc = unknown service cubelet.services.images.v1.Images
检查/data/log/Cubelet/Cubelet-req.log发现如下错误日志
[INFO] network plugin init begin
[INFO] tap plugin init: enable_network_agent=trueendpoint=grpc+unix:///tmp/cube/network-agent-grpc.sock
[WARN] network-agent not ready yet, retry_interval=1s err=context deadline exceeded
[WARN] network-agent not ready yet, retry_interval=1s err=context deadline exceeded
...
[FATAL] plugin network init failed: network-agent health check failed at init,waited 30s for network-agent readiness: context deadline exceeded
[WARN] failed to load plugin {"type":"io.cubelet.internal.v1"}
[FATAL] plugin workflow init fail: failed to get internal plugin: network-agent health check failed
[FATAL] plugin gc-service init fail: ...
[FATAL] plugin cubebox-service init fail: ...
[WARN] failed to load plugin {"type":"io.cubelet.images-service.v1"}
[INFO] cubelet successfully booted in 31.374417s
[ERROR] cubelet successfully booted in 31.374456s
日志表明,cubelet 启动时等待30 秒才去 connect /tmp/cube/network-agent-grpc.sock,但是 network-agent 真正 listen 的时刻晚于 cubelet 30s 超时点,导致cubelet 等不及就 FATAL。此时cubelet 进程虽然没有完全退出,但 Images / Workflow / Cubebox-Service 等关键服务全部加载失败,时序图如下:
解决方法很简单,因为 network-agent 早已就绪,直接单独重启 cubelet即可
sudo pkill -9 -f "/Cubelet/bin/cubelet"
sleep 2
sudo ls -la /tmp/cube/network-agent-grpc.socksudo bash -c "nohup /usr/local/services/cubetoolbox/Cubelet/bin/cubelet \--config /usr/local/services/cubetoolbox/Cubelet/config/config.toml \--dynamic-conf-path /usr/local/services/cubetoolbox/Cubelet/dynamicconf/conf.yaml \> /var/log/cube-sandbox-one-click/cubelet.log 2>&1 < /dev/null &"
之后我们运行官方的 Python 测试代码如下(具体步骤和CUBE_TEMPLATE_ID见模板创建部分)
with Sandbox.create(template=os.environ["CUBE_TEMPLATE_ID"]) as sandbox:result = sandbox.run_code("print('Hello')") # 这里抛错
出现如下报错,检查Sandbox.create() 本身成功了(拿到了 sandbox-id)。问题出在 run_code 时 SDK 通过 https://<sandbox-id>-49999.cube.app 访问 sandbox 内服务,域名解析失败。
httpcore.ConnectError: [Errno -2] Name or service not known
进一步检查发现install.sh脚本设置了 cube-dns0 link 给 systemd-resolved,但没有改 nsswitch.conf 让 NSS( Name Service Switch) 也走 systemd-resolved。/etc/resolv.conf 直接指向上游 DNS(不是 systemd-resolved 的 stub 127.0.0.53),导致大部分应用绕过 resolved。
我们把 resolve NSS 模块加进 hosts 解析链,强制让系统所有应用先问 systemd-resolved,再走传统 DNS
sudo sed -i.bak \'s/^hosts:.*/hosts: files resolve [!UNAVAIL=return] dns myhostname/' \/etc/nsswitch.conf
[!UNAVAIL=return]的语义:如果resolve(systemd-resolved)返回 NXDOMAIN,直接返回,不再 fallback 到 dns(上游 DNS)。这样*.cube.app走 systemd-resolved,其他域名走 systemd-resolved 失败再走上游 DNS。
模板创建与 SDK 验证
创建模板
cubelet 就绪后直接创建模板成功
sudo cubemastercli tpl create-from-image \--image cube-sandbox-cn.tencentcloudcr.com/cube-sandbox/sandbox-code:latest \--writable-layer-size 1G \--cpu 2000 --memory 4000 \--expose-port 49999 \--expose-port 49983 \--probe 49999
参数说明如下,更多参数
| 参数 | 含义 |
|---|---|
--image |
源 OCI 镜像。 |
--writable-layer-size 1G |
sandbox 可写层大小(overlay 上层),决定 sandbox 可写多少数据 |
--expose-port 49999/49983 |
镜像内监听的端口(sandbox-code 镜像约定) |
--probe 49999 |
健康探针端口;模板就绪条件就是这个 HTTP GET 通 |
执行成功后会输出模板id,总耗时约 40 秒(镜像 layer 已被预拉到 docker 内)。
job_id: <JOB_ID>
template_id: <TEMPLATE_ID>
status: PENDING
phase: PULLING
...
status: READY
phase: READY
progress: 100%
distribution: 1/1 ready, 0 failed
如要监控较长任务可以通过如下命令完成
sudo cubemastercli tpl watch --job-id <JOB_ID>
E2B SDK 代码
安装 SDK
sudo dnf install -y python3 python3-pip
pip3 install e2b-code-interpreter
准备 mkcert CA:
sudo cp /root/.local/share/mkcert/rootCA.pem /tmp/cube-rootCA.pem
sudo chmod 644 /tmp/cube-rootCA.pem
设置环境变量,其中**SSL_CERT_FILE 必须设置。sandbox URL 是 HTTPS(https://*.cube.app),证书由 cubesandbox 自带的 mkcert 本地 CA 签发。SDK 走 Python httpx(基于 httpcore + 系统 OpenSSL),不会自动信任这个 CA,必须显式指向 rootCA.pem。
export E2B_API_URL='http://127.0.0.1:3000'
export E2B_API_KEY='dummy'
export CUBE_TEMPLATE_ID='<TEMPLATE_ID>'
export SSL_CERT_FILE='/tmp/cube-rootCA.pem' # mkcert 签的本地 CA
测试脚本:
import os
from e2b_code_interpreter import Sandboxprint(f'API URL: {os.environ.get("E2B_API_URL")}')
print(f'Template: {os.environ.get("CUBE_TEMPLATE_ID")}')with Sandbox.create(template=os.environ['CUBE_TEMPLATE_ID']) as sandbox:print(f'Sandbox ID: {sandbox.sandbox_id}')# 1) 基础打印r1 = sandbox.run_code("print('Hello from Cube Sandbox, safely isolated!')")print('stdout:', r1.logs.stdout)# 2) 隔离性验证r2 = sandbox.run_code('''
import platform, os, sys, socket
print(f"Python: {sys.version.split()[0]}")
print(f"Hostname: {platform.node()}")
print(f"Kernel: {platform.release()}")
print(f"CPU count: {os.cpu_count()}")
print(f"IP: {socket.gethostbyname(socket.gethostname())}")
''')print('stdout:', r2.logs.stdout)# 3) 计算r3 = sandbox.run_code('print(sum(range(1_000_000)))')print('stdout:', r3.logs.stdout)
实际测试输出,宿主机内核版本为6.1.x-amzn2023,sandbox内核版本6.6.1199-0009-03_2.0.1,看到独立内核版本说明VM生效
=== Test 1: Hello world ===
stdout: ['Hello from Cube Sandbox, safely isolated!\n']=== Test 2: Compute (verify isolation) ===
stdout: ['Python: 3.12.12Hostname: tpl-XXXXKernel: 6.6.1199-0009-03_2.0.1CPU count: 2IP: 127.0.0.1']=== Test 3: Math ===
stdout: ['499999500000\n']
使用sandbox分析数据
接下来我们使用PydanticAI集成sandbox的能力,实现自然语言提问 ,AI 写代码 ,Cubesandbox 运行代码 ,产出图表的全过程。整体架构如下:
首先我们确认cudesandbox集群就绪
# cube-api 在监听
sudo ss -tlnp | grep :3000# 健康检查通过
curl -sS http://127.0.0.1:3000/health# 模板已 READY
sudo cubemastercli tpl ls# mkcert CA 可读
sudo cp /root/.local/share/mkcert/rootCA.pem /tmp/cube-rootCA.pem
sudo chmod 644 /tmp/cube-rootCA.pemls -la /tmp/cube-rootCA.pem
通过将把 E2B_API_URL 指向 E2B 沙盒,LLM 决策层和代码执行层通过 E2B SDK 完全解耦。
确认Cubesandbox 集群就绪后,准备好使用的模板 ID
export CUBE_TEMPLATE_ID=tpl-xxxxxxxxxxxxxxxxxxxx
PydanticAI 0.8 通过 OpenAIChatModel 支持任何 OpenAI 兼容的端点。本文实测用一个 LiteLLM proxy 当 LLM 网关,模型路由到指定模型:
export LLM_BASE_URL='http://litellm.your.network:4000/v1'
export LLM_API_KEY='sk-xxx'
export LLM_MODEL='modelname'
模板 ID 为之前创建的模板
export CUBE_TEMPLATE_ID=tpl-xxxxxxxxxxxxxxxxxxxx
完整的示例代码analyst.py
"""
PydanticAI 0.8.1 + Cubesandbox + LiteLLM 数据分析助手
功能:CSV 上传 + 自然语言问答 + 错误自动重试 + 图表回传到本地
"""
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Annotatedfrom pydantic import Field
from pydantic_ai import Agent, ModelRetry, RunContext
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
from e2b_code_interpreter import Sandbox# ============ Cubesandbox 配置(同机直连,是与 E2B 公有云的唯一区别)============
os.environ['E2B_API_URL'] = 'http://127.0.0.1:3000'
os.environ['E2B_API_KEY'] = 'dummy'
os.environ['SSL_CERT_FILE'] = '/tmp/cube-rootCA.pem'TEMPLATE_ID = os.environ['CUBE_TEMPLATE_ID']# ============ LLM 后端(任何 OpenAI 兼容端点)============
LLM_BASE_URL = os.environ.get('LLM_BASE_URL', 'http://localhost:4000/v1')
LLM_API_KEY = os.environ.get('LLM_API_KEY', 'sk-xxxxxx')
LLM_MODEL = os.environ.get('LLM_MODEL', 'anthropic.claude-opus-4-7')model = OpenAIChatModel(LLM_MODEL,provider=OpenAIProvider(base_url=LLM_BASE_URL, api_key=LLM_API_KEY),
)# ============ Agent 依赖:跨工具调用共享 sandbox + 上传清单 ============
@dataclass
class Deps:sandbox: Sandboxuploaded_files: dict = field(default_factory=dict)# ============ Agent 定义 ============
agent = Agent(model,deps_type=Deps,system_prompt="""你是数据分析助手。可用工具:
- list_uploaded_files: 看用户上传了哪些文件(返回 sandbox 内绝对路径)
- run_python: 执行 Python 代码(pandas / matplotlib / numpy / seaborn)
- save_chart: 把 sandbox 里的图片保存到用户本地规则:
1. 收到问题先 list_uploaded_files 看数据
2. 写代码用 sandbox 内路径(/work/data.csv,不是用户本地路径)
3. matplotlib 用 Agg 后端:plt.switch_backend('Agg');图片存 /tmp/xxx.png 然后 save_chart
4. 报错时根据 stderr 修,不要假装成功
5. 同一 sandbox 内变量、import 跨多次 run_python 持久化
""",retries=3, # ModelRetry 最多重试 3 次
)# ============ 列出已上传文件 ============
@agent.tool
def list_uploaded_files(ctx: RunContext[Deps]) -> dict:"""返回 {本地原路径: sandbox 内路径}"""return ctx.deps.uploaded_files or {"info": "未上传任何文件"}# ============ 执行 Python 代码(核心,带自动重试)============
@agent.tool
def run_python(ctx: RunContext[Deps],code: Annotated[str, Field(description="Python 代码")]
) -> str:"""在 sandbox 执行 Python,返回 stdout。出错时触发 LLM 自我修正。"""r = ctx.deps.sandbox.run_code(code)# 报错ModelRetry把错误回传给 LLM,然后改代码再调if r.error:raise ModelRetry(f"代码报错:{r.error.name}: {r.error.value}
"f"stderr: {''.join(r.logs.stderr)}")out = r.logs.stdoutreturn ''.join(out) if isinstance(out, list) else (out or '(无 stdout)')# ============ 把 sandbox 内的图表传回本地 ============
@agent.tool
def save_chart(ctx: RunContext[Deps], sandbox_path: str) -> str:"""读取 sandbox 内的图片,保存到本地 ./output/ 目录"""try:content = ctx.deps.sandbox.files.read(sandbox_path, format='bytes')except Exception as e:raise ModelRetry(f"读取失败:{e}")out_dir = Path('./output')out_dir.mkdir(exist_ok=True)local = out_dir / Path(sandbox_path).namelocal.write_bytes(content)return f"图表已保存到本地:{local.absolute()}"# ============ 主程序 ============
def main():with Sandbox.create(template=TEMPLATE_ID, timeout=1800) as sandbox:print(f"sandbox: {sandbox.sandbox_id}")deps = Deps(sandbox=sandbox)# 文件上传循环while True:f = input("上传文件 (留空跳过): ").strip()if not f:breakp = Path(f).expanduser()if not p.exists():print(f" ! 文件不存在")continuesb_path = f"/work/{p.name}"sandbox.files.write(sb_path, p.read_bytes())deps.uploaded_files[str(p)] = sb_pathprint(f" ✓ 上传到 {sb_path}")# 对话循环(message_history 保留多轮上下文)history = []print("输入 'exit' 退出")while True:q = input("你: ").strip()if q in ('exit', 'quit', ''):breaktry:result = agent.run_sync(q, deps=deps, message_history=history)history = result.all_messages()print(f"AI: {result.output}")except Exception as e:print(f"出错: {type(e).__name__}: {e}")if __name__ == '__main__':main()
以上示例中,sandbox 单例实现跨调用持久化
with Sandbox.create(template=TEMPLATE_ID, timeout=1800) as sandbox:deps = Deps(sandbox=sandbox)while True:agent.run_sync(...)
每次新建 sandbox 有 1-3 秒开销,以上代码整个对话只用一个 sandbox。之后第一次工具调用 import pandas as pd; df = pd.read_csv('/work/sales.csv') 之后,后续工具调用都能直接用 df 。LLM 也知道这一点(看 system_prompt 最后那句),不会重复 import。
这里还有一个问题,文件是如何从sandbox中拷贝到宿主机的?即content = ctx.deps.sandbox.files.read(sandbox_path, format='bytes')检查如下源码
# e2b/sandbox_sync/filesystem/filesystem.py
def read(self, path, format="text", ...):params = {"path": path, "username": username}r = self._envd_api.get(ENVD_API_FILES_ROUTE, params=params, ...)if format == "bytes":return bytearray(r.content)
通过 HTTP GET URL 子域名编码端口和 sandbox-id来实现文件读取
https://49983-993e9bbbff29496a8737ce04fda56341.cube.app/files?path=/tmp/x.png└────┘ └────────────────────────────────────────┘端口 sandbox_id
请求会被转发到MicroVM 内 的envd,它是sandbox 内的 daemon,监听 :49983,提供 REST API如下
| 路由 | 功能 |
|---|---|
GET /files?path=... |
读文件 |
POST /files?path=... |
写文件 |
POST /commands |
执行命令 |
GET /processes |
列进程 |
POST /init |
初始化(envvars / cwd / mount) |
env收到 GET /files?path=/tmp/x.png 请求后执行文件 IO open(path,'rb').read()。因此SDK 只是 HTTP 客户端的封装
启动与文件上传
准备一个测试 CSV:
cat > /tmp/sales.csv <<'EOF'
order_id,product,amount,region,date
1,iPhone 15,8999,华东,2024-09-01
2,MacBook Air,9999,华北,2024-09-02
3,iPhone 15,8999,华东,2024-09-03
4,AirPods,1899,华南,2024-09-05
5,iPhone 15 Pro,11999,华西,2024-09-06
6,iPhone 15,8999,华北,2024-09-08
7,MacBook Pro,18999,华东,2024-09-10
8,AirPods,1899,华东,2024-09-11
9,iPhone 15 Pro,11999,华南,2024-09-12
10,iPad Air,4799,华东,2024-09-15
EOF
启动分析应用python3 analyst.py
=== 数据分析助手 (PydanticAI 0.8.1 + Cubesandbox + LiteLLM) ===
sandbox: 9e7db5f7a959431cb92edc0c941849ff上传文件 (留空跳过): /tmp/sales.csv✓ 上传到 /work/sales.csv
上传文件 (留空跳过): 输入 'exit' 退出你:
真实对话示例
下面是 4 轮真实对话的完整 transcript。每轮 AI 内部都会自动调用 list_uploaded_files、run_python、save_chart 等工具。
Q1:基本的数据内容查看
你: 数据共多少行?什么列?日期范围多大?AI: 数据基本情况如下:**📊 sales.csv 概览**| 项目 | 值 |
|------|-----|
| **行数** | 10 行 |
| **列数** | 5 列 |**列结构:**
- `order_id` (int) — 订单 ID
- `product` (str) — 产品名(如 iPhone 15、MacBook Air)
- `amount` (int) — 金额
- `region` (str) — 区域(华东、华北等)
- `date` (date) — 日期**日期范围:** 2024-09-01 ~ 2024-09-15,跨度 14 天数据规模比较小(只有 10 条订单)。需要我接着做哪方面分析?比如:
- 按产品/区域汇总销售额
- 每日销售趋势图
- TOP 产品排名
Q2:同时触发 run_python + save_chart 两个工具:
你: 按产品统计销售额排行榜,画柱状图保存到 /tmp/top_products.png,用 save_chart 传出AI: 完成 ✅**📊 产品销售额排行榜**| 排名 | 产品 | 销售额 |
|------|------|--------:|
| 🥇 1 | iPhone 15 | 26,997 |
| 🥈 2 | iPhone 15 Pro | 23,998 |
| 🥉 3 | MacBook Pro | 18,999 |
| 4 | MacBook Air | 9,999 |
| 5 | iPad Air | 4,799 |
| 6 | AirPods | 3,798 |**洞察:**
- **iPhone 15** 系列(标准版 + Pro)合计 50,995,是绝对主力,占总销售额 ~53%
- **MacBook 系列** 合计 28,998,排第二梯队
- **配件类**(iPad Air、AirPods)销售额较低,可考虑捆绑促销柱状图已保存到本地 `/tmp/output/top_products.png`,可直接查看。
Q3:继承上下文,复用已加载的 DataFrame:
你: 华东区每个产品的总销售额?给出表格AI: ## 📊 华东区产品销售额明细| 排名 | 产品 | 销售额 | 订单数 | 占比 |
|:---:|------|--------:|:---:|:---:|
| 🥇 1 | MacBook Pro | 18,999 | 1 | 43.48% |
| 🥈 2 | iPhone 15 | 17,998 | 2 | 41.19% |
| 🥉 3 | iPad Air | 4,799 | 1 | 10.98% |
| 4 | AirPods | 1,899 | 1 | 4.35% |**汇总:**
- 华东区总销售额:**43,695**
- 华东区总订单数:**5 单**
- 客单价:**8,739****洞察:**
- 华东区 **MacBook Pro 单笔贡献最高**(43.48%),高客单价产品表现突出
- **iPhone 15** 走量优势明显(2 单),是华东最受欢迎产品
- 高端产品(MacBook Pro + iPhone 15)合计占华东 ~85%,消费力较强
统计图结果如下
Q4:用 seaborn 画热力图:
你: 用 seaborn 画一个产品×区域的销售额热力图(plt.switch_backend Agg),保存到 /tmp/heatmap.png 并 save_chart 传出AI: 完成 ✅ 热力图已传出到本地 `/tmp/output/heatmap.png`。**📊 产品 × 区域 销售额矩阵**| 产品 \ 区域 | East 华东 | North 华北 | South 华南 | West 华西 |
|------|--------:|--------:|--------:|--------:|
| iPhone 15 | **17,998** | 8,999 | 0 | 0 |
| iPhone 15 Pro | 0 | 0 | **11,999** | **11,999** |
| MacBook Pro | **18,999** | 0 | 0 | 0 |
| MacBook Air | 0 | **9,999** | 0 | 0 |
| iPad Air | 4,799 | 0 | 0 | 0 |
| AirPods | 1,899 | 0 | 1,899 | 0 |**洞察:**
- 🔥 **华东是核心市场**:覆盖 iPhone 15 / MacBook Pro / iPad Air / AirPods 4 个品类
- 📱 **iPhone 15 Pro 偏南/西**:标准版主攻北方,Pro 版主攻南方/西部,定位差异明显
- 💻 **MacBook 区域分布单一**:Pro 仅华东、Air 仅华北,覆盖面偏窄
- ⚪ **数据稀疏**:矩阵里大量 0 值,反映样本量小(只有 10 单)附注:因 sandbox 默认字体不支持中文,区域名做了英文映射(华东→East、华北→North、华南→South、华西→West),避免图上出现方块。
退出后查看本地落盘:
$ ls -la ./output/
-rw-rw-r--. 1 ec2-user ec2-user 52649 May 29 03:41 heatmap.png
-rw-rw-r--. 1 ec2-user ec2-user 45826 May 29 03:40 top_products.png$ file ./output/*.png
./output/heatmap.png: PNG image data, 960 x 720, 8-bit/color RGBA, non-interlaced
./output/top_products.png: PNG image data, 1080 x 600, 8-bit/color RGBA, non-interlaced
热力图结果
错误重试
LM 写错代码是常态。传统做法是写一堆 try/except + 手动构造 follow-up prompt。PydanticAI 的优雅写法:
if result.error:raise ModelRetry(f"代码报错: {result.error.value}\nstderr: ...")
retries 次数要合理,如果设太大可能让 LLM 在错误里打转消耗不必要的token。
抛 ModelRetry 后框架自动:
- 把错误信息作为新的工具调用结果塞回对话历史
- 让 LLM 看到错误并产出新的工具调用
- 最多重试
Agent(retries=N)次 - 用户视角:只看到最终成功的输出
在运行过程中,第 2 题和第 4 题各遇到一次 save_chart 偶发失败(cube-proxy DNS 还没完全就绪),LLM 表现:
ModelRetry在 3 次重试后仍失败,PydanticAI 把异常抛给应用层- 但 LLM 没有崩溃,它知道数据已经在 sandbox 算好了,照常返回完整文字答案,只是诚实地说图传不出来
ModelRetry设计的优势让重试链中得到的中间结果(已算好的数字)不丢失,最终回答仍然有用
AI: 图已经成功生成(63KB),但 save_chart 一直报 DNS 错误(Name or service not known),看起来是上传服务暂时连不上,不是代码问题。[销售额排行表 + 数据洞察 ...]图片保存在 sandbox 的 /tmp/top_products.png。save_chart 这边是后台传输服务的问题,
麻烦你过一会儿让我重试一次,或者刷新一下会话再试。要我现在再试一次吗?
第二次跑(连接已 warm up)就完全正常了。
cubemastercli 视角
cubemastercli 是 Cubesandbox 自带的 CLI 工具,用于管理模板、Sandbox 和节点。PydanticAI agent跟cubemastercli能看到同一件事的两个角度。可以清楚看到 sandbox 生命周期。cubemastercli 与 E2B SDK 的关系如下图所示,两者最终都通过 cubemaster 操作 cubelet,底层执行路径一致:
我们运行一个定时1s循环脚本查看sandbox详情
# 列出sandbox的ID
cubemastercli ls -q# 查看sandbox的详细info
cubemastercli info -s "$SBID" 2>/dev/null)
同时运行agent和脚本,跑完后提取关键事件时间线如下
- 从
Sandbox.create()到cubemastercli ls能看到sandbox 创建过程很快 192.168.0.11是 sandbox 内部 tap 网络的 IP。每次新建 sandbox 都从一个内网池分配status=running全程不变 ,即使 LLM 那边在等模型响应,cubelet 视角下 sandbox 是 idle 但 running,等待下一个 run_code 调用- Q1 → Q2 跨 37 秒,期间 cubemastercli 看到的就是同一个 sandbox,一次会话只有一个 sandbox,所有工具调用都打到它
[05:25:19 CLI] cubemastercli ls -> 0 sandbox # analyst.py 启动前
[05:25:20 APP] sandbox: 9aed036150994045bf5609c4096fc83d # Sandbox.create() 完成
[05:25:20 APP] 上传文件 (留空跳过): ✓ 上传到 /work/sales.csv # files.write() 完成
[05:25:20 CLI] cubemastercli ls -> 9aed036150994045bf5609c4096fc83d status=running sandbox_ip=192.168.0.11 # 立即观察到sandbox创建
[05:25:21 CLI] cubemastercli ls -> 9aed036150994045bf5609c4096fc83d status=running sandbox_ip=192.168.0.11
[05:25:22 CLI] cubemastercli ls -> 9aed036150994045bf5609c4096fc83d status=running sandbox_ip=192.168.0.11
[05:25:23 CLI] cubemastercli ls -> 9aed036150994045bf5609c4096fc83d status=running sandbox_ip=192.168.0.11
...
[05:25:44 APP] AI: `sales.csv` 数据概况: # 第 1 个回答 (24s 后)
[05:26:21 APP] AI: 完成 ✅ # 第 2 个回答 (37s 后)- sandbox: `/tmp/q1.png` # 图表已生成本地
其他常用的cubemastercli命令如下
# 节点
$ sudo cubemastercli node ls# 模板
$ sudo cubemastercli tpl create-from-image --image <ref> --writable-layer-size 1G --expose-port 49999 --probe 49999
$ sudo cubemastercli tpl status --job-id <job>
$ sudo cubemastercli tpl watch --job-id <job>$ sudo cubemastercli tpl ls$ sudo cubemastercli tpl info --template-id <id>
$ sudo cubemastercli tpl info --template-id <id> --json
{"ret": { "ret_code": 200, "ret_msg": "success" },"template_id": "tpl-6f3b0a181586406db7620784","instance_type": "cubebox","version": "v2","status": "READY","replicas": [{"node_id": "172.31.24.141","node_ip": "172.31.24.141","instance_type": "cubebox","spec": "cpu=2000m,mem=2000Mi","snapshot_path": "/usr/local/services/cubetoolbox/cube-snapshot/cubebox/tpl-.../2C2000M","status": "READY","phase": "READY"}]
}$ sudo cubemastercli tpl redo --template-id <id> --failed-only --wait # 重试失败模板
$ sudo cubemastercli tpl delete --template-id <id> # 删除模板# Sandbox
$ sudo cubemastercli ls$ sudo cubemastercli ls -w # 详细视图(含 template_id、annotations 等)$ sudo cubemastercli ls -q # 只输出 sandbox_id$ sudo cubemastercli ls --filter "status=running"$ sudo cubemastercli info -s <sandbox-id> # 查 sandbox 详情
SANDBOX_ID 2c6ca28b2feb4abfbe8fe376c2f85754
STATUS running
HOST_ID 172.31.24.141
HOST_IP 172.31.24.141
SANDBOX_IP 192.168.0.4
TEMPLATE_ID tpl-6f3b0a181586406db7620784
NAMESPACE default
ANNOTATIONS {"cube.master.appsnapshot.template.id":"tpl-..."}
LABELS {"cube.numa_node":"1","cube.master.instance.type":"cubebox",...}
CONTAINERS 1$ sudo cubemastercli cubebox destroy <sandbox-id>
2026/05/29 03:28:01 doDestroySandbox RequestId:28a680fe-..., code:200, message:Success, cost:510
2026/05/29 03:28:01 destroyed: 2c6ca28b2feb4abfbe8fe376c2f85754
