给 Cube Sandbox 装一个"OJ 引擎":让 4 个 LLM 解法在 4 个 MicroVM 里同台竞技

一道 LeetCode、4 个候选解法、4 个独立 MicroVM 并行评判 —— 这一篇我把 Cube Sandbox 当作"AI 代码评测的执行底座"用了一遍,跑出正确性 + 性能 + 内存 + 异常隔离四维度的真实成绩单,外加一份给上层 Agent 直接吃的 JSON / PNG / Markdown 三件套交付物。

全程在一台 OpenCloudOS 9.4 的腾讯云 CVM 上跑(IP 129.211.223.113,PVM 内核 6.6.69-cube.pvm.host),所有命令、日志、截图均来自真实复现,文末附完整脚本,clone 即用。

关键词:Cube Sandbox · Agent 代码执行 · OJ 评测 · MicroVM · E2B 兼容 · OpenCloudOS 9


0. 这次不写"沙箱有什么用",写一个真实的小工程

很多写沙箱的文章会停在 “Hello World” 或者"看,我能跑 rm -rf / 不影响宿主"。这两件事在第一次上手时确实有用,但是写过 Agent 的人很快会问下一个问题:

当我手里有 4 段 LLM 输出的代码 —— 来自 GPT-4o、DeepSeek-V3、Qwen2.5-Coder、本地的 Code Llama —— 我怎么知道哪一段"既对又快又安全"?

这是 AI Code Agent 工程里最日常的问题:评测

而评测的本质就是 OJ(Online Judge)干的事:把不可信的代码丢进沙箱、喂测试用例、看是否通过、量耗时、量内存、设 timeout 兜底。OJ 这个工程范式已经被验证了 20 年(Codeforces、LeetCode、洛谷……),只是过去的"不可信代码"是参赛选手交的,现在变成了 LLM 生成的而已。

那这一篇就把这件事做小、做完、做出可复用的代码

  • 题目:LeetCode 42 接雨水(Trapping Rain Water),经典且解法分布广;
  • 4 个候选:正确慢解(暴力 O(n²))、正确快解(双指针 O(n))、典型 LLM 幻觉式答错、纯死循环;
  • 执行底座:4 个独立 Cube Sandbox MicroVM,并行跑、互不影响;
  • 交付物:JSON 排行 + 沙箱内 matplotlib 画的 PNG 排行榜 + Agent 给用户的 Markdown 报告。

这不是 demo。这是一个真正可以塞进某个 AI Code Agent 评测后台的最小骨架。


1. 为什么是 Cube Sandbox

我跳过"什么是 MicroVM、什么是 E2B、PVM 内核怎么装"的科普 —— 这些第一篇文章已经写得很细了。这里只摆三条做这件事必须依赖的事实:

  1. Cube 的 Sandbox.create() 是 60-150 ms 量级的,4 个并发也不会破百毫秒区间 —— 意味着我可以为每个候选解法开一个独立沙箱,而不必担心调度开销
  2. 每个沙箱是一份独立的 Linux 内核 + 独立的 rootfs,A 解法 OOM 或者 D 死循环,不会拖到 B 和 C —— 这是 OJ 评判的命根子;
  3. run_code(code, timeout=N) 是 SDK 自带的兜底,N 秒后直接抛 TimeoutException,沙箱整个生命周期由 Python with/kill() 控制 —— 死循环代码不再是阻塞调度器的炸弹。

这三条加起来翻译成大白话:对一段我不信任的 Python,启动 60ms、跑出来的 stdout 我直接拿、卡死了 N 秒后自己回来 —— 我作为评测器的代码可以非常短

那就开写。


2. 实战 Step 1:把"评测器"塞进沙箱

评测器(judge harness)的逻辑特别直白:把候选解法 solution.py 写进沙箱的 /workspace,然后在沙箱里跑一段标准评测代码:3 组公开用例 + 1 组隐藏 n=2000 用例,再用 time.perf_counter 测 P50、用 tracemalloc 抓峰值内存。

# scripts/oj_judge.py(节选)

