让一个编程 Agent 把同一件不简单的任务做两遍,你会得到两份不同的实现——都还行,都说得过去。这种情况 Denis 已经留意了好几个月,却一直没管,因为只跑一次的话,你其实无可作为。单次运行只是个孤例:你看看 diff,觉得喜欢或不喜欢,就此翻篇,真正的问题根本不会浮出水面。
真正的问题是:另外那九次,它会写成什么样?
n=1 时你看不到这一点。n=2 也帮不上什么忙——两次运行只能算对比,而他想要的是一份分布:模型在哪些地方稳定落点,哪些地方其实是在悄悄替他抛硬币。
于是他花了一天时间搭了个框架:把同一个任务并行跑十次,每次运行彼此完全隔离,然后展示这十次各自的差异。它叫 multirun,地址在这里:
注:本文直接承接 Denis 此前的《Preview Environments》和《Agentic Workflows: Branched Environments》两篇文章,用的是同一套核心技巧,只是换了个用武之地。如果你还不了解"数据库分支"是什么,第二篇里有完整讲解,这里就不再重复了。
引言
他搭这个框架纯粹出于好奇。结果捣鼓出来两个值得推荐给别人的用途,所以先讲用途,机制放到后面再说。
第一个用途是评测。Denis 从前没写过 eval——正经的那种,针对自己提示词的 eval。每次聊到这个话题,听起来都像在搞基础设施(测试框架、裁判模型、黄金数据集、一个要去追逐的分数),于是它就被丢进了"等有空的一周再做"清单。结果嘛……那一周始终没来。
把同一个任务跑十遍,看十份答案在哪里达成一致,这就是一次评测。虽然粗糙,却是实打实的评测——整篇文章里没有裁判模型,也没有基准测试。这十次运行在 schema 上完全收敛,然后在两个独立的决策上恰好五五开,这些分歧就是结果本身。它们指出了任务文件里缺失的句子。他不必去猜,也不需要额外搭建什么,只要一个能让任务多跑几遍的循环就够了。
第二种做法他称之为“结果分支”(results branching),这个概念直接借自数据库分支,而且他打算保留这个名字。它适用于那种你即将构建、并且要与之长期相处的场景:租户模型、认证边界、数据库 schema——一旦落库,三年内的数据都会堆在里面,很难再推翻重来。平时的习惯是问一下 Agent,看看它给出的答案,要么照单全收,要么跟它辩一辩。但你看到的只是整个分布中的一个样本,分布本身你根本看不到。
所以别急着接受第一个答案。把同一个任务同时分发给十个运行,让它们彼此隔离地跑,读完全部结果再决定采纳哪个。这篇文章里的扇出实验耗时五十分钟,按 API 价格算大约三十二美元——不算便宜,但比起十八个月后才发现的错误 ON DELETE 所付出的代价,实在是九牛一毛。
这两件事都不需要多聪明。真正需要的是十次互不干扰的运行,而这才难办。
隔离是整个设计的核心问题,可以拆成两半来看。
十个 Agent 同时改一个代码仓库显然不现实,这一半很好解决:git 的 worktree 早就有了,每个运行分配一个,搞定。十个 Agent 共用一个数据库才是难的那一半。这个问题难了很久,直到数据库学会了分支,它才不再是难题。也正因为如此,这个项目才能用一天而不是一个季度做出来。
TL;DR
multirun 接收一个指定 sha 的代码仓库和一项任务,扇出 N 个相互隔离的 Claude Code 运行,并完整记录每个运行的所作所为。
每个运行都会获得一个 git worktree 和一个专属的 Postgres 分支,二者都从同一个带种子数据的父分支创建。把运行分支与父分支做 diff,就能看出 Agent 对数据动了什么手脚,而不只是改了哪些文件。
第一次真正的扇出实验:十个运行、同一任务、同一模型、
max_parallel: 3,跑在他的 k3s 集群上。10/10 全部完成,20/20 检查全过,耗时五十分钟,共 930 轮对话,零分支泄漏。十个运行都得出了相同的 schema 结构。但在“删除团队时是否删除其数据”上五五开,在关联表主键的选择上五五开,在新列是否允许 NULL 上则是 8 比 2。
结构上收敛,判断上抛硬币。不跑上十次,这些根本看不出来。
由此衍生出两种用法。其一,它是一种低成本的评估手段——十个答案产生分歧的地方,往往就是你的提示词含糊的地方。其二,它让你能用分支的方式审视那些需要长期承受的决策,而不是直接采纳 Agent 的第一个答案。
什么是一次运行
整个框架可以浓缩成一句话:
run = (source, task, variant) -> artifacts
Source是一个位于特定 sha 的代码仓库,外加可选的数据库种子。从不把依赖打包进来,始终锁定版本。如果十次运行的起始代码树不是逐字节一致,那比较就没有意义。
Task是一份 markdown 提示词及其校验规则,以文件形式做版本管理,这样日后可以用旧任务对新版本的 source 重跑。
Variant是每次运行之间不同的东西:模型、提示词变体,或者什么都不变。最后这种情况正是他关心的——当其他条件完全一致时,剩下的唯一变量就是模型本身。
系统里其他一切(数据表、仪表盘、对比报告)都只是对这些产物的视图。
为什么分支优于复制
十次运行需要十个数据库,而且它们在起点必须完全一致。
显而易见的做法是把一个数据库复制十份,而这个显而易见的做法很糟糕。每复制一次都要付出整个数据集的存储代价,耗时以分钟计,事后还得记住删掉十个真实的数据库。数据只有 30 MB 时,这顶多算烦人;一旦接近生产环境的规模,你绝不会干第二次。
Neon 把计算和存储分离,所以分支只是指向同一存储的一个新逻辑指针,只有真正发生分叉的页面才会写入,事先不复制任何东西。他在Agentic Workflows: Branched Environments里详细讲过其中的机制,这里只看数字就够了:在写这篇文章时,从这个基准测试的父库切出一个分支,1.38 秒即可就绪。
这个父库大约 30 MB。他没有在 300 GB 规模上验证过,所以这算推理而非实测。不过写时复制的意义恰恰在于这个数字不该有太大波动,因为根本没有东西被复制。
差异对比
快速创建环境固然好,但他这样设计架构,真正的原因在于 diff。
每次运行都从同一个父库分支出去,运行结束后把分支与父库做 diff,由此得到两样东西:schema 差异(发出了哪些 DDL)和数据差异(按表统计新增、修改、删除的行)。
对话记录只能告诉你 Agent 声称做了什么,分支 diff 展示的才是它实际做了什么。十份 diff 可以直接横向比较,因为它们都是对着同一个起点度量的。
代价是,分支(branch)是真实存在的对象,要真金白银地按量付费。他的方案包含十个分支,超出后按分支数每月收费——而泄露(泄漏)的分支恰恰是那种一个季度都没人发现的问题。所以销毁成了一等公民状态,在结果数据库里有自己的一行记录,而不是埋在代码某处的finally块里,每次运行无论成功、失败还是超时,都必须经过这一步:
PENDING -> PROVISIONING -> SPAWNED -> RUNNING -> DIFFING
-> TEARDOWN -> DONE | FAILED
注意DIFFING排在TEARDOWN前面。diff 需要分支还活着,而分支又必须死掉。这两个步骤一旦顺序颠倒,你就会收获十个孤儿分支外加空空如也的数据。
一份配置文件描述整个扇出任务。global:存放默认值,runs:则是每次运行的覆盖项列表。这个扇出中所有run的配置完全相同——这正是设计意图所在。
部署到集群上
在本地,这一切只是一个循环。到了他的 k3s 集群上,它就变成了一个控制器。
core是一个普通的 Deployment,没有 CRD,也没有 operator 那些模板代码。他不想为此写 reconciler,也认为没必要。它监听一个 ConfigMap 收件箱——multirun publish会把扇出配置丢进去——然后为每个 run 合并全局配置和单次运行配置,渲染成各自的 ConfigMap,并精确地启动一个 Kubernetes Job。运行状态存放在 Postgres 结果数据库里,而不是 etcd,因为他希望几个月后还能用 SQL 查询运行历史,而不是等着看kubectl get输出中早已过期的记录。
runner 本身刻意保持“愚钝”。它读取/config/run.yaml,执行运行,把产物写到共享 PVC。它从不调用 Kubernetes API,从不调用 Neon API,也不导入任何编排器代码。配置进、产物出,这就是全部契约。正因如此,失败的 Pod 才容易排查:一份 YAML 文件加一个目录,就能完整还原一次运行的全貌。
智能体通过 Anthropic 的 Agent SDK 运行在他的 Claude 订阅上,而非直接调用原始 API。由此带来两个坑,都让他踩过。其一,Pod 内的订阅凭据会轮换,过期的 Secret 认证失败时,日志里看不出任何明显线索。其二,同一订阅上同时跑十个智能体会触发限流,这也是max_parallel默认值为 3 的原因。十个分支不等于十个同时运行。
他偷懒的地方
这套东西在接入 ArgoCD 之前,就已经在集群上跑起来了,而且工作正常。
清单文件是有的,注册 PR 也开着。但为了不让扇出任务被卡住,他先用kubectl apply手动部署,“当作临时过渡”。结果嘛……过渡方案成了正式部署。集群上所有东西都由 GitOps 管理,唯独这件东西是手动放置的冒牌货——这 arguably 比根本不部署还糟糕:表面上一切正常,直到某天有人执行 sync 才会露馅。
就这样被顺利接管了:应用本身、git 里的清单文件、用 SealedSecrets 管理的密钥。接管过程没有触发任何变更。他手动 apply 的内容和 git 里声明的内容完全一致,所以当时正在运行的 fan-out 甚至毫无察觉。
他从这次事故中总结的教训比“用 GitOps”更小,但更有用。他验证的是核心 pod 在运行,而他真正想声明的是应用已部署。这是两句话,他检查了容易的那一句。
接管之后。他手动 apply 的 bridge 和 git 里的清单逐字节一致,所以什么都没触发。
十个 Agent,同一个任务
他挑的任务是软件行业里最无聊的迁移,而且正因为无聊才挑了它。拿一个按单用户设计的应用,给它加上团队功能。每个 SaaS 迟早都要做这件事,指望最后有什么巧妙的解法是不存在的。
提示词是一份简短的 markdown 文件,关键是它刻意不指定的东西:
这个应用是按单用户设计的:所有关键数据行都挂在单个 user id 上,schema 里根本不知道"团队"为何物。给它加上团队功能。
- 用户可以创建团队、加入团队,并在团队上下文中操作。
- 原本按用户隔离、现在应该在团队内共享的数据,队友可以访问;应该保持私有的数据继续私有。判断哪些该共享、哪些该私有,本身就是任务的一部分。
- 已有数据行必须照常工作:老用户登录后什么都不丢。
- 改造完成后访问控制依然成立——团队 A 的成员无法通过你新增的任何路径访问团队 B 的数据。
怎么建模——tenant 字段、关联表,还是别的方案——由你决定。用真正的 migration 完成 schema 变更,接好应用代码,最后说明你选了什么方案、代价是什么。
源代码是他自己的一个 Next.js + Drizzle + Neon 模板,固定在某个 sha 上。预置数据的父数据库只有 3 个用户、3 份资料和 4 个资产,刻意做得很小,方便他亲眼核对数据 diff。检查项就两个,都很笨:应用还能构建成功,schema 里存在名字带"team"的东西。真正的评分在 diff 里,不在检查项里。
跑十次,全部用 sonnet。10/10 完成,20/20 项检查通过,三个并发共耗时 50 分钟。每次运行在 77 到 117 轮之间,十次合计 462k 输出 token、6300 万次缓存读取,改动 143 个文件,十条分支事后全部发布。
▶ 逐次运行对比:每次运行的轮数、输出 token、成本、耗时和触碰的文件数,这是一个可交互的组件,运行在原帖中。
从追踪数据中提取每次运行的工作量指标。切换衡量维度,十条柱子依然一动不动。
它们达成共识的地方
每一次运行都构建了同样的东西:teams 表、team_members 关联表,以及 assets 表上的 team_id 列。
没有一次运行采用纯租户列方案,也没有人搞出什么花活。十次运行全都认定资产才是可共享的对象,个人资料保持私有——尽管提示词从未说明哪个是哪个——而且十次运行都保留了所有已存在的行。行级安全(Row-level Security)从头到尾没人提过。访问控制十次全部留在应用代码里。
他本以为结果会各奔东西,没想到它们高度趋同。
它们产生分歧的地方
删除团队时,要不要删掉团队的数据?五次运行选了
SET NULL,资产保留下来,回退为个人资产。另外五次选了CASCADE,资产随团队一起被删除。这是一个用户能直接感知的产品决策,任务却从没让 Agent 去做,结果恰好五五开。关联表的主键用什么?五次用复合主键
(team_id, user_id),五次用代理主键id,再对这对字段加唯一约束。新列允许为 NULL 吗?八次运行让
assets.team_id可空。NULL 表示个人资产,已有数据无需改动,迁移也就是纯 DDL 操作。两次运行则彻底拒绝 NULL:用NOT NULL,在 teams 表加一个personal标志,再做一次数据迁移——为每个已有用户创建一个个人团队,把他们的资产都挪进去。同一个任务,两种截然不同的多租户哲学。
更细的分歧还有不少。六次用 Postgres 枚举类型表示成员角色,三次用带 CHECK 约束的文本,一次干脆用带默认值的裸文本。六次给团队加了唯一 slug,四次没有。六次在个人资料里加了"当前团队"指针,但字段名又分成了两种。
十次运行不足以确定这些比例。在 n=10 时出现 5/5,真实比例哪怕是 70/30 也很容易产生这种结果,没法排除。所以从中能得出的结论是:一个任务文件默认只有唯一答案的决策,实际上存在分歧——而不是纠结于具体是不是精确的 50/50。要拿到真实比例就得跑更多次,基准测试本来支持,因为这只是改个配置值的事,但他还没去跑。
▶ 十次运行的结果分布:十次完全相同的 Agent 运行,得到七种 schema 决策,这是一个交互式组件,运行在原帖中。
七项决策全部列出,按十次运行中赞成人数排序。这是从运行 diff 中提取的真实对比数据。悬停在色块上可查看具体运行编号。
十次运行中,只有 Run 06 的数据库在teams和team_members表里真的写入了数据:一个团队、两个成员。没有任何迁移插入这些记录,说明这个 Agent 通过运行中的应用实测了自己的功能,然后把测试数据留在了原地。要不是数据 diff 把它抓了出来,谁也不会知道——对话记录里没有任何线索能让人注意到这件事。
失败的部分
失败的部分反而更有价值。
他第一次做这个并行扩散,10 次全军覆没,耗时不到十分钟。Runner 镜像里没装openssh-client,导致通过 SSH 克隆私有模板时,init 容器每次都挂掉。
冒烟测试一直没发现这个问题,因为冒烟测试克隆的是 HTTPS 上的公开仓库,走的代码路径和真实运行完全不同。修复只需在 Dockerfile 里加一行。
好在清理逻辑守住了底线。那十个分支最后都正常释放了,所以他才能耸耸肩,直接重跑。
看着它发生
十个 Agent 在看不见内部的 Pod 里并行工作,这种感觉很不安心。所以最后一块拼图是一个只读仪表盘:展示实例、并排显示各次运行,每次运行都有详细视图,包含各阶段耗时、检查结果、分支 diff 和产物。
原计划是为实时视图改造 Runner,把每一轮的输出实时流式写入共享 PVC 上的文件。结果动手实现时才发现,Runner 从第一天起就是这么做的。每条 Agent 消息都会追加到trace.jsonl,逐条刷盘,就放在 PVC 上、紧挨着产物目录。
于是仪表盘通过 WebSocket 跟踪这个文件,实时查看和回放因此成了同一件事。四个小时前结束的运行,回放时走的代码路径与正在进行的运行完全一致,全程不碰任何 Pod 日志,所以即使 Pod 早已销毁,回放照样可用。
这台测试机跑过的所有并行扩散,包括那次 10/10 全军覆没的。
add-teams 的十次运行。注意 effort 值多么集中:不管方差来自哪里,都不是 effort 的方差。
单次运行:各阶段耗时、两项检查,以及它对数据做了什么。
再说成本读数。SDK 报告的是按 API 价格折算,每次运行本应花费的金额:单次 $2.49 到 $4.39,十次合计 $32.25。实际上一分钱都没扣,因为跑在他自己的订阅里。但这个数字可以用来衡量一次扇出到底值多少钱——三十美元出头,完全负担得起,让他得以看看另外九次运行本来会做出什么。
结语
方差是模型和提示词本身的属性——这是他从这整套实验中得到的最大收获。
十次完全相同的运行并没有产生十个随机结果:schema 层面十次全部一致,而在两个没人要求它决策的问题上,出现了两次精确的 5/5 分裂。这两件事都有可操作的结论。一致性说明模型对“如何在 Postgres 中建模多租户”有很强的先验,他大可不必再逐行死磕那部分代码;分裂则精确告诉他,任务文件里缺了哪几句话。
而且一旦能度量这种分裂,就可以尝试消除它:把提示词写得更精确,再跑十次,看 5/5 会不会变成 9/1。这就是一个评估(eval)。它只需要一个循环加一份配置文件,全程没有任何裁判模型——而在本周之前,他根本没有办法针对自己的提示词跑这样的评估。
另一半收获才是他周一就会真正用上的。挑出下一个你真心不愿搞砸的改动:多租户模型、鉴权边界,或者那个会比架在上面的代码活得更久的 schema。别直接采纳 Agent 给出的第一个答案,跑十遍,看结果的分布。十次全部一致的地方可以放心快进——那是模型的先验,通常就是无聊但正确的答案;出现分裂的地方,说明那里埋着一个本来就该由你来做的决定,而 Agent 一直在替你做。
他把这个做法称为结果分支(results branching)。机制和评估完全一样,只是把矛头从整个提示词对准了单个决策。
他反复提到自己在分支环境那篇文章里写过的观点:正是那些 AI 出现之前的旧技术,才让智能体方案真正跑得起来。这次又是一回事,只是往上一层罢了。Worktree、数据库分支、GitOps、把环境销毁做成状态机——这些都不是什么新东西,也都和 AI 无关。全都是无聊的基础设施实践,但恰恰是它们,才让十个 Agent 能同时运行而互不干扰。
他一开始想知道的是能不能信任模型。到现在他也没真正得到答案。他得到的是一份清单,上面记录着所有他还没拿定主意、模型替他做决定的地方。事实证明,这反而更有用。
感谢阅读 :)
文末附注
_N1: 文中的基准测试、扇出运行和数据都是真实的。生成这些结果的配置就在仓库里,你也可以自己跑一遍同样的扇出流程。
N2: 如果这篇文章让你有所思考,欢迎发邮件给 Denis 一起探讨!他很喜欢聊技术,尤其欢迎同样活跃在"竞技场"里的朋友。_
关于 Denis 的一切,尽在 denislavgavrilov.com/about
感谢阅读,祝你一切顺利!
.mr-signoff{display:flex;gap:.7rem;align-items:flex-start;margin:2.25rem 0 1rem}
.mr-signoff>img{width:40px;height:40px;min-width:40px;border-radius:9999px;margin:.1rem 0 0;display:block}
.mr-signoff-name{font-size:.8em;line-height:1.3;margin:0 0 .4rem}
.mr-signoff-name span{color:var(--color-muted)}
.dark .mr-signoff-name span{color:var(--color-muted-dark)}
.mr-signoff-msg{display:inline-block;border:1px solid var(--color-rule);border-radius:.9rem;padding:.4rem .8rem;font-size:.95em;line-height:1.45}
.dark .mr-signoff-msg{border-color:var(--color-rule-dark)}
Darling · Denis 的助手
这是我们共同打造的





