跳到主要内容

万档代码转换:在机械式代码迁移中运行智能体集群

· 阅读需 12 分钟
Tian Pan
Software Engineer

每一个框架迁移的模式都大同小异。你编写一个 codemod,在代码库中运行它,它能干净利落地转换 80% 的文件 —— 也就是那些符合 codemod 作者预期的模式的文件。然后你会遇到长尾问题:某些测试文件中有人对渲染器进行了猴子补丁(monkey-patched),某些组件触及了框架内部机制,或者某个模块是 2017 年由一位早已离职的工程师编写的,使用的是没人再采用的惯用法。Codemod 可以正常解析这些文件,只是没有适用的规则。因此,最后 20% 的迁移工作消耗了 80% 的日程,只能由那些宁愿做任何其他事情也不愿干这活的工程师们手工逐个文件完成。

编程 Agent 颠覆了这种经济效益。难倒确定性 AST 转换的手写怪癖,恰恰是模型擅长处理的 —— 它阅读文件,理解意图,并像人类一样重写代码,而不需要为每种变体制定明确的规则。Airbnb 在大规模实践中证明了这一点:在短短六周内,将近 3,500 个 Enzyme 测试文件迁移到了 React Testing Library,而最初估计的人工工作量是 1.5 年。但头条新闻中被忽略的一点是:难点并不在于提示词(prompting)。一旦你让 Agent 集群处理一万个文件,工程问题就不再是 AI 问题,而变成了批处理操作问题 —— 分片、验证、隔离和合并策略。正确的心理模型是一个 Mapper 具有随机性的 MapReduce 作业。

Codemod 赢在头部,Agent 赢在尾部

确定性 codemod —— 如 jscodeshift、OpenRewrite、comby —— 仍然是处理分布头部的正确工具。它们运行速度快、成本低,且输出结果具有可证明的一致性:相同的输入总是产生相同的输出,因此审查一次转换就等于审查了所有转换。如果一个模式以完全相同的形式出现了五千次,你需要的是一条规则,而不是五千次模型调用。

失败模式已有详尽记录:codemod 在面对变体时会停滞不前。导入别名、不规范的代码风格、AST 匹配器无法看穿的动态结构 —— 每一个边缘情况要么被跳过,要么需要扩展 codemod,而为了代码库中只出现四次的模式去扩展 codemod 是一笔亏本买卖。这就是为什么像 Google 这样的组织构建了人工参与的工具链(Rosie)来引导大规模变更:自动化生成提交,但长尾部分总是会流回到人工处理。

Agent 改变了长尾部分的成本曲线。Google 对 LLM 辅助迁移进行的为期 12 个月的研究发现,在 39 次不同的迁移和 93,574 次修改中,大约 69% 的修改是由模型生成的,开发人员估计总迁移时间减少了 50%。Airbnb 的结果更为显著,因为他们更依赖自动化:75% 的文件在最初的 4 小时内完成迁移,经过 4 天的迭代优化后比例达到 97%,最后仅剩约 100 个文件需要人工处理。

实际的经验是建立混合流水线,而不是进行非此即彼的选择:

  • 先运行确定性 codemod。 它能廉价地处理头部,并为你提供一个干净的基准差异(diff)。
  • 将 codemod 失败和跳过的文件交给 Agent。 针对每个文件提供丰富的上下文:文件本身、它的导入项,以及几个已经迁移好的兄弟文件作为范例。
  • 人工处理两者都无法解决的部分。 如果规则和模型在多次尝试后都无法转换某个文件,那么它确实很奇特,值得人工介入。

Mapper 是随机的 —— 为重试而设计,而非为正确性而设计

Codemod 要么奏效,要么不奏效;运行两次不会产生任何新结果。Agent 则是一种不同类型的工人。同一个文件使用相同的提示词,在第一、二次失败后,可能会在第三次尝试时成功。这一个特性重塑了整个流水线的设计。