JUDGE_HARNESS = r'''
import json, time, tracemalloc, traceback, random, sys

ns = {}
exec(compile(open("/workspace/solution.py").read(), "solution.py", "exec"), ns)
trap = ns.get("trap")

PUBLIC = [([0,1,0,2,1,0,1,3,2,1,2,1], 6), ([4,2,0,3,2,5], 9), ([], 0)]
random.seed(42)
HIDDEN = [random.randint(0, 10000) for _ in range(2000)]

result = {"correctness": [], "p50_us": None, "peak_kb": None, "err": None}
try:
    for i, (inp, expected) in enumerate(PUBLIC):
        got = trap(list(inp))
        result["correctness"].append({"case": f"public_{i}", "ok": got == expected,
                                      "got": got, "expected": expected})
    big = trap(list(HIDDEN))
    result["correctness"].append({"case": "hidden_n2000", "ok": isinstance(big, int),
                                  "got": big})
except Exception as e:
    result["err"] = f"{type(e).__name__}: {e}"

if result["err"] is None:
    times = []
    for _ in range(10):
        t0 = time.perf_counter(); trap(list(PUBLIC[0][0]))
        times.append((time.perf_counter() - t0) * 1e6)
    times.sort(); result["p50_us"] = round(times[5], 1)
    tracemalloc.start(); trap(list(HIDDEN))
    _, peak = tracemalloc.get_traced_memory(); tracemalloc.stop()
    result["peak_kb"] = round(peak / 1024, 1)

print("__JUDGE_RESULT__" + json.dumps(result))
'''

注意最后一行 __JUDGE_RESULT__ 这个 marker —— 这是给宿主侧解析用的"信封头"。沙箱里 stdout 里可能有 pip 安装日志、ipykernel banner 之类杂讯,我用一个固定字符串把"机器可读结果"和"人类可读日志"分开。这是和 LLM 输出打交道的老套路,AI Agent 工程里也常用(function-call 之前 <final_answer>...</final_answer> 这一类)。

宿主侧 judge_one() 就一个三步走:

def judge_one(name: str, source: str, slot: int) -> dict:
    sb = Sandbox.create(template=TEMPLATE, timeout=60)         # 1. 起一个 MicroVM
    sb.files.write("/workspace/solution.py", source)           # 2. 把解法塞进去
    res = sb.run_code(JUDGE_HARNESS, timeout=8)                # 3. 跑评测,8s 兜底
    stdout = "".join(res.logs.stdout) or ""
    if "__JUDGE_RESULT__" in stdout:
        record["judge"] = json.loads(stdout.split("__JUDGE_RESULT__")[-1])
    sb.kill()
    return record

timeout=8OJ 评测最关键的一个参数 —— 它把"卡死的 LLM 代码"直接斩首。一会儿就能看到效果。

并行用 ThreadPoolExecutor(max_workers=4),4 个候选同时跑,每个一个独立沙箱。


3. 实战 Step 2:4 个候选同台竞技 —— 真实截图

直接看跑出来的真实输出(这一段是宿主侧 python3 oj_judge.py 的实测):

4 个解法并行评测:C_buggy 答错 2/4;A_brute_force 4/4 但慢 7.4μs;B_two_pointer 4/4 又对又快 2.1μs;D_infinite_loop 被 8 秒 timeout 截断

这一张图是本文的 MVP(最小可复现物证)。4 个候选、4 种状态,全部在一次跑里被同时呈现

候选 状态 P50 峰值内存 通过 我的解读
B_two_pointer ✅ 又对又快 2.1 μs 15.8 KB 4/4 标准答案
A_brute_force ✅ 慢但对 7.4 μs 31.5 KB 4/4 暴力解,O(n²) 比 O(n) 慢 3.5×、内存大 2×
C_buggy ❌ 答错 0.6 μs 15.7 KB 2/4 经典 LLM 幻觉:return 一个看起来差不多的表达式
D_infinite_loop ⏱ 被斩首 0/1 TimeoutException 在 8s 后准时把它带走

信噪比最高的一行是最后一行。它证明的不是"我能 detect 死循环"——这件事 kill -9 也能做。它证明的是:run_code(timeout=8) + 沙箱级别的资源边界,让我作为评测器的代码完全不需要写"防 LLM 写飞"逻辑。LLM 写出 while True 我就当它是"算到一半超时了",下一轮继续接 next 评测,整个 OJ 调度器零额外代码。

回看 4 个候选的coldstart:105、120、132、154 ms。这是4 个 MicroVM 在同一秒被并发拉起来的成本,仍然在 <200ms 区间内 —— 与其说是冷启动,不如说是"零启动",正好是 LLM 一轮 token 时间的零头。


4. 实战 Step 3:让沙箱自己画排行榜

光有 JSON 不够。AI Code Agent 给用户最终交付的,多半是一张人类一眼就能读懂的图

ChatGPT Code Interpreter 的产品做法是:“让模型在沙箱里 import matplotlib,把图存到工作区,前端去抓这个 PNG”。我把同样的事做一遍:

