Skip to main content
200 条评估跑了两个小时,你想让它快一点。第一反应是把并发调高,但并发只是原因之一。可能是没改的题在重复执行,可能是每条都在重新创建 Sandbox,也可能是 Provider 的配额先到了上限。 确定要调并发时,记住同时跑几条 Attempt 受两级限制:全局并发管这台机器和 Provider 承受得住多少,实验自己的 maxConcurrency 管这个实验最多并行几条。两级都有空位,Attempt 才会开始。 先判断时间花在哪,再动对应的设置:

各个并发限制管什么

同时能跑几个 Attempt,由下面几项一起决定。任何一项没有空位,Attempt 就会排队: 全局并发按这个顺序取值:--max-concurrency,其次是配置里的 maxConcurrency,最后是当前 Sandbox Provider 的推荐值(docker 10、e2b 20、vercel 1)。推荐值反映的是 Provider 那边的限制。你自己的 Agent 接口有限速时,用 --max-concurrency 压低:

名额怎么分

下面是一批混跑:全局 3 个并发位,fast 是普通实验,slow 声明了 maxConcurrency: 1。 从这张图里能读出三件事:
  • slow 永远只占一条道。 它的第二个 Attempt 要等第一个的 teardown 和 Sandbox 销毁完成才开始。同批其它实验不受影响,照常用剩下的位。
  • 名额优先给还要跑最多轮的实验。 快慢实验放在一条命令里跑是安全的,不需要手工分批,也不需要给快任务预留名额。快的任务会自动补上空位。
  • 退避重试中的 Attempt 让出全局位,但不让出实验自己的位。 所以撞上限流时,live 面板的 running 会超过上限,超出的数量正好是正在退避的数量。任一时刻真正在执行的仍然不超过上限,这不是并发失效。
等待实验级 setup(起隧道、起共享服务)的 Attempt 不占并发位,计数里显示为 queued。所以隧道启动慢时,面板长时间显示 “0 running、N queued”,问题在 setup,不在并发配置。 等待 Docker profile 容量的 Attempt 也显示为 queued,排队原因是 provider-capacity。它不会显示 creating sandbox,也不占普通并发位。profile 有空余后,它重新拿到并发位,分到资源才进入 running。同批用其它 Provider 的 Attempt 可以继续启动,不会被排在前面的 Docker 任务卡住。

在 live 面板确认并发生效

运行开始时,PLAN 面板告诉你这次开几路、这个数来自哪里。声明了 maxConcurrency 的实验跟在后面,各自列出上限:
(from flag) 表示 19 来自 --max-concurrency。没传 flag、配置里也没写时,这里会显示 (from vercel default) 这类 Provider 推荐值。只开 1 路不是调度出了问题,vercel 的推荐值就是 1。slow ≤1 表示这个实验受自己的上限限制,调大 --max-concurrency 也不会让它多开。 运行中看首行计数:
  • running 一直顶在上限、queued 逐步减少:并发就是瓶颈,机器还有余量就往上调。
  • running 长期不满:瓶颈在别处,可能是实验自己的上限、Provider 容量,或者在等实验级 setup。等容量的 Attempt 在活动行显示 waiting for provider capacity。

跨 Attempt 共享状态时串行

多个评估用例都要读取、修改并回存同一份宿主机文件或服务端状态时,把这个实验降到一条道:
maxConcurrency: 1 只让这一次 niceeval exp 命令里的 Attempt 串行。另一个终端同时跑时,sharedState.key 保证两边不会同时改同一份 checkpoint:
  • 租约在 Experiment 和 Sandbox 的 .before() 之前取得,等 Sandbox 清理、Provider 收尾和 Experiment teardown 全部完成才释放。
  • 排队的一方不会提前创建 Sandbox。拿到租约后,它照自己的计划继续跑,不读取或沿用对方的结果。
sharedState 只防止同时修改,不负责保存 checkpoint,也不负责原子提交、回滚或被强杀后半次写入的恢复。能原子回存就原子回存,做不到时换一个新 key,从干净状态重建。跨机器协调仍需外部编排。 NiceEval 不会因为超时、进程号或心跳就把租约交给别人。持有方暂停时仍持有租约;它被强杀或清理失败后,排队方会一直等,避免两边同时改同一份状态。确认持有方已经退出、外部状态不再变化后,按运行被强杀后恢复里的命令检查持有方,并手动恢复。 只是某个 Agent 服务经常限流时,也在这个实验上设 maxConcurrency: N,不要降低全局上限,那会拖慢同批其它实验。实验级上限在退避期间不会放第 N+1 个 Attempt 进来,所以服务限流时不会被继续加压。

