让 ChatGPT review Claude 写的代码,它挑了 35 条,没一条是错的

当 AI 几乎挑不出错,真正的难题不再是找 bug,而是知道什么时候该停

slashslashdev·

让 ChatGPT review Claude 写的代码,它挑了 35 条,没一条是错的

这本该是件好事。直到第三轮我才反应过来:一个挑不出错的 AI 审查者,可能比一个粗心的更难对付——因为它根本不打算停下来。

起因:一段我本来会直接上线的代码

事情是这样的。上周我接了个很普通的需求:给登录的邮箱验证码加发送限流,别让接口被人薅着刷爆 SMTP 额度,也别被攻击者拿去群发垃圾邮件。

这种代码我写过无数遍,闭着眼都能写。我的习惯是让 Claude 先出一版,扫一眼能跑就提交。但这次我鬼使神差多做了一步:把 Claude 写好的代码原封不动丢给 ChatGPT,让它来挑刺。

我没想到,这一丢,丢出了 35 条意见、三轮来回,和一个让我重新琢磨「到底该信谁」的问题。

先剧透一句、免得被标题带偏:这 35 条确实没一条是错的,但我最后只照做了 25 条——剩下 10 条该不该听,才是这篇真正想聊的事。

先把规矩说清楚:ChatGPT 只许挑问题、不许动手重写,每条意见按「严重度 | 位置 | 问题 | 为什么 | 怎么改」列给我;我再拿着这些回到 Claude,让它逐条回应,要么改、要么说清楚为什么不改;我自己是看着两边、最后拍板的那个人。这不是拿玩具代码摆拍的演示,是真实需求——只是为了通用阅读,下面的示例我把框架剥掉了、写成等价的纯 Node + pg 形式(真实项目是 NestJS + Prisma),限流规则也取了注册场景的一组:同邮箱每分钟 1 次、每天 5 次,同一 IP 每分钟 3 次、每天 10 次,状态全落库。

还有一点得提前讲明,它其实正是这篇的伏笔:下面这段「第一版」(我叫它 v1)等价于我项目里此刻在跑的那一版——剥离了框架,但该有的问题一个不少;而后面 v2 到 v4 的演进,是我用这套 AI review 循环跑出来的结果——到今天,我也只把其中很小一部分合并回了主仓库。为什么没全合,正是这篇后半段要讲的事。

第一版:能跑起来,但藏着不少坑

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 条意见,三条 P0。其中最致命的是竞态条件。它之所以致命,不是「理论上可能」,而是一张具体的并发时序就能砸实——假设某个窗口限额是 5 次,此刻该邮箱已发 4 次:

时刻 请求 A 请求 B
t0 SELECT count(*) → 4
t1 SELECT count(*) → 4(还没看到 A 的写入)
t2 4 < 5,通过 4 < 5,通过
t3 INSERT(第 5 条)
t4 INSERT(第 6 条)← 越限了

「检查」和「写入」之间没有原子性,于是两个请求都读到 4、都判定通过、都写入,第 6 条就突破了限额。并发越高,绕过越狠——每分钟、每天、按 IP 的几档限制全线失守。另两条 P0 是 x-forwarded-for 可被客户端伪造(每次换值即伪装新 IP,IP 限流形同虚设)和 Math.random() 非密码学安全随机源。

值得记下的是:第一轮 15 条意见,没有一条误报。这些问题单拎出来我都懂,但 Claude 的初版几乎全踩了一遍——而这恰恰是我平时会直接上线的那种代码。我采纳了绝大多数,只驳回一条「用计数器结构替代 count(*)」,理由是过早优化。

第二、三版:挑出的问题一个比一个深

接下来两轮,事情才真正有意思起来。

Claude 修复竞态时引入 Postgres advisory lock,却只锁了邮箱维度。第二轮 ChatGPT 立刻指出 IP 维度仍可并发绕过,并抓住 Claude 新埋的坑——发信被放进了事务内,锁横跨 SMTP 这个慢 IO,高并发下会拖垮连接池。这两条逼出「预占 + 补偿」:短事务内原子完成检查与占位、提交释放锁,再在事务外发信,失败时回滚占位记录。