# scripts/oj_viz.py(节选)—— 把 judge_result.json 喂回沙箱出图
sb = Sandbox.create(template=TEMPLATE, timeout=180)
sb.files.write("/workspace/judge_result.json", json.dumps(result))
sb.run_code(PLOT_CODE, timeout=120)                     # 沙箱里 matplotlib boxplot
data = sb.files.read("/workspace/leaderboard.png", format="bytes")
pathlib.Path("/root/arena/leaderboard.png").write_bytes(data)   # 拉回宿主

注意三行体现产品意图的代码:sb.files.write 把"上一步的产物"喂进沙箱、sb.run_code 让沙箱画图、sb.files.read 把 PNG 拉回宿主。这就是 ChatGPT Code Interpreter 后端在做的事,原汁原味。

宿主侧能看到这一段流程的时间分布:

宿主侧 oj_viz.py 输出:一个全新沙箱启动 + 装 matplotlib + 画图 + 写文件,端到端 8.1 秒,PNG 60 KB

8.1 秒里 95% 是 pip 装 matplotlib(USTC 镜像 ~7s),真正的画图只用了几百毫秒。生产里只要把 matplotlib 预装进自定义模板镜像,这一步就会从 8s 压到 <500ms。

接下来就是这次实战最直观的一张产物图 —— 由沙箱内的 matplotlib 画好、由宿主的 Python 拉回来的排行榜:

沙箱内画好、宿主拉回的排行榜:左 P50 延迟、中 峰值内存、右 测试通过率;颜色编码:绿=满分、橙=部分通过、红=异常

这张图想读出三个层次的信息:

  1. 左 P50 延迟图B_two_pointer 2μs vs A_brute_force 7μs,倍数关系一目了然;C_buggy 1μs—— 答错的代码确实可以"很快",因为它绕过了真正的计算(这是 LLM 评测里要警惕的点:单看"通过率 + 时间"是不够的,必须正交看);D_infinite_loop 直接用文字 “TLE/ERR” 标出来。
  2. 中 峰值内存:暴力解 32 KB vs 双指针 16 KB,差 2× —— 跟算法书上 O(n²) 占临时空间的理论一致。
  3. 右 通过率4/4 4/4 2/4 0/1,四个数字一行排出,连最后那条死循环 0/1 都画进去(绿色满柱代表满分、橙是部分、红柱被 TLE 吃掉所以高度是 0)。

这三张子图之所以值得做出来,是因为Agent 给用户的"答案"未来会越来越像产品的输出而不是终端的输出。Cube Sandbox 把"Agent 在沙箱里画图、宿主拿回 PNG"这条链路做得跟在本机 plt.savefig 一样自然。


5. 实战 Step 4:再生成一份"评测报告"给上层 Agent

为什么还要 Markdown?因为上一层 Agent 接到这份评测后,最自然的下一步是**“在用户聊天里发一段总结”**。Markdown 是 LLM 最舒服的输入/输出格式,又恰好可以渲染到任何 ChatUI、Notion、Discord、飞书。

oj_report.pyjudge_result.json 重排为带分数 + 详情的 Markdown:

宿主侧 cat report.md:Agent 给用户的最终交付物,按分数倒序排出 1~4 名,每个候选有详细执行轨迹和测试用例对比

读图三秒钟看清四件事:

  • 排名第一B_two_pointer,4/4 通过、P50=2.1 μs —— 双指针稳赢;
  • 排名第二A_brute_force,4/4 通过但 P50=7.4 μs —— 慢但对;
  • C_buggy 拿了 2/4 —— 注意它的输出 34 与期望 69 —— "看起来差一点点的答案"是 LLM 最常见的失败模式;
  • D_infinite_loop 端到端耗时 = None ms,备注 TimeoutException —— 沙箱 timeout 兜底符号化地呈现在了报告里。

到这里整套链路就完整了:Sandbox.create → run_code → files.read 三个原子操作 × judge → viz → report 三个语义层 = 一个最小但完整的 AI 代码评测后端


6. 顺手做的额外实验:宿主侧的"运维视角"

写到这里我有个好奇心 —— 当上面这 4 个沙箱并行跑的时候,从宿主侧 cubemastercli ls 看出来是什么样?这件事在调试 Cube 节点过载、排查"哪个用户的会话还在烧 CPU"等场景里非常实用。

我让一个守护线程每秒跑一次 cubemastercli ls,记录沙箱列表的变化(脚本:oj_observe.py)。挑了 T+1s(评测刚开始)、T+5s(4 解法并发中)、T+10s(评测结束、沙箱被回收)三帧关键时刻:

