1. 当AI把80万行Rust代码交到你手上,真正的功课才刚开始
前阵子有个事在圈子里讨论得挺热:一个团队让AI Agent把一整个中型项目的代码库从一种语言迁移到Rust,最后产出了大约80万行代码。消息传开之后,大部分人的第一反应是"AI写代码的速度已经到这个程度了",第二反应是"这代码能跑吗、能维护吗"。但真正让我停下来想了很久的,是另一个细节——这个项目里,团队花在"让AI读懂现有代码"上的精力,大约是花在"让AI写新代码"上的十倍。
这个比例非常反直觉。我们平时聊AI Agent,聊的都是生成、补全、自动修复,好像AI的价值就在于"往外吐代码"。但任何一个真正做过大型代码迁移的人都知道,迁移这件事的难点从来不在写,而在读。你得先搞清楚旧代码里每一个模块在干什么、依赖关系长什么样、哪些是历史包袱、哪些是隐藏的契约,然后才能决定新代码该怎么写。AI再能写,如果读不懂上下文,写出来的东西就是一堆看起来很像样、实际上到处漏风的空壳。
这篇东西我想聊的就是这个:为什么在AI辅助的代码迁移里,"读"的投入应该远大于"写",以及具体怎么把这件事做扎实。关键词里提到了Rust、AI Agent、TypeScript、Node.js、代码迁移,我会围绕这几条线展开,但重点不在某个具体工具的用法,而在整套"让AI读懂代码库"的方法论。如果你正在做或者准备做跨语言迁移,或者你只是好奇AI Agent在真实工程里到底怎么用才不翻车,这篇应该能给你一些能直接抄作业的东西。
先说清楚适用人群:如果你是完全没碰过代码迁移的新手,这篇会让你少走至少半年的弯路;如果你已经做过几次迁移,但每次都被"改完之后到处是bug"折磨,这篇里的排查链路和验证方法应该能帮到你;如果你只是对AI Agent的能力边界感兴趣,那你可以把这篇当成一个真实工程案例来看,看看AI在"理解"这件事上到底能做到什么程度、又在哪里必须靠人兜底。
2. 为什么"读代码"在AI迁移里是十倍投入的那一环
2.1 迁移的本质是语义翻译,不是语法替换
很多人对代码迁移的想象是这样的:把Java的for循环换成Rust的for循环,把TypeScript的interface换成Rust的struct,把Node.js的require换成Rust的use。如果迁移真的只是这种级别的替换,那AI确实可以一天干完80万行。
但真实的迁移完全不是这么回事。我举个最简单的例子:TypeScript里一个函数返回Promise<User | null>,你在Rust里对应写什么?Option<User>?那异步呢?async fn返回Result<Option<User>, Error>?错误类型用什么?原来的null在业务语义上到底代表"没找到"还是"出错了"还是"未初始化"?这些问题,语法替换解决不了,必须回到代码的语义层面去理解。
AI Agent在这件事上的价值,恰恰是它能同时"看"很多文件,把散落在各处的语义线索拼起来。但前提是你得给它拼的素材,也就是让它先把代码读进去。这就是为什么"读"的投入是"写"的十倍——写只是最后一步的输出,读才是整个迁移的认知基础。
2.2 旧代码里的隐性契约,AI不读就一定会漏
我做过一个从Node.js迁移到Rust的项目,规模不算大,大概十几万行。迁移到一半的时候发现一个诡异的问题:某个接口在旧代码里偶尔会返回一个空数组,新代码里对应返回了None,结果下游一个统计模块直接panic了。
查了半天才发现,旧代码里那个空数组其实是一个隐性契约——下游模块约定"空数组代表这个时间段没有数据,但统计逻辑仍然要跑一遍初始化"。这个约定没有写在任何文档里,也没有类型能表达,它只存在于两处代码的配合关系里。AI如果只读单个文件,永远发现不了这种契约;只有让它把调用方和被调用方一起读,把数据流串起来,它才能识别出"这里返回空数组不是bug,是feature"。
这就是"读"的核心价值:把隐性契约显性化。而隐性契约的数量,在任何一个存活超过两年的代码库里,都远远超过你的想象。我粗略估过,一个中等复杂度的业务系统,显性接口契约可能有几百个,隐性契约至少是这个数的三到五倍。AI写代码的时候如果不知道这些契约,写出来的东西就是定时炸弹。
2.3 AI Agent的"读"和人的"读"不是一回事
这里要澄清一个误区。很多人觉得"让AI读代码"就是把代码喂给模型,然后问它"这段代码干什么的"。这是最浅层的读,效果也最差。AI Agent的"读"应该是一个主动的、结构化的过程,我把它拆成三层:
第一层是结构层:模块划分、依赖关系、调用链路。这一层AI做得比人快,因为它可以瞬间遍历几千个文件,画出完整的依赖图。但你要给它正确的工具,比如让它用cargo metadata或者自己写脚本解析AST,而不是让它凭记忆瞎猜。
第二层是语义层:每个函数、每个类型到底在业务上代表什么。这一层AI需要人的引导,因为业务语义往往不在代码里,而在需求文档、注释、甚至提交历史里。你得把这些上下文一起喂给它。
第三层是意图层:这段代码为什么这么写?是为了性能?为了兼容某个历史版本?还是单纯因为当年写的人偷懒?这一层最难,AI基本只能靠推断,而且经常推断错。我的做法是,意图层的东西必须由人来标注,AI只负责把标注和代码对应起来。
三层读下来,你会发现"读"的工作量确实远大于"写"。但正是这三层读透了,后面写的时候才能一气呵成,不用反复返工。
3. 把代码库喂给AI之前,先做这四步预处理
3.1 第一步:用依赖分析工具画出真实的模块边界
在让AI读任何代码之前,我做的第一件事永远是跑一遍依赖分析。以Node.js项目为例,我会用madge或者dependency-cruiser生成完整的依赖图,然后重点看三样东西:循环依赖、跨层依赖、以及那些被大量引用的"事实上的核心模块"。
为什么这一步不能省?因为AI读代码是按文件读的,它不知道哪些文件重要、哪些是边缘的。如果你直接把几万个文件丢给它,它会平均用力,结果就是核心模块读得不够深,边缘模块浪费了大量token。有了依赖图,你就可以按重要性排序,让AI优先读那些被引用最多的模块。
我通常会把依赖分析的结果整理成一张表,标注每个模块的入度(被多少模块依赖)、出度(依赖多少模块)、以及是否在循环依赖里。入度高的模块就是迁移的重中之重,必须让AI读透;出度高的模块往往是工具类,迁移相对独立;循环依赖则是必须优先打破的,否则迁移顺序没法排。
3.2 第二步:用类型信息给AI搭骨架
TypeScript项目有一个天然优势:类型信息丰富。但很多人不知道怎么用。我的做法是,先用tsc --declaration把所有类型声明导出来,然后让AI基于这些声明去理解每个模块的对外契约。
这里有个技巧:不要一次性把所有类型都喂给AI,而是按模块分组。每个模块的类型声明单独成组,让AI先读这个模块的类型,再读实现。这样它读实现的时候,脑子里已经有了"这个模块对外承诺了什么"的框架,理解起来会准得多。
对于没有类型信息的旧代码(比如纯JavaScript),我会先让AI根据使用情况反推类型。具体做法是:找出所有调用这个函数的地方,看调用方传了什么参数、怎么用返回值,然后让AI归纳出一个类型签名。这个过程很费时间,但一旦做完,后面迁移的时候就是照着类型写,效率极高。
3.3 第三步:把测试用例当成行为规格说明书
旧代码里的测试用例,是AI理解代码行为最宝贵的素材。因为测试用例是唯一一种"既描述了输入输出、又描述了边界条件"的文档。我通常会让AI先读测试,再读实现,而且要求它回答一个问题:这个测试到底在验证什么行为?
举个例子,一个函数有十个测试用例,其中八个测正常路径,两个测异常路径。AI读完这十个用例之后,应该能总结出"这个函数在什么情况下返回什么、在什么情况下抛什么错"。这个总结就是迁移时的行为规格。新代码写完之后,把旧测试改写成Rust测试,跑一遍,行为对上了,迁移才算基本成功。
注意:如果旧代码没有测试,或者测试覆盖率很低,那你在迁移前必须先补测试。没有测试的迁移就是盲人摸象,AI写得再快也没用。
3.4 第四步:标注"不要动"的区域
这一步最容易被忽略,但极其重要。任何存活一段时间的代码库里,都有一部分是"看起来可以优化、但实际上不能动"的。可能是为了兼容某个老版本客户端,可能是绕过了某个底层库的bug,可能是性能敏感的热点路径。
我的做法是,在预处理阶段就让团队里的人把这些区域标出来,写成一份"禁区清单"。然后让AI读代码的时候,明确告诉它哪些区域是禁区,迁移时保持原样或者只做最小改动。没有这份清单,AI会"好心办坏事",把那些看似冗余的代码优化掉,结果引入一堆难以排查的问题。
4. 让AI真正读懂Rust目标形态的三个关键动作
4.1 先让AI读目标语言的惯用法,再让它写
这是我在踩了无数次坑之后总结出来的最重要的一条。如果你直接让AI把TypeScript翻译成Rust,它会写出"用Rust语法写的TypeScript"——到处是Rc<RefCell<T>>、到处是clone()、错误处理用unwrap()、异步用block_on。代码能跑,但完全不是Rust的味道,性能和维护性都很差。
正确的做法是,在迁移开始之前,先让AI读一批高质量的Rust代码,建立对Rust惯用法的认知。读什么?我通常选三类:标准库的核心模块源码、一个同领域的知名开源项目、以及团队自己写的Rust代码(如果有的话)。让AI读完之后,总结出这个领域的Rust代码通常怎么组织、怎么处理错误、怎么管理生命周期。
这个"读目标语言"的步骤,我一般会花整个迁移周期的百分之十左右。看起来是额外投入,但它能让后面百分之九十的写代码工作质量提升一个档次。AI有了Rust的"语感"之后,写出来的代码会自然地用Result而不是panic,用迭代器而不是手写循环,用所有权而不是到处clone。
4.2 用"对照阅读"建立迁移映射表
具体到每个模块的迁移,我用的方法是"对照阅读":让AI同时读旧代码和新代码的骨架,然后生成一张映射表。这张表里,每一行是旧代码里的一个概念,对应新代码里的一个概念,以及迁移时需要注意的事项。
比如旧代码里有一个UserService类,有getUser、updateUser、deleteUser三个方法。映射表里就会写:UserService对应Rust里的UserRepositorytrait,getUser对应async fn get_user(&self, id: UserId) -> Result<Option<User>, UserError>,注意事项是"旧代码里getUser返回null表示未找到,新代码用Option表达,但调用方需要区分未找到和出错"。
这张映射表是迁移的施工图。有了它,写代码的时候就是按图索骥,不用每次重新思考。而且这张表本身就是最好的文档,迁移完成后留给团队,后面维护的人一看就懂。
4.3 让AI自己解释"为什么这样迁移"
每迁移完一个模块,我会让AI写一段说明:这个模块在旧代码里是怎么工作的、新代码里是怎么实现的、为什么选择这种实现方式、有哪些地方和旧代码行为不完全一致。
这个动作有两个好处。一是强迫AI把理解显性化,如果它解释不清楚,说明它没真读懂,那就得回去重读。二是这些说明积累起来,就是一份高质量的迁移文档,比事后补写的文档准确得多。
我印象很深的一次,AI在解释一个模块时写道:"旧代码里这个缓存没有过期时间,是因为上游数据每天凌晨全量刷新,所以缓存可以一直用到第二天。新代码里我加了过期时间,因为Rust的缓存库默认需要TTL,但我把TTL设成了24小时,行为和旧代码一致。"这段解释让我发现了一个我原本没注意到的隐性契约,也让我确认了AI确实读懂了。
5. 迁移过程中最容易翻车的五个地方
5.1 错误处理的语义漂移
这是最高频的问题。旧语言里的错误处理往往很随意,比如JavaScript里一个函数可能返回null、可能抛异常、可能返回undefined,调用方靠if (!result)一把抓。迁移到Rust之后,Option和Result是严格区分的,如果你不仔细对应,就会出现"该报错的地方返回了None,该返回None的地方panic了"。
我的应对方法是,在映射表里专门加一列"错误语义",逐个函数标注旧代码里的错误情况对应新代码里的哪种类型。这个工作很枯燥,但省不得。我见过一个项目因为没做这一步,迁移后线上出了十几次空指针panic,最后不得不回滚重做。
5.2 异步运行时的行为差异
Node.js的事件循环和Rust的async运行时(比如tokio)在行为上有微妙但重要的差异。比如Node.js里一个没被await的Promise会继续执行,而Rust里一个没被spawn的future根本不会跑。再比如Node.js的setTimeout和tokio的sleep在精度和调度顺序上也不一样。
这些差异在单元测试里往往看不出来,一到生产环境高并发的时候就暴露。我的做法是,在迁移异步代码时,专门写一批并发测试,模拟高负载场景,对比新旧代码的行为。另外,所有涉及超时、重试、取消的逻辑,都要单独review,确保语义一致。
5.3 字符串和编码的坑
TypeScript的字符串是UTF-16,Rust的String是UTF-8。大部分时候没问题,但一旦涉及字符串索引、切片、长度计算,就会出问题。比如旧代码里str.length返回的是UTF-16码元数,Rust里str.len()返回的是字节数,对于一个包含中文的字符串,这两个值完全不同。
我在迁移时养成了一个习惯:所有涉及字符串长度、索引、切片的代码,全部标红,逐个检查。能用chars().count()的地方就用,不能用地方就明确注释"这里依赖字节长度"。这个习惯帮我避免了好几个隐蔽的bug。
5.4 依赖库的能力不对等
旧项目用的某个npm包,在Rust里可能没有完全对应的crate。这时候AI往往会找一个"差不多"的库替代,但"差不多"往往意味着某些边界行为不一致。比如日期时间处理,JavaScript的Date和Rust的chrono在时区、夏令时、闰秒上的处理就不完全一样。
我的做法是,对于每一个替换的依赖库,都让AI列出一份"行为差异清单",然后针对这些差异写测试。如果差异太大,宁可自己写一个薄封装,也不要用一个行为不一致的库硬凑。
5.5 AI的"自信错误"
这是最危险的一类问题。AI有时候会非常自信地写出一个错误的迁移,而且解释得头头是道。比如它可能把旧代码里一个有副作用的函数当成纯函数迁移,或者在处理生命周期时想当然地加了一个'static,编译能过但语义错了。
对付这个问题,我的经验是:永远不要相信AI对"这段代码干什么"的解释,只相信它给出的证据。它说这个函数是纯函数,那就让它指出所有调用点,确认没有副作用;它说这个生命周期没问题,那就让它写出具体的借用场景。没有证据的解释,一律当作猜测处理。
6. 迁移完成后的验证:怎么确认80万行代码真的能用
6.1 行为对拍:新旧代码跑同一批输入
迁移完成后,我做的第一件事是行为对拍。具体做法是:把旧代码和新代码都跑起来,喂同一批输入,对比输出。输入要覆盖正常路径、边界条件、异常情况。输出要逐字节对比,不能只看"大概对"。
对于有状态的服务,对拍会复杂一些,需要把状态也纳入对比。我的做法是,在关键状态变更点打日志,对比新旧代码的状态变更序列。如果序列一致,基本可以确认行为一致。
这个对拍过程,我通常会跑至少三轮:第一轮用单元测试的输入,第二轮用生产环境的采样数据,第三轮用专门构造的极端输入。三轮都过了,才敢说迁移基本成功。
6.2 性能基准:Rust不该比Node.js慢
迁移到Rust的一个主要动机就是性能。如果迁移后性能反而下降了,那说明迁移方式有问题。我会在迁移前后各跑一套基准测试,对比关键路径的延迟和吞吐。
这里要注意,Rust的性能优势不是自动获得的。如果AI写出来的代码到处clone、到处Arc<Mutex>、异步用得一塌糊涂,性能可能还不如Node.js。所以基准测试不只是验证,也是发现迁移质量问题的手段。哪个模块性能不达标,就回去看那个模块的迁移代码,通常能找到明显的优化点。
6.3 内存和并发的压力测试
Rust的内存安全是编译期保证的,但内存泄漏和并发死锁仍然是运行期问题。我会用valgrind或者heaptrack做内存分析,用loom做并发模型的验证,再配合长时间的压力测试,观察内存增长和线程状态。
这一步在Node.js迁移里尤其重要,因为Node.js是单线程事件循环,很多并发问题在旧代码里根本不存在,迁移到Rust的多线程模型之后才会暴露。我见过一个项目迁移后出现死锁,查了一周才发现是旧代码里一个"反正单线程不会出问题"的共享状态,在多线程下成了死锁源头。
6.4 灰度发布和回滚预案
不管验证做得多充分,上线时我都会走灰度。先切百分之一的流量到新代码,观察错误率、延迟、资源使用,确认没问题再逐步放大。同时准备好回滚预案,一旦出问题能快速切回旧代码。
灰度期间要重点监控那些"行为对拍没覆盖到"的场景,比如真实的用户输入、真实的网络条件、真实的并发模式。这些场景往往能暴露出测试环境发现不了的问题。
7. 这套方法迁移到其他语言对也成立吗
7.1 从Java到Go:读的重点在并发模型
Java到Go的迁移,语法层面的差异不大,真正的难点在并发模型。Java的线程池、Future、CompletableFuture,对应到Go的goroutine、channel、context,语义差异很大。这时候"读代码"的重点就要放在并发逻辑上:哪些操作是原子的、哪些共享状态需要保护、哪些阻塞调用需要改成非阻塞。
我的做法是,在预处理阶段专门做一份"并发地图",标注每个模块的并发模型、共享状态、同步机制。然后让AI基于这份地图去读代码,理解每个并发原语在业务上的作用。
7.2 从Python到Rust:读的重点在动态特性
Python到Rust的迁移,最大的挑战是Python的动态特性:鸭子类型、猴子补丁、元类、运行时反射。这些在Rust里都没有直接对应。AI读代码的时候,必须识别出哪些地方依赖了动态特性,然后决定怎么在Rust里用trait、泛型、宏来替代。
我通常会先让AI做一份"动态特性清单",列出所有依赖动态行为的地方,然后逐个设计替代方案。这个过程很费脑子,但比迁移到一半发现某个核心模块没法用静态类型表达要好得多。
7.3 从C++到Rust:读的重点在所有权
C++到Rust的迁移,表面上看最接近,实际上最微妙。因为C++的程序员习惯了手动管理内存,而Rust的所有权系统要求你重新思考每一个资源的生命周期。AI读C++代码的时候,必须理解每个指针的所有权语义:谁拥有、谁借用、什么时候释放。
我的经验是,C++迁移到Rust,读代码的时间要占到整个项目的百分之七十以上。因为所有权设计一旦错了,后面全是返工。我通常会让AI先画出一份"所有权图",标注每个资源的生命周期,然后再开始写Rust代码。
8. 我在这个项目里踩过的三个具体坑
第一个坑是过度信任AI的依赖分析。项目初期我让AI自己分析模块依赖,它给出的依赖图和实际差了很多,因为它只看了import语句,没看动态require和运行时注入。后来我改用工具生成依赖图,再让AI基于工具的输出做语义分析,准确率才上来。这件事的教训是:AI擅长语义理解,不擅长精确的静态分析,两者要分工。
第二个坑是一次性迁移太大。我们一开始想按模块迁移,但模块之间的耦合比预想的大,迁到一半发现新旧代码必须共存,接口对不上。后来改成按"垂直切片"迁移,每次迁移一个完整的业务功能,从入口到存储全部换掉,新旧代码通过API边界隔离。这样虽然单次迁移量大,但边界清晰,不容易出问题。
第三个坑是忽略了构建系统。Node.js的构建和Rust的构建差异很大,我们迁移到后期才发现CI流程要重写,依赖缓存策略要调整,交叉编译要配置。这些工作如果提前规划,可以并行做,不占关键路径。后来我把构建系统的迁移提前到项目初期,整个节奏就顺了。
9. 给准备做AI辅助迁移的人几句实在话
如果你问我这个项目最大的收获是什么,我会说:AI在代码迁移里的价值,不在于它能写多少行,而在于它能读多少上下文。80万行代码听起来很吓人,但如果这80万行是在充分理解旧代码的基础上写出来的,那它就是资产;如果是在没读懂的情况下硬生成的,那它就是负债。
具体到操作上,我的建议是:把整个项目的时间预算重新分配一下。如果你原本打算花百分之二十的时间读代码、百分之八十的时间写代码,那把它反过来。读代码的那百分之八十里,又有一半要花在"让AI把读懂的东西表达出来"上——写映射表、写迁移说明、写行为规格。这些文档看起来是额外工作,实际上是迁移质量的保证。
还有一点:不要指望AI一次读对。我通常会让AI对同一个模块读三遍,第一遍读结构,第二遍读语义,第三遍读意图。每遍读完之后让它写总结,三遍总结对上了,才算真读懂。这个过程很慢,但比写完再返工快得多。
最后分享一个我一直在用的小技巧:让AI在迁移每个模块之前,先写一段"如果我是旧代码的作者,我为什么这么写"的推测。这段推测不需要准确,它的作用是强迫AI从作者视角去理解代码,而不是从翻译视角去替换代码。实测下来,这个动作能让迁移质量明显提升,尤其是对那些写得很"绕"的历史代码。
代码迁移这件事,说到底是一场理解力的较量。AI给了我们前所未有的理解工具,但理解本身仍然需要投入。那十倍的精力,花得值。