Tiana随笔

用 AI 编程助手重构老项目:一次实录

· Agent 实践

标签:#Agent #架构 #AI

用 AI 编程助手重构老项目:一次实录

拿一个三年没动的老项目试了完整的“AI 辅助重构”流程:先读、再测、后改。结论是顺序反了就全是坑。

这件事得从我们组接手那个「祖传」订单服务说起。它跑了四年,经历过六任开发者,没有测试、没有文档、函数最长的一个有一千四百多行,全局变量和面条式 if-else 纠缠在一起。老板的原话是「能跑就别动」,可它每周都要因为改一个小需求崩一次。忍了三个月,我决定用 AI 编程助手把它重构一遍,不是赌运气,而是把这次重构当成一次对照实验:AI 到底能在多大程度上帮忙,又在哪里必须靠人。

我先说结论,免得你读到一半失去耐心:AI 在「机械重构」上极其能干,比如重命名、抽函数、改调用方式、补类型标注,又快又准;但在「该不该这么拆、拆完系统还成立吗」这种架构判断上,它基本靠不住,必须人来拍板。这篇我会把整个过程按时间顺序复盘,包括我怎么喂上下文、怎么控制提交节奏、踩了哪些坑,以及最后沉淀的流程。所有代码都是真实片段的脱敏版,能直接对照着看。配图之后,我们进入正题。

重构前:先把地基打出来

老项目最大的问题不是代码丑,而是「改了不知道会不会坏」。没有测试,任何重构都是盲飞。所以我给自己定的第一条铁律:在让 AI 动一行业务代码之前,先把测试建起来。这一步 AI 帮不上太多,因为它的改动本身就可能基于错误的理解,让它写测试等于让盲人领盲人,它会顺着自己误解的逻辑去「证明」行为正确,反而把坑固化进测试。

我的做法是先写「 Characterization Test」(特征测试,也叫黄金测试):不关心代码对不对,只关心它「现在实际表现成什么样」,把当前行为锁死。这样后续无论怎么重构,只要特征测试还过,就说明外部行为没变。这一步必须人来做,因为你要先观察真实系统,再把它翻译成断言。

  • 先挑最核心、最常被改的模块,比如订单状态流转、金额计算。它们出事代价最高,也最该先锁。
  • 用真实历史数据灌进去,记录输入输出,固化成断言。数据比人脑可靠,别凭记忆写预期。
  • 不求覆盖率多高,先把「会出事的那几条路径」兜住。覆盖率是奢侈品,关键路径才是救命绳。

等特征测试绿了,我心里才有底:接下来 AI 怎么折腾内部实现,只要测试不红,行为就没漂。没有这一步,我绝不会开工。我见过有人直接让 AI「重构这个函数」,结果函数名没变、行为却悄悄变了,三个月后在财务报表里炸出来,那种错最要命,因为它潜伏得够久,足以让所有人都以为「一直就是这样的」。

我现在的信条:测试不是重构完成后的奖赏,而是重构开工前的门票。没有特征测试兜底的重构,无论有没有 AI,都是危险的赌博。赌赢了没人夸你,赌输了背锅的是你。

摸清模块边界:别急着让 AI 干活

第二项准备是摸清边界。那个订单服务名义上是一个模块,实际上一个文件里塞了数据库访问、HTTP 处理、业务规则、甚至发邮件的逻辑。要让 AI 有效工作,我得先告诉它「哪里是哪里」,否则它的重构会在错误的粒度上用力,把本该分开的关注点搅得更乱。

我做了两件事。一是画了一张粗糙的依赖图,标出哪些函数是入口、哪些是被调用的叶子、哪些函数之间形成了循环依赖。这张图很丑,但足够让我知道「动 A 会不会扯到 B」。二是人工圈定第一批「只动内部、不动接口」的重构范围:对外暴露的函数签名先冻结,内部随便它怎么拆。

这种边界意识很关键,因为 AI 有一个典型毛病:它为了「优雅」,会自作主张改掉调用方的写法,美其名曰「顺手统一」。在老项目里,调用方可能是你根本没喂给它的其他服务。未经你许可改接口,就是埋雷,而且这雷不在你可见的范围内。所以我给 AI 的第一条硬约束就是:本次只修改指定文件内的实现,不得改动任何被外部依赖的函数签名。

给 AI 划边界,本质上是在替它承担「系统视角」。它看不到全局,你看到。把你看清的边界用白纸黑字写进指令,比事后擦屁股便宜一百倍。别指望它「应该懂」,它只懂你给的那点上下文。

