让 ChatGPT review Claude 写的代码,它挑了 35 条,没一条是错的
当 AI 几乎挑不出错,真正的难题不再是找 bug,而是知道什么时候该停
slashslashdev·

这本该是件好事。直到第三轮我才反应过来:一个挑不出错的 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,由后台任务把超时的 pending 转 failed 并归还额度(限流计数只算 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 审查者几乎一定会滑向过度设计?不是因为它「坏」,而是因为它没有任何理由停下来:
- 它不用承担维护成本。对人来说,多一个组件就是多一份排查、监控、交接的负担,这份「累」会自然让你掂量值不值。AI 不承担后果,所以对它来说,加复杂度是免费的。
- 「审查」这件事本身就鼓励多挑。你让它审查,它默认的目标就是「多找出几个问题」,而「已经够好了,别改了」这种话,几乎不会出现在它的回答里。
- 它不知道你的真实规模。它不清楚这个接口每秒是 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 能帮你把代码逼得更扎实,但最后拍板、敢说「够了,不必再改」的那个人,目前还只能是写代码的我自己。