跳转到主要内容
Coding Agent 要在一个能随意改文件、跑命令的环境里工作,而且不能碰到你的机器。这个隔离环境就是 Sandbox,由 Provider 负责创建和销毁。 NiceEval 提供三种官方 Provider:本地 Docker、Vercel 和 E2B。本地调试用 Docker 最方便;需要大量并行,或者本机跑不动时,换成云端的 Vercel 或 E2B。 不管用哪种 Provider,评估用例和 Adapter 看到的都是同一个 Sandbox 接口。换 Provider 只改 Experiment 里的一行,评估用例不用动。

选择 Provider

先按你在哪里跑评估来选: 在 Experiment 的 sandbox 字段里选一个:
评估用例和 Experiment 都没有选择 Sandbox 时,运行会在创建任何 Sandbox 之前报错,NiceEval 不会替你猜一个 Provider。 三个内置 Provider 的 SDK 不随 NiceEval 一起安装。用哪个就装哪个,用不到的依赖(以及它们的原生构建脚本)不会进入你的项目: 漏装时,创建 Sandbox 的那一刻会报错并给出安装命令,例如 Docker sandbox requires 'dockerode'. Install it with: pnpm add dockerode @types/dockerode。照着装好再运行即可。

Docker Compose:workspaceService 指定主 Sandbox

一道题需要不止一个容器时(比如应用容器加一个数据库),用 dockerComposeSandbox 声明整份 Compose 环境:
workspaceService 指定 Compose 文件里哪个 service 是主 Sandbox。Agent、t.sandbox 的命令和文件操作、workdir、文件改动记录,都只落在这一个容器上。 其它 service(数据库、mock server 之类)只是陪跑的资源:不能对它们 runCommand、上传或读写文件,也看不到它们的文件改动。要用到它们,只能靠网络访问,例如在 client 容器里通过 Compose 内置 DNS 访问 db:5432。

让 Agent 自己运行 Docker

Agent 需要执行 docker build、docker run 或 docker compose 时,可以挂载宿主机已有的 Unix socket,也可以选择 raw privileged 或 managed rootless Docker-in-Docker。三种模式的适用场景、Dockerfile 和完整配置见让 Sandbox 使用 Docker。

在 Adapter 和评估用例里操作 Sandbox

不管用哪种 Provider,Adapter 和评估用例拿到的都是同一个 Sandbox 接口。常用操作: 两个 OrThrow 方法抛出的错误消息会附带 stderr 的末尾几行,已经清理、脱敏并截断。stderr 为空时改用 stdout。排查时先看这段摘要,需要完整输出时再读 SandboxCommandExitError.result。

按顺序准备 Sandbox

Agent 开始工作前,Sandbox 里通常要先装工具、写配置、放好素材。用 .before() 写这些准备步骤,用 .after() 写无论成败都要执行的收尾。每个 Provider 函数都返回一个可以继续追加步骤的 SandboxLayer,追加时不会修改原来的值:
changeFrequency 决定步骤的顺序:数值小、很少变的步骤排在前面,数值大、经常变的排在后面。这样只改了后面的步骤时,前面的步骤可以直接沿用上次的结果。它是一个普通的非负数,内置常量只是方便阅读:
Experiment、评估组、评估用例和 Sandbox Agent 的准备步骤排进同一个顺序:先满足显式的 dependsOn,再按数值从小到大执行。所以评估组里很少变的步骤,可以排在 Experiment 里经常变的步骤前面。 运行时,每个准备步骤都会显示在进度里,但不会泄露内容:
  • shell() 显示你写的 command,不显示外面包的 /bin/sh -lc;command() 和 exec 显示可执行文件和参数。
  • writeText() / writeBytes() 只显示目标路径、字节数和摘要,不显示正文。
  • 上传只显示摘要、目标路径和字节数,不显示宿主机上的源路径。
  • Git checkout 的地址会去掉用户信息、query 和 fragment;环境变量只列出名字。
