一天三万次合并之后,决策依然是瓶颈
Anthropic 用五个月把 monorepo 从 git 迁到自研的 Scion,一天合并三万次代码。写入这条路可以做到几乎无锁,而决策这件事天生要串行。
- #thoughts
- #engineering
- #ai-agent
- #decision-making
- #source-control
Anthropic 十月初发了一篇工程博客,《Redesigning source control at Anthropic》,讲他们怎么用五个月把整个 monorepo 从 git 迁到自研的 Scion(原文)。这篇文章的大意是,写代码已经不是瓶颈,真正的瓶颈是那些按人的规模设计出来的开发基础设施,所以源码管理需要按“客户端始终在线、服务端负责一切、提交足够便宜”重新设计一遍。通过一些数字可以看到 Anthropic 在这件事上做得有多么极致,一天合并三万次变更,做这套系统的团队只有七个人。
文章讲的是源码管理,我读完之后更有兴趣的,是它没有讲清楚的那部分。写入这条路径可以做到几乎无锁,决策这条路不行。它把合并代码的门禁从“合进来之前拦住”换成了“合进来之后再检测”,这样改变之后就出现了一些真正有价值的新需求,而当前并没有任何一种现成的工具能满足。
下面我先补充几件原文没有直接讲、读过原文的人也未必清楚的事,一部分来自作者在 Hacker News 上的补充,一部分来自其他参与者。然后再谈谈我自己的看法,以及对这件事往后走向的想象。
核心思路是去掉不必要的分布式逻辑
原文对于现状的描述这里就不重复了,只记录一下相关的结论。他们把 git 当成集中式系统来用,却为 git 的分布式设计付出了代价。git 假设每个客户端都可能离线,所以每一份克隆都要能独立完成 diff、读历史、检出任意版本;而 Anthropic 的客户端几乎全是常驻在线的 agent 和 CI 任务。一天三万次合并、两千五百万次代码检出,落在这样的假设之下,就成了纯粹的浪费。
Scion 并没有去改功能,它做的事只有一件,就是去掉那些不必要的分布式逻辑。客户端不再保留整个仓库的副本,历史、blame、合并、rebase 都交给服务端回答,本地只留一个容量有限的缓存;创建变更不涉及 packfile,也不用移动 ref,refs 基本用不上,真要移动,也只是 Spanner 里一行记录的 compare-and-swap;变更本身存的是“相对第一父提交变化的文件列表”加上指向压缩文件内容的内容寻址指针,不是逐行 diff;至于 blame、路径历史这类查询,都交给一套叫 derived data 的机制处理,它按父节点的值递归计算,可以先算好,也可以等到需要时再算。
结果是,源码管理这套基础设施消耗的 CPU 和内存降到了原来的 1/200,代码检出从分钟级缩短到秒级,仅 CI 一项就省下 25% 的 CPU 容量。写入这条路已经不再是瓶颈。
这套设计解决的是吞吐问题,而文章里并没有谈及的问题,恰好是 Hacker News 上讨论最多的。
关于值不值得自研的问题,作者补充了一些关键信息
那篇帖子下面有近千条评论,声音大致可以分成四种。
最多的一种是“这不就是 SVN 吗”。中心化服务端、本地只留缓存、提交直接落到服务端,这些特征让很多人想起 SVN、Perforce 甚至 ClearCase 和 Epic 的 Lore。有人算过一笔账,Google 在搬到 Piper 之前,Perforce 就支撑起了数万工程师、每秒数百条命令的负载,而 Anthropic 一天三万次合并换算下来也不过每几秒一次,买现成的方案可能比自研更划算。
第二种是设计上的相似。有读者读完之后直接把 Meta 的 Mononoke 摆了出来,说 Scion 与它高度相似,尤其 derived data 这套概念。这种说法是有道理的。Mononoke 是 Sapling 的服务端,它的代码库里就有一个 derived_data 目录,blame、fastlog、unodes、skeleton manifest 这些派生数据一应俱全。Anthropic 在文中引了 Piper 和 Sapling,并没有展开讲 Mononoke。
第三种最值得注意,问的是谁在审、谁负责。一天三万次变更,就算只看意图,一个人也看不完;模型自审自批之后,最终责任落在谁头上。
第四种是怀疑与怨气。有人怀疑一个模型大量代笔的版本控制系统,实现质量究竟如何,也有人对 Scion 不开源表达不满。
这四种声音里,前两种其实争不出结果,文章自己就写了 Scion 不通用、不打算开源,它的前提是超大 monorepo 加海量 agent。真正有信息量的是文章作者本人在讨论里补充的三件事,它们恰好补上了原文最关键的空白。
原文的说法是,团队成员仍然会评审每一个变更请求,只是从逐行读代码改成看变更的意图。作者进一步说明,他们做的代码评审工具 Antique 能用语义化约束表达审批条件,把只改注释这类改动和会改逻辑的改动区分开,让人类评审者只看最要紧的那部分。也就是说,这里不是没人审,而是审核分成两层,模型承担大部分评审与批准,人只看那些系统标为关键的变更。配套那篇讲 CI 的文章说得更直白,Claude 不仅写了 80% 的代码,也在评审和批准环节承担了大量工作。
文章里那句“并发变更会冲突,但 Claude 修冲突比人发现冲突还快”读起来像句口号,作者在讨论里把前提讲清了,文本冲突他们不允许进入主干,但语义冲突在超大仓库里不可避免,除非把合并完全串行化、在每个候选批次上跑一遍完整验证,而这在那种提交率下不现实。他举的例子是,有人改了某个函数的逻辑,顺手清理了所有调用点,同时又冒出一个新的调用点。
底层存储上,一个文件一个 blob,内容寻址,没有 packfile 也就没有 delta 压缩,作者承认这一点,同时说加一层客户端不必知道的 delta 压缩并不难。他还提到,他们的仓库里几乎不放二进制,体积较大的二进制早在迁移前就清掉了,因为那类文件压缩不动,检出时改一大批还会拖慢 git。用他的话说,这里面其实全都是文本。
我的看法
门禁从合并前挪到了合并后
文章里有一句容易被忽视的内容。他们没法照搬工业界的标准做法,也就是给合并队列加一把全局锁,于是改为把力气花在合并之后、部署之前的自动化测试上。
这不是疏漏,是明确的取舍。所谓合并队列全局锁,就是“进来之前拦住”,把候选变更排在一条队里,逐个在最新主干上跑一遍验证,通过了才放行。它保证主干在任何时刻都能通过验证,代价是串行执行限制了吞吐。Anthropic 放弃了这道门禁,把质量保证挪到了合并之后。
配套那篇讲 CI 的文章里记录着这样设计带来的代价。他们的测试影响分析服务里,listener 负责记录每次 CI 的测试结果,selector 根据历史结果决定每个变更该跑哪些测试。当 listener 落后 20 分钟,就有几万条测试结果进不了 selector,作者列出的第一个后果,就是坏变更合了进来,随后其他人的测试开始失败,一群人花时间排查跟自己无关的问题。那篇文章还提到,他们为这个服务打的三个补丁分别撑了 70 天、29 天和不到一天。
所以真正的风险不在源码管理。写入这条路径再快,也不会让“这批变更合起来是否还成立”这件事变得更容易确认。Anthropic 把这个问题交给了检测,而检测能跑多快,取决于验证这一环能承受多少压力。
只看意图,挡不住细节上的自作聪明
只看意图这个选择本身没错,它能拦住方向性的错误。但它有一个明显的盲区,意图和实现之间的落差没有人再看第二遍。补一层兜底、加一层校验、悄悄换个默认值,恰好是模型最顺手的动作。Anthropic 自己也观察到,对抗式评审会让模型为一些假想的风险把复杂度翻几倍,模型在“什么才算关键”这件事上做决策时,并不依赖组织的语境。
有个案例很典型。某服务从线上配置系统读配置,实现的时候,AI 顺手加了一层“降级”,连不上配置系统就用默认配置。问题在于,默认配置里写的是当时的真实值。后来运维层的配置出了问题,这个服务真的连不上配置系统,但因为默认值恰好等于真值,谁都没有发现异常。再后来,外部条件变化,这个值本该跟着改,服务却一直在用写死的旧值,直到线上故障才把根因追出来。回查提交记录,那个 PR 的描述只写了“实现配置系统的对接”,没有提这层兜底,评审的人也没看出来。那一次改动有数千行代码,只是扫一眼的话,很容易错过。
下图是这件事的顺序。
兜底这件事本身不算错,错的更可能是执行方式。静默降级把“启动失败、立刻暴露”换成了“带着错的配置继续跑”,故障从一次发布事故变成了一个藏了几个月的隐患。更合理的做法是 fail-fast,连不上配置系统就拦住启动,k8s 的 rolling update 会让这次发布停住,损失是可控的。如果确实需要容灾,正确做法是使用显式声明的本地快照,并带上过期时间和告警,而不是把一个看上去正确的值写死进仓库默认值。
再往里一层,这层兜底其实把“对配置系统的依赖”降级成了一个没人维护的常量,而这个常量的有效期取决于外部世界,代码里没有任何地方记录这件事。它不是模块之间的设计冲突,而是描述与实现之间的落差,形状却完全一样,都是各自都合格、合起来不合格。Anthropic 把评审从逐行读改成看意图,这个落差正好落在新的评审方式的盲区里。现在项目小、变更少,扫一眼还能兜住;等生产能力再上一个台阶,这不会是一个小概率事件。
门禁应该设在哪里
通常的担心是版本切分的粒度。版本切得大,包含的变更多,聚合测试和上线前的验证会成为瓶颈,交付时间和质量都不好控;版本切得小,又没法并发,功能排队,吞吐反而下降。
这种担心成立的前提是使用“版本火车”,也就是把一批变更攒成一个版本再发布。但在 trunk-based 的 monorepo 里,release 只是主干上某个点的快照,不存在一批变更排队进版本这回事。Scion 的开发流程里不鼓励使用 refs,只留主干和 release 分支,也是这个形状。
所以粒度问题换了个问法,真正的问题变成门禁应该设在哪里。门禁可以设在变更上,也就是每个变更合并前必须整体成立;可以设在快照上,也就是切版本时做一次完整验证;也可以设在部署上,靠灰度、监控和回退来兜住。一天三万次合并的时候,中间那个位置已经用不了,文章自己也承认了这一点。剩下的选择是设在部署上,把质量的希望寄托在检测速度和回退速度上。
这也解释了紧急问题为什么在新体系里更难办。他们确实有自动化的 bisect 和 revert 流水线,服务端 API 让这类工具在规模上可行,但这两样都只能在事后发挥作用,解决的是“已经坏了,多久能找到、多久能退回”。而检测有多快,又回到上一节说的验证环节上。一个藏着几个月的错误配置,靠回退是兜不住的,因为它压根没有触发过任何一次失败。
功能横跨模块之后,谁保证快照仍然成立
再退一步,就算质量门禁很严,也还有一个它够不到的地方。
一个功能的开发往往横跨多个模块。假设每个功能都有一个负责人盯着,变更也分别通过了各自模块的检查,那谁来保证这些变更合并进主干之后,那个 release 快照仍然完整实现了这个功能,而且没有和同一时间在开发的其他功能打架?
这里面有两个关键问题。
第一个关键问题是判定。要确定一段代码是否实现了某条需求,一般来说是做不到的,这是 Rice 定理的内容(一个程序有没有某个非平凡的语义性质,不存在通用的办法判定)。能判定的只有你写下来的那部分规格,类型、契约、形式验证都算,但它们的覆盖面取决于规格写了多少。模型当然能给出一个有道理的答案,拿不到的却是保证。人是另一种意义上的不够用,没读完代码,只做了基本的评审和验收测试,凭什么确认这个功能的变更和同时在开发的其他功能不会撞上。
第二个关键问题是测试的覆盖范围。既然确认不了,就多写自动化测试,这是最自然的反应。但代码是一棵极复杂的决策树,路径组合是指数级的,而且测试只能验证你写下来的期望,你没写下来的需求它抓不到。形式验证在 seL4 那种场景才做到端到端,日常工程靠的是抽样。Anthropic 自己的测试影响分析就是在优化抽样,选哪些测试跑最划算,这本身就说明覆盖率是个真实存在的难题。
把这两个关键问题放在一起,就得到一个不太容易让人接受的结论,这种并行开发模式存在物理上限。产出可以随 agent 数量线性增长,而“这批变更合起来是否还成立”这类决策却需要全局信息,这一步只能串行。Anthropic 的作者在讨论里说的那句“除非把合并完全串行化并在候选批次上跑全量验证”,其实就是同一件事的另一种说法,只不过他从吞吐的角度说,我从决策的角度说。两边在这个点上重合,我觉得不是巧合。
如果源码管理再往前一步
下面的内容比较发散,近几年未必能落地,我自己也不敢说它们一定会成真。但它们指向的方向值得写下来。
隐式依赖一直存在,只是没人把它当回事
今天代码之间的大量关系并不存在于源码里。谁调用了谁、谁会牵动谁、类型和数据流怎么走,这些都是 language server 临时计算出来给开发者看的附加信息。源码本身只存文本,agent 能拿到的也只有文件。
如果把这张网正式写进源码,改代码就不再是在文件里改文本,而是根据这张网来改。这个变化的意义在于,影响面从“靠经验估计”变成“可以声明,也可以检查”。
开发之前就把关联方锁上
顺着这个方向再走一步,源码管理的最小单位可以从文件下沉到功能模块、函数、语句,把带有明确含义的语句当作交付的原子单位。改动发生之前先声明影响范围,在对应的节点上提前加锁,然后主动通知上下游,形成一个 agent 之间专用的协作网。
下图是这几层的关系。
和今天相比,最大的区别是通知的方向反了过来。现在所有告知都是事后的,CI 红了、测试挂了、报警响了,受牵连的人才知道这事跟自己有关。提前通知则是反过来,改之前就知道谁会跟着动。
这种方案有两个难点。第一个难点在于,网很难做到完整。反射、动态导入、配置驱动的分支、跨语言边界、生成代码,都会让静态索引漏边,而一旦漏掉某些边,锁和通知给出的就是虚假的安全感。第二个难点在于,文本本身就是一种通用接口,所有现成工具都能读,开发者也都习惯。改成用网来存,就等于放弃这个接口,而 Anthropic 恰恰把“仓库里全是文本”当成一个简化前提。
所以短期内更可能实现的是文本和网并存,把网当作 agent 的辅助信息来源,而不是替换掉文本。现在已经有开源项目做了这方面的工作:Meta 的 Glean 把源码索引成可查询的事实,谁调用了这个函数、传递依赖是什么,都能直接问;Unison 从设计上就是一张定义级依赖图,重命名和替换顺着图走;JetBrains 的 MPS 早就在做“模型是唯一真相,文本只是渲染出来的投影”;Bazel 的依赖分析加上 OWNERS,回答的是“该把谁叫来看”;而 Anthropic 的测试影响分析,就是“顺着网决定跑什么”的粗糙版本。缺的那一半,恰好就是语句级的原子单位、提前锁定和主动通知。
AI 的产出未必再是源码文件
随着 AI 和人在工程上的合作关系继续演进,我隐约觉得,AI 需要输出的东西会变。它不必再是今天这种源码文件,人其实也不需要。更可能出现的是一种能表达业务原子逻辑的高层形式,接近编译器里的 IR,真正执行的时候再用 AOT 或 JIT 落到机器上。
只有 IR 会丢掉大量上下文,所以这套东西上一定会挂上 annotation,把设计意图、上下游的关联关系、不变量这些信息一起存进去。今天挂在低层产物上的元数据其实已经有了,debug 信息和 source map 都是,只是它们服务于排障,而不是服务于决策。
这种思路也已经有相对成熟的系统在做了。DMN 就是把业务决策建模之后交给引擎执行,开源引擎到现在还在维护;硬件的设计意图留在寄存器传输级,靠综合工具落到门级网表;CompCert 证明高层到低层的翻译本身可以是可信的;program synthesis 这个领域本来就在做从规格生成程序。Hacker News 上甚至有人提到同一个想法,把 prompt 当源码提交,把生成的代码当成一种昂贵的构建产物。
继续推演下去,还会遇到同样的问题
这种方案会遇到的问题,和前面那个配置兜底的案例情况类似。抽象层级越高,总有表达不了的地方,那些地方就靠执行器自己补一个默认行为,而这个默认行为无人知晓。兜底发生在配置读取那一层,会成为藏了几个月的错误配置;兜底发生在 IR 到机器码的翻译里,就会变成一段没人写过、没人审过、也没人知道的行为。
annotation 也有它自己的覆盖率问题。谁写、什么时候写、怎么保证不漏,和测试覆盖率是同一类问题,只是换了个位置。
Scion 把源码管理做到了几乎无锁的水平,一天推进三万次变更,但这条路上真正困难的部分它没有碰到,比如这批变更合起来是否还成立,一个新的默认行为该不该存在,一个设计范式能不能引进来。这些决策今天仍然靠人,而人的精力不会随着 agent 数量增加。
Anthropic 把这件事交给了检测和回退,短期内它跑得通,因为他们有足够多的 token、足够强的模型、足够快的事后修复。但对绝大多数团队来说,值得先问的可能不是“我们的 agent 够不够快”,而是“我们的 agent 提交得最快的时候,还有谁在为我们做决策”。