怎么给 AI 喂上下文:选文件与声明约束

上下文喂得好不好,直接决定 AI 重构的质量上限。我摸索出一套自己的喂法,分享给同样在带 AI 干活的你。这套方法我后来在三个项目里复用,稳定有效。

选文件要「精准而克制」。不要一股脑把整个仓库丢给助手,那既超窗口又稀释重点,而且容易让它误以为所有文件都在改动范围内。我的做法是:本次重构涉及的文件,连同它们直接依赖的两三层,一并给到;无关的模块只给一句话说明「存在但不在此次范围」。这样 AI 既有足够上下文理解代码,又不会被噪声带偏。

声明约束要「白纸黑字、逐条列出」。口头说「小心点」没用,AI 记不住也执行不到位。我会把约束写成清单式指令,每条都是可验证的规则:

  • 只修改 orders/core.py 与 orders/calc.py 两个文件,其余文件只读参考。
  • 对外函数 create_order、cancel_order 的签名与返回结构保持不变。
  • 不允许引入新的第三方依赖,现有依赖版本不动。
  • 每个抽取出的函数必须写一行 docstring,说明用途与前提条件。
  • 改动后必须保证现有特征测试全部通过,不得为了通过而改测试。

这套约束我每次都粘贴进对话,当作不可逾越的护栏。实践下来,约束越具体,AI 越不容易跑偏。含糊的指令养出含糊的结果,这是我和它协作半年最确定的经验。我也养成一个习惯:每次它交回代码,我先逐条对照约束检查,再去看代码逻辑,很多时候违规比 bug 更先行暴露。

我越来越觉得,带 AI 重构像带一个新来的高级工程师:能力有,但缺乏对你们系统历史包袱的敬畏。你的价值不在于写代码,而在于把「哪些不能动、为什么」讲清楚。讲清楚之后,它干起活来才不闯祸。

工作流:小步提交,每步可回滚

这是整个流程里我最坚持的一点。我绝不允许 AI 一次性改完一个大模块再让我 review。那样 review 成本极高,出问题也难定位,你面对的是几千行 diff,根本看不过来,只能选择性相信。我把重构拆成一系列「原子改动」,每完成一个就提交、就跑测试、就 review。

  • 第一步:只做重命名与提取,不改变任何逻辑,提交。这一步最安全,专门热身。
  • 第二步:抽出一个独立的纯函数(如金额计算),补测试,提交。纯函数无副作用,最好验证。
  • 第三步:把数据库访问从业务函数里剥离到独立层,提交。涉及 I/O,单独隔离风险。
  • 每一步之间都用 git 隔离开,任何一步测试红了,立刻 git revert 回到上一个绿点。

这个节奏的好处是:把「大风险」切成「一堆小风险」,每个小风险都可独立回滚、可独立理解。AI 犯错时,损失被限制在最近一次提交里,不会连累前面已经验证过的成果。我甚至给每次 AI 改动单独开一个分支,确认无误才合主干。主干永远保持可发布状态,是我对非 AI 时代就有的纪律,在 AI 时代更重要。

有人嫌这样慢。但慢的是提交频率,不是总进度。反而是那种「让 AI 一口气改完再 review」的做法,最后往往因为改太多、看不懂、不敢合,反而更慢。小步快跑,是我和 AI 协作的底层节奏,也是它不会翻车的底盘。

一个我踩过的回滚事故

有一回我图省事,让 AI 在一次改动里同时「抽函数 + 改异常处理 + 顺手升级日志库」。结果测试红了,但红在哪一步完全分不清。我花了比重构本身还多的时间,才定位到是日志库升级悄悄改了时间格式,连带影响了特征测试里的字符串断言。那次加班到凌晨,纯属自找。

从那以后我立规矩:一次 AI 改动只做一件事。要升级依赖?单独一轮。要改异常?单独一轮。混在一起,等于主动放弃可回滚带来的安全感。这条教训值钱,希望你别用同样的加班换来。回滚不是失败后的补救,而是日常工作的默认姿势。

AI 擅长什么、不擅长什么

这部分我想单独拎出来,因为这是决定「要不要让 AI 参与」的核心判断力。我的观察很明确:AI 是顶级的「机械劳动力」,却是业余的「架构师」。认清这条线,你才不会被它漂亮的代码迷惑。