Airbnb 最反直觉的教训是,暴力重试优于复杂的提示词。他们没有去磨炼一个万能提示词,而是为每个文件构建了一个状态机 —— Enzyme 重构、Jest 修复、lint、TypeScript 校验,完成 —— 任何失败的检查都会将文件连同错误输出一起重新投喂给模型。大多数中低复杂度的文件在 10 次尝试内都能完成转换。对于困难文件,起决定性作用的不是更聪明的指令,而是更多的上下文:提示词增长到了 40,000 到 100,000 个 token,引入了多达 50 个相关文件、兄弟测试和团队特定的模式。

如果你操作过 MapReduce 或任何大型批处理系统,你应该会对这种方式感到熟悉。你不会去调试单个不稳定的 mapper;你会设计好任务,让重试变得廉价且安全:

  • 单文件的幂等性。 每次尝试都从原始源代码开始,而不是基于上一次尝试的部分输出。迁移了一半的文件是损坏的状态,而不是进度。
  • 将失败输出作为输入。 第 N 次尝试的类型检查错误是第 N+1 次尝试提示词中最有价值的 token。这相当于 Agent 集群的预测执行(speculative execution)—— 廉价、机械,并且能挽回很大一部分失败。
  • 先在样本上调优,然后全面铺开。 Airbnb 处理长尾问题的循环:将剩余的失败按模式分类,挑选 5-10 个代表性文件,调整流水线直到这些文件通过,然后对剩余的所有文件重新运行。每一次扫描都是廉价的,因为集群是并行的;每一轮调优都是有据可依的,因为样本量足够小,完全读得过来。

心理转变在于从“让 Agent 变得正确”变为“让错误变得廉价”。你永远无法让一个模型一次性完美处理一万个文件。你也并不需要它这样做。

验证门禁即代码审查 —— 因为没人能读完一万个 Diff

这是一个令人不安的数学问题:每个 diff 花费两分钟 —— 对于一次有意义的代码审查来说,这已经是一个极其乐观的速度了 —— 一万个 diff 就需要资深工程师投入超过 300 小时。没人能做到这一点。在缺乏工程化设计的迁移项目中,实际发生的情况比跳过审查更糟糕:审查者会机械地通过前两百个 diff,随后批准就变成了一场“表演”,将未经审查的变更“洗白”并合入代码库。

诚实的做法是承认:基于单个 diff 的人工审查无法规模化,必须用每个文件都必须通过的机器门禁(Machine Gate)来取代它:

  • 编译并通过类型检查。 这是最基本的要求,也是你拥有的成本最低的信号。
  • 测试通过 —— 且依然在测试有效内容。 对于测试迁移,迁移前后的断言数量和代码覆盖率是你衡量意图是否得以保留的代理指标。如果一个 Agent 通过删除断言来“修复”失败的测试,它能通过幼稚的门禁,但逃不过覆盖率增量(coverage-delta)的检查。
  • Diff 保持在范围内。 进行语义 diff 检查,甚至是粗略的检查 —— 确保变更只触及预期的 API,不增加依赖,不修改其分片之外的文件。Agent 偶尔会产生“创意性的绕道”;而门禁就是让这种创意束手就擒的地方,这是刻意为之。

门禁必须是工学上的对抗关系,而非协作关系。如果 Agent 能看到门禁的实现逻辑,那么门禁就会变成提示词(Prompt)的一部分,而模型非常擅长在钻检查规则空子的同时违背其初衷。将验证逻辑留在 Agent 的上下文之外;只给它反馈失败的输出。

人工审查依然存在,但它上升了一个层级:人类审查的是流水线 —— 包括代码修改规则、提示词、门禁定义,以及从每个模式桶中随机抽取的通过文件样本。你审查的是工厂,而不是每一件产出的产品。

分片、隔离与平稳落地

一旦转换工作开始运转,还剩下三个操作层面的问题,其中任何一个都能独立搞垮项目。

