AI驱动的大型代码重构实践:规范先行与自动化验证
1. 项目概述:当AI编码助手遇上大型重构
最近我主导了一个相当硬核的代码库重构项目,整个过程可以说是一次对“规范先行”开发模式与AI编码助手能力的极限压力测试。项目背景是一个拥有71.7万行代码的大型TypeScript单体应用,核心目标听起来简单,做起来却让人头皮发麻:拆除一个横跨189个文件的核心架构不变量,并且在整个过程中,我们没有可用的测试预言机,也没有安排人工代码审查环节。
这听起来有点像在钢丝上跳舞,对吧?没有测试意味着我们无法通过运行测试来验证每次改动是否正确;没有人工审查意味着每一次提交都直接进入主分支,风险极高。但我们恰恰选择在这种“高压”环境下,验证“规范先行”与AI代理协同工作的可行性。所谓“规范先行”,就是在动手写代码之前,先用一种精确的、机器可读的形式化语言(我们用的是TypeScript类型系统和一些自定义的契约)来定义清楚“代码应该做什么”以及“代码结构必须遵守的规则”。然后,我们将这个规范交给AI编码助手(比如基于大型语言模型的智能编程工具),让它来执行具体的代码修改任务,我们的角色则转变为规范的制定者和进度的监督者。
这个案例的核心价值在于,它跳出了“AI辅助写单行代码或单个函数”的范畴,探索了AI在理解并执行架构级意图方面的潜力。当代码变更的影响范围达到189个文件时,任何手动操作都极易出错且效率低下,而传统的重构工具又难以理解如此复杂的语义约束。我们这次实践,就是想看看,一个足够清晰的规范,加上一个足够“聪明”的AI代理,能否在缺乏传统安全网(测试和审查)的情况下,可靠地完成一次深度的、破坏性的架构演进。接下来,我会详细拆解我们是如何设计规范、如何与AI协作、以及在这个过程中踩了哪些坑、总结了哪些经验。
2. 核心挑战与“规范先行”策略设计
2.1 剖析项目面临的三大核心挑战
在深入策略之前,必须彻底理解我们面对的困境,这决定了后续所有技术选型和操作路径。
2.1.1 挑战一:缺乏测试预言机这是最大的风险点。在典型的重构中,测试套件是你的安全网。即使改变了实现,只要测试通过,你就有信心功能未被破坏。但在这个历史悠久的代码库中,许多核心模块的测试覆盖率极低,尤其是涉及这个旧架构不变量的部分,几乎没有可靠的集成测试或单元测试。我们没有一个自动化的、可信的“裁判”来告诉我们AI的修改是否正确。这意味着,正确性的验证必须前置到“规范”的定义中,并且依赖其他形式的验证,比如类型检查、静态分析和非常有限的手动抽查。
2.1.2 挑战二:变更范围巨大且分散需要修改的189个文件并非集中在一个目录下,而是像血管一样遍布整个应用:从后端的领域模型、服务层,到前端的组件、状态管理,甚至是一些构建配置和脚本。这个旧的不变量(例如,一个全局的、单例的配置对象,或者一个特定的类继承层次约束)已经渗透到系统的各个角落。手动查找所有引用点本身就是一项浩大工程,更不用说保证每一处修改都符合新的架构意图。任何遗漏或错误修改都可能导致运行时难以追踪的Bug。
2.1.3 挑战三:零人工代码审查为了极致地测试AI代理的自主性和可靠性,我们决定在此次重构中不引入传统的人工代码审查流程。这并非最佳实践,但在本次实验中是必要的约束条件。它迫使我们将“审查”的职责也编码进“规范”和自动化流水线中。每一次AI提交的代码都必须能通过一系列自动化关卡,这些关卡的设计必须足够严格,以替代人眼的审查。
2.2 “规范先行”的具体内涵与工具选型
面对上述挑战,“拍脑袋”或者给AI一个模糊的指令(如“把那个旧的XXX模式都改掉”)是绝对行不通的。我们必须提供一份机器可执行、无歧义的施工蓝图。
2.2.1 规范的核心构成我们的规范不是一个文档,而是一套组合工具链:
- 类型契约(Type Contracts):这是核心。我们利用TypeScript强大的类型系统,将旧不变量和新架构模式定义为类型。例如,旧模式可能要求某个函数参数必须是
LegacyConfig类型,而新模式要求是ModularConfig。通过全局地查找LegacyConfig类型的所有使用位置,我们就得到了需要修改的文件列表。更进一步,我们可以编写类型级别的“转换规则”,虽然TypeScript本身不执行转换,但可以用于验证转换后的代码是否符合新类型。 - 自定义ESLint规则:对于无法完全用类型表达的代码风格或结构约束,我们编写了自定义的ESLint规则。例如,旧模式可能允许在某些地方使用
any类型来绕过不变量,新模式则禁止。一条自定义规则可以扫描出所有这类“违规”代码,并为AI代理提供具体的修复建议(甚至自动修复)。 - 架构依赖图(Architecture Dependency Graph):我们使用
madge或dependency-cruiser等工具生成了代码库的依赖图,并标注了受不变量影响的模块边界。这帮助AI(和我们自己)理解变更的传播路径,避免在修改时意外破坏模块间的封装。 - 精确的自然语言指令集:这是给AI的“操作规程”。它不仅仅是“做什么”,还包括“怎么做”和“不能怎么做”。例如:“在
./src/modules/目录下,将所有从‘../../core/legacy’导入LegacyService的语句,替换为从‘@new-arch/core’导入{ NewService }。注意:NewService的构造函数需要传入当前模块的ID作为参数,这个ID可以从文件名中提取(规则如下…)。如果遇到LegacyService被用作类型注解,请相应地将类型改为NewServiceInterface。”
2.2.2 为什么选择TypeScript作为规范载体?TypeScript是本项目的原生语言,其类型系统本身就是一种优秀的、渐进的规范语言。它提供了:
- 静态验证能力:
tsc --noEmit可以在不运行代码的情况下发现大量类型不匹配错误,这是在没有测试的情况下最重要的安全阀之一。 - 重构友好性:像“重命名符号”、“查找所有引用”这类IDE功能,在TypeScript中非常可靠,为AI代理提供了精准的操作坐标。
- 表达能力强:泛型、条件类型、模板字面量类型等高级特性,允许我们定义非常复杂的约束。例如,我们可以定义一个类型
ValidatedParam<T>,来确保传入某个函数的参数必须已经过某种验证流程。
注意:定义规范本身是一项高投入的工作。在这个项目中,我们花了大约一周的时间来精确刻画这个旧不变量和期望的新状态,并制作相应的验证工具。这个时间成本必须被考虑在内,但它是一次性的,并且为后续大规模的、自动化的修改铺平了道路,总体效率远高于手动修改189个文件。
3. AI编码代理的选型、配置与协作模式
3.1 代理选型与能力边界评估
市面上AI编码助手很多,从IDE插件到命令行工具。对于这个项目,我们需要的不只是一个代码补全工具,而是一个能够理解复杂任务上下文、执行多步骤文件操作、并遵守严格约束的“智能体”。
3.1.1 我们为何选择基于LLM的CLI代理我们最终选择了一个基于大型语言模型(如GPT-4系列)的命令行接口代理,而不是普通的IDE插件。关键考量如下:
- 项目级上下文感知:CLI代理通常可以接受整个项目或特定目录作为上下文,能够分析文件间的关联。而IDE插件往往更专注于当前编辑的文件。
- 批量操作能力:我们需要代理一次性分析上百个文件,制定修改计划,然后逐个或分批处理。CLI代理在脚本化、批量化任务上更有优势。
- 与自动化流水线集成:CLI代理可以很容易地被集成到CI/CD脚本或我们自定义的Node.js/Python驱动脚本中,形成“规范验证 -> AI代理执行 -> 再次验证”的闭环。
- 可编程的指令注入:我们可以通过系统提示词(System Prompt)和外部知识库(如我们定义的规范文档、类型定义文件),精确地控制代理的行为边界,减少其“自由发挥”可能导致的风险。
3.1.2 明确代理的“能做”与“不能做”在项目开始前,我们必须清醒地认识AI代理的局限性,并据此设计协作流程:
- 它能做:
- 基于我们提供的精确模式和规则,进行代码搜索和替换。
- 理解简单的类型转换逻辑(如将
string类型替换为StringLiteral<T>)。 - 在单个文件或一组相似文件中,保持代码风格一致。
- 生成符合新接口的适配器代码(如果提供了适配器模板)。
- 它不能(或风险很高)做:
- 理解模糊的业务逻辑:如果修改涉及复杂的业务规则变化,AI很可能出错。
- 进行创造性的架构设计:所有新架构的细节必须由我们预先定义好。
- 在没有明确规则的情况下,决定“最佳”修改方式。比如,它不知道两种重构方案中哪个对性能更好。
- 保证修改后的代码在运行时语义100%等价。这是测试预言机的职责,而我们现在没有。
因此,我们的策略是:让AI代理做它擅长的、模式化的、重复性的代码转换工作;而将高层次的决策、验证和风险控制牢牢掌握在自己手中。
3.2 构建人机协作工作流
我们设计了一个迭代的、可监控的协作工作流,而不是“一键执行,等待结果”。
3.2.1 工作流步骤
- 规范输入与任务分片:我们将整个“拆除不变量”任务,按照模块或依赖关系,切割成多个子任务。例如,先处理所有领域实体(Entity),再处理服务层(Service),最后处理UI组件。每个子任务都对应一份独立的、更细致的规范文档和操作指令。
- 代理执行与增量提交:对于每个子任务,我们让AI代理在一个独立的Git分支上操作。代理会先输出一个修改计划(例如,“我将在以下15个文件中,将
X改为Y”),经我们快速扫描确认无重大方向错误后,再允许其执行修改。修改完成后,立即提交。 - 自动化验证关卡:每次提交后,自动触发一个验证流水线,顺序执行:
tsc --noEmit:进行全项目类型检查。eslint . --fix:运行所有ESLint规则(包括自定义规则)。dependency-cruiser --validate:验证架构依赖没有出现违规的新依赖。- 如果有任何关卡失败,流水线会自动终止,并将该分支标记为“需修复”。我们会分析失败原因,是规范有漏洞?还是AI理解有偏差?然后更新规范或指令,让代理重试。
- 有限但关键的手动验证:虽然无全面审查,但在每个关键子任务完成后(例如,完成整个用户模块的重构),我们会进行冒烟测试。即手动启动应用,执行该模块最核心的1-2个用户流程,确保基本功能可用。这作为最后一道、非自动化的安全网。
3.2.3 配置与提示词工程心得与AI代理有效协作,提示词的质量至关重要。我们的经验是:
- 提供上下文,但要有边界:我们会将相关目录的代码摘要、关键的类型定义作为上下文提供给AI。但不会一次性把整个71万行代码库都塞给它。这既受限于上下文长度,也为了避免信息过载导致其注意力分散。
- 指令要具体、可操作、带示例:
- 差指令:“更新所有使用旧配置的地方。”
- 好指令:“在
src/services/目录下,搜索所有调用getGlobalConfig().apiUrl的语句。将其替换为从当前文件的导入项config中获取config.api.endpoint。注意:config对象已在文件顶部从‘@module/config’导入。如果文件顶部没有该导入,请先添加import config from ‘@module/config’;。以下是三个修改示例:[示例1代码块]、[示例2代码块]。”
- 设定角色和约束:在系统提示词中明确告知AI:“你是一个严谨的TypeScript重构专家,必须严格遵守提供的代码规范和风格指南。对于任何不确定的修改,必须优先选择保持原样并输出日志告知,而不是猜测。”
- 利用其“链式思考”:要求AI在做出修改前,先简要说明它发现了什么、准备怎么改、为什么这样改符合规范。这虽然增加了输出长度,但极大地提升了过程的可解释性和我们的监控能力。
实操心得:不要指望一次提示就能完美解决一个复杂子任务。这是一个“对话”过程。AI可能会提出它无法解决的问题,或者做出不符合预期的修改。这时,你需要像调试程序一样“调试”你的指令和提供的上下文。往往问题不在于AI不够聪明,而在于你的规范不够精确,或者你给的示例存在歧义。
4. 分阶段实施与关键环节拆解
4.1 第一阶段:代码分析与影响范围精确测绘
在让AI动任何一行代码之前,我们必须自己先成为这个“旧不变量”的专家。
4.1.1 静态分析工具链组合使用我们使用了多种工具进行交叉验证,确保189个文件的名单没有遗漏:
- TypeScript编译器API:编写一个小脚本,利用TS Compiler API解析整个项目,遍历所有AST节点,查找与旧不变量相关的类型标识符(如特定的接口名、类名、类型别名)的所有引用。这是最权威的来源。
- grep/find + 正则表达式:作为快速验证和补充。例如,查找所有包含特定字符串常量的文件。但这种方法精度低,容易误报和漏报,只能作为辅助。
- 依赖关系分析:使用
dependency-cruiser生成可视化图表,从入口点开始,追踪旧不变量相关模块的传入和传出依赖。这帮助我们理解哪些模块是“源头”,哪些是“叶子”,从而确定重构的先后顺序(通常从叶子模块开始风险更小)。
4.1.2 建立“修改清单”数据库我们将分析结果整理成一个结构化的JSON文件或数据库,每个条目包含:
{ "filePath": "src/modules/user/UserService.ts", "changeType": "import_replacement", "oldCodeSnippet": "import { LegacyAuth } from '../../core/legacy'", "newCodeTemplate": "import { NewAuthProvider } from '@new-arch/auth'", "additionalContext": "该文件中 LegacyAuth 被用作一个类,需要实例化。NewAuthProvider 是单例,应使用其静态方法 `getInstance()`。", "validationRule": "tsc_no_error && eslint_custom_rule_pass" }这份清单成为了AI代理的“任务工单”,也是我们事后验证的检查表。
4.2 第二阶段:逐模块渐进式重构
我们并没有让AI一次性修改所有189个文件,而是采用“分而治之”的策略。
4.2.1 模块隔离与接口适配选择受影响最深的某个相对独立的模块(例如Payment支付模块)作为第一个试点。首先,为该模块创建新的、符合目标架构的接口。然后,编写一个适配层(Adapter),让新接口在内部暂时仍调用旧的、未修改的代码。这样,我们可以先确保新接口的设计是合理的,并且该模块的对外行为没有改变。 接着,我们指导AI代理在这个模块内部,根据规范将旧实现逐步替换为新实现。由于模块外部通过适配器调用,因此模块内部的重构可以相对独立地进行,即使暂时出错,影响范围也被限制在该模块内。
4.2.2 AI代理的微观操作实录以修改一个具体的导入语句为例,我们给AI的指令可能是: “在文件PaymentService.ts中,将第3行的import { LegacyLogger } from ‘../../../shared/logging’替换为import { getLogger } from ‘@new-arch/telemetry’。同时,该文件中所有new LegacyLogger(‘payment’)的实例化语句,需要替换为getLogger(‘payment’)。注意,getLogger返回的是一个已配置好的日志器实例,无需new关键字。” AI代理在执行时,会先进行语法分析,定位到准确的代码位置,然后进行替换。它可能会发现文件中存在多处实例化,需要全部修改。
4.2.3 提交与验证循环每次AI完成一个或一组文件的修改并提交后,自动化流水线立即启动。如果tsc报出新的类型错误(例如,getLogger返回的类型与LegacyLogger不兼容),流水线失败。我们会检查错误信息:是AI改错了,还是我们的新接口设计有问题?如果是前者,我们调整指令;如果是后者,我们回去修改新接口的类型定义。这个过程可能反复多次,直到该模块的所有修改通过验证关卡。
4.3 第三阶段:集成验证与冒烟测试
当一个模块内部重构完成,并且通过了所有静态检查后,我们会移除该模块的适配层,让外部代码直接调用新的实现。此时,进行模块级别的集成验证。
4.3.1 构建与启动测试由于没有单元测试,我们依赖的是:
- 项目构建是否成功:运行
npm run build或tsc --project,确保没有编译错误。这在TypeScript项目中是基础但至关重要的第一步。 - 应用启动是否成功:尝试在开发环境中启动应用,观察控制台是否有该模块相关的运行时错误(如依赖注入失败、未找到模块等)。
- 核心流程手动执行:对于支付模块,我们就手动走一遍“创建订单->支付->回调”的流程。虽然不能覆盖所有边界情况,但能快速发现毁灭性的功能断裂。
4.3.2 监控与回滚机制我们为每个子任务分支都设置了详细的监控。除了流水线状态,我们还引入了简单的代码变更分析:
- 变更行数统计:如果AI某个提交修改了异常多的行数(比如超过200行),我们会自动标记该提交为“高风险”,即使静态检查通过,也会触发一次额外的人工代码快照浏览。
- 回滚点:每完成一个模块的重构并成功集成到主分支(或集成分支)后,就打一个Git Tag,作为稳定的回滚点。这样,如果后续某个模块的重构引发了不可控的问题,我们可以快速回退到上一个稳定状态。
5. 遇到的问题、排查技巧与经验总结
5.1 典型问题与解决方案实录
在整个过程中,我们遇到了各种各样的问题,以下是一些最具代表性的案例及其解决方法。
5.1.1 AI的“过度泛化”与“创造性误解”
- 问题:我们指示AI“将所有对
config.host的引用改为env.API_HOST”。结果AI不仅改了代码,还把项目里一个名为hosting的变量也改成了env.API_HOSTING,甚至修改了注释里的单词“host”。 - 排查:通过代码Diff工具审查AI的提交,发现修改范围超出了预期。问题出在指令使用了简单的文本匹配,而AI的语义理解有时会“过度联想”。
- 解决:优化指令,强调精确匹配。改为:“使用AST语法分析,只修改作为对象属性访问的
config.host(例如config.host,this.config.host),不要修改变量名、字符串字面量或注释中的‘host’字样。请在执行后列出所有被修改的具体位置以供复核。”
5.1.2 类型系统的“漏网之鱼”
- 问题:静态类型检查通过了,但运行时出现
undefined is not a function错误。原因是旧不变量中,某个属性在某些条件下是null,而新类型定义其为string。AI在替换代码时,没有处理这些边界条件。 - 排查:运行时错误堆栈指向了AI修改过的一个文件。通过代码审查和日志分析,发现了一处未进行空值检查的直接属性访问。
- 解决:这暴露了规范的不完整性。我们补充了自定义的ESLint规则,专门用于检测对新类型可能为
null或undefined的值进行不安全访问的情况。同时,在给AI的指令中增加了关于空值安全的明确要求:“在替换访问点后,如果原代码没有空值检查,而新类型可能为空,请使用可选链操作符(?.)或添加空值判断。”
5.1.3 循环依赖与修改顺序死锁
- 问题:模块A依赖模块B中已重构的接口X,模块B又依赖模块A中未重构的接口Y。AI在单独重构任何一个模块时都会因类型错误而失败。
- 排查:依赖分析图清晰地显示了这两个模块间的循环依赖。
- 解决:这是架构问题,AI无法自行解决。我们手动介入,进行了一次小规模的重构来打破循环依赖:要么提取公共部分到第三个模块C,要么使用依赖注入等技术解耦。之后,再将清晰的、无循环的任务指令交给AI。
5.2 有效性评估与核心经验
项目结束后,我们评估了这次实践的效果:
- 效率:实际修改189个文件的核心工作,由AI代理在约40个人工干预小时(主要用于制定规范、调试指令、验证结果)内完成。如果完全由资深工程师手动操作,预计需要2-3周(约80-120小时),且精神疲劳导致的错误率会更高。
- 质量:通过自动化流水线(类型检查、Lint)和有限的冒烟测试,修改后的代码在集成后没有引发严重的、阻断性的线上故障。当然,一些潜在的、深层次的逻辑Bug可能依然存在,这凸显了测试预言机不可替代的价值。
- 可靠性:在规范极其明确、上下文清晰的模式化修改上,AI代理的准确率接近100%。但在涉及些许业务逻辑判断或复杂条件分支时,仍需人工复核。
5.2.1 核心经验总结
- 规范的质量决定一切:AI编码代理是一个强大的执行引擎,但它完全依赖于你提供的“图纸”(规范)。模糊、矛盾或不完整的规范必然导致错误的结果。在让AI工作之前,投入足够时间精炼和验证你的规范,是性价比最高的投资。
- 人依然是架构师和风险控制者:AI擅长执行,不擅长做高层次的架构决策和风险评估。项目的拆分、优先级、关键决策点、安全网的设计,必须由人来把控。不要陷入“全自动”的幻想。
- 验证关卡必须自动化且严格:在没有测试和人工审查的情况下,自动化的静态检查(类型、Lint、依赖规则)就是生命线。这些关卡的严格程度,直接决定了你能否安心地将代码合并。宁可让流水线频繁失败、反复调整,也不能降低标准。
- 从小处开始,快速迭代:选择一个影响范围最小、最容易验证的模块开始试点。快速跑通“规范->AI执行->验证”的完整循环,积累经验,优化流程,建立信心。然后再逐步扩展到更复杂、更核心的模块。
- “规范先行”本身是极佳的架构梳理过程:为了给AI写规范,你被迫要以一种极其精确、无歧义的方式去思考你的架构、接口和约束。这个过程本身就会暴露出原有代码中大量模糊、矛盾的设计,促使你进行更好的架构设计。可以说,收益的一半在“规范”制定阶段就已经获得了。
这次实验让我深刻认识到,AI编码代理在大型、复杂、模式化的代码重构中具有巨大潜力,但它不是银弹。它更像是一个能力超强但需要极其清晰指引的实习生。未来的方向,或许是“规范语言”的进一步发展和标准化,以及AI在理解代码语义和生成验证用例(充当测试预言机)方面能力的突破。目前,将“规范先行”与AI代理结合,已经是应对大规模代码库演进的一件强大而实用的武器。