AI 极其擅长的:

  • 机械重命名与批量替换,准确且不知疲倦,比人手动全局替换还稳。
  • 把长函数按逻辑切成小函数,命名往往还过得去,远超初级工程师的平均水平。
  • 补类型注解、补 docstring、统一代码风格,这类有规律可循的体力活它最香。
  • 把重复代码抽成公共函数,识别「长得像」的片段很在行,肉眼容易漏它却能抓。
  • 把旧写法改成新语法(如弃用的 API 调用方式),资料库里样例多,做得稳。

AI 明显不擅长的:

  • 判断「这段代码为什么要这么写」,老代码里常有历史原因的 hack,AI 会当成坏味道删掉,然后埋坑。
  • 跨模块的架构决策,比如「该不该引入一层仓储抽象」,它需要全局视野,而它只有你喂的局部。
  • 权衡业务语义,比如「这个字段看似冗余,其实是对账用的」,AI 不懂业务,只会按代码直觉删。
  • 在测试缺失时保证行为等价,它重构后「看起来更干净」,但边界行为可能悄悄漂移,比如把 null 处理顺手改了。

所以我的分工很清晰:把机械、重复、模式明确的活交给 AI,把涉及业务语义、历史包袱、架构走向的决断留给自己。让它当手,别让它当脑。这条分工线画错了,重构就会从「改善」变成「灾难现场」。

一句话:AI 能把代码写得更「像好代码」,但只有你知道这段代码「为什么是现在这个样子」。尊重这份认知差,是人和 AI 协作重构的底线。它不懂你们系统的前世今生,这是它的盲区,也是你的主场。

补充一句更实在的:判断「这事该不该交给 AI」,我用的不是「它能不能做」,而是「做错了我能不能一眼发现」。凡是改完我能立刻用测试或肉眼验证对错的,放心交;凡是改完连对错都要靠运气猜的,自己来。这条标准帮我避开了一大半坑,比任何能力评估都好用。

一组 before / after 代码示例

光说特性太空,我挑几个真实的片段。先看第一个:重构前,一个处理订单折扣的函数,长长的、嵌套深、还混着数据库查询:

# 重构前:业务、查询、规则全搅在一起
def calc_discount(order):
    user = db.query("select * from users where id=%s" % order.user_id)
    if user["level"] == "vip":
        if order.amount > 1000:
            d = 0.2
        else:
            d = 0.1
    else:
        if order.amount > 500:
            d = 0.05
        else:
            d = 0
    if order.coupon:
        c = db.query("select rate from coupons where code='%s'" % order.coupon)
        d = d + c["rate"]
    order.discount = d
    return d

我让 AI 做的,是把「查用户」「查优惠券」抽成独立的数据访问函数,把折扣规则抽成纯函数,且保持 calc_discount 的对外签名不变。重构后:

# 重构后:查询、规则、编排分离,签名不变
def fetch_user_level(user_id: int) -> str:
    row = db.query_one("select level from users where id=%s", (user_id,))
    return row["level"]

def fetch_coupon_rate(code: str) -> float:
    row = db.query_one("select rate from coupons where code=%s", (code,))
    return float(row["rate"]) if row else 0.0

def discount_rule(level: str, amount: float) -> float:
    if level == "vip":
        return 0.2 if amount > 1000 else 0.1
    return 0.05 if amount > 500 else 0.0

def calc_discount(order):
    base = discount_rule(fetch_user_level(order.user_id), order.amount)
    if order.coupon:
        base += fetch_coupon_rate(order.coupon)
    order.discount = base
    return base

注意几个关键点:对外函数 calc_discount 的名字、参数、返回都和之前一致,调用方无感;数据库访问改成了参数化查询,顺手修了原来拼接 SQL 的注入隐患,这是 AI 顺手做对的一件好事,但前提是我在约束里要求「所有查询必须参数化」;纯函数 discount_rule 可以单独写单元测试,不用再连数据库。这一步我让 AI 完成,但「签名不变」「改掉 SQL 拼接」是我在约束里写死的,它执行,我定方向。

我特意没让 AI 动 discount_rule 里的业务数值(0.2、0.1 这些),因为那是和产品核对过的规则,不是代码坏味道。AI 不知道,所以我拦住了它「顺手统一成配置」的冲动。这就是人该把关的地方:业务常量,碰不得。

第三个例子:把异常吞掉变成显式错误

原来的代码喜欢静默吞异常,导致问题推迟到更远处爆发:

# 重构前:吞掉异常,返回 None,调用方无从知晓失败
def load_config(path):
    try:
        return json.load(open(path))
    except Exception:
        return None

