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 收尾和 Experimentteardown全部完成才释放。 - 排队的一方不会提前创建 Sandbox。拿到租约后,它照自己的计划继续跑,不读取或沿用对方的结果。
sharedState 只防止同时修改,不负责保存 checkpoint,也不负责原子提交、回滚或被强杀后半次写入的恢复。能原子回存就原子回存,做不到时换一个新 key,从干净状态重建。跨机器协调仍需外部编排。
NiceEval 不会因为超时、进程号或心跳就把租约交给别人。持有方暂停时仍持有租约;它被强杀或清理失败后,排队方会一直等,避免两边同时改同一份状态。确认持有方已经退出、外部状态不再变化后,按运行被强杀后恢复里的命令检查持有方,并手动恢复。
只是某个 Agent 服务经常限流时,也在这个实验上设 maxConcurrency: N,不要降低全局上限,那会拖慢同批其它实验。实验级上限在退避期间不会放第 N+1 个 Attempt 进来,所以服务限流时不会被继续加压。
Hook 里的状态按 Sandbox 存
并发时,同一个模块同时服务多个 Sandbox。setup 拿到的句柄不能存进普通模块变量,否则会被后一个并发 Attempt 覆盖。以 Sandbox 实例为键来存取:
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 服务还有余量后,直接在两个终端运行:
- 两边的
--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 和实验级生命周期写在哪。运行器
发现、派发、重试与预算的完整机制。