但第三轮,ChatGPT 拆穿了补偿的根本缺陷:

若进程在 COMMIT 之后、发信之前崩溃(Pod 被杀、主机重启),补偿逻辑永远不会执行。该请求会占掉额度、留下有效验证码,但用户从未收到邮件。

这其实是分布式系统里一个有名字的老问题:「双写」(dual write)——「写数据库」和「发邮件」是两个无法放进同一个事务的副作用,任何「先提交再发送」的写法,都在两步之间留了一个崩溃就不一致的窗口。try/catch 删记录治不了它,因为崩溃根本不会进入 catch。教科书级的解法是把副作用建成状态机outbox:让发送有显式生命周期 pending / sent / failed,崩溃留下的记录停在 pending,由后台任务把超时的 pendingfailed 并归还额度(限流计数只算 pending 和 sent,转成 failed 就等于把名额退回)——状态在库里、可恢复:

// 预占时写 pending,从此刻起即计入限流(防并发误伤)
INSERT INTO otp_send_log (email, ip, status) VALUES ($1, $2, 'pending');
// 发信成功 → sent;失败 → failed(不计入限流);不再 DELETE

(顺带一提,即便 sent,也只代表邮件已经交到 SMTP 服务器手上;它有没有最终送到收件人,这一跳你无从确认。这个不确定性代码层面消不掉,只能在产品语义上承认。)

Claude 据此改成状态机版本(v4):崩溃可恢复、并发不误伤、限流原子。三轮累计,ChatGPT 共提 35 条意见,始终零误报、零越界,没有一次擅自重写代码。

轮次 意见数 误报 采纳并改 部分采纳 暂缓/不采纳
1 15 0 12 2 1
2 11 0 8 2 1
3 9 0 5 1 3
合计 35 0 25 5 5

这里得先点破一件容易被误读的事:零误报,不等于全采纳。 这 35 条我最终只全照做了 25 条,剩下 10 条要么只采纳一部分、要么干脆没改——不是因为它们说错了,而是因为它们超出了这个接口真正需要的复杂度。这个区别,恰恰是这篇后半段的全部重点。

故事看似圆满:一个能上线却漏洞百出的接口,被严苛的 ChatGPT 逼成了优等生。但这不是最值得记录的结论。

被忽略的代价:每修好一处,都带来新的麻烦

把四个版本摆在一起看,会发现一件容易被忽略的事——每修掉一个问题,都同时引入了新的、需要长期维护的东西

  • 修竞态 → 引入 advisory lock → 带来死锁风险(双锁必须全局同序,否则 AB-BA 死锁),以及热点串行化(同一 NAT 出口的大量请求争同一把 IP 锁,吞吐退化为单线程);
  • 修「锁跨 SMTP」→ 引入预占/补偿 → 带来崩溃窗口
  • 修崩溃窗口 → 引入状态机 + reaper → 带来一个新的后台运维组件(reaper 本身会不会挂?超时阈值怎么定?pending 泄漏怎么告警?)。

换句话说,从 v1 到 v4,代码并不是简单地越来越安全,而是把风险从一处挪到了另一处:从「会被绕过」挪成了「可能死锁、可能有 pending 记录漏掉没处理、还多了个要专人盯着的定时任务」。每一步都对,但整体的复杂度和运维负担是实打实地涨上去了。ChatGPT 只负责「找出下一个问题」,不负责「这些新加的东西以后谁来维护」——而后者才是真实项目里最花钱的部分。

真正的发现:审查者停不下来,因为加复杂度对它不花钱

把三轮连起来,ChatGPT 的路线很清楚:先找 bug,再挑锁的正确性,再要求一致性模型,最后开始建议上消息队列、outbox、专门的计数器表。 每一条都成立,但它一直在做同一件事——不停往上加复杂度。