很短的进度更新可能被合并显示。想稳定看到一个长脚本的细粒度进度,把它拆成几个较小的步骤,并让每一步本身运行得足够久,或者自己报告进度。NiceEval 不追踪 shell 脚本内部的步骤。动态回调的进度由 context.progress() 自己报告。

声明准备缓存覆盖范围

在支持的 Provider 上,没变的准备步骤下次运行可以直接沿用,不重新执行。为此,每个 .before(action) 都要如实说明它会改动 Sandbox 的哪些部分:
  • 不写 cache.state 等于 sandboxState.all:这个步骤的全部改动都要能被保存和恢复。它不是让 Provider 自己挑一部分目录缓存的开关。
  • 只有步骤的全部改动都限于 Sandbox 里已经停止写入的 /var/lib/docker 时,才写 sandboxState.dockerData。完整的 Docker profile 示例见让 Sandbox 使用 Docker。
cache.state 写在步骤定义处或单次调用处都可以,但只能写一处,调用处也不能覆盖定义处。 步骤的 ID、输入、展开后的命令、声明的范围和内容摘要都会被自动比较,任何一项变了,这一步就重新执行。cache.fingerprint 只用来补充 NiceEval 看不到的版本信息,不能代替自动比较。 各 Provider 能保存的范围不同: 沿用只能从第一步开始连续往下。遇到第一个无法保存的步骤,比如默认 all 但 Provider 不支持、写了 opaque 的回调,或者 Provider 不支持的范围,这一步和它后面的所有步骤都会真实执行。后面的步骤即使声明了 dockerData,也不能跳过,因为它依赖的前面状态没有被保存。 在本地单容器 Docker 上,只改了最后的 .env 步骤时,前面固定的 Git checkout、目录上传和工具安装都不会重跑。每个 Attempt 都从缓存结果的一份独立副本启动,不会继承上一个 Agent 留下的改动。 想确认哪些步骤会沿用,运行 pnpm exec niceeval debug <experiment> <eval> 查看计划,见运行前检查 Sandbox 计划。

定义可复用的准备步骤

内置的 shell()、writeText()、uploadDirectory() 和 gitCheckout() 都是 SandboxAction。同一类准备步骤要在多处用时,可以用相同的机制定义自己的步骤:
自定义步骤只能组合 NiceEval 提供的固定操作,拿不到运行中的 Sandbox。上面关于缓存范围和自动比较的规则同样适用。

写入、上传与 Git checkout

这些内置函数可以直接传给 .before(): uploadFile() 和 uploadDirectory() 接收 new URL("fixture/", import.meta.url) 这样的地址,不接收会随当前目录变化的相对路径。文件内容、权限和目录结构都会被自动比较。gitCheckout() 只接受不含凭据的 HTTPS 地址和完整的 commit ID。 要等 Agent 返回后才给出的隐藏内容,不要放进 .before()。先用 sandboxContent.file() 或 sandboxContent.directory() 取得一个句柄,再在 test(t) 里调用 t.sandbox.upload(handle, targetPath)。句柄只带内容摘要,不暴露宿主机路径。

动态回调与收尾

只有运行时才能决定的分支,直接写 .before(async (sandbox, context) => ...)。回调每次都真实执行,它后面的步骤也不再沿用缓存。 成功取得资源后,立即用 context.onCleanup() 登记释放。闭包里可以直接保存这次拿到的句柄,不需要再写一个回调去猜资源状态:
回调里的 sandbox.sandboxId 是当前 Sandbox 在 Provider 那边的原生 ID。一般只把它当作关联用的值,不要解析它。只有这个 layer 明确绑定了某个 Provider 时,你自己的可信代码才应该把它交给对应 SDK,去创建这个 Attempt 用的辅助资源。 这些辅助资源不归 NiceEval 管理,也不会被缓存;创建成功后必须立即登记可重复执行的清理。第一个回调开始前,NiceEval 可能换掉底下的 Sandbox 实例;回调开始之后,这个 Attempt 的清理和 .after() 看到的都是同一个 ID。 无论成败都要执行、而且可以重复执行的收尾,写成 .after()。所有登记过的收尾按登记顺序的相反顺序执行,前面的收尾失败也会继续执行后面的:
回调里的 context.progress() 只更新当前 Attempt 的临时状态,context.diagnostic() 保存事后需要回看的问题。环境没法继续时直接抛错,NiceEval 会把这个 Attempt 记为环境错误,而不是 Agent 做错了题。 不带 Sandbox 的 defineAgent 没有准备步骤。Sandbox Agent 可以在自己的 sandbox 字段里加 sandboxLayer() 形式的命令步骤,但不能选择或替换 Provider 和 template。