分片(Sharding)。 并行 Agent 绝不能触碰重叠的文件,否则你大把的时间都会花在解决机器人之间的合并冲突上。文件级分片是迁移任务的自然默认选择 —— 每个文件都是一个独立的作业项 —— 但分片边界也应遵循所有权边界(codeowners、目录树),因为这决定了当合入的变更出现异常时该呼叫谁,并确保每一批合入的代码都能由单个团队进行审查。给每个 Worker 一个隔离的工作树(worktree);共享检出(shared checkouts)会导致两个 Agent 同时编辑同一个 lock 文件。

隔离(Quarantine)。 某些文件会一次又一次地尝试失败。设置一个重试预算 —— Airbnb 的数据表明,十次尝试足以覆盖常规情况 —— 之后,将该文件隔离:将其从任务中移出,记录其失败模式,然后继续。隔离列表并不是项目的失败;它正是项目交付给人工阶段的产出。一百个带有失败原因分类的隔离文件是一个为期两周的任务。而一万个未经分拣的文件则是你开始时那个为期十八个月的任务。你最不该做的就是让集群在值得隔离的文件上死磕,在 97% 的文件等待落地时,为那 3% 的顽固分子浪费 token 和实际耗时。

落地(Landing)。 一个转换了所有内容却无法合并的迁移,只是一个非常昂贵的分支。数千个提交与正常的业务流量竞争,会在合并队列中饿死,或者更糟,被迫导致代码库冻结,让整个组织都站到项目的对立面。这套打法借鉴了 Bors 风格的合并队列和 Google 的 TAP:按分片对落地变更进行批量处理,让队列乐观地并行测试这些批次,在失败时对批次进行二分查找(bisect),而不是剔除所有内容。因为每个变更已经通过了验证门禁,队列失败应该是罕见的,且几乎总是意味着环境抖动或真正的文件间交互 —— 无论哪种情况,你都希望它们能暴露出来。从第一天起就持续落地;一个长期存在的迁移分支是与整个公司的合并冲突。

仪表盘才是交付物

通过询问一个团队在任务运行时看什么,你就能判断他们是否理解规模化迁移。如果答案是“Agent 的运行记录”,那他们是在做演示。一个涉及一万个文件的代码修改任务的运行视图看起来应该像一个批处理作业仪表盘,因为它本质上就是:

  • 按阶段划分的漏斗图: 有多少文件是由确定性的 codemod 转换的,有多少在第一次尝试时通过,有多少在重试后通过,有多少被隔离。这个漏斗的形状会告诉你应该在哪里投入 —— 肥大的重试环节意味着你的提示词需要更好的上下文;肥大的隔离环节意味着你遇到了未建模的模式桶。
  • 重试直方图: 如果两次扫描之间重试次数的中位数从两次攀升到六次,说明某些东西退化了 —— 可能是提示词的改动、模型版本的更迭,或是门禁的收紧。这是你的错误预算信号。
  • 单文件落地成本: token 加上计算成本除以实际合并的文件数。Airbnb 的总投入 —— 六周的工程投入加上 API 成本 —— 与 1.5 年的人工投入相比只是微不足道的尾数,但这仅仅是因为他们盯紧了尾部风险,停止为无望的重试付费。
  • 隔离文件老化: 人工待办事项,按失败模式分组,最早的排在前面。

这些都不神秘。这正是数据工程在二十年前就确立的运维纪律,只是应用到了一个恰好是语言模型的 Worker 身上。在 Agent 集群上挣扎的团队通常擅长编写提示词,但不擅长批处理操作;而成功的团队则将模型视为系统中极其普通的一个组件 —— 一个具有已知失败率、包裹在重试机制中、受门禁验证、分片隔离并通过队列落地的映射器(mapper)。

你待办事项中那些被评为“成本太高而无法完成”的框架迁移、语言升级和 API 弃用,都是基于旧的经济模型定价的,即长尾部分需要手工劳动。那个价格已经过时了。重新定价不再是一个编写提示词的练习 —— 而是一个分布式系统的课题,而你的团队早已知道该如何处理这些问题。

References:Let's stay in touch and Follow me for more thoughts and updates