23 · 代码执行工具(Code Interpreter)接入 DocMind#
目的:给 agentic 检索循环补上”对检索到的数据做精确计算/统计”的能力。需求是开放式数据分析——用户提问无法预先枚举(如”剔除异常值后中间 5 年的 CAGR 并与行业均值对比”),只能让 LLM 现写分析代码执行。
落地时间:2026-06。配套执行计划见
~/.claude/plans/10-stage-0-stage-unified-puddle.md。
0 · 执行摘要#
| 维度 | 结论 |
|---|---|
| 要解决的问题 | agentic 循环原本只有 4 个检索类工具(searchDocs / keywordSearch / webSearch / recall_memory),缺”算”的能力。检索回来一堆数字,均值/同比/环比/CAGR 只能让模型心算——不可靠。 |
| 为什么不是计算器 | 计算需求开放、无法预先枚举参数,必须让 LLM 现写代码。这就引出”执行不可信代码”问题。 |
| 为什么必须沙箱 | 决定要不要沙箱的是代码由谁写,不是复杂度。LLM 生成的是不可信代码,进程内 eval 是大忌(n8n 因进程内 Pyodide 吃过 9.9 分 RCE)。→ 进程外沙箱。 |
| 选型 | 不自造轮子。选 阿里开源 OpenSandbox:Apache-2.0、可自托管 Docker、原生 Java/Kotlin SDK(零 HTTP 胶水)、内建 Code Interpreter(Jupyter 有状态)、可选 gVisor/Kata/Firecracker 隔离、CNCF Landscape。 |
| 接入点 | executeCode 作为 agentic 循环的第 5 个工具(不是独立一步)——因为 LLM 必须先”看到”检索回来的数据,才能照着写代码。 |
| 结果回流 | 新增「计算结果通道」复刻 memoryContext 那条链,把计算结果带回最终 prompt,不被 rerank 丢弃、不占检索 token 预算。 |
| 安全边界 | 仅内部(不进对外 MCP);动态一键开关;禁出网(egress deny-all,fail-closed);超时/输出/代码长度三道护栏;userId/kbIds 不可被 LLM 篡改沿用既有收口。 |
1 · 为什么是”代码执行”而不是”计算器”#
一个固定签名的 calculate(numbers, op) 计算器只能覆盖预先枚举的操作。真实的数据分析问题是开放的:
“把这几年的营收去掉最高最低后求几何平均,再和上一年同比,顺便算个标准差看波动。”
这种问题无法用有限的工具参数覆盖。唯一通用的做法是让 LLM 现写一段分析代码。工具签名因此取最通用形态:
executeCode(code: String) // LLM 把所需数据内联进 Python 代码,用 print(...) 输出结果java不预设数据 schema、不假设”一定是 10 年数据”。Python 为主(沙箱镜像也支持多语言)。
2 · 为什么必须进程外沙箱(这道题面试常被追问”需要沙箱吗”)#
判据:决定要不要沙箱的,是”代码由谁写”,不是”计算复杂不复杂”。
- 我自己写死的计算逻辑 → 可信,进程内跑没问题。
- LLM 现写的代码 → 不可信输入。在 JVM 进程内
eval等于把任意代码执行权交给模型输出,可读环境变量、连内网、删文件。
行业反例:n8n 的 Code 节点早期用进程内 Pyodide,吃过 CVE-2025-68668(CVSS 9.9) 沙箱逃逸 RCE,后来才转向独立 task-runner 进程隔离。FastGPT 早期 vm2/seccomp 缺 cgroups 也踩过坑。
结论:不可信代码 → 进程外强隔离,不在 JVM 里 eval。
顺带砍掉了一个一度想用的方案:GraalVM/Rhino 进程内多语言执行。主流产品基本没人这么干(面试不好讲),且仍在进程内,隔离性不足。直接弃用。
3 · 选型:主流产品的沙箱实现横向对比#
| 产品 | 隔离技术 | 备注 |
|---|---|---|
| OpenAI Code Interpreter | gVisor | 用户态内核,syscall 拦截 |
| E2B | Firecracker microVM | 强隔离,但自托管偏重 |
| Dify(dify-sandbox) | seccomp + chroot | 轻,但无 Java SDK |
| FastGPT | vm2 / seccomp | 早期缺 cgroups → CVE |
| n8n | Pyodide → task-runner | 进程内吃过 9.9 RCE,后转独立进程 |
| Coze Studio / Riza | WASM | 沙箱化但生态/包支持受限 |
| microsandbox | libkrun microVM | 轻量 VM |
| 阿里 OpenSandbox | 多 runtime(docker / gVisor / Kata / Firecracker 可选) | 原生 Java/Kotlin SDK、内建 Code Interpreter、Apache-2.0、CNCF Landscape |
选 OpenSandbox 的决定性理由:DocMind 是 Java 后端,OpenSandbox 提供原生 Java SDK(com.alibaba.opensandbox:code-interpreter),不用自己拼 HTTP/SSE 胶水;其余开源轻量沙箱要么只有 Python/JS SDK,要么得自己造客户端。隔离档位可按环境从 docker 一路升到 gVisor/Kata/Firecracker,dev 轻、prod 强。
方法论复盘:第一轮我用关键词搜索得到的候选偏 SEO/榜单(dify-sandbox 之类),漏了 OpenSandbox/AIO Sandbox 这类厂商新项目。教训:选型调研要枚举 awesome-list + 厂商最新开源,不能只信搜索头部结果。
4 · 架构:计算结果如何回流到最终答案#
4.1 关键约束:生成阶段是终态,不调工具#
DocMind 的生成阶段(SSE 流式作答)不调用任何工具,只有写进 AgentToolContext 的东西能进最终 prompt。所以 executeCode 的结果必须走一条”通道”回流——复刻已有的 memoryContext 那条链:
AgentToolContext.computations (ThreadLocal,工具执行时写入)
→ SupervisorResult.computations() (收尾时打包)
→ AgentState.computations (DocMindAgent 合并,与 memoryContext 并列)
→ PromptAssembler.formatComputations → {{computations}} 模板占位符plaintext收益:计算结果不参与 rerank、不被压缩丢弃、不占检索 token 预算,按原样作为”已执行的精确计算”区注入 prompt。
4.2 为什么 executeCode 在 agentic 循环里,而不是独立一步#
因为 LLM 必须先看到检索回来的数据(chunk 内容在工具结果消息里可见),才能照着把数字内联进代码。所以它是循环里的第 5 个工具,模型自驱”先检索 → 再写代码算 → 看结果决定下一步”。
4.3 引用处理的取舍(容易被问)#
计算结果不分配 [n] 编号。因为 [n] 引用体系绑定在 参考来源(chunks)上,CitationParser 会按 sources 校验越界编号(越界 → invalidRefs、压低覆盖率)。做法:把计算块作为权威数值区单列,提示模型”数值以计算为准、但对数字所依据的原始数据来源标 [n]”。既保证数值可信,又不破坏引用覆盖率。
5 · 三阶段落地#
| 阶段 | 内容 | 验证 |
|---|---|---|
| Stage 0 | 跑通 OpenSandbox + 薄 Java 客户端(SandboxConfig + SandboxCodeExecutor)+ 冒烟测试。先证明”Spring 能调沙箱跑 Python 拿回结果”再往下做。 | mvn compile 对真 JAR 校验 API;SandboxSmokeTest 环境门控,2+2=4。 |
| Stage 1 | executeCode @Tool + 接入 agentic 白名单 + 计算结果通道 + prompt 注入。修早停护栏(executeCode 只产计算不产 chunk,须计入”本轮产出”否则被误判零增益提前收尾)。 | 单测:白名单含 executeCode;computation 流入 SupervisorResult.computations()。 |
| Stage 2 | 护栏(sys_ai_config 动态:enabled/超时/输出上限/代码长度)+ 禁出网(egress deny-all)+ 可观测(code_exec SSE 事件 + 持久化 code+result 到 trace)+ 前端时间线”执行”节点 + 隔离档位文档。 | 139 单测全绿;前端 type-check 通过。 |
Stage 3(未做,记录待办):pandas/numpy/matplotlib + 图表产物(PNG → MinIO → 返回 URL)。届时
executeCode接口不变,仅扩展结果通道支持二进制产物。
6 · 安全边界与护栏(fail-closed)#
| 护栏 | 实现 | 默认 |
|---|---|---|
| 一键开关 | sandbox.enabled(sys_ai_config,热切换)。关闭时 executeCode 不进 agentic 白名单(模型根本看不到),工具层再做 no-op 纵深防御。 | false |
| 禁出网 | docmind.sandbox.deny-egress → 建箱时 NetworkPolicy.defaultAction(DENY),deny-all egress,防数据外泄/拉恶意载荷。 | true(fail-closed) |
| 执行超时 | sandbox.timeout_seconds → 沙箱生命周期 TTL,超时 server 回收(OpenSandbox server 硬下限 60s,低于会被钳到 60;沙箱用后即 kill,TTL 仅兜底)。 | 60s |
| 输出上限 | sandbox.max_output_chars → stdout/返回值截断,防超长输出灌爆生成上下文。 | 10000 |
| 代码长度上限 | sandbox.max_code_chars → 超长直接拒绝、不进沙箱。 | 10000 |
| 临时沙箱 | 每次执行临时创建 + 用后 kill()(finally 保证),换强隔离;池化复用列为后续优化。 | — |
| 仅内部 | 挂 @Tool 供 agentic 反射 + 进白名单,但不加入 McpToolsConfig 的对外 MethodToolCallbackProvider → 不对外暴露(对外跑任意代码安全面太大)。 | — |
| 越权防护 | userId/kbIds 沿用既有 AgentToolContext 收口,LLM 不可篡改。 | — |
| 隔离档位 | dev 容器隔离 + drop_capabilities + no_new_privileges;prod 可切 gVisor/Kata/Firecracker。 | — |
所有执行异常(连接失败/运行时错误/超时)都被收敛成 ExecResult.failure,工具优雅返回 error——模型可在”无计算结果”下继续作答,不中断主流程。
7 · 面试讲法 / 高频质疑应答#
Q:为什么不直接让大模型算? A:模型对多步数值计算(同比、CAGR、剔异常值后的统计量)容易算错、不可复现。把计算下沉到确定性的代码执行,数值有据可查、可回放(trace 持久化了 code+result)。
Q:跑 LLM 写的代码不危险吗?
A:所以必须进程外沙箱。判据是”代码由谁写”——LLM 写的是不可信输入,绝不在 JVM 里 eval。我们用 OpenSandbox 进程外隔离 + 禁出网 + 超时/输出/长度三道护栏 + 仅内部暴露,且默认整条能力关闭(fail-closed)。n8n/FastGPT 的进程内方案都吃过 RCE,是反面教材。
Q:为什么不用 E2B / 自己写 Docker 调度? A:E2B 自托管偏重;自己调度 Docker 是造轮子且易踩 cgroups/seccomp 配置坑。OpenSandbox 开源可自托管、有原生 Java SDK、隔离档位可调,工程量最小、最稳。
Q:计算结果怎么进最终答案、会不会被检索管线丢掉?
A:生成阶段是终态不调工具,所以我复刻了 memoryContext 的回流链做了一条「计算结果通道」,计算结果不参与 rerank/压缩、不占检索预算,按原样注入 prompt 的”已执行的精确计算”区;引用仍标在底层数据源上,不破坏引用覆盖率。
Q:关掉沙箱会怎样?
A:sandbox.enabled=false(默认)时 executeCode 根本不进 agentic 白名单,模型看不到这工具;常规构建/运行/测试零影响(冒烟测试环境门控自动跳过)。
8 · 关键文件#
- 连接/执行器:
config/DocmindSandboxProperties、config/SandboxConfig、service/sandbox/SandboxCodeExecutor、service/sandbox/ExecResult - 工具:
mcp/CodeExecTool(@Tool executeCode,仅内部) - 通道:
agent/AgentToolContext(computations)、agent/supervisor/SupervisorResult、agent/state/AgentState、agent/DocMindAgent(合并) - 接入 + 可观测:
agent/supervisor/AgenticSearchOrchestrator(白名单门控 + 早停修正 + code_exec 事件 + 日志持久化)、agent/emit/StageEmitter#codeExec - prompt:
service/rag/PromptAssembler#formatComputations、prompts/knowledge_qa.txt/knowledge_qa_low_confidence.txt - 护栏配置:
config/AiConfigInitializer(sandbox.* 动态项) - 部署:
docker-compose.yml(全栈,含 opensandbox + 后端经DOCMIND_SANDBOX_DOMAIN=opensandbox:8090连接)与docker-compose.dev.yml(host-dev 轻量 infra,也含 opensandbox)、docker/opensandbox/config.toml - 前端:
views/chat/ChatView.vue(code_exec 时间线节点) - 依赖:
pom.xml(com.alibaba.opensandbox:code-interpreter:1.0.12+ Maven Central 兜底源)
8.5 · 本机联调踩坑(真实部署到跑通的 4 个坑)#
把 SDK 接进来能编译 ≠ 能跑通。本机起 OpenSandbox server 实测时,依次踩了 4 个坑,都已在代码里修掉(默认值即正确):
- 沙箱
timeout有硬下限 60s:POST /v1/sandboxes校验timeout ge 60,传 15s 直接422 Unprocessable Entity。→ 执行器把 builder timeout 钳到max(60, 配置值);sandbox.timeout_seconds默认/最小值改为 60(沙箱用后即 kill,TTL 只是兜底)。 resourceLimits必填:无poolRef时 server 要求resourceLimits,SDKSandbox.builder()默认不带 → 422。→ 加静态配置cpu-limit(默认 “1”) /memory-limit(默认 “1Gi”),经.resource(Map.of("cpu",..,"memory",..))传入。- 桥接网络下沙箱 endpoint 宿主直连不通:JVM 在宿主、server/sandbox 在 Docker 桥接网,SDK 默认直连沙箱 endpoint →
health check timed out after 30s。SDK 报错里就给了答案:useServerProxy=true。→ConnectionConfig.useServerProxy(true)(默认开),所有沙箱流量经 server 代理转发,免暴露沙箱端口 / 配 host_ip。 - kotlinx-serialization 版本冲突(最隐蔽):沙箱能建、能跑,但 SDK 反序列化执行结果时
AbstractMethodError: GeneratedSerializer.typeParametersSerializers()。根因:SDK 要kotlin-stdlib 2.2.21 + kotlinx-serialization 1.9.0,而 Spring Boot 3.4 BOM 把它们下钉到 kotlin 1.9.25 + serialization 1.6.3——1.9.0 编译出的$$serializer调了 1.6.3 没有的接口方法。→ 在spring-boot-dependencies之前导入kotlin-bom:2.2.21+kotlinx-serialization-bom:1.9.0(dependencyManagement 同 GA 首条生效 → 覆盖 BOM 下钉)。
面试点:这 4 个坑里第 4 个最值得讲——第三方 Kotlin SDK 接进 Spring Boot(Java)项目,最容易踩 BOM 版本下钉导致的运行时
AbstractMethodError。排查靠mvn dependency:tree对比”SDK POM 要的版本”vs”classpath 实际解析的版本”,解法是用官方 BOM 在 Spring Boot BOM 之前导入抢占版本。
本机验证记录(已通过)#
docker compose -f docker-compose.dev.yml up -d opensandbox # server healthy: {"status":"healthy"}
# 预拉镜像(9.37GB,国内走阿里云 registry):opensandbox/code-interpreter:v1.0.2 + execd:v1.0.18
SANDBOX_SMOKE=1 mvn test -Dtest=SandboxSmokeTest
# → [SandboxSmokeTest] ok=true stdout=4 result=4 error=null latencyMs=8937 BUILD SUCCESS
mvn test -Dtest='!MediRagApplicationTests' # 139 passed / 0 failed(沙箱冒烟无 SANDBOX_SMOKE 时自动跳过)plaintext镜像约 9.37GB(含 Python/Java/Go/Node 多运行时);首次建箱拉镜像慢,建议预拉。沙箱用后即 kill,实测无残留容器。
9 · 来源#
- OpenSandbox:github.com/opensandbox-group/OpenSandbox(原 alibaba/OpenSandbox,已迁移);server/docker-compose.example.yaml、server/configuration.md
- Maven Central:
com.alibaba.opensandbox:code-interpreter1.0.12(2026-06) - n8n CVE-2025-68668(进程内 Pyodide 沙箱逃逸 RCE,CVSS 9.9)