上周三,一个新模块评审会开到一半,同事把AI生成的两千多行代码推给我,附了一句“看着挺完整的”。我大概花了十五分钟读完,然后把整个PR打回。说实话,在过去一年多里,我审的代码里AI产出的比例越来越高,Cursor、Copilot、GPT-4这类工具写出的东西确实比早期版本进步明显,但一个典型的幻觉是:它会用非常自然的表达,写出一段逻辑上根本站不住的实现。更麻烦的是,AI的代码从来不会主动告诉你它“不确定”。所以我越来越确信一个观点:AI编程能不能用,关键不在于AI怎么写,而在于你怎么审。这篇不是科普,也不是规范文档,是我实际在审查AI产出的过程中沉淀下来的四条清单,以及印象最深、两次把代码打回去的过程分析。
1. 审AI代码,最怕把“看起来对”当“真的对”
1.1 AI的流畅表达是一种“伪逻辑”
大模型生成代码的本质,是按概率逐token预测下一个字符,它学习的是“代码长什么样”,而不是“代码实际怎么运行”。这就导致一个非常隐蔽的问题:AI写出来的代码在形状上极度接近人类工程师的产出——函数命名规范、类型标注齐全、注释写得像教科书,甚至单元测试也是绿的。但它对“业务事实”的理解,基本来源于训练数据里那些通用项目里的平均逻辑,而不是你手里这个项目的真实规则。
我经常做一个比喻:AI像是在考场上见过大量优秀作文的学生,它知道一篇好文章该有开头、论证、结尾,也知道常用的金句怎么安放。但你要它真的理解你这家公司的会员体系、库存扣减规则、优惠券叠加限制,它做不到,只能靠“相似项目的常见做法”去猜。猜对了就是惊喜,猜错了就是事故。
所以审查AI代码时,我最警惕的不是“代码哪里丑”,而是“代码哪里太顺了”。一段代码如果从结构到注释都让人看着非常舒服,反而要多留一个心眼:它很可能用流畅的假逻辑掩盖了某个真实业务约束。
1.2 审人和审AI代码,本质上不是一回事
审人类同事的代码,我会默认他理解业务背景,只是可能有疏忽、有遗漏,或者对某段逻辑的理解有偏差。审AI的代码,我不能做这个假设。它不知道你们上周刚定的规则,不知道生产环境那台老数据库的查询压力,也不知道用户点了某个按钮之后当天不能再点第二次。
人类代码的问题,往往藏在“他以为自己在做A结果做成了B”的认知偏差里。而AI代码的问题,藏在“它根本不知道有这个业务规则存在”的空白区里。
我举个例子。一个下单模块,AI生成的价格计算逻辑里有一个discount_rate = 0.9,然后直接对总价做乘法。从代码语法看没有任何问题,但它没有考虑“这个折扣是否应该叠加会员优惠”“是否超过每日折扣上限”“是否对部分商品不生效”。这些规则不在训练数据里,AI只能按照最常见、最通用的做法去写。这样的代码,静态检查根本查不出来,必须靠人对业务规则的核对。
1.3 在审查AI代码时,我的角色变成了“翻译器”
既然AI不理解真实业务,审查者就必须充当中间翻译:把需求文档里的业务规则,翻译成一条一条可验证的代码断言;把用户可能遇到的操作路径,翻译成边界条件下的代码行为。比如“订单取消后库存要回滚”是一条业务规则,你在审查时就要翻代码,找到取消接口的整个链路,确认回滚动作发生在事务边界内,而不是挂在某个回调里随缘执行。
这套检查和过去人审人的方式有很大区别。过去我倾向于“通读+抽查”,现在我会强制自己带着断言去读,每读一段就对着需求画一个勾。看到第四个清单的“两看两查”,我把它做得越来越细,久而久之就成了一套固定的流程。
2. 我用来审AI代码的四条清单,分“两看两查”
四条清单不是拍脑袋列出来的,是我在连续踩了若干次坑之后总结出的顺序。先用“两看”把握大方向,再用“两查”深入细节。顺序反了很容易出问题——如果一上来就抠边界条件,你可能在一段根本不该存在的代码上浪费一整天。
2.1 清单A:需求映射核对
第一遍只看“代码做的事情是不是需求要求的事情”。我会把需求文档里的功能点逐条列出来,对照代码实现:
- 输入/输出是否与产品定义一致?
- 是否遗漏了某个分支(比如“当用户未登录时”这个分支)?
- 错误提示是否在正确的位置出现,还是直接抛出了底层异常?
- 状态流转是否符合业务表的约束(比如订单不能从“已关闭”跳到“已完成”)?
这张清单最大的价值,是提醒自己和团队不要因为“代码能跑”就觉得“功能实现正确”。AI代码往往90%的路径是对的,问题经常藏在剩余10%的交互分支里。
2.2 清单B:结构合理性核对
第二遍看结构。AI特别喜欢生成“工业级”代码,但这里的“工业级”不一定是好事。前端一个小工具,它能给你抽象出plugin机制;后端一个内部API,它能给引入消息队列。
结构审查时我的问题很直接:
- 这个抽象真的解决了当前问题,还是只解决了“未来可能的问题”?
- 是否引入了需求范围内用不到的依赖?
- 是否符合团队现有分层约定?
- 如果让一个新人来看这段代码,他需要多久才能讲清楚数据流向?
这条清单其实是在控制技术债务上限。AI生成代码时没有成本意识,它不知道一个抽象层意味着四个接口、三套实现、两处注册和无数个间接调用。你不去按需求压它,它会按训练样本里的“通用最佳实践”一路放飞。
2.3 清单C:正确性与异常路径核对
大方向和结构都没问题时,我开始查细节。这块最容易暴露AI代码的问题,我见过太多“常规路径全绿、异常路径全炸”的案例。
我重点看这几类:
- 空值、空集合、零长度字符串
- 时间边界(跨天、跨月、跨年,以及时区)
- 重试与幂等(网络超时后重发请求,会不会产生两条订单?)
- 并发状态(多个线程/进程同时改同一个变量?)
- 资源关闭(文件句柄、数据库连接、内存缓存有没有正确释放)
这一轮我会特别细地读每一个条件判断,问自己一句:如果这个条件不成立,程序会走到哪?能不能接受?
2.4 清单D:性能、安全与技术债核对
最后再看性能和安全性。很多AI生成的代码在小规模测试环境没问题,放到生产压力下就会暴露复杂度问题:
- 是否存在循环内反复查询数据库?
- 是否存在O(n²)的双层循环处理海量数据?
- 是否把敏感信息直接打到日志里?
- 是否在鉴权环节只做了“前端隐藏”而没有做“后端校验”?
- API密钥、连接配置是否出现在提交里?
这四条清单看起来多,但练熟之后一次PR的审查大概在半小时到一小时之间。我会把每条清单都做成可勾选的条目,有时候甚至复制到review comment里让AI按条自查。后面我会具体讲反馈模板。
3. 第一次打回:AI写了一段“自我感动式”的抽象架构
3.1 代码长什么样
第一次打回的项目是一个内部数据导入模块,需求很简单:从两种固定格式的文本文件里读取数据,清洗之后写入数据库。AI第一次提交的代码,用了一个非常漂亮的abstract factory + plugin注册模式。
class BaseParser: def parse(self, content: str) -> Dict[str, Any]: raise NotImplementedError class CsvParser(BaseParser): def parse(self, content: str) -> Dict[str, Any]: ... class JsonParser(BaseParser): def parse(self, content: str) -> Dict[str, Any]: ... class ParserRegistry: _parsers = {} @classmethod def register(cls, fmt, parser_cls): cls._parsers[fmt] = parser_cls @classmethod def create_parser(cls, fmt: str) -> BaseParser: return cls._parsers[fmt]()单看这代码,没什么毛病,抽象也还算合理。但面对“两种固定格式”的需求,它明显重了。我把整个文件的调用关系梳理出来,发现为了支持两种文件格式,代码里多出来一个注册表、一个工厂函数、一个基类、两个子类,以及一套错误处理分支。实际的解析差异只有三十行,但这个架构让理解成本翻了三倍。
3.2 我当时的判断路径
我没有只凭感觉说“这代码太复杂”,而是走了一条可解释的判断路线:
第一,当前需求里解析器的数量和种类是固定的,没有任何迹象表明未来一个月内会出现第三种格式。技术选型不应该为一个“可能出现的未来”买单。
第二,abstract factory的意义在于调用方不需要关心具体实现类。但这个项目里调用方就一个,且它对格式是明确知道的,工厂模式没有带来任何调用上的简化。
第三,plugin注册表引入了全局可变状态。万一多个测试用例并行跑,注册表里残留的parser会互相污染。为了一个不存在的扩展需求,引入一个潜在并发问题,这是典型的负资产。
3.3 我写下的打回意见
打回评论我没有写得模糊,而是直接把理由和要求写清楚:
当前需求只有两类固定格式的源文件,抽象工厂和plugin注册目前无法带来可验证的收益,却导致阅读时需要先理解四个类的关系。请先按普通函数实现,由模块入口直接根据文件后缀分发。等第三个真实来源出现时再做抽象,并把抽象后的独立验证用例一并补上。
这里的关键是“等第三个真实来源出现时再做抽象”这句话。我这几年审代码最大的感受是:过早抽象比没抽象更害人,因为你根本不知道未来需求会往哪个方向变。AI不懂这个道理,它只会从统计上觉得“框架化的代码更像优质代码”。
3.4 为什么AI那么容易过度设计
原因其实不复杂。AI在训练数据里见过的“教科书级代码”大多来自开源项目,这些项目为了应对复杂生态,确实需要高度抽象。大模型在生成时就带着强烈的先验倾向:抽象工厂优于普通函数,设计模式优于直接实现。它没有成本感知能力,也很难理解“老板要的只是一个明天就要上线的内网小工具”。
这次打回之后,我又在多个项目里发现了同样的倾向。所以后来我把“是否为了可能发生的未来牺牲了当下的简单性”直接写进了清单B,作为固定审查项。这个改动,帮我后面省下了一大批所谓“优雅但没必要”的代码。
4. 第二次打回:一个时区问题伪装成“随机失败”
4.1 问题是怎么被发现的
第二次打回更典型,因为代码表面上完全正常,测试也全绿,直到压测环境在某个临界时间点开始报错。场景是一个限时活动模块,活动开始时间从配置中心读取,格式是“2025-03-16 10:00:00”。AI生成的判断逻辑很直白:取当前时间字符串,与配置时间字符串直接做比较。
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") if now_str >= start_time_str: # 开始活动单独看这个逻辑,在没有时区要求的环境里也没错。但我们的服务跑在多地多可用区,服务器默认时区是UTC,而配置中心的时间是北京时间。当北京时间的活动任务在早上八点开始时,UTC服务器上的datetime.now()得到的是“2025-03-16 00:00:00”,和“2025-03-16 08:00:00”比较,自然判断为还未开始,活动窗口被整整推迟八小时。
更隐蔽的是,如果活动时间落在UTC和北京时间的重叠日期里,比如北京时间上午九点对应的UTC时间还是前一天,字符串比较永远不会满足。这个bug不会每次都出现,只在特定时间窗口内出现,所以特别容易被当成随机网络抖动或者配置延迟。
4.2 完整排查链路:从现象到根因
我复盘一下当时的排查顺序,这比答案本身更有价值:
第一步,线上监控看到活动入口点击量在开始时间那一瞬间没有上涨,反而延迟了。先排除了流量问题和网络问题。
第二步,查看服务日志,发现没有报错,接口正常返回“活动未开始”。说明代码逻辑本身在执行,只是结果不对。
第三步,人为构造了一个“当前时间刚好等于活动开始时间”的测试用例,在本地复现失败。此时才把怀疑焦点转到时间比较逻辑上。
第四步,在代码里加了一行临时日志,打印now_str和start_time_str,发现两边时区不一致:一个是UTC,一个是北京时间。
第五步,确认根因:AI生成代码时直接沿用了最常见的datetime.now()写法,没有考虑服务部署环境的时区设置,也没有把时间统一成UTC存储。
4.3 为什么自动化测试没拦住
这可能是比bug本身更值得反思的地方。单元测试当时覆盖了“活动开始之前”“活动开始之后”“活动已结束”三个场景,看起来已经很完整了。问题在于测试用例里的“当前时间”都是通过mock生成的一个固定本地时间,没有覆盖服务器时区和配置时区不一致的情况。
我后来在清单C里加了一条强制要求:涉及时间的代码,至少要补一个“固定时区+跨临界点”的测试用例。比如强制把TZ=UTC跑一遍测试套件,再把代表业务时区的用例跑一遍。这个习惯成本极低,但能挡住大量只在特定时间段爆发的隐蔽问题。
4.4 这次打回,我是这样要求的
打回意见写得比第一次更长,因为它涉及两个层面的修改:
定位:activity.py中start_time判断逻辑。 问题:采用本地时间字符串与配置时间比较,导致UTC环境判断结果错误。 为什么是问题:服务运行在多时区环境,需求约定所有内部时间统一使用UTC。 期望改法:配置读取后统一转成UTC时间戳;展示层再由前端转时区;当前时间统一使用utcnow,禁止在业务逻辑里使用datetime.now。 验收标准:TZ=UTC环境下,用北京时间活动配置跑三组用例:开始前、开始瞬间、开始后,结果必须全对。
第二次打回后,AI的修改还算干净,它把所有业务逻辑里的datetime.now()都换成了datetime.now(timezone.utc),并把时间比较全部改为时间戳数值比较。这次经历让我意识到,AI在代码风格和局部逻辑上的能力已经足够强,但它缺少对运行环境和部署约束的全局认知。这类约束必须靠审查者硬性补进去,AI自己很难主动想到。
5. 审后反馈怎么写,AI才不会“口是心非地改”
5.1 最差的反馈:是让AI“看着办”
我发现很多人把代码打回去之后,反馈写得非常随意,比如“这里逻辑不对,请重写”或者“代码太复杂,优化一下”。这种反馈扔给AI,它只会返回一版“看起来不同但问题依旧”的代码,因为它不知道你期望的目标是什么。
有一次同事让AI重写一个分页查询接口,AI把limit和offset换成了cursor分页,代码复杂了一倍,但原来的问题——缺少排序稳定性——依然没有解决。原因很简单:AI不知道你真正不满意的地方在哪,它只能在你指出的区域做一次表面翻新。
5.2 我使用的反馈结构:定位、理由、期望、验收
经过实践,我打磨出一套适用于“AI程序员”的审后反馈模板:
定位:文件xxx,函数yyy,第xx行到第xx行 问题:具体是什么 为什么这样是问题:影响范围,比如“会导致并发下单重复扣库存” 期望改法:你希望它怎么做,给出可执行的方向 验收标准:怎么判断修改符合要求
这套模板的精髓在于“验收标准”。没有验收标准,AI就会自己发明一套标准来证明自己改对了;有了验收标准,你可以直接构造用例去验证。
5.3 一次打回能迭代到什么程度
我个人的经验是:一次打回,AI能稳定解决60%到70%的问题;如果运气好,你说的够具体,能到80%。剩下那20%到30%,要么是它改完A问题又引入了B问题,要么是它根本没理解你给的约束。
所以我给自己定了一个规则:同一个模块最多让AI自动迭代两到三轮,超过这个轮数,我会把代码拆出来,自己动手重写核心逻辑,或者把它重新拆成更小的函数再交给AI。这不是否定AI的价值,而是承认模型的边界——反复打回的成本,有时候比自己写还高。
另外我还会把每次打回案例的反馈文本收集起来,存成一个专门的review-prompt库。下一次给AI布置类似任务之前,我会先把这个库里的几个典型案例贴进去,告诉它“这些是之前踩过的坑”。这个方法效果比我预料中好很多,AI在一开始就会避开一部分高频错误,相当于从源头降低了打回概率。
6. 把审查经验变成团队资产,而不是个人手感
6.1 让清单从文档变成可执行的规则
四条清单如果只存在我脑子里,价值十分有限。我把它们整理成了团队内部代码评审的默认检查项,并且映射到了实际的工程工具上:
- 静态可查的部分(比如“禁止datetime.now”、禁止日志打印密钥)直接用lint规则加进CI流程,不让这类代码走到人工审查环节。
- 结构性问题(比如过度抽象)靠人工评审,但会在AI生成代码提示词里主动追加一句约束:“在满足当前需求的前提下,选择最简单直接的实现方案,不为未出现的需求做抽象。”
- 边界条件这类问题,靠补测试用例来兜底,我会要求每个涉及时间、金额、状态的PR至少补一组边界测试。
6.2 用“反例库”增强每一轮AI产出质量
前面提到的review-prompt库,我会不断更新。每打回一次,就把打回的根因、反馈、修改后结果整理进去。这些反例不是给人类看的,而是作为few-shot示例,在下一次给AI下发编程任务时一起提交。AI看到“这个模块之前因为时区问题被返工过”,它就会在自己的候选方案里优先排除掉依赖本地时间的写法。
这个方法本质上是在给AI补“团队记忆”。模型本身不具备你公司的上下文,但通过提示词里的历史反例,你可以把“团队踩过什么坑”临时塞进它的上下文窗口。我现在看AI代码的初审通过率,已经比最早的时候高了不少,靠的正是这种持续喂养反例的机制。
6.3 最后一道防线永远是人的判断力
无论AI的代码看起来多完整,最终责任都在审查通过的那个人身上。清单是骨架,反馈模板是工具,但真正决定代码能不能上线的,是你对这个系统有多了解、对业务规则有多清楚。AI可以帮你写一百个函数,但它不会在凌晨两点帮你处理生产事故。
所以我的最后一条建议很朴素:不要让AI替你思考“这个模块该不该存在”。功能必要性、方案取舍、风险边界,这三件事永远由人来做。审AI代码不是走流程,它更像验收一场外包交付——你不验收,它不是你的;你认真验收,产出的质量才能算到你头上。
我现在的习惯是:代码注释里不再写“这段由AI生成”,因为那段代码如果出了问题,同事不会去找AI,只会来找我。四条清单两次打回,说到底就一句话:AI负责写得快,我负责看清楚。