1. 一个反直觉的工程现象:写得多不如读得透
第一次看到"AI写了80万行Rust,最值得学的却是它花十倍精力读代码"这个说法,我的反应是:这不就是典型的"慢就是快"吗?但仔细琢磨之后,我发现这里面藏着一个被大多数人忽略的工程真相——在代码迁移这类高风险任务中,理解现有代码的成本远高于生成新代码的成本。
这个项目做的事情,说白了就是一次超大规模的代码迁移:把一套体量庞大的既有系统,用Rust重写。80万行Rust是最终产出,但真正决定成败的,是AI Agent在动手写之前,花了大约十倍于写代码的时间去读代码、理解代码、建立上下文。这个比例听起来夸张,但如果你做过任何一次真实的迁移项目,就会知道它一点都不离谱。
我先把结论摆在这里:代码迁移的核心难点从来不是"写",而是"读懂旧代码到底在干什么,以及新代码必须保持哪些行为不变"。这个项目之所以值得研究,是因为它把"读代码"这件事从人类工程师的隐性劳动,变成了AI Agent的显性工作流,并且用工程化的方式把它拆解、量化、优化。这套方法论不只适用于Rust迁移,任何涉及TypeScript、Node.js、Python甚至跨语言重写的场景都能借鉴。
这篇文章我会从几个层面拆开讲:为什么读代码要花十倍精力、AI Agent是怎么组织这套阅读流程的、Rust作为目标语言带来了哪些额外约束、以及我在类似项目里踩过的坑和总结出的可复现步骤。适合正在做代码迁移、正在搭AI Agent工作流、或者单纯想理解"AI写代码"这件事边界在哪里的读者。
2. 为什么代码迁移里"读"比"写"贵十倍
2.1 迁移任务的本质是行为等价,不是功能重写
很多人对代码迁移有个误解,觉得"迁移"就是把A语言的语法翻译成B语言。如果你真这么干,项目大概率会在上线后炸掉。迁移的本质是在保持外部行为完全等价的前提下,替换底层实现。这意味着你不仅要理解代码"做了什么",还要理解它"为什么这么做"、"在什么边界条件下会出问题"、"哪些看似冗余的逻辑其实是历史补丁"。
举个我亲身经历的例子。之前迁移一个Node.js服务到Rust,旧代码里有一段看起来毫无意义的空循环,注释写着"等待外部状态同步"。新人一看就想删掉,结果删了之后线上偶发数据不一致。后来翻git历史才发现,这段循环是为了绕过一个第三方库的异步时序问题。这种知识不在代码表面,而在代码的演化历史和使用语境里。AI Agent如果只读当前代码快照,同样会漏掉这类信息,所以它必须读的不只是代码本身,还有测试、注释、提交记录、issue讨论。
这就是"读"贵的第一层原因:读的对象远不止源码。一个80万行的Rust项目,对应的旧系统可能有几十万行源码、上万条测试用例、几千个issue、几百份设计文档。AI Agent要把这些信息全部纳入上下文,才能做出正确的迁移决策。
2.2 上下文窗口的物理限制逼出"精读策略"
即便现在的大模型上下文窗口已经很大,面对几十万行代码也是杯水车薪。你不能把整个代码库塞进去让模型"自己看",那样既慢又贵,而且模型在超长上下文里的注意力会稀释,关键信息反而被淹没。
所以AI Agent必须有一套分层阅读策略:先粗读建立全局地图,再精读关键模块,最后针对每个迁移单元做深度理解。这个过程的计算成本,天然就比"直接生成代码"高一个数量级。生成代码是线性的——输入需求,输出代码;而理解代码是网状的——你要在模块之间建立依赖关系、在行为之间建立等价映射、在历史变更里识别意图。
我实测过一个粗略的比例:在一个中等规模(约5万行)的TypeScript到Rust迁移里,如果让Agent直接翻译,生成速度大约是每分钟几百行,但错误率高得离谱,返工成本巨大;如果让Agent先花时间建立理解,前期"读"的时间确实占到总时间的70%以上,但后期返工率能降到个位数百分比。十倍精力读代码,换来的是整体工期缩短和上线风险下降,这笔账算得过来。
2.3 Rust的所有权模型放大了理解成本
如果目标语言是Python或TypeScript,迁移的理解成本还相对可控,因为内存管理是自动的,你主要关心逻辑等价。但Rust不一样,它的所有权、借用、生命周期系统要求你在写代码之前就想清楚数据的流动路径。
这意味着AI Agent在读旧代码时,不能只理解"逻辑上数据怎么流转",还要理解"在Rust里这些数据应该归谁所有、在哪里借用、生命周期怎么标注"。旧代码里一个随手传递的对象引用,在Rust里可能对应好几种不同的设计:&T、&mut T、Arc<T>、Rc<RefCell<T>>,选错了要么编译不过,要么性能崩掉,要么引入死锁。
所以读代码阶段,Agent实际上在做两件事的叠加:理解旧逻辑 + 预判Rust实现约束。这个双重任务让"读"的成本进一步上升。我在自己的项目里深有体会:一段在TypeScript里20行搞定的异步数据共享逻辑,在Rust里为了满足借用检查器,我反复读了旧代码三遍,才确定用Arc<Mutex<HashMap>>还是tokio::sync::RwLock。这种决策没法拍脑袋,必须基于对旧代码行为的精确理解。
3. AI Agent读代码的工作流拆解
3.1 第一层:建立代码库的全局索引
Agent读代码的第一步不是逐行看,而是建立索引。这跟人类工程师接手新项目的做法一样:先看目录结构、看模块划分、看依赖关系图,形成一个"这个系统大概由哪些部分组成"的心智模型。
具体做法上,Agent会先扫描所有源文件,提取出模块名、公开接口、依赖关系,生成一张结构化的地图。这张地图不需要包含实现细节,只需要回答"谁依赖谁"、"数据从哪进从哪出"、"哪些模块是核心哪些是边缘"。我在自己的Agent工作流里,这一步通常用静态分析工具配合LLM来做:工具负责提取客观的依赖关系,LLM负责给每个模块生成一句话的功能描述。
这一步的产出物非常关键,它是后续所有精读的导航图。没有这张图,Agent在几十万行代码里就会迷路,读着读着就忘了自己在看什么。我踩过的坑是:早期我让Agent直接开始读具体文件,结果它读得很细但完全没有全局观,迁移到一半发现模块划分错了,前面全白干。后来加上索引层,效率立刻不一样。
3.2 第二层:按迁移优先级做模块精读
有了全局地图,Agent开始按优先级精读模块。优先级怎么定?我的经验是三个维度综合打分:依赖被引用次数、业务关键程度、逻辑复杂度。被引用越多、越核心、越复杂的模块,越要先读透。
精读一个模块时,Agent要做的事情包括:读懂每个函数的输入输出契约、识别副作用(比如文件IO、网络请求、全局状态修改)、提取隐含假设(比如"这个参数一定不为空")、记录边界条件处理。这些信息会被结构化成"模块理解卡片",供后续生成代码时调用。
这里有个实操细节值得说:Agent精读时不能只读代码,还要读测试。测试用例是行为契约最精确的表达。一个函数可能有十种调用方式,但测试只覆盖了三种,那另外七种要么是死代码,要么是隐藏风险。我在迁移时会让Agent把测试用例和实现代码对照着读,凡是测试没覆盖到的分支,都标记为"需要人工确认"。这个策略帮我抓出过好几个隐藏的bug。
3.3 第三层:跨模块的行为一致性校验
单个模块读懂了还不够,迁移最大的风险在跨模块的交互行为。旧系统里模块A调用模块B时,可能依赖了B的某个副作用;迁移后如果B的实现变了,A就可能出问题。
所以Agent在精读之后,还要做一轮跨模块的行为校验:把所有模块间的调用关系梳理出来,逐个确认"调用方对被调方的行为假设"在新实现里是否依然成立。这一步最容易出问题的地方是错误处理和异常传播。旧代码里一个函数抛异常,调用方可能靠捕获异常来做流程控制;Rust里没有异常,用Result,如果迁移时没把这个控制流转换对,逻辑就断了。
我自己的做法是让Agent生成一份"行为契约清单",列出所有跨模块的关键假设,然后逐条验证。这份清单在后期测试阶段也是宝贵的资产,可以直接转化成集成测试用例。
4. Rust迁移中的关键技术决策点
4.1 异步运行时的选择与旧代码的映射
旧系统如果是Node.js写的,它的异步模型是基于事件循环的,Promise和async/await是核心。迁移到Rust时,第一个大决策就是选哪个异步运行时。主流选择是tokio和async-std,我几乎无脑推荐tokio,因为生态最成熟、文档最全、社区支持最好。
但选运行时只是开始,真正的难点是把Node.js的异步语义映射到Rust的异步语义。Node.js里一个async函数返回Promise,调用方await它;Rust里async fn返回Future,需要被executor驱动。表面看差不多,但细节差异很多:Node.js的Promise一旦创建就开始执行,Rust的Future是惰性的,不await就不执行。这个差异会导致迁移后的代码行为完全不同。
我在项目里遇到过这个问题:旧代码里有个"发完请求就不管"的fire-and-forget逻辑,迁移时如果直接写成创建Future但不await,请求根本不会发出去。正确做法是用tokio::spawn把它扔到运行时里。这类语义差异,只有把旧代码读透了才能识别出来,光看语法翻译是发现不了的。
4.2 数据共享:从引用传递到所有权设计
TypeScript和Node.js里,对象引用满天飞,大家共享一个对象改来改去是常态。Rust里这套行不通,你必须明确每个数据的归属。迁移时最常见的重构是把"共享可变状态"改成以下几种模式之一:
| 旧代码模式 | Rust对应方案 | 适用场景 | 注意事项 |
|---|---|---|---|
| 单线程共享对象 | Rc<RefCell<T>> | 单线程内多处可变访问 | 运行时借用检查,可能panic |
| 多线程共享对象 | Arc<Mutex<T>> | 跨线程读写 | 注意锁粒度和死锁 |
| 多线程只读共享 | Arc<T> | 配置、常量 | 最轻量,优先考虑 |
| 异步任务间共享 | Arc<tokio::sync::RwLock<T>> | 异步环境读写 | 不能用std的锁 |
| 消息传递 | channel | 生产者消费者 | 避免共享,首选 |
这张表是我踩了无数坑总结出来的。新手最容易犯的错是在异步代码里用std::sync::Mutex,编译能过,但一旦跨await持有锁就会出问题。Agent在读旧代码时,如果识别出某段逻辑是异步环境下的共享状态,就必须提醒用tokio的同步原语。
4.3 错误处理:从异常到Result的完整转换
异常到Result的转换是迁移里最琐碎也最容易出错的部分。旧代码里一个try-catch可能捕获多种异常,每种异常对应不同的恢复策略。迁移到Rust时,你要把这些策略映射到Result的Ok/Err分支,还要决定用什么错误类型。
我的建议是:不要一开始就追求完美的错误类型设计,先用anyhow::Error把所有错误统一起来,保证功能跑通,后期再逐步细化成自定义错误枚举。这个策略的好处是迁移速度快,坏处是错误处理不够精确。但对于80万行这种规模的项目,先跑通再优化是唯一可行的路径。
Agent在读旧代码时,要特别关注异常的传播路径:哪些异常被吞掉了、哪些被转换了、哪些触发了重试。这些行为在迁移后必须保持一致,否则系统的容错特性就变了。
5. 实操流程:从零搭建一个迁移Agent
5.1 环境准备与工具链搭建
先说环境。Rust工具链用rustup装,这是标配。异步运行时用tokio,数据库如果旧系统用的是MySQL,Rust这边推荐sqlx,它支持编译期SQL检查,能提前发现查询错误。构建工具用cargo,跨平台支持很好,Windows、Linux、macOS都能跑。
Agent这边,我建议用TypeScript或Python来写编排逻辑,因为生态成熟、调试方便。核心组件包括:代码解析器(用tree-sitter做多语言AST解析)、向量数据库(存代码片段的embedding,做语义检索)、LLM调用层(负责实际的代码理解和生成)、以及一个状态管理器(跟踪每个模块的迁移进度)。
# Rust环境准备 curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh rustup component add rust-analyzer cargo install sqlx-cli # Agent编排环境(以Node.js为例) npm init -y npm install tree-sitter @tree-sitter/rust openai better-sqlite3工具链搭好后,第一步是让Agent把旧代码库完整索引一遍。这一步可能要跑几个小时,取决于代码量,但只需要跑一次,结果存到数据库里反复用。
5.2 分阶段迁移的执行步骤
我把整个迁移拆成五个阶段,每个阶段都有明确的输入输出和验收标准:
阶段一:索引与地图构建。Agent扫描全部旧代码,生成模块依赖图和功能描述。验收标准是:随便指一个模块,Agent能说出它依赖谁、被谁依赖、大概干什么。
阶段二:模块精读与理解卡片。按优先级逐个精读模块,产出结构化的理解卡片。验收标准是:每张卡片包含接口契约、副作用清单、边界条件、测试覆盖情况。
阶段三:Rust骨架生成。根据理解卡片,先生成Rust的模块骨架——类型定义、函数签名、trait定义,但不填实现。验收标准是:骨架能编译通过(用todo!()占位)。
阶段四:实现填充与单元测试。逐个填充函数实现,同步生成单元测试。验收标准是:单元测试全部通过,且测试用例覆盖了理解卡片里记录的所有边界条件。
阶段五:集成测试与行为校验。跑跨模块的集成测试,对照行为契约清单逐条验证。验收标准是:新旧系统在相同输入下产生相同输出。
这个流程的关键在于每个阶段都有可验证的产出,不会出现"写了一堆代码但不知道对不对"的情况。我在项目里严格执行这个流程后,返工率从最初的40%降到了5%以下。
5.3 关键参数与成本控制
Agent跑起来之后,成本是个绕不开的问题。LLM调用按token计费,80万行代码的迁移,token消耗是天文数字。控制成本的核心手段有三个:
第一,缓存理解结果。同一个模块不要重复读,理解卡片生成后存起来,后续生成代码时直接调用。第二,分级用模型。简单的索引和骨架生成用便宜的小模型,复杂的逻辑理解和实现填充用大模型。第三,批量处理。把多个小模块打包成一批一起处理,减少调用次数。
我实测下来,一个5万行的迁移项目,用这套策略能把成本控制在可接受范围内。80万行的项目规模大很多,但单位成本反而更低,因为索引和缓存复用的比例更高。
6. 常见问题与排查技巧实录
6.1 Agent读代码读偏了怎么办
这是最常见的问题:Agent读着读着,理解出了偏差,生成的代码和旧行为不一致。排查思路是回到理解卡片,逐条核对。如果卡片本身就错了,说明精读阶段出了问题,需要重新读那个模块,并且检查是不是上下文给少了。
我的经验是,读偏通常发生在有隐式依赖的模块上。比如一个函数看起来只依赖入参,实际上还读了全局配置或环境变量。解决办法是在精读阶段强制Agent列出"所有外部依赖",包括全局状态、环境变量、文件系统、网络。这个清单越完整,读偏的概率越低。
6.2 编译过了但行为不对
Rust编译器很严格,能过编译说明类型和所有权没问题,但不代表逻辑对。这类问题的排查要靠行为对比测试:把旧系统的输入输出录下来,作为新系统的测试用例,逐条比对。
我遇到过一个典型案例:旧代码里有个时间戳处理,用的是本地时区,迁移时Agent默认用了UTC,编译完全没问题,但业务逻辑全错了。这种问题只有靠行为对比才能发现。所以我在流程里强制要求:每个模块迁移完,必须跑一遍录制回放测试。
6.3 性能不达标的定位方法
Rust迁移后性能反而下降,这事听起来离谱但确实会发生。常见原因是锁竞争和不必要的克隆。旧代码里共享状态可能很随意,迁移时为了满足借用检查器,Agent可能会大量使用clone(),导致性能崩掉。
定位方法是先用cargo flamegraph生成火焰图,看热点在哪。如果是锁竞争,考虑换更细粒度的锁或者改用channel;如果是克隆,考虑用引用或Arc替代。我在项目里总结的原则是:能用引用就不用克隆,能用Arc就不用Mutex,能用channel就不用共享状态。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 编译报借用错误 | 生命周期设计不当 | 看错误提示的借用冲突点 | 调整所有权或引入Arc |
| 异步任务不执行 | Future未await或未spawn | 检查Future的驱动方式 | 用tokio::spawn |
| 运行时panic | RefCell借用冲突 | 检查借用作用域 | 缩小借用范围或换Mutex |
| 性能下降 | 过度克隆或锁竞争 | flamegraph定位热点 | 减少克隆,细化锁 |
| 行为不一致 | 语义映射错误 | 行为对比测试 | 修正映射逻辑 |
| 死锁 | 锁顺序不一致 | 检查多锁获取顺序 | 统一锁顺序或用单锁 |
这张表是我从多个项目里攒出来的,基本覆盖了Rust迁移中80%的问题。遇到新问题先查表,查不到再深入分析。
7. 我从这个项目里学到的几件事
做完几个类似的迁移项目后,我最大的体会是:AI Agent的能力边界,不在于它能写多少代码,而在于它能理解多少上下文。80万行Rust听起来很唬人,但如果Agent没有花那十倍精力去读代码,这80万行大概率是一堆编译能过但行为不对的废代码。
另一个体会是,"读代码"这件事值得被工程化。以前我们靠资深工程师的经验和直觉来做代码理解,现在可以把它拆解成索引、精读、校验三个可复现的步骤,交给Agent执行。这不意味着工程师不重要了,而是工程师的角色从"读代码的人"变成了"设计读代码流程的人"。
最后分享一个我在实操中反复验证的小技巧:让Agent在读完每个模块后,用自然语言写一段"如果我要给新人讲这个模块,我会怎么说"。这段自然语言描述往往能暴露出理解中的盲区——如果Agent讲不清楚,说明它没真读懂。这个技巧比任何自动化检查都管用,强烈建议你在自己的Agent工作流里加上。