预制环境

每个 Attempt 都要下载或安装的同一份内容,如果稳定、昂贵或体积大,就把它做进预制环境,让 Attempt 直接从装好的环境启动。三种 Provider 的写法统一:
怎样从官方基线派生镜像、快照和 template,以及怎样用运行时 checkpoint 在 Attempt 之间延续缓存,见预制 Sandbox 环境。

瞬时错误重试

内置 Provider 创建 Sandbox 时,遇到限流、fetch failed、连接重置、5xx、临时网络不可达这类临时失败,会自动按指数退避重试。模板不存在、凭据缺失这类配置错误第一次就报错。重试用完后,这个 Attempt 记为 errored。defineSandbox 自定义 Provider 的 create 是你自己的函数,NiceEval 不替它重试。 readText、readBytes、downloadFile、uploadFile、文件写入和目录上传,遇到 429、5xx、fetch failed、连接重置等临时传输错误时,会自动重试有限次。文件不存在、权限错误、取消和 Sandbox 已终止不重试。 runCommand 和 runShell 不自动重试,因为命令可能已经产生了副作用。只有你确认某条命令可以安全重复时,才在 hook 或评估用例里自己重试。

自定义 Provider

要接入其它服务,用 defineSandbox。你要自己负责文件系统、进程、网络和凭据的隔离,NiceEval 不会为任意一个 Sandbox 实现自动补上这些安全边界。 create 拿到的 feedback 用来报告创建阶段的状态,比如分配实例、拉镜像或恢复快照:
返回值实现 Sandbox 接口即可。recommendedConcurrency 是没有其它设置时的默认并发数。 不要把 Provider SDK 的原始日志直接写到宿主进程的 stdout / stderr:临时状态用 feedback.progress,需要保留的问题用 feedback.diagnostic,无法创建环境时抛错。这样终端的进度面板不会被日志打乱,CI 里也只有一份有序的输出。

权限和执行身份

不同 Provider 对执行用户、网络、文件系统和进程生命周期的限制不同。写 Fixture 时尽量不要依赖宿主机环境,把依赖写进 package.json 或 Fixture 的准备步骤。

性能建议

  • 把稳定的重依赖做进 image、template 或快照,或者写成低频的 .before() 步骤。
  • 减小 Fixture 依赖的体积。
  • 动态内容用小而明确的缓存或预先检查。
  • 控制 maxConcurrency(Experiment 字段或 --max-concurrency),避免本地 Docker 资源耗尽。
  • 把慢的检查拆开:必须通过的保留为 gate,可有可无的用 .optional()。

在 Attempt 之间复用 Provider 启动

Sandbox 启动时间占了大头时,在 Experiment 里声明 sandboxReuse: true。多个 Attempt 会依次共用一台 Sandbox:每台只创建一次,每个 Attempt 仍然执行自己的 .before() / .after(),题与题之间工作目录会被重置。Docker 上没变的准备步骤还可以直接沿用。
复用写在 Experiment 文件里,不是命令行开关:同一个实验不能临时打开或关闭。需要全新 Sandbox 做对照时,另写一个不声明复用的实验。代价、各层生命周期的执行次数和准备代码要满足的要求见复用 Sandbox。