Bun 敢让 AI 重写一百万行代码,底气不是模型

小岛AI 2026 / 07 / 10

535,496 行 Zig,1,448 个文件,一次性全部重写成 Rust。从开跑到六个平台的测试套件百分之百通过,11 天。

这几个数字来自 Bun 官方博客这两天发的复盘长文 Rewriting Bun in Rust,作者是 Bun 的创始人 Jarred Sumner。文章在 Hacker News 上挂了一整天,评论区从「不可能」吵到「他真的做到了」。

先把利益关系摆桌面上,这篇复盘自己也在开头就披露了,Bun 在 2025 年 12 月被 Anthropic 收购,Jarred 和团队现在都是 Anthropic 员工,重写用的还是预发布版的 Claude Fable 5。所以它天然带广告属性,你可以打个折看。但工程细节做不了假,19 个回归 bug 的 issue 编号全在 GitHub 上挂着,翻车记录写得比成功记录还详细。恰恰是这些翻车记录,让我觉得这篇东西值得认真拆一遍。

Bun 是什么,做后端或者前端工程的朋友大概率碰过,一个想把 Node.js 拍在沙滩上的 JavaScript 运行时,转译器、打包器、包管理器、测试框架全都自己造,CLI 月下载量超过 2200 万,Claude Code 和 OpenCode 都拿它当底座。这么大一个东西,创始人说重写就重写,还是换编程语言的那种重写。

写代码的人都知道这有多犯忌讳。

Joel Spolsky 在 2000 年写过一篇流传很广的 Things You Should Never Do,把「从头重写」称为软件公司能犯的最严重的战略错误,网景就是这么把浏览器市场让出去的。二十六年来这条规矩基本没人敢碰,Jarred 自己在文章里也认账,535,496 行 Zig,重写要一小队工程师干整整一年,期间 bug 不能修、安全补丁不能打、新功能全冻结。他的原话是,我们永远不会那么干。

那为什么还是干了。

因为不重写的日子也不好过。文章贴了 v1.3.14 一个版本修掉的 bug 清单,我挑几个你感受下,node:zlib 里流还在线程池上异步写入时调 reset 会 use-after-free,http2 的回调触发哈希表扩容会让内部指针失效,fs.watch 的引用计数下溢导致 watcher 永远不被垃圾回收。清一色的内存生命周期问题。

根子在 Bun 的特殊处境。它要同时伺候两套内存,一套是 JavaScriptCore 引擎里归垃圾回收器管的,一套是 Zig 这边手动分配手动释放的,两套生命周期互相缠绕。Zig 的哲学是没有隐藏控制流,清理代码要在每个调用点用 defer 显式写出来,忘写一处就是泄漏,错误处理路径里多写一次就是 double-free。团队不是没努力,给 Zig 编译器打补丁上了 ASAN(内存错误检测工具,专抓越界和释放后使用),用 Fuzzilli 全天候做模糊测试,端到端泄漏测试写了一堆,该做的都做了,bug 还是像潮水一样一波一波来。

Jarred 说他受够了每天睡前担心 Bun 在谁的生产环境里崩掉。这句我信,凌晨被告警拉起来看 core dump 的滋味,做过线上服务的人都懂。

Rust 对这类问题的答案简单粗暴,use-after-free 和 double-free 在 safe Rust 里直接是编译错误,清理逻辑靠 Drop 自动执行。他在文章里有句话我很喜欢,编译错误是比代码规范更好的反馈回路。规范靠人 review 来执行,编译器不跟你商量。

道理都对,但一年的代价付不起。于是有了那个转折,他想,要不花一周时间,试试 Anthropic 的新模型能不能把 Bun 重写成 Rust。他自己都没抱期望。几天之后测试通过率一路往上爬,新写出来的 Rust 和原来的 Zig 逐段对得上,他的态度从「值得一试」变成了「我要合并它」。

好家伙,一周的赌注,赌出来一次载入工程史的迁移。

接下来这部分是全文最值钱的,他到底是怎么干的。不是那种「Claude 帮我重写了项目」的爽文,是一套完整的工程方法,我给你顺着捋一遍。

开工前他先做了两个决定。第一,不搞增量迁移,一次性全部重写,因为增量方案要写大量迟早会删掉的临时胶水代码。第二,做机械式移植,让 Rust 代码长得就像从 Zig 直译过来的,架构、性能、功能全部保持不变,等 v1.4 发布之后再慢慢重构成地道的 Rust。这个决定看着保守,其实是整个项目能验收的前提,因为 Bun 的测试套件是 TypeScript 写的,跟底层实现语言无关,一百多万个断言原封不动,就是最硬的验收标准。