为什么一个 AI 审查者几乎一定会滑向过度设计?不是因为它「坏」,而是因为它没有任何理由停下来

  1. 它不用承担维护成本。对人来说,多一个组件就是多一份排查、监控、交接的负担,这份「累」会自然让你掂量值不值。AI 不承担后果,所以对它来说,加复杂度是免费的。
  2. 「审查」这件事本身就鼓励多挑。你让它审查,它默认的目标就是「多找出几个问题」,而「已经够好了,别改了」这种话,几乎不会出现在它的回答里。
  3. 它不知道你的真实规模。它不清楚这个接口每秒是 5 个请求还是 5 万个,于是干脆按「最坏情况、最大规模」来建议——可绝大多数接口根本到不了那个量级。

所以对一个 AI 审查者来说,「更严谨」是没有尽头的:给它任何代码,它都能继续挑下去,而且每条都很专业、都不好反驳。这其实和大家抱怨 AI 写代码「爱过度设计」是同一回事,只是换到审查的位置上被放大了——因为挑刺、加固、上架构,本身就是「显得专业」的方向。

那么,一个真实的 OTP 接口该停在哪版?与其凭感觉,不如给个判断框架:

你的实际情况 够用的版本 理由
单实例、低流量、内部系统 v1 + 一把锁 即可 竞态窗口极小,状态机是负担
多实例、中小流量、面向公网 v2/v3 原子限流 + IP 防伪足矣
高并发、把「额度不被薅」当硬指标 v4 状态机 崩溃可恢复才有意义
极高并发 / 跨服务一致性 MQ / outbox 此时才值回那份复杂度

我最终停在 v4,把 MQ、计数器表记进「以后再说」,没有继续往下——不是 ChatGPT 错了,而是再往下,收益已经走平,复杂度还在涨。一个诚实的复盘是:前两轮我采纳得其实偏顺,该叫停的地方,我本可以更早出手。

这就是「AI 写 + AI 审」循环里最关键的一课:两边天性相反但都危险——负责写的一方(这次是 Claude)倾向欠考虑(初版 15 个坑),负责审的一方(这次是 ChatGPT)倾向过度设计(无限升级的架构),而它们都不会自己停下来。判断「对这个场景已经够了」并喊停,是这个循环中唯一无法外包给 AI 的环节。

看清这一点,会悄悄改变你和 AI 审查者打交道的姿势。你会下意识先把规模约束喂给它——「这是个峰值 50 QPS、单区部署、两人维护的内部服务」——它的建议立刻就收敛,因为大半过度设计源于它默认按最坏规模脑补;你会让它在每条建议后补一句「这会新增什么运维负担」,等它自己写下「需要一个常驻 reaper 加超时告警」,值不值就显形了;你甚至会反过来问它「这些建议里,哪些对一个低流量小服务属于过度设计」,它往往能自己删掉一半。但这些动作说到底是同一件事的衍生:判断「够了」的权力,必须攥在人手里——AI 给得出每一条建议,却给不出那条「到此为止」的线。

一个必须说明的局限:换一次对话,结论可能就变了

最后必须泼盆冷水,以免「AI 评 AI」被当成客观裁判。

上述全部结论,高度依赖于这一次的会话、提示词与模型组合。换个会话、换个模型,ChatGPT 的关注点甚至结论都可能反转——这一轮称赞状态机,下一轮完全可能批评它「为简单接口过度设计」。同一个模型在不同会话里自我否定前案,并不罕见。所以不要把任何单次 AI 意见当作定论:它更像一位博学但立场随风漂移的陪练,能把欠考虑的代码逼向更扎实的形态,却给不出标准答案。

这也指向一个值得单独成文的延伸话题:既然单次意见不可尽信,更稳的用法或许不是「让 ChatGPT 审一次就定」,而是让方案反复自我 review、在多轮甚至多模型的相互否定中收敛——把「不轻信」制度化进流程,而不是寄望于某一次对话的灵光。那是另一篇文章的题目了。

就这件事而言,结论很朴素:AI 能帮你把代码逼得更扎实,但最后拍板、敢说「够了,不必再改」的那个人,目前还只能是写代码的我自己。

LLMAIAI CodingCode Review
DEV WEEKLY

开发者周报

精选编程语言、开发者工具和新产品,5 分钟读完。