cubemastercli ls 三帧抓拍:T+1s 6 个 → T+5s 7 个(多了 8fb3f2dd…,本次评测沙箱)→ T+10s 回到 6 个

这一张图想说三件事:

  1. SANDBOX_COUNT 6 → 7 → 6 证实了沙箱的整个生命周期(创建 / 持有 / 回收)在宿主层面是可见的,不是 SDK 自己藏着的玄学;
  2. 我能看到每个沙箱的 host_id 10.206.0.13create_atstatus running/paused —— 这就是给你做 Agent 平台资源监控的现成原始数据;
  3. T+5s 那一帧里多出来的 8fb3f2ddfa3c4f4589b47fc1e5be0107,正是我在 judge_one()Sandbox.create() 拿到的实例 —— SDK 层和 cubemastercli 看到的是同一个真理。

小坑cubemastercli ls 列出的 sandbox_id 都是 32 位 hex 的小写,而 SDK Sandbox.create() 返回的 sb.sandbox_id 是大小写混合的 32 位 hex。它们是同一个 ID 的不同表示,做日志关联时记得 lower() 一下再比对。


7. 一个意料之外的工程发现:run_code 默认是"无状态"的

写完上面这套,我顺手测试了一个长期被忽视的细节 —— 同一个沙箱里多次 sb.run_code(...),是 Jupyter kernel 持久状态,还是每次新进程?

很多人凭直觉以为前者(毕竟 SDK 包装名字就叫 “Code Interpreter”),但实测下来 Cube Sandbox 当前版本是后者

同一沙箱里:A 直接用 globals 传 df 失败;B 改用 /workspace 文件传 + sb.files.read 成功;C sb.commands.run 直接跑 shell 命令拿到 uname/id/df

读图三段:

  • [A] 错误示范:step1 在 sandbox 里定义了 df,step2 直接 print(df) —— 结果 step2 拿到 step2 sees df ? False全局变量没有跨调用持续
  • [B] 正确做法:step1 把 df 写到 /workspace/df.json、step2 读回打分、step3 再读回看一眼 —— 跨步骤状态用文件传。最后 A graders: ['Carol'] 证明了三步链路完整。
  • [C] Bonussb.commands.run("uname -r") 不走 ipykernel、直接 spawn 一个进程 —— uname -r 漂亮地返回 6.6.69-cube.pvm.guest.005.x-gb85200d80fa2(沙箱里的 guest 内核!),id 显示用户是 uid=1000(user) —— 这才是沙箱真正的执行身份

来自 SDK 源码的注释e2b_code_interpreter 2.6.2 提供了 sb.create_code_context(language="python") 用来开"持久 Jupyter context";但我实测 Cube Sandbox 当前版本(v0.2.2/PVM)这个 endpoint 还会 404 —— 也就是说E2B 的 Context API 在 Cube 里暂未实现。所以用文件 + sb.files API 跨步传状态是当前最稳的姿势;如果你像我一样要做 ReAct/Code Interpreter 类应用,这是必须知道的工程现实。

这个发现直接影响所有打算把 Cube Sandbox 当 ChatGPT Code Interpreter 后端用的同学:你要么自己用文件传状态,要么等 Cube 升级 contexts API(issue 我已经记下来了)。


8. 性能复盘:4 个沙箱并发的真实成本是多少

judge_run.log 的关键数字抽出来:

候选 沙箱冷启 评测端到端 备注
C_buggy 154.9 ms 319.1 ms 答错跑得快
A_brute_force 120.2 ms 475.7 ms 暴力解最慢
B_two_pointer 132.2 ms 869.2 ms “Hello” 进沙箱 + run_code 总开销
D_infinite_loop 105.1 ms (timeout 8s 触发) 死循环被截断

四个数字翻译成 Agent 工程语言:

  • 冷启动 105-155 ms:4 并发条件下 P95 仍 < 200ms,跟单并发相比几乎没退化;
  • 评测端到端 ~470 ms(除了 D 异常):包含网络往返 × ≥4 次(write file + run_code + read stdout + kill),实际"代码执行 + 评测"只占其中一小部分;
  • D 死循环被 8 秒一刀:拿到 TimeoutException,沙箱被 kill() 立刻回收,整个评测调度器的 wallclock 就是 max(其它 3 个) + 8 秒。

这个数字落在 Agent 工程的舒适区:一个 ReAct 循环 5-8 步 × 评测 1 步 ≈ 8-10 个沙箱的生命周期,总额外 overhead < 2 秒,远小于一次 LLM 推理。


9. 这件事还能怎么变