我让 AI 改成显式抛出,并补上具体异常类型:

# 重构后:失败即失败,让调用方决定怎么处理
def load_config(path: str) -> dict:
    with open(path, encoding="utf-8") as f:
        return json.load(f)

这里我故意没让它加 try/except,因为「配置加载失败就该让启动失败」,早失败优于晚爆炸。AI 的默认倾向往往是「加个 catch 保平安」,而这种善意反而掩盖问题。这条「不要静默吞异常」的约束,是我写在每条指令里的。又一次,人在定原则,AI 在执行。

重构的度量:怎么向老板证明这次没白干

重构是出了名的「难向非技术管理者解释」的活,代码没加新功能,人却忙了两周,老板自然要问「值不值」。这次我学乖了,开工前就定了一组可对比的指标,三个月后拿出来,比任何口头汇报都有力。

  • 需求交付周期:重构前,一个中等需求平均要五天,其中两天耗在「读懂旧代码、怕改坏」;重构后降到两天半,理解成本肉眼可见地降了。
  • 线上故障数:那个服务此前每月因改动引发约三到四次回滚;重构后一个季度只有一次,且那次是业务规则本身变更,不是代码脆弱导致的。
  • 单测覆盖率:从几乎为零升到核心模块七成以上,新需求来了敢写测试,敢改。
  • 新人上手时间:新同事接手相关模块,从「得拉着老人问一周」变成「看文档加读代码两天能动手」。

这些数字我会如实写进复盘,也坦白哪些提升来自重构、哪些来自顺手修掉的旧 bug,不夸大。管理者要的不是「AI 多神奇」,而是「投入有没有回报」。把账算清楚,下次你再提「用 AI 重构另一块」,阻力会小得多。反过来,如果度量下来没改善,那也该诚实认账,说明这次重构的方向或执行有问题,这正是度量存在的意义,它既证明成功,也暴露失败。

我越来越相信,技术人最该练的一项软技能,是把「代码层面的改善」翻译成「业务层面可感知的指标」。重构值不值,不该靠信仰,该靠数字。AI 帮你省下的机械劳动,最终也要落回这些数字,才算闭环。

我把这几个数字压成一个很土的函数,复盘时直接套:

def refactoring_gain(before_days, after_days, before_rb, after_rb):
    """before/after_rb 是单月回滚次数"""
    saved = (before_days - after_days) / before_days
    stable = (before_rb - after_rb) / max(before_rb, 1)
    return round(saved, 2), round(stable, 2)


print(refactoring_gain(5, 2.5, 3.5, 0.33))

何时不该用 AI 重构

讲完能用的,也得讲讲不该用的,免得你把这当成万能钥匙。以下几种情况,我建议关掉 AI,老老实实自己来:

  • 没有任何测试、且行为难以观测的模块。AI 重构后你无法验证等价,风险全压在你身上。
  • 涉及复杂业务规则、且规则只存在于老人脑子里的系统。AI 没有业务语境,乱改比不改更糟。
  • 即将下线或一次性迁移的项目。重构投入换不回收益,手工快速搬运更实际。
  • 对正确性有强证明要求的场景,比如清算、加密、协议解析。这里要的是可证明正确,不是概率式相近。

我把这四条当成「AI 重构禁区」贴在了团队 wiki 上。不是 AI 不行,而是它的能力边界和这些场景的需求边界不重合。在边界外硬用,性价比为负。

重构之后:回归与线上监控不能断

代码合进主干只是上半场,下半场是确认它真的比之前稳。我在这次重构后做了一件原先没计划、但事后觉得必不可少的事:把新老两套行为在影子流量下对照跑了一阵。具体做法是用真实请求同时喂给旧逻辑(只读不改)和新逻辑,比对输出差异,任何不一致都拉出来人工判读。这步帮我们抓到两个 AI 重构后悄悄改变的边界行为,避免了线上事故。

线上监控也得跟上。老项目崩是崩在「改需求」,新项目要防止崩在「重构埋的雷」。我加了几条针对重构区域的告警:

  • 重构过的纯函数若输入分布突变(比如金额忽然出现负数),立刻告警,因为纯函数本不该有这种输入。
  • 抽出来的数据访问层若错误率高于历史基线,说明剥离时可能漏了某种分支。
  • 关键路径的耗时,重构后应持平或更好,若变慢要回头查是不是抽层引入了多余调用。

这些监控不华丽,但能让我睡安稳。重构的意义在于「以后改需求更稳」,如果重构本身引入了新的不确定,那就本末倒置了。监控是把「更稳」从口号变成可验证事实的唯一手段。

