我让 ChatGPT Review Claude 写的代码,它提了 35 条建议,但我没全采纳
35 条建议几乎都没有错,但真正困难的不是发现问题,而是判断什么时候应该停。
slashslashdev·

我原本以为,多一个 AI 做代码评审,总归是件好事。直到第三轮评审结束,我才意识到:一个几乎不会漏掉问题的 AI 审查者,有时反而比一个粗心的审查者更难应付。
因为它不会主动停下来。
起因:一段原本准备直接上线的代码
上周,我接到一个很常见的需求:给登录邮箱验证码接口增加限流逻辑,避免接口被恶意调用,既防止耗尽 SMTP 发信额度,也避免被用于批量发送垃圾邮件。
类似的需求我已经做过很多次。平时我的工作流是先让 Claude 给出技术方案,确认实现思路后直接编码,功能验证通过就提交。
这一次,我多做了一步:把 Claude 生成的代码原封不动交给 ChatGPT,请它做一次代码评审。
没想到,这个简单的尝试,引出了一个比代码本身更值得思考的问题。
整个过程中,ChatGPT 在多轮评审中累计提出了 35 条修改建议。这些建议几乎都言之有据,但真正让我犹豫的并不是它是否正确,而是:当两个 AI 给出不同的工程判断时,开发者应该如何取舍?
最终,这 35 条建议中,我采纳了 25 条,另外 10 条则选择部分采纳或直接放弃。真正想讨论的,也正是这部分内容。
整个评审流程很简单:
- ChatGPT 负责发现问题,不直接修改代码;
- 每条意见按照「严重程度|代码位置|问题描述|原因分析|修改建议」输出;
- 我再把这些意见交给 Claude,由它逐条回应——要么修改,要么说明为什么保持现状;
- 最终是否采纳,由我决定。
为了方便阅读,下面的示例代码进行了简化:去掉了框架相关代码,并改写成 Node.js + pg 的形式(实际项目使用的是 NestJS + Prisma)。
业务场景是注册流程中的邮箱验证码发送,限流规则如下:
- 同一邮箱:每分钟最多发送 1 次,每天最多发送 5 次;
- 同一 IP:每分钟最多发送 3 次,每天最多发送 10 次;
- 所有发送记录均写入数据库。
第一版:功能可以运行,但存在不少隐患
Claude 给出的第一版实现十分直接:依次检查各项限流规则,通过后生成验证码、写入数据库并发送邮件。
const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
// ...规则查询:按邮箱 / IP 查近一分钟、近一天的发送次数...
const code = Math.floor(100000 + Math.random() * 900000).toString();
await pool.query(`INSERT INTO otp_codes ...`);
await sendOtpEmail(email, code);ChatGPT 很快给出了第一轮评审结果,共提出 15 条建议,其中有 3 条属于 P0 级问题。
最严重的问题是竞态条件(Race Condition)。
这并不是一种理论上的风险,只要把两个并发请求的执行顺序展开,就能发现问题。假设某个邮箱当前已经发送了 4 次验证码,而限额是 5 次:
| 时刻 | 请求 A | 请求 B |
|---|---|---|
| t0 | SELECT count(*) → 4 |
|
| t1 | SELECT count(*) → 4(尚未看到 A 的写入) |
|
| t2 | 4 < 5,通过 | 4 < 5,通过 |
| t3 | INSERT(第 5 条) |
|
| t4 | INSERT(第 6 条)← 突破限额 |
问题出在「检查」与「写入」之间没有保持原子性。
两个请求都读到了相同的数据,都认为当前还未达到限制,于是分别完成写入,最终导致第 6 次发送成功。并发越高,这类绕过限流的情况就越明显,邮箱限流、IP 限流等所有规则都会受到影响。
另外两条 P0 问题同样值得关注。
第一,代码直接使用 x-forwarded-for 获取客户端 IP。由于这个请求头可以被客户端伪造,攻击者只需不断修改请求头,就可以轻松绕过基于 IP 的限流。
第二,验证码使用 Math.random() 生成。它并不是密码学安全随机数,在某些场景下存在被预测的风险,更合适的选择应该是安全随机数生成器。
这一轮评审中,15 条建议没有一条属于误报。
回头看,Claude 的第一版几乎踩中了所有关键问题,而这恰恰也是过去我很可能直接上线的实现方式。
最终,我采纳了其中绝大多数建议,只保留了一项不同意见:ChatGPT 建议使用独立的计数字段替代 count(*) 查询,而我认为,这属于典型的过早优化。在当前的数据规模下,引入额外的数据结构并不能带来实际收益,因此没有采纳。
第二、三版:问题开始层层递进
接下来的两轮「代码修改 → AI 复审」,让我看到另一件事:解决一个问题,往往会暴露出新的问题。
为了修复第一版中的竞态条件,Claude 引入了 Postgres 的 advisory lock。
不过,它只锁住了邮箱维度。ChatGPT 很快指出,同一 IP 维度仍然存在并发绕过限流的可能。同时,它还发现了一个新的隐患:发送邮件被放进了事务里,导致数据库锁跨越了 SMTP 这个慢 IO。 一旦并发请求增加,事务持锁时间就会被拉长,数据库连接池很容易成为新的瓶颈。
针对这两个问题,Claude 又调整了实现方案,引入了「预占(Reservation)+ 补偿(Compensation)」机制。
具体来说,就是在事务中完成限流检查和额度占位,事务提交后立即释放锁,再到事务外发送邮件。如果发送失败,再回滚之前占用的额度。
这个方案解决了锁持有时间过长的问题,却很快又迎来了下一轮评审。
ChatGPT 指出了它最大的漏洞:
如果进程在事务提交(COMMIT)之后、邮件发送之前发生崩溃(例如 Pod 被杀或主机重启),补偿逻辑将永远不会执行。最终结果是:额度已经被占用,验证码已经生成,但用户却从未收到邮件。
这个问题几乎无法反驳。
它对应的正是分布式系统里一个非常经典的问题——双写(Dual Write)。
「写数据库」和「发送邮件」属于两个独立的副作用,它们无法放进同一个事务里完成。只要采用「先提交数据库,再发送邮件」的模式,两者之间就一定存在一个不可避免的时间窗口:一旦应用在这里崩溃,系统状态就会出现不一致。
很多人第一反应会想到用 try/catch 删除已经写入的数据,但这种方式并不能解决问题——因为真正的崩溃根本不会进入 catch 分支。
更稳妥的方案,是把发送过程设计成状态机(State Machine),或者采用 Outbox Pattern。
与其删除发送记录,不如为每条记录增加一个明确的生命周期,例如:
pendingsentfailed
这样,即使应用在发送邮件之前崩溃,数据库中仍然保留着一条 pending 状态的记录。后台任务可以扫描这些长时间未完成的记录,将其标记为 failed,同时归还对应的限流额度。
由于状态始终保存在数据库中,整个流程就具备了恢复能力。
对应到代码,最核心的变化只是增加了一个状态字段:
-- 创建发送记录时先写入 pending,并立即占用限流额度
INSERT INTO otp_send_log (email, ip, status)
VALUES ($1, $2, 'pending');
-- 邮件发送成功更新为 sent;
-- 发送失败更新为 failed,不再删除记录这里还有一个容易被忽略的业务边界。
即使状态最终变成了 sent,也只能说明邮件已经成功提交给 SMTP 服务,并不能保证收件人一定已经收到邮件。SMTP 后续是否成功投递,并不是应用能够控制的范围,因此这个不确定性只能体现在产品语义上,而无法通过代码彻底消除。
按照这一路思路,Claude 最终将代码演进到了状态机版本(v4):竞态问题得到解决,限流具备原子性,应用崩溃后也可以恢复未完成的发送记录。
整个过程中,ChatGPT 累计提出了 35 条修改建议,几乎没有误报,也没有越界修改代码,而是始终停留在代码审查者的角色。
不过,零误报,并不意味着所有建议都应该采纳。
最终,我真正接受的只有 25 条,剩下的 10 条,要么只采纳了一部分,要么直接放弃。
原因并不是这些建议有问题,而是它们已经开始超出这个接口真正需要承担的复杂度。
直到这里,整个过程看起来仍然像是一场成功的 AI Code Review:一个原本存在不少隐患的接口,被一步步打磨得越来越完善。
但真正让我开始反思的,也正是从这里开始。
每解决一个问题,也会引入新的成本
把几轮代码评审放在一起看,我开始意识到一个容易被忽略的事实:
每修复一个问题,往往也会引入一笔新的、需要长期承担的维护成本。
整个演进过程大致如下:
- 修复竞态条件 → 引入 advisory lock → 带来新的死锁风险(双锁必须遵循统一顺序,否则可能出现 AB-BA 死锁),以及热点串行化问题(同一 NAT 出口的大量请求竞争同一把 IP 锁,吞吐量可能退化为串行执行);
- 修复事务跨 SMTP → 引入预占 + 补偿机制 → 暴露新的崩溃窗口问题;
- 修复崩溃窗口 → 引入状态机 + Reaper → 增加新的运维成本(Reaper 是否可用、超时时间如何设置、pending 状态是否堆积、如何监控和告警等)。
从 v1 演进到 v4,代码确实越来越健壮,但风险并没有凭空消失,而是从一种形式转移到了另一种形式。
最初的问题是「限流可能被绕过」,后来变成了「可能发生死锁」「可能留下未处理的 pending 状态」,以及「需要长期维护一个后台恢复任务」。
这些改动本身都没有问题,它们解决了真实存在的风险。但与此同时,系统的复杂度和维护成本也在不断增加。
直到这里,我才意识到一个容易被 AI 忽略的问题:
ChatGPT 的职责是发现下一个问题,却不会评估这些新增机制是否值得长期维护。
而在真实项目里,后者往往才是成本最高的部分。
对 AI 来说,增加复杂度几乎没有成本
回顾三轮评审,ChatGPT 的思路其实非常一致:
先发现 Bug,再检查并发模型,然后讨论一致性,最后进一步建议引入消息队列、Outbox、独立计数器等更复杂的架构。
如果继续讨论下去,我相信它还能提出更多建议,而且大多数都会有充分的理由。
为什么 AI 作为代码审查者,几乎天然会朝着「过度设计」的方向发展?
我认为并不是因为它做错了,而是因为它没有任何理由停下来。
第一,它不用承担维护成本。
对于开发者来说,每增加一个组件,都意味着新的部署、监控、排障和维护工作。这些真实存在的成本,会让人不断权衡:「这件事真的值得吗?」
AI 不需要面对这些问题。
对它来说,增加一个状态机、一个后台任务、一个消息队列,几乎没有额外成本。
第二,代码评审本身鼓励它不断发现问题。
当我们让 AI 做 Code Review 时,我们给它的目标其实就是「尽可能找出更多问题」。
因此,它几乎不会主动告诉你:「这已经足够好了,不需要再改。」
因为停止发现问题,并不是它当前任务的一部分。
第三,它不了解真实的业务规模。
AI 不知道这是一个每秒只有 5 个请求的内部系统,还是一个每秒数万请求的互联网服务。
在缺乏上下文的情况下,它只能按照最坏情况进行推演,因此天然会倾向于推荐更复杂、更稳妥的方案。
但现实中的大多数接口,并没有复杂到需要引入这些机制。
所以,对 AI 审查者来说,「更严谨」几乎没有终点。
无论给它什么代码,它都还能继续发现新的风险,而且这些建议往往都专业、合理,也很难反驳。
这其实与很多开发者抱怨的「AI 写代码容易过度设计」是同一个现象,只不过放到代码评审场景中,被进一步放大了。
因为对于 AI 来说,不断发现风险、不断增强健壮性、不断引入更完善的架构,本身就是完成任务的一种方式。
那么,一个真实项目中的 OTP 接口,究竟应该停在哪一版?
与其凭经验拍脑袋,不如先建立一个简单的判断框架:
| 实际场景 | 建议方案 | 原因 |
|---|---|---|
| 单实例、低流量、内部系统 | v1 + 一把锁 | 并发窗口极小,引入状态机的收益有限,维护成本反而更高 |
| 多实例、中小流量、面向公网 | v2 / v3 | 原子限流配合可信 IP 获取,已经能够覆盖绝大多数场景 |
| 高并发,且「额度不能被薅」属于核心业务指标 | v4(状态机) | 崩溃可恢复开始具有实际价值 |
| 极高并发,涉及跨服务一致性 | MQ / Outbox | 此时才值得承担更复杂架构带来的维护成本 |
最终,我把实现停在了 v4,而没有继续引入消息队列、Outbox 等更复杂的组件。
这并不是因为 ChatGPT 的建议有问题。
恰恰相反,那些建议几乎都成立。
只是继续往下优化之后,收益已经开始递减,而复杂度仍在持续增加。
这也是整个「AI 写代码 + AI 审代码」过程中,我最大的收获。
Claude 和 ChatGPT,其实代表了两种截然不同的工程倾向。
负责生成代码的一方,更容易低估问题。
第一版代码就是典型例子:能够正常运行,却隐藏着十几个真正会影响线上稳定性的缺陷。
而负责代码审查的一方,则更容易高估风险。
只要继续讨论下去,它总能找到新的改进方向:一致性可以再加强,恢复机制可以再完善,架构还可以继续演进。
两者都没有错。
真正缺少的,是一个能够判断对于当前场景,这已经足够了的人。
至少目前,这个角色还只能由开发者自己承担。
意识到这一点之后,我与 AI Code Review 的协作方式也发生了变化。
现在,我会在评审开始之前,就告诉 AI 足够多的业务上下文,例如:
这是一个峰值约 50 QPS 的内部服务,单实例部署,由两名开发者维护。
有了这些信息之后,它给出的建议通常会明显收敛。
因为很多「最佳实践」,其实都是建立在高并发、大规模、多团队协作等前提之上的。当这些前提不存在时,相应的复杂度往往也没有必要。
除此之外,我还会要求 AI 在每一条建议后补充一句:
采用这项方案,需要额外承担哪些维护成本?
例如,它可能会告诉我:
- 需要部署一个长期运行的 Reaper;
- 需要设计超时回收策略;
- 需要增加 pending 状态的监控和告警。
当收益和成本同时摆在面前时,是否值得采用,判断就容易得多。
有时我还会直接追问一句:
这些建议里,哪些对当前项目来说已经属于过度设计?
很多时候,它自己都会收回一部分建议。
但归根结底,这些做法都指向同一件事:
AI 可以不断提出新的优化方向,却无法告诉你什么时候应该停止优化。
换一次对话,答案可能就变了
还有一个现象,也让我重新认识了 AI Code Review。
前面这些结论,都建立在这一次的会话、提示词以及模型组合之上。
如果重新开启一个对话,甚至只是更换另一个模型,结论就可能发生变化。
这一轮,它推荐状态机;下一轮,它也可能反过来认为状态机属于过度设计。
同一个模型在不同会话里推翻自己之前的判断,其实并不少见。
因此,我越来越倾向于把 AI 当作一位经验丰富、但观点并不固定的同行。
它擅长发现问题,也擅长不断提出新的思路,却很少能够给出一个放之四海而皆准的标准答案。
这也让我开始思考另一件事。
真正可靠的流程,也许不是「让 ChatGPT 审一次代码」,而是让方案经历多轮 Review、多模型交叉评审,以及不断自我推翻的过程。
不要依赖某一次回答,而是让不同观点不断碰撞,最终逐渐收敛。
就像这次 OTP 接口的演进一样。
AI 帮我发现了许多原本不会注意到的问题,也让代码比最初更加健壮。
但最终决定停在哪一版、哪些问题值得解决、哪些复杂度暂时没有必要承担的人,仍然只能是开发者自己。
因为只有人,才真正知道业务需要什么,也只有人,才能判断一句工程实践中最重要的话:
到这里,已经足够了。