如果你已经 follow 到这里,这套骨架可以再扩出至少 4 条产品线:

  1. 给 SWE-Bench 类 benchmark 做后端 —— 把"LeetCode 42 + 4 候选"换成"GitHub 真实 issue + 候选 patch",Cube Sandbox 给每个 patch 一个独立 MicroVM 跑测试;
  2. 接成在线判题系统 —— 把 oj_judge.py 包成 HTTP 服务,前端 textarea 传 Python 代码、后端走 Cube 评测 → 返回 JSON。冷启动 ~150ms 完全够实时使用;
  3. Code Agent 自评估 —— LLM 写代码 → Cube 执行 → 失败 → 把 traceback 喂回 LLM → 它自己改 → 再跑。这就是 SWE-agent / Aider 的核心循环;
  4. AI 课程作业自动批改 —— 把"4 个候选"换成"全班 60 个学生的提交",并发跑、生成 60 份 Markdown 反馈。

每一条都不是凭空想的 —— 它们都是把这一篇里的 Sandbox.create / run_code / files.read / commands.run / kill 5 个原子操作重新排列。这就是 Cube Sandbox 给 Agent 工程师的"乐高积木"。


10. 写在最后:把沙箱用成 Agent 的"工序车间"

这一篇我刻意没写"沙箱有多安全 / 隔离多强 / 性能多牛" —— 这些第一篇文章已经说足了。这一篇想强调的是另一件事:

Cube Sandbox 真正的位置,是 Agent 工程里的"工序车间" —— 一个标准化的、可重复进出的、出错也不会污染主厂房的执行环境。每一道工序(评测、画图、报告生成、运维观察)都对应到 SDK 里 1-2 个原子调用,没有黑盒、没有特殊版本、没有"魔法配置"。

你走完这一篇之后,应该能体会到三件事:

  1. 沙箱不是"为了隔离"而存在,是"为了可拼装"而存在 —— 65ms 的冷启动让"为每个工序开一个独立沙箱"从一个昂贵的设计变成了一个默认的设计;
  2. E2B SDK 在 Cube 上不是 100% 兼容,但 90% 够用 —— Sandbox / run_code / files / commands / kill 都跑通;Context 暂时不可用 → 用文件传状态绕过即可;
  3. OpenCloudOS 9 + PVM 内核 + 一台普通 CVM 就能撑起这整个工序车间 —— 不需要裸金属、不需要嵌套虚拟化、不需要专属 K8s 集群。

如果你最近在做 AI Code Agent / 在线判题 / 自动评测 / SWE-bench 复现,把这一篇里的 4 个脚本(oj_judge.py / oj_viz.py / oj_state.py / oj_report.py / oj_observe.py)clone 下来,改 5 行就能接你自己的 LLM 输出。这就是开源工具的乐趣所在。


附录 A:完整脚本清单

全部基于 IP 129.211.223.113、OS OpenCloudOS 9.4、内核 6.6.69-cube.pvm.host.005.x-gb85200d80fa2、CPU AMD EPYC 9K65 8 vCPU、内存 15 GiB、模板 tpl-90d8079679a2410c8b64c7b0 的真实环境跑通。

脚本 功能 配套日志
oj_judge.py 4 解法并行评测主程序 logs/judge_run.log
oj_viz.py 沙箱内 matplotlib 画排行榜 logs/viz_run.log
oj_state.py 验证 run_code 状态 / sb.files / sb.commands logs/state_run.log
oj_report.py 生成 Markdown 评测报告 logs/report.md
oj_observe.py 守护线程跑 cubemastercli ls 抓沙箱生命周期 logs/observe_run.log

启动前的环境变量(一次性 export 即可):

export E2B_API_URL="http://127.0.0.1:3000"
export E2B_API_KEY="dummy"
export CUBE_TEMPLATE_ID="tpl-90d8079679a2410c8b64c7b0"
export SSL_CERT_FILE="/root/.local/share/mkcert/rootCA.pem"

跑通顺序:

python3 oj_judge.py     # ~9s
python3 oj_viz.py       # ~8s(含 pip install matplotlib)
python3 oj_report.py    # 即时
python3 oj_state.py     # ~5s
python3 oj_observe.py   # ~12s(含 1s × 12 帧观测)

附录 B:参考链接


复盘小结一行话:当一个工程问题(“评测 4 个 LLM 输出哪个最好”)正好对应到沙箱的 5 个原子操作时,整个解决方案 < 200 行 Python,每一行都站得住、所有数字都来自真实运行。Cube Sandbox 在 OpenCloudOS 9 上恰好把这条路铺得很平 —— 这就是"开箱即用"的具体含义。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