然后是准备工作。他花了大概三个小时跟 Claude 讨论 Zig 的各种模式怎么映射到 Rust,把讨论固化成一份 PORTING.md,这份文档后来自己上了 Hacker News。接着跑了一个工作流,把全库每一个 struct 字段的生命周期都分析一遍,追踪控制流,提出这个字段在 Rust 里该标什么生命周期,再让两个审查 agent 复核,最后固化成一张 LIFETIMES.tsv 表,给后面所有干活的 Claude 当地图。

正式放量之前,先拿 3 个文件做试点。跑通了,才铺开到全部 1,448 个文件。

整个流水线里我最想展开讲的是对抗式审查这个设计。写代码的 Claude 和审代码的 Claude 用完全隔离的上下文,实现者手里有 Zig 原文件、移植计划和自己的完整推理过程,审查者只拿到 diff,其它什么都看不到,并且被明确告知,假设这段代码是错的,穷举它不能工作的理由。为什么要这么拧巴,因为写代码的 Claude 跟写代码的人一样,心里想的是让代码被合并,立场天然不中立。一个实现者,配至少两个对抗审查者,实现者不审查,审查者不实现。

我日常的工作就是给模型搭这种让它真正能干活的工程层,看到这段职业病直接犯了。多数人用 AI 写代码翻车,翻在把生成和验证塞进了同一个上下文里,模型自己写自己夸,你在旁边看着它把 bug 合并进主干。把裁判和运动员拆开,各给各的信息,各给各的立场,这一手不新鲜,但在一百万行的尺度上被验证有效,含金量完全不同。

规模也值得说一嘴。高峰期 4 个工作流并行,每个里面跑 16 个 Claude,一共 64 个同时在写,峰值每分钟产出约 1,300 行代码,单小时最多 695 个 commit。11 天总共 6,502 个 commit,最终落地的 diff 是加了 1,009,272 行。

你以为这一路很顺,那当然没有,翻车现场比想象的密集,而且每一个都特别有画面感。

放量两分钟后,一群 Claude 开始互相拆台,有的跑 git stash,有的跑 git stash pop,还有一位直接 git reset —hard,把别人刚写的东西全扬了。解法不是挨个救火,是改流程规则,禁止一切不是「提交单个具体文件」的 git 命令,顺便把 cargo 和所有慢命令也禁了,然后按 4 个 worktree 分片隔离。

修编译错误的阶段更逗。Claude 把「让这个 crate 编译通过」创造性地理解成「把报错的函数整个 stub 掉」,注释还越写越长,一段一段地解释这个 workaround 为什么合理。Jarred 给审查者加了一条规则,我建议每个团队都抄走贴在墙上,如果你需要一整段注释来解释这个变通方案为什么没问题,那代码就是错的,去修代码。改了这一句 prompt,几个小时后这类行为绝迹。

还有更土味的翻车。他忘了调大 EC2 实例的磁盘 IOPS,一条慢 grep 就能让整台机器的读写卡住几分钟。测试套件里有耗尽整机 TCP 连接的压力测试、往磁盘写几个 GB 的测试、一口气 spawn 一万个进程的测试,靠嘴上说「请不要乱来」根本管不住,最后上了 systemd-run 做 cgroups 隔离,限内存限 CPU 限进程命名空间,即便如此机器还是崩了好几回。

你看出味道没有。这 11 天里人干的事情,不是替 AI 写代码,也不是逐行检查 AI 写的代码,是盯着流程的输出,发现不对就去修生成代码的流程本身。发现互相踩 git,改规则。发现偷懒 stub,改审查标准。一处 prompt 的修改,胜过一万次人肉救火。这是整篇文章方法论浓度最高的一句,出了问题,修流程,不修代码。

拆掉环形依赖之后暴露出大约 16,000 个编译错误,对一个人是绝望的数字,对 64 个并行的 Claude 就是个待办清单,逐 crate 跑 cargo check,错误按文件分组,一修二审一应用。第一次跑 CI,972 个测试文件失败,两天后剩 23 个,再过一天半 Linux 全绿。最终 macOS、Linux、Windows 三系统六个平台全部通过,0 个测试被跳过或删除,Jarred 还专门人工确认了测试真的在跑而不是被静默略过。这个细节很见人品,AI 时代最容易造假的就是绿色的 CI。

该聊账单了。合并之前一共烧掉 59 亿未缓存输入 token、6.9 亿输出 token、720 亿缓存读取,按 API 定价折算约 16.5 万美元。贵吗,看跟什么比,三个熟手工程师一年的人力成本远不止这个数,何况那一年里 Node.js 兼容性、bug 修复、安全补丁全得停摆。

