ARTICLE · 1305259
AI 重构百万行代码,怎么知道没改坏?
19 个小时,100 万行代码砍掉三分之一。
2026 年,两个开源项目先后让 AI 重构了自己的代码库,然后按下了合并键。
第一场是 Bun:下载量两千多万的 JavaScript 工具,创始人把 53 万行 Zig 代码全部重写成 Rust,从写下第一行到全部测试通过只用了 11 天,干活的是 AI,人盯着。
第二场是 Hermes Agent:Nous Research 的开源项目,代码库超过 100 万行 Python,他们让自己的 agent 重构自己的代码——一个主 agent 派出 1,393 个子 agent(派出去干活的 AI 程序),最多 218 个同时开工,19 个小时干完,代码量砍掉三分之一。
事后,两边都发了复盘。它们面对的是同一个疑问:AI 生成上百万行代码,凭什么敢按合并键?代码还没经过真实环境的检验,就直接进了主干。
复盘给出的答案出人意料:他们回答的其实是两个问题,而且分开回答。
第一,功能还在吗? 传统答案是「人看过」,但百万行代码,一个团队通读要一年,这条路走不通。
第二,老问题解决了吗? 动手重构是因为有旧账要还——Bun 的旧账是反复出现的内存 bug,Hermes 的旧账是永远读不完的巨型文件。改完之后,这些老问题真的解决了吗?
很多人以为这是一件事,其实是两件事:功能全保住了、老问题原封不动,那是白干一场;老问题解决了、功能改坏了,那是灾难。功能还在,靠验证;老问题解决,靠换约束或者删复杂度——两套机制各管各的,混在一起反而都做不好。下面把这两套机制拆开讲。
一、两个相反的选择
先看策略。两场重构在「怎么定调子」上做了相反的决定。
Bun 的选择:换语言,但不改架构。创始人 Sumner 定了两条规矩。第一,一次性全量重写,不做渐进式——渐进式要写临时桥接代码,短中期更痛。第二,机械移植,不写「地道」的 Rust:让 Rust 代码看起来像 Zig 的翻译版,架构、数据结构、控制流一律不动,等发布后再慢慢改成惯用写法。
Hermes 的选择:不换语言,但大改架构。3.5 万行的巨型文件拆成四个模块,重复的辅助函数合并,长长的 if-else 判断链改成查表(像查字典:按名字直接找到要执行的代码,不再一层层问下去)。语言还是 Python,代码组织全变了。
一个换皮不换骨,一个换骨不换皮。两个决定看似极端,其实都在回答同一个问题——什么必须冻结?
Bun 冻结的是架构。测试测的是行为,行为由架构决定:架构不动,旧的测试套件才有资格当新代码的裁判,测试结果才有可比性。
Hermes 冻结的是接口。工具的 JSON 描述、命令行的帮助文本,一个字节都不能变。改的是内部实现,用户看得见的东西一律不动。
两边的冻结项都写成了文档,而且文档本身也要过审。
Bun 先写了一份 PORTING.md(移植指南),把 Zig 的每种写法怎么对应到 Rust 列清楚。又起了一个专门的工作流,遍历代码库里每个结构体的每个字段,分析出 Rust 需要的内存生命周期标注,整理成一张表。这两份文档先让几个 AI 审查员互相挑毛病,最后创始人本人通读。
传统上,重构的准备工作是「理清楚要改哪里」;这两场重构的启发是:先写清楚「什么不许变」。规则层一旦有错,后面每一行代码都会继承这个错——所以契约文档的审查级别,比代码还高。
「机械移植」会引出一个疑问:翻译腔的代码,以后还能维护吗?Bun 的答案是分期还账:v1.4 先原样发布,之后慢慢改成地道的 Rust,unsafe(Rust 里少数允许绕过内存保护的「危险模式」)的部分也计划逐步消化。这笔账他们记在了明处,不是装作不存在。
二、凭什么敢合入:验证是一层层搭出来的
回到开头的问题:怎么验证百万行 AI 代码?
答案没有什么绝招:好几层机制叠在一起,每层专门抓上一层漏掉的东西。
第一层:裁判必须和被裁判的东西分开
Bun 最幸运的一点:测试套件用 TypeScript 写——上百万条断言(检查程序行为是否符合预期的语句),而 TypeScript 是另一门语言,不依赖被重写的 Zig 或 Rust。同一套测试,重写前后都能跑,都能当裁判。
这样的裁判可遇不可求。Hermes 没有这么现成的裁判,它的做法是把关键接口做成可以逐字节比对的快照:工具的 JSON 描述和原来的一模一样,命令行帮助文本逐字节对比。接口没变,就是没改坏的最低标准。
两层合起来是一句话:验证的尺子,不能是同一批被改写的代码。 如果测试和实现是同一种语言、同一套框架,重写时一起被翻译过去,测试通过就什么也证明不了。回头看看你项目里的测试——和实现同一门语言、同一个仓库、同一次提交,严格说它们只是「碰巧还通过」。
第二层:不追求全绿,追求「失败集合不扩张」
这一层最反直觉,因为它推翻的可能是你一直默认的想法。
据第三方对这次重构 PR(合并申请)的审计:Hermes 重构后跑全量测试,44,859 个通过,51 个失败。
大多数人的第一反应是:失败?不是应该全绿吗?
Hermes 没有隐瞒这 51 个失败,而是把重构前的代码在干净环境里重跑了一遍——同样是这 51 个失败,一个不多一个不少。也就是说,这些失败是项目本来就有的(环境问题、缺凭证),和这次重构无关。归因于重构的回归数:零。
「重构后测试必须全绿」这个默认要求,其实既不现实也不必要。老项目多少都有几个跑不通的测试,追求全绿要么逼你先修完历史欠账,要么逼着 AI 去动测试让它「变绿」——后者是灾难。正确的问法不是「全绿了吗」,而是「失败清单和改动之前完全一样吗」。
Bun 那边同理:CI(每次提交代码自动跑测试的流水线)失败的测试文件从 972 个降到 23 个,再降到零,六平台全绿。而且人工核实过测试确实在跑——AI 偷懒跳过测试是经典坑,这次一个都没跳过、没删掉。
第三层:写代码的和审代码的,必须是两个 AI
Bun 的每个文件都走同一条流水线:一个 AI 写,两个 AI 审,一个 AI 根据审查意见修。关键设计有三条:
审查员在独立的上下文里,只拿到代码改动(diff),看不到写代码那个 AI 的推理过程;审查员拿到的指令是「假设这段代码是错的,你的任务是找出它错在哪」;写代码的不审,审代码的不写。
为什么审者要无知?Sumner 的原话:写代码的那个 Claude 希望代码被接受。AI 和人一样,自己写的东西自己舍不得挑毛病。所以审者必须不知道作者的意图,只看代码本身,只做一件事:证伪。
乍一听这是左手查右手——都是 Claude,审得动吗?恰恰是「无知」这条设计让它审得动:审者不知道写者的思路,只能从代码本身找茬——和人类评审「作者不审自己的代码」是同一个道理。效果也有数:下面这三个 bug 全是审查员抓的。
第一个:异步关闭的句柄。代码把管道交给系统库异步关闭——系统库先记下管道的位置,等下一轮事件循环再释放内存;但管道变量在这行代码结束时就销毁了。于是系统库攥着一块已释放的内存,回调时还会再释放一次。编译器不报错,运行九成情况也没事,审查员从时序上推出来这是内存错误。
第二个:1970 年以前的时间戳。把秒数拆成「秒 + 纳秒」时,代码用了「向零取整」:-1.5 秒拆成 -1 秒加 -5 亿纳秒——而纳秒不允许是负数,这个时间戳是非法的。
正确做法是「向下取整」:-1.5 秒拆成 -2 秒加 5 亿纳秒。这个 bug 只在处理 1970 年以前的文件时间时触发,测试很难覆盖。
第三个:急于求值的默认值。合并两种颜色占比时,代码写「第一色缺省就用 1 减第二色」——但这种写法会先把「1 减第二色」算出来,哪怕第一色根本不缺省;算的时候如果第二色也没给占比,程序当场崩溃。改成惰性写法(用到才算)就好了。
三个例子全部「能编译、看着合理」。恰恰是这种「看起来对」的代码最危险——人工评审里最怕的也是这种。
第四层:承认测试不够,人必须上
这一层最让人意外。
自动化测试对照基线通过之后,Hermes 的两轮人工评审仍然抓到了真实的回归。两类典型:
一类是「仓库里没人用了」的陷阱。AI 看到某个公开函数在仓库内没有任何调用者,判断是死代码,删了。但外部插件还在 import(引用)它——仓库内没有调用者,不等于没有调用者。人知道这个项目的生态,AI 不知道。
另一类是自动化改写的语义漂移。AI 批量改写异常处理代码,改了大约 65 处,每处单独看都「等价」,合起来却改变了异常的抛出行为。测试没抓住。
官方复盘的原话很直白:这些是「现有测试漏掉的真实回归」。这句话值得反复读——它没有辩解「测试已经足够强」,而是承认测试有盲区,用人审补上。敢在复盘里这么写,比测试全绿本身更有说服力。
Bun 那边没有做同等强度的人审,这也成了它最大的争议点,后面记账的时候再说。
三、老问题是怎么解决的
功能还在的问题,到这里有了答案的骨架。但动手重构不是为了「不改坏」,是为了把旧账还掉。老问题解决了吗?两场在这方面走的路完全不同。
Bun 的旧账是内存安全。Zig 这门语言要手动管理内存。每一处释放,程序员都要自己回答两个问题:这些字节在哪里释放?怎么保证只释放一次?
偏偏 Bun 又嵌着带垃圾回收的 JavaScript 引擎。手动管理和自动回收混在一起,麻烦就来了。于是三类 bug 反复出现:use-after-free(用了已释放的内存)、double-free(释放两次)、错误路径漏释放。
关键前提是:他们不是没治过。用带内存检测的编译模式重新编译、每次提交跑检测、24 小时不停歇的模糊测试(用随机输入轰炸程序,看会不会崩)——投入不小,bug 仍在漏。现有治法有天花板,这才值得动手术。
Bun 的解法是换约束。Rust 这门语言里,上面三类错误直接是编译错误——代码写错了编译不过去,根本进不了仓库。内存的清理由语言的 Drop 机制自动完成,值离开作用域就释放,不靠程序员在每个调用点手写。Sumner 的总结:编译器报错是比代码规范更好的反馈回路。
这一段值得停下来想清楚:重写本身没有解决任何问题,解决问题的是新语言的约束。 重写只是把旧架构搬进了这套约束里。这也解释了为什么「机械移植」就够了——不需要重新设计,只需要换载体。
Hermes 的旧账完全不同:不是 bug,是复杂度。百万行代码里,最大的文件 3.5 万行,接近 200 个函数超过 300 行,最长的 if-else 判断链有 92 个分支。技术含量不高,但工作量巨大,永远排不进开发计划。
Hermes 的解法是直接删:代码量从约 106 万行降到约 70 万行,砍掉 34.4%;巨型文件拆成模块,超长函数从 192 个降到 2 个,92 分支的判断链降到 9 分支。
效果怎么证明?他们没有用「代码更整洁了」这种话交差,而是设计了一个可复现的测量:模拟对同样的 4,000 个函数和类做查找,比较两个版本每次查找要读多少内容。
结果:平均每次查找要读的内容从 2,218 个字块(模型读文本的最小单位)降到 993 个。同时声明了边界:测的是查找成本,不是「agent 完成工程任务的能力」——后者没测。
测到哪、没测到哪,说清楚,比把话说满体面得多。
四、账要分开记:Kelley 的质疑
Bun 重写完成后,成绩单很漂亮:修复 128 个老 bug(从内存泄漏、崩溃到帮助文本颜色错误),某个连续构建场景的内存占用从 6.7 GB 降到 609 MB,二进制体积缩小 20%,HTTP 吞吐还快了 4.8%。
这份成绩单差点让人信了「Rust 重写带来一切」。好在 Zig 语言的作者 Andrew Kelley 写了一篇回应,把这笔账拆开了。其中有一击非常重:
你们发布百万行未经审查代码的理由,是测试套件足够强,能抓住所有问题。那你们为什么说 Zig 代码里有那么多恼人的 bug?同一套测试,抓不住 Zig 代码里的 bug,却抓得住百万行没人审过的代码?
这个悖论没有好答案。测试真的足够强,旧代码的 bug 早该被抓完了。测试没那么强,那它凭什么担保新代码?它提醒每个做重构的人:别拿「测试都过了」当万能挡箭牌。
Kelley 还拆了收益的来源,同样锋利:
性能提升主要来自链接优化(LTO)。而 Zig 从 Bun 诞生起就支持 LTO,只是因为编译器 bug 默认关着——那些 bug 同样影响 Rust。
二进制缩小 20%,靠的是清理无用数据、延迟加载、链接器代码折叠,全是和语言无关的工程活,在原来的 Zig 代码库里一样能做。Zig 团队为其中的不规范用法警告了多年。
也就是说,漂亮成绩单里,真正归功于「换语言」的只有内存安全那一类问题——128 个修复里,内存类的算它的功劳,崩溃和杂项类哪门语言都能修;性能、体积里相当一部分,是「顺带补交的工程欠账」——欠了很久、一直没排期的活,这次大规模重写时顺手干了。
想通这一点之后,「重构收益」的记法就变了。如果把「顺带补欠账」记成「重构方法的收益」,就会高估这套方法的效果——你的项目可能没有那些欠账可补,照搬方法,收益对不上。
代价也要如实记。Bun 官方列出了 19 个重写引入的回归,多数源于「两门语言长得一样、含义不同」。
举个最隐蔽的:Zig 的断言(程序运行时自检的「体检语句」,条件不满足就报错停下)是函数调用,每次都会执行;Rust 的对应写法在发布版里整段删除——连断言里夹带的实际操作一起删了。于是一个功能只在发布版失效,测试环境一切正常,初期根本发现不了。
还有编译速度:博客通篇没提。作为对照,Zig 编译器自己 60 万行代码,全量构建只要 16 秒、日常改动增量构建 90 毫秒——换成 Rust 后的 Bun 有没有这个水平,文章没有给出数字。
Hermes 的账单同样诚实:文件拆细后,模块数从 1,152 涨到 1,551,import 依赖边多了 36%,部分入口启动变慢。官方复盘明确写着:单个片段更好读了,但没有解决它们之间的耦合,还有 6 个文件超过 5,000 行。
两边的官方文档都主动列出了自己新引入的问题。这也是一条经验:重构报告里没有代价清单的,不值得信。
五、对重构项目的启发
两场重构的规模不可复制——一个背后是 Anthropic,一个有数月日常协作积累下来的工程经验库。但把规模去掉,剩下的方法是可以搬的:验证栈不是天生就有的,Bun 和 Hermes 都是一层层搭出来的,不需要先有百万断言级的测试套件,从接口快照这种小成本的事做起就行。
两个问题,两条线,可以按这个顺序来:
先回答「功能还在吗」,四步:
第一步,先立裁判,再动代码。 检查测试是不是和实现解耦:测试改用另一种方式写(不同的语言、不同的层),或者把关键接口做成可逐字节比对的快照。这一步是大前提——裁判和选手是同一批代码,后面全部白搭。
第二步,记录当前的失败集合。 在干净的代码基线上跑一遍全量测试,把失败清单存下来。这就是基线。重构后不追求全绿,只要求失败清单和基线完全一致。
第三步,写行为契约。 一页纸就够:哪些接口必须原样保留、哪些行为不许变、提交前要过哪些检查。你亲自通读一遍。这份文档比代码重要——规则错了,每一行都错。
第四步,写审分离,小模块先行。 AI 审查就按第三层的规矩来:审者无知,只看代码,只做证伪;人审则别让作者审自己。然后拿一个小模块走完整流程——「改写 → 独立审查 → 测试对照 → 人审 → 合入」,跑通了再扩大。
再回答「老问题解决了吗」,两步:
第五步,先给老问题归类,再选解法。 问一句:这个老问题,是「现有治法有天花板」(Bun 的内存 bug,检测工具、代码规范都试过仍在漏),还是「没有排期的工程清理」(Hermes 的巨型文件)?
归类错了,动作就错了:给「没排期」的问题换语言,是高射炮打蚊子;给「治法到顶」的问题做清理,是原地打转。
第六步,收益单独记账。 重构完成后,把成绩单拆成三栏:哪些收益来自「换了约束/删了复杂度」,这是方法的功劳;哪些是顺带补交的欠账,你的项目未必有;哪些是新引入的代价,比如 Bun 的 19 个回归、Hermes 多出的 36% 依赖边。三栏混在一起,就会高估方法、低估代价。
最后是一条底线,两场重构用各自的方式验证了它:
门禁能证明「已知的行为没变」,证明不了「没人想到去测的行为没变」。 Bun 因为只靠测试被质疑,Hermes 因为加了人审,复盘里敢直接承认测试的盲区。测试、契约、基线、人审,一层补一层的漏洞,缺了哪层,哪层就是下一个事故的入口。
而这一切,始于独立的裁判和一份可机器检查的行为契约。要重构的项目如果还没有,先补上——再谈规模。
参考:
Jarred Sumner, "Rewriting Bun in Rust"(bun.com 博客,2026-07-08)
Andrew Kelley, "My Thoughts on the Bun Rust Rewrite"(andrewkelley.me,2026-07-09)
Teknium, "Refactoring Hermes with 1,393 agents"(nousresearch.com 博客,2026-09)
文中 44,859/51 测试对照、模块数与依赖边变化等 PR 级数字来自第三方审计转述,非官方口径;其余数字均出自上述三篇官方原文。















































