Hook 里的状态按 Sandbox 存

并发时,同一个模块同时服务多个 Sandbox。setup 拿到的句柄不能存进普通模块变量,否则会被后一个并发 Attempt 覆盖。以 Sandbox 实例为键来存取:
如果这些 Attempt 本来就必须按顺序读写同一份业务状态,不要用 WeakMap 掩盖这一点,直接把实验降到 maxConcurrency: 1。

让相关评估按组复用 Sandbox

只有几组评估需要共享环境时,用 defineEvalGroup() 列出同组成员。每个组同一时刻只跑一个 Attempt,最多复用一台 Sandbox;不同组和没分组的评估用例继续用剩下的并发位。你不需要把整个 Experiment 设成 maxConcurrency: 1。 evals 数组只列成员,不决定顺序。组内按评估用例 ID 排序执行;attempts > 1 时,同一道题的几次 Attempt 连续跑完再轮到下一道。完整目录、Experiment 写法和 --dry 检查见用评估组复用 Sandbox。 评估组不是任务依赖图。沿用上次结果和命令行过滤可能让前一道题不执行,题间重置也会撤销前一道题对 workdir 的修改。后一步必须读前一步的文件时,把它们写进同一个评估用例,不要依赖组内顺序。

只要读起来有序就不必串行

结果总是按发现顺序输出,和这次开了多大并发无关。终端里和报告里的行序因此是稳定的,可以直接 diff。只希望输出有序的话到这里就够了,命名前缀照样生效,不用牺牲吞吐。

让首过即停真的省钱

earlyExit 停的是还没派发的 Attempt。默认并发下,同一个评估用例的多次 Attempt 可能已经同时开跑,第一次通过时后面几次也在跑,省不下钱。要做到“跑一次,过了就停,没过才跑下一次”:

多开终端时共用结果文件

两个终端可以在同一个项目里同时运行,结果都写进 .niceeval/record.sqlite。但它们不会自动分工。确认本机、Provider 和 Agent 服务还有余量后,直接在两个终端运行:
两边各自规划完整的选择,可能重复执行相同的评估用例,因为谁都看不到对方还没完成的 Attempt。一个 Attempt 完成后立刻就能查到,即使它所在的 Run 还在运行。需要把结果分开时,在另一个项目目录运行;NiceEval 不会合并两个项目的结果。 多开时注意:
  • 两边的 --max-concurrency 会相加,Provider 容量不会。配额紧张时把两边都调低。
  • 实验自己的 maxConcurrency 也是每个终端各算各的:两条命令都声明 3 时,合计最多跑 6 个。
  • 两个终端的 Sandbox 也各自独立。只有确实共享外部 checkpoint 时才声明 sharedState.key,它和结果文件无关。
  • 不要在运行期间复制 .niceeval/record.sqlite。等命令成功结束后,再复制或归档这个文件。
跑完后用 pnpm exec niceeval show 在终端查看结果,或用 pnpm exec niceeval view 在浏览器里查看。

边界

  • 并发上限管的是资源占用,不是花钱。限制花费用 --budget,见控制运行成本。
  • 有的 Provider 声明自己只能一个接一个运行,--max-concurrency 调多大都不会改变这一点。这是正确性要求,不是调度参数。
  • Agent 服务按“同时在跑的任务数”计费或限额时,全局上限贴着限额配压不稳:退避让出的位马上被新 Attempt 补上,服务那边的并发一直顶在上限。单个终端可以用实验级 maxConcurrency 留出余量。多开时各边的值会相加,账户级配额要分别调低,或交给外部编排。
  • 声明了 sandboxReuse: true 的实验,每个 Sandbox 内部一个接一个跑,Sandbox 之间并行,见复用 Sandbox。
  • 评估组内部按顺序执行,不同组仍可并行,见评估组。

接着看

重跑与沿用

哪些结果这次不用再花钱。

复用 Sandbox

公共准备只付一次的另一条提速路径。

评估组

同组稳定串行复用 Sandbox,不同组继续并行。

写实验

maxConcurrency 和实验级生命周期写在哪。

运行器

发现、派发、重试与预算的完整机制。