我现在的体会:重构不是一次性事件,而是一种状态。代码合进去那天,状态才刚建立,能不能维持住,靠的是回归和监控这两道持续的闸。闸不牢,重构的红利会在某个深夜悄悄流走。

团队协作:让 AI 重构可审计可追溯

这次重构不是我一个人闷头干的,组内另外两个同事也接手了不同模块。我们很快发现,AI 重构最容易被团队忽视的代价是「可审计性」:当一段代码是 AI 生成的,出了事没人记得当初为什么这么改,review 记录里也只有一句「AI 重构」。我们立了几条协作规矩,效果不错。

  • 每条 AI 改动都必须在提交信息里标注:喂了哪些文件、给了哪些约束、人 review 了哪几行。这样半年后回看,能还原决策链。
  • 重大架构改动必须先在文档里写清「为什么」,再让 AI 执行「怎么做」。文档先行,避免 AI 自作主张定方向。
  • 禁止直接把 AI 的输出合主干而不经第二人 review。AI 的代码再顺眼,也得有双人类眼睛背书,这是底线。

这套规矩初期显得繁琐,但当我们第一次因为监控告警回溯到某次 AI 抽层遗漏的分支时,所有人都认同了它的价值,我们只花了五分钟就定位到那次提交和当时的约束,因为信息都在。可审计不是 bureaucracy,是给未来的自己留的一条退路。

让 AI 做自审:重构完再让它挑自己的刺

还有一个我后来才用顺的技巧:重构提交前,再开一轮对话,把刚改完的代码连同原始需求喂回去,专门让它「以资深 reviewer 的视角挑毛病」。这一步不是重复劳动,而是利用它「不带有刚才写代码时的思维惯性」去发现盲点。人 review 自己的代码也常有盲区,AI 这轮相当于一个不知疲倦的第二 reviewer。

我特别让它重点看三件事:第一,有没有引入新的隐式依赖或全局状态;第二,边界条件(空值、超长、负数)是否被正确处理;第三,对外签名是否真的没变。这三件恰恰是人容易在大量 diff 里漏掉的。它挑出来的问题,大约一半是误报,但另一半确实补上了我 review 时的疏忽。我觉得这比单纯靠人 double check 性价比高,毕竟它不累、不烦、不会「看了两千行就走神」。

把 AI 既当写手又当 reviewer,是这个流程里我最意外的收获。它写代码时犯的错,换一个语境让它看,往往能自己认出来。让同一个人(或同一个模型)先写后查,比只写不查稳得多,前提是,最终的否决权始终留在人手里。

经验与教训总结

复盘完过程,我把这次实录里最值得带走的经验,压成下面几条,都是真金白银换来的,按重要性排。

  • 测试先行,且先用特征测试锁行为。没有这层兜底,AI 的「等价重构」只是你的一厢情愿,三个月后会在最不该出现的地方咬你。
  • 边界先定,约束写死。告诉 AI 能碰哪些文件、不能改哪些签名,比事后 review 省十倍力气。约束是护栏,不是客套。
  • 小步提交、每步可回滚,一次只做一件事。可回滚不是备胎,是日常盔甲。混改等于主动放弃安全感。
  • 机械活给 AI,决策活留自己。重命名、抽函数、补注释放心交出去;架构、业务语义、历史 hack 必须自己拿主意。
  • 永远 review 它改的每一行。AI 不会恶意,但会「善意地犯错」:比如删掉它以为无用的字段、加一个看似安全的 catch。你的眼睛是最后一道闸。
  • 别让它引入新依赖、别让它改接口、别让它碰你没喂给它的模块。这三条我写在每条指令末尾,雷打不动,是红线。
  • 把重构成果用特征测试固化,合主干后立刻跑一遍全量测试,防止延迟爆炸。绿了,才算这一轮真正结束。

最后说点心里话。这次重构下来,那个订单服务的可读性、可测性都上了台阶,最让我满意的不是代码变漂亮,而是「改需求不再每周崩一次」了。AI 在其中确实省了我大量机械劳动,那些抽函数、补注解的苦力活,过去最耗我的耐心,现在它包了。但真正决定成败的,依然是我对系统、对业务、对边界的理解。工具越强,人的判断越值钱,这不是客套话,是这次实录最实的结论。如果你也正打算用 AI 重构老项目,希望我踩过的坑,能替你少加几次班,多睡几个好觉。

相关文章