但代价不止美元。重写引入了 19 个已知回归,全修掉了,Jarred 把最典型的几个原样贴了出来,这部分是全文技术含金量的巅峰,因为它们清一色属于同一类,代码在两种语言里长得几乎一样,语义却不一样。

给你看最阴的一个。Zig 的 assert 是普通函数,参数在任何构建模式下都会执行。Rust 的 debug_assert! 是宏,release 构建里整个表达式直接被抹掉。原代码把一个有副作用的 insert_stale 调用写在断言里面,Zig 版没事,Rust 版一到 release 这行调用就凭空消失,热更新在特定场景下直接失灵,issue #30678 记录在案。还有 Zig 的切片转换会静默忽略末尾的奇数字节,Rust 的 bytemuck 遇到同样输入直接 panic,于是 Blob.text() 碰上 UTF-16 字节序标记加奇数长度就把进程崩了。还有 Zig 的 ReleaseFast 构建不做数组边界检查,一个从原代码原样搬过来的 ptrs[4095] 越界访问,在 Zig 里静默潜伏了不知多少年,到了 Rust 的 release 构建里当场 panic 暴露。最好笑的是一个显示 bug,Zig 的格式串是编译期处理的,颜色标记在参数替换前就被改写掉,Rust 没这个机制,终端超链接结尾的转义符被当成标记吃掉,包名 oxfmt 硬是显示成了 oxfmtr,issue #30693 里连截图都有。

Zig 和 Rust 格式串语义差异导致的显示 bug,oxfmt 显示成了 oxfmtr

嘶。最后这个仔细想想不算 Rust 的锅,那个越界在 Zig 里一直存在,只是没人看见。

所以别把这 19 个回归读成 AI 不靠谱的证据,人来做这种移植,栽的是同一批坑,而且人未必有两个只拿 diff 的对抗审查者帮忙兜底。真正的结论是移植类工作最大的敌人从来不是谁在写,是两种语言里长得一样的东西悄悄换了含义。

成果这边,数字同样硬。v1.4.0 修掉了 128 个在 v1.3.14 上可复现的 bug。进程内每调一次 Bun.build 泄漏约 3MB 的老毛病,同一个项目连续构建 2,000 次,旧版内存涨到 6,745 MB,新版稳在 609 MB。

连续构建下的内存占用对比,Zig 版一路泄漏到 6.7GB,Rust 版靠 Drop 稳在 609MB

二进制体积在 Linux 和 Windows 上缩了两成。跨语言链接时优化让整体性能快了 2% 到 5%。Prisma 把新的公测服务跑在了 Rust 版上,专门拿旧版翻车过的故障模式去测,全扛住了。Claude Code 从 6 月中旬起悄悄换上了 Rust 版 Bun,Linux 上启动快了 10%,除此之外几乎没人察觉。Jarred 对此的评价是,无聊就是好事。

做基础设施的人才懂这句的分量。

Claude Code 生产遥测,换上 Rust 版 Bun 后 Linux p50 启动时间从 517ms 降到 464ms

行,事实讲完了,聊点我自己的看法,不一定对。

这篇复盘发出来之后,最顺理成章的误读就是,重写不再危险了,把祖传老代码丢给 AI 吧。慢着。回头看整个故事,Jarred 敢按下按钮的底气,其实不是 Claude 有多神,是 Bun 手里握着一份用 TypeScript 写的、跟实现语言完全无关的测试套件,一百多万个断言,覆盖到连热更新跑 100 次这种场景都有测试盯着。所有工作流、所有对抗审查、64 个 Claude 的一切产出,最终都要过这道语言无关的验收关。模型能力是租来的,谁充钱都有,这份测试资产是人家五年一个 bug 一个 bug 攒下来的家底。

你手里那座不敢动的山,缺的多半不是一个更强的模型,是那一百万个断言。AI 把重写的执行成本从一年压缩到了 11 天,厉害了,真的,但它没有替任何人回答那个更老的问题,改完之后,你拿什么证明它还是对的。这个问题在 LLM 出现之前就在那里,现在只是被照得更亮了。

Joel 那篇网文写于 2000 年,从头重写是最不该犯的错,这条规矩管了行业二十六年。现在看它没有被推翻,是第一次有人给它列出了例外成立的条件清单,语言无关的完整测试、机械式移植不夹带重构、隔离上下文的对抗审查、出问题修流程不修代码,四条凑齐,缺一不可。文章结尾 Jarred 只写了一句话,今天一个工程师能做的事,比一年前多得多。

我愿意再补半句。杠杆确实变长了,但支点还是你自己一行一行攒出来的。那些测试写得扎实的团队,接下来会突然发现自己坐在金矿上。

而金矿这种东西,从来都是埋的时候最不起眼。