过去大半年,我团队里几乎每个工程师都在用AI辅助写代码,功能交付速度肉眼可见地快了很多。可与此同时,有一件事让我越来越警惕——PR上明明写着“新增某功能”,单元测试也全是绿的,合并上线之后,系统却开始出现各种莫名其妙的线上问题。我抽了一批这类事故复盘,发现一个共性:“功能完成了”和“系统没被改坏”是两件完全独立的事,而AI特别擅长把第一件做漂亮,然后在第二件上翻车。
这篇文章想聊的就是:AI的diff到底是怎么把系统改坏的,为什么它倾向于这么做,以及我们该如何在享受AI效率的同时,把这种“系统级破坏”按回去。内容适合所有正在用AI写代码、管代码、审代码的人,不管你是普通开发者、技术负责人,还是刚接触AI辅助开发的初学者,下面这些案例和排查思路应该都能派上用场。
1. 一个“功能全绿、系统负伤”的典型事故复盘
1.1 需求、交付、线上:看似正常的一条时间线
先讲一个我自己实际处理过的案例,背景是一个常见的电商后台订单系统。当时的需求很简单:给运营加一个“按订单状态导出Excel”的功能。需求方给的原话是“加个导出按钮,把筛选后的订单导出来就行了”。
接手这个需求的工程师当时正忙,就把任务直接扔给了AI,提示词写的是“实现订单列表导出Excel功能,支持按状态筛选,参考现有导出工具类”。AI很快给出了一大段代码:新加了一个导出接口、一个导出Service、一个异步任务,还补了单测。本地跑了一下,编译通过,导出功能也确实能出文件。工程师看了一眼输出,觉得“挺完整”,于是生成PR、找人review、合并、上线。
时间线看起来非常正常:
- 10:00 代码合并,发布
- 10:30 运营开始用导出功能,反馈“能导出,没问题”
- 11:20 订单列表接口开始偶发500
- 11:40 对账定时任务失败率上升
- 12:10 第三方物流状态回写接口超时增多
功能成功,系统却开始“生病”,而且病的还不是导出功能本身,而是周边一堆看起来毫无关联的模块。
1.2 三处“顺手改动”如何串联成一场事故
事后我把这次PR的完整git diff拉出来,发现AI的改动范围远超“订单导出”本身。真正出问题的不是新增代码,而是三处看起来“很合理”的顺手修改。
第一处,AI在订单状态枚举里新增了一个“退款中”的状态。这对运营来说是真实需求,但问题是AI把它插在了枚举的中间位置,而不是末尾。这个系统的订单状态是会通过RPC传给下游、也会落到数据库整型字段里的,依赖的是枚举的序列化序号。枚举中间插了一个值,后面所有状态的序号全部错位,下游按老序号解析状态的服务当场就乱了。
第二处,AI发现导出功能里频繁要查询“某个状态的订单总数”,于是顺手加了一层缓存,缓存key用的是order:summary:{status}。巧的是,系统里已经有一个定时统计任务在用一模一样的key,只是它存的数据结构不同。两边互相覆盖,导致导出统计和定时任务的汇总数据偶尔对不上。
第三处,AI在导出代码里需要格式化时间,发现现有工具类输出格式是yyyy-MM-dd HH:mm:ss,觉得“不够精确”,就把公共工具类里的格式改成了带毫秒的yyyy-MM-dd HH:mm:ss.SSS。这个工具类被几十个模块共用,对外接口文档里写死的还是旧格式。上游服务按照旧格式解析我们回传的时间字符串,毫秒多出来之后直接解析失败。
三条线单独看,每一个改动都有“合理”的理由:支持新状态、性能优化、更精确的时间。但合在一起,就是把一套运行良好的系统的隐性约定全部破坏了一遍。
1.3 事故复盘的第一教训:diff才是真正的变更契约
这个案例给我的第一教训是:**代码评审时,功能描述是表面契约,diff才是真正的契约。**你PR描述里写“新增导出功能”,但diff里实际发生的变更,可能包括了枚举重排、缓存key新增、公共工具类改写。任何一个变更,只要有“顺手优化”“顺带调整”“类似改动”这三个字,都必须被当成独立的设计决策来评审,而不是功能的一部分。
很多团队在引入AI辅助开发之后,review习惯还停留在过去:看功能需求实现了没有、测试是不是绿的、代码风格是不是规范。这三条检查维度,恰好都是AI最容易“表现优秀”的维度。而真正能判断系统是否被改坏的,是diff里那些“与需求无关的改动”。AI不会告诉你它顺手做了什么,它只会在PR描述里写“完成了导出功能”,剩下的需要人去审。
2. 四个让AI“做对功能、做坏系统”的深层原因
如果说上面是现象,那这一节我想聊底层的机制。为什么AI特别容易犯这类错误?为什么同样一个需求,人来做可能平安无事,AI一做就出事?我总结了四个原因,它们相互叠加,才是问题的全貌。
2.1 全局不变式对AI是隐形的
每个系统里都存在一些“全局不变式”(global invariant),这些规则没人写在接口文档里,但所有代码都在默默依赖它。比如“订单状态枚举的序号永远不能重排”“对外API的字段类型和格式不能变”“缓存的key空间是全系统共享的,不能随意占用”。
人在写代码时,靠的是长期记忆和团队语境。老工程师改枚举时,脑子里会闪过“这玩意儿好像下游在用”,改公共工具类时会想“这方法可不止我一个人调”。这些信息不在需求文档里,而在经验里。
AI没有这种长期记忆,它的全部注意力都在当前上下文窗口内。如果提示词没有特别强调“不要动枚举顺序”,它在新增状态时,只会按照人类编程习惯把新状态放在逻辑最顺的位置——插到同类状态旁边。在AI看来,这是“优化”;在系统看来,这是“破坏约定”。
我常用一个装修类比来解释这件事:AI就像一个手艺很好但没看过房屋结构图的装修师傅,你把“把卧室刷成蓝色”的需求给它,它能刷得很漂亮,但如果刷墙过程中碰到一面承重墙,它不会知道这堵墙不能拆,因为它没有整栋楼的结构知识。除非你提前告诉它“承重墙绝对不能动”。
2.2 系统之间的隐性契约靠的是“记忆”,而AI没有长期记忆
比全局不变式更隐蔽的,是跨系统、跨团队的隐性契约。这类契约往往只存在于双方负责人的聊天记录甚至默契里。
举个例子,订单系统对外输出状态码,过去一直是“整数0到5”,下游物流系统拿0到5去映射自己的状态。这个映射关系写在物流系统的代码里,注释是“按订单系统的文档对接”。而订单系统的文档已经半年没更新了。只要订单系统的人不改枚举,一切正常。AI不知道这些,它在枚举里插入新状态后,0到5对应的业务含义全部位移,物流系统拿到的状态全错了。
这类隐性契约在人类团队里靠什么维持?靠“老员工记得”和“出过事”。而AI既没有老员工记忆,也没有出过事的经验。你要它只凭一次对话里的需求描述,就推断出“这里有个下游系统正在依赖我现在的枚举序号”,这确实超出了当前AI能力的合理预期。所以在工程上,我们必须要有一个机制去补充这块信息的缺失——后面我会讲到,这就是约束文档和测试护栏的用武之地。
2.3 自证陷阱:AI写测试,测试替AI圆谎
很多人觉得“AI写完代码之后让AI补了测试,测试也过了,所以系统是安全的”。这是当前AI辅助开发里最大的误区。
AI生成的单元测试,有一个非常隐蔽的问题:它和被测代码是“同一套思维”的产物。AI写了一个解析状态的工具方法,然后它又为这个方法写了测试。测试断言的是“方法在输入X时输出Y”,但这个Y是AI自己定义的行为,而不是系统原有的契约。
这就好比一个人既当运动员又当裁判:AI写了个新枚举映射逻辑,然后AI自己断言“这样映射是对的”,测试当然全绿。但这个绿证明的只是“AI自己的代码符合AI自己的预期”,完全不能证明“AI的代码符合系统原有契约”。
我在团队里称之为“自证陷阱”。要破解它,唯一的办法是让测试先于实现存在,而且测试的断言必须来自旧系统的既定行为,不是来自AI对需求的重新理解。换句话说,测试应该是“冻结现有行为的快照”,而不是“验证新功能的演示”。
2.4 diff力度失控:AI倾向重写而不是最小修改
还有一个统计规律值得注意:AI生成的代码,平均diff规模明显大于人类完成同样功能时的diff规模。这不是玄学,背后有很现实的原因。
对AI来说,生成一段完整、自洽的新代码,比精确定位并修改一处旧代码要“容易”——因为前者只需要在高概率的续写路径上推进,后者则需要深入理解旧代码并做出最小干预。这就导致AI天然倾向于“重写”而不是“最小修改”。明明只是要加一个字段,AI可能连带着把整个DTO类重排了一遍;明明只是要改一个判断条件,AI可能顺手把整个if-else链改写成switch。
人类的工程美学讲究“最小diff”:改动范围越小,引入回归的风险越低。AI对这个美学没有概念,它每次出手都是“推倒重来”。diff越大,出事的概率就指数上升。事故复盘时你会发现,真正引爆问题的那行代码,往往就藏在一个看起来“重构得很干净”的段落里。
3. 从单次辅助到AI Agent:风险在哪个环节被放大
前面聊的是开发者在IDE里用AI补全、生成代码的场景。这两年更流行的是AI Agent——让AI自己读代码、自己跑命令、自己修报错,甚至自动提PR。我试用和研究了不少这类工具,也为团队搭过AI Agent工作流。必须承认效率提升很猛,但风险也在被放大,而且放大的方式非常值得单独拎出来说。
3.1 自主多轮修改:一个重构引发的连环扩散
单次生成代码,AI的“破坏半径”通常在一个文件内。AI Agent不一样,它可以在一次任务里进行多轮工具调用:读文件A,发现依赖B,读B后发现C里“有重复代码”,于是顺手重构C,重构完C发现调用方D还在用旧方法名,又去改D……
这个链式反应是人类开发者经常干的“探索式修改”,但人类有一个克制机制:每改一处会评估“这跟我的任务有关吗”。AI Agent没有这个克制机制,它的目标是“让任务完成”,所以一切阻碍任务完成或看起来“不顺眼”的代码,都可能被它列为修改对象。
我之前让一个Agent“修复某个查询接口的N+1问题”,它修完查询之后,发现相关的Mapper里有个“冗余if判断”,顺手删了;删完之后某个老旧的兼容逻辑也不依赖了,又删了一段。结果就是,一个N+1修复任务,最后diff里多出三个文件的“清理”。其中有一处“冗余if”实际上是老版本客户端的兼容分支,删掉之后,某些老客户端的请求直接走到了新逻辑,行为全变了。
3.2 多AI协作的群体盲区:每个agent都只看到局部
比单个Agent更进阶的玩法是“多AI协作”:让AgentA去改造订单模块,AgentB去改造支付模块,然后两个diff合并。听起来很先进,但实际跑下来你会发现一个扎心的现象——群体盲区。
AgentA可能是知道AgentB要改支付模块的,但它不知道AgentB具体要改哪个接口、哪个字段。当AgentA在订单模块里加了“支付状态”的引用时,它默认的是支付模块的旧结构。而AgentB那边正巧把支付状态枚举从String改成了Integer。两边各自按自己的想法推进,各自测试都绿,合到一起就编译失败,就算编译过了,运行时也全是问题。
人类的多人协作之所以能降低这类冲突,靠的是代码评审、接口文档和团队共识。现在的多AI协作并没有形成这套机制。我在实际项目里观察到,多Agent协作的冲突率甚至比多人协作更高,因为Agent不会主动问。
3.3 我最警惕的动作:AI主动“清理”看似冗余的代码
如果让我选一个AI Agent最危险的行为,不是“改错了”,而是“主动清理”——它看到一段代码觉得没用,就删了,理由通常是“这段分支永远不会执行”。
问题在于,“看起来永远不会执行”和“真的永远不会执行”是两码事。很多线上代码里的看似死代码,实际上是防御性编程、历史兼容、或者应对极端场景的兜底逻辑。比如一段if (orderId == null)的判断,正常人看会觉得多余,但它的存在可能是为了防止某个老版本客户端传入空值导致NPE。AI删掉它之后,所有测试还是绿的,因为测试里根本没有覆盖“老客户端传空orderId”的场景。
AI Agent做这种“清理”时,它自己非常自信,因为它在“优化”。而恰恰是这种自信,让我对Agent在公共代码、核心数据模型、兼容层上的自主操作高度警惕。后面讲护栏时我会专门说怎么限制这个行为。
4. 一次线上事故的完整排查链路:从500报错到git diff
这一节,我把第1章的案例完整展开,走一遍当年真实的排查链路。不是为了讲故事,而是想分享一个可复用的排查思路:从现象出发,逐层往下,最终落到diff上。很多AI改坏系统的事故,现象五花八门,但根因往往就三类:枚举/常量变化、缓存/存储key冲突、公共方法行为变化。
4.1 第一根因:新增枚举值插在中间,序列化全线错位
事故最早的信号,是订单列表接口开始出现500,报错日志指向序列化异常,错误信息大意是“无法将值3映射到订单状态”。
为什么之前能映射?因为系统内部订单状态的数值序号是稳定的。AI新增“退款中”枚举后,把原本序号为3的状态推到了4。旧的数据库记录里存储的字段值是3,代码却开始用新序号规则解析,3对应成了别的状态。有些地方只做展示还好,一旦把状态值传给下游系统,下游也按新旧规则映射,数据就全乱了。
排查这类问题的关键在于:**凡是看到“无法映射”“非法值”“枚举下标越界”这类异常,先怀疑枚举序号是否发生过变动。**尤其是当这个PR里刚好有新状态新增时,直接去git diff里看枚举的定义顺序。这个定位成本比追日志低得多。
修法也相对简单:要么把新状态追加在枚举末尾;要么如果实在要插中间,就显式给每个枚举写死序列化序号,禁止依赖默认的顺序。前者是一劳永逸的工程约定,后者是防御措施。
4.2 第二根因:缓存key与既有任务静默冲突
第二路信号来自导出功能自身:运营反馈“导出的订单数,跟列表页显示的不一致,差了几百条”。这类数据对不上的问题,第一优先怀疑对象就是缓存,而且是不同业务共用了同一个缓存key。
排查链路是这样的:先用同样的筛选条件分别查数据库和查接口,结果不一致;然后去掉缓存查一次,结果和数据库一致;接着看缓存key的生成逻辑,发现是order:summary:{status};再全代码库搜这个key,找到了另一个定时统计任务也在用它,但value结构完全不同。两个功能交替写入、互相覆盖,数据自然时对时错。
这类问题很容易被AI触发,因为AI在生成缓存代码时只会按“直观语义”去设计key,不会全局搜索这个key是否已被占用。在一套没有明确缓存key命名规范的系统里,这种冲突几乎是必然的。修复方案是给缓存key加上业务前缀,比如order:export:summary:{status},并排查是否有其他模块用到了相同格式的key。
4.3 第三根因:公共工具类的“顺手重构”
第三路信号最隐蔽:第三方物流状态回写接口超时增多。一开始我们一直以为是物流系统接口不稳定,观察了两天,发现成功率持续偏低,才开始怀疑是我们这边发出的数据有问题。
抓包对比后发现,我们回传的“操作时间”字段格式从yyyy-MM-dd HH:mm:ss变成了yyyy-MM-dd HH:mm:ss.SSS。物流系统那边用接口文档写的格式硬解析,多出一个毫秒段就抛异常,解析失败后疯狂重试,表现为“接口超时增多”。
回到代码里一查,改动来自之前的导出功能PR——AI改了一个被所有模块共用的时间格式化工具类,原因是它觉得“导出文件里带毫秒更精确”。这就是典型的“AI用局部需求覆盖全局契约”。定位到之后,我们把公共工具类恢复原状,导出功能里单独做一个本地格式化方法,问题立即消失。
这类公共方法改动是AI改坏系统事故里占比最高的一类,因为AI在生成代码时总是倾向于“找到那个公共方法然后优化它”,而不是“在调用侧做局部适配”。
4.4 排查思路总结:现象→契约→diff的三层定位
这次事故排查下来,我总结出一个三层定位法,基本可以覆盖市面上绝大多数AI改坏系统的场景:
第一层看现象,不猜原因。收集所有异常类别,按“序列化类”“数据一致性类”“接口兼容类”分类。 第二层想契约,不做修补。问三个问题:有没有枚举或常量表被动过?有没有缓存key或存储字段被复用?有没有公共方法的返回值、参数、格式被改变? 第三层查diff,不看功能描述。重点看AI生成PR里“与需求无关”的改动文件,逐个确认。
我建议所有深度使用AI辅助开发的团队,把这三层定位法写进自己的事故复盘SOP里。它不复杂,但能在你最焦虑的时候提供一条稳定的排查路径。
5. 四道护栏,让AI在“完成功能”的同时不“破坏系统”
复盘做多了会发现,AI改坏系统不是“运气问题”,而是“机制问题”。只要不在流程上设置干预点,AI一定会按照它的天性去重排枚举、占用缓存key、改公共方法。下面这四道护栏,是我们团队用了大半年、经过反复验证后留下来的做法,每一条都能落地。
5.1 护栏一:把约束清单写进提示词,并沉淀为工程文档
第一道护栏,看起来最简单,但效果最明显——在给AI的任务描述里,显式声明“不要做什么”。我见过太多人写提示词时只写目标,不写约束。但AI对“系统级禁忌”一无所知,必须由你喂给它。
下面是一份可复用的提示词模板,你可以按项目情况调整:
请实现以下功能:[具体描述] 硬性约束: 1. 禁止修改与本需求无关的文件、方法、枚举、常量。 2. 禁止改变任何公共方法、接口参数、返回值、异常类型的签名和语义。 3. 禁止重排或调整枚举定义顺序,如需新增状态请追加在末尾。 4. 禁止占用或修改现有的缓存key、定时任务key、消息topic。 5. 禁止改动公共工具类、序列化配置、日志输出格式。 6. 尽量复用现有代码,采用最小diff原则,不要顺手重构。 7. 如果发现现有代码与需求冲突,请先在回复里说明,而不是直接改。如果你用的是Cursor、Copilot这类支持项目级指令的工具,可以把这份约束写入项目的AGENTS.md或CLAUDE.md,让AI每次读上下文时都能看到。补一句:约束文档要放在版本库里,随项目演进维护,而不是放在个人配置里。
5.2 护栏二:测试先行,要求AI先断言旧契约
第二道护栏针对前面说的“自证陷阱”。操作上分两步走。
第一步,先让AI为现有系统行为写“契约测试”,不写任何新功能。你给它的指令是:“为模块中的订单状态解析、金额格式化、缓存读写这几个公共行为各写一个测试,断言当前行为不要改变。”这一步产出的测试,是旧系统行为的“冻结快照”。
第二步,再让它实现新需求,并要求“所有既有契约测试必须保持通过”。
这样AI即使想重排枚举,契约测试会立刻失败,它就会停下来。更重要的是,你在review时有了一个客观工具:diff再大,只要契约测试全绿,系统行为大概率没变。契约测试的维护成本不高,但价值极大,强烈建议每个核心模块都配一套。
5.3 护栏三:人工Code Review从“看功能”转向“看diff”
第三道护栏是改变评审习惯。过去review时,我们的注意力会放在“新功能对不对”;引入AI之后,我要求团队把80%的review注意力放在“diff里除了需求,还发生了什么”。
实际操作中可以这样:打开PR时先不读描述文字,直接看Files Changed列表。如果改动文件数超过需求合理范围,优先逐个检查“与需求无关”的文件。找出那些“顺手优化”“顺带重构”“格式调整”的片段,逐个问作者一个问题:“这一处改动的业务动机是什么?如果删除它,需求还能实现吗?”答不上来就说明这处改动极可能是AI的自由发挥,坚决不要。
我还会看一个指标:这个PR的新增代码行数,是不是完成该需求的最小规模。如果明显超标,会让作者重新收敛。这套方法不依赖任何工具,只要有review纪律就能执行。
5.4 护栏四:Agent模式下限制授权、隔离验证
最后一道护栏,针对AI Agent和多AI协作场景。原则很简单:Agent能做的事,必须比人能做的事少。
具体落地:
- 任务拆分时,把Agent的授权范围限定到“单个文件”或“单个函数”,不允许它自动跨模块改动。
- 禁止Agent自主执行“重构”和“清理死代码”类指令,这类指令是主动破坏的源头。
- 如果是多Agent并行,先约定好公共接口和数据模型,交互边界写清楚,不允许Agent各自改到共享代码上的同一区块。
- Agent跑完后,不要直接合入主干,必须在隔离分支上跑全量测试、契约测试,再交给人工review。
听起来保守,但实际跑下来,AI Agent的真正价值在于“快速完成机械性任务”,而不是“自主设计系统结构”。你给它越大的自由,它给你的惊吓越多。把它关在笼子里用,效率和安全可以兼得。
5.5 场景延伸:嵌入式、系统级和集成类项目的额外提醒
这套护栏不只适用于业务后端。如果你在做嵌入式、机器人、WMS仓储系统这类对状态机、硬件时序、系统集成高度敏感的项目,AI改坏系统的威力更大也更隐蔽。
比如在STM32这类裸机或RTOS项目里,AI如果改动了一个GPIO初始化顺序、把一个定时器中断优先级改了、或者调整了消息队列长度,单测基本发现不了——因为问题的表现不在函数返回值上,而在硬件时序和中断行为上。在ROS系统里,Node之间的消息频率和Topic结构一变,轻则仿真出问题,重则真机失控。这类项目里,约束文档和契约测试的价值会被无限放大。
针对这类项目,我的额外建议是:给AI的提示词里明确加上“禁止改动初始化代码、中断配置、RTOS任务优先级、消息结构定义;所有涉及硬件资源和时序的改动,必须输出书面说明”这几条。并且,任何涉及硬件行为的diff,都要过一遍硬件在环测试,不能只依赖软件单测。
我自己在带团队过程中,最深的体会是:AI写代码的能力问题,现在已经不是主要矛盾;真正的主要矛盾,是“AI带来的系统变更失控”。这不是一个靠升级模型就能解决的问题,它需要的是工程流程的调整。我们不可能回到“不用AI”的时代,但我们可以通过约束文档、契约测试、diff评审、权限隔离这套组合拳,让它聪明地干活,而不是聪明地闯祸。
最后再分享一个我一直在用的小技巧:每次让AI完成需求后,合并之前,多问它一句——“这个diff里,有哪些改动是与需求无关的?请逐条列出,并说明是否可以还原。”这一个问题,能让AI主动交代它的“自由发挥”。我自己在多个项目里试下来,每次它列出来的清单,都比我肉眼review找出来的更全。