审查 AI 生成的代码时,只检查语法、分支和异常处理并不够。一次看似正常的搜索缓存实现,因为缓存键遗漏了分类和价格参数,让不同查询错误地共享结果。这个问题暴露出的并非单纯编码失误,而是 AI 对业务上下文作出了未经验证的假设,也要求审查者重新调整检查重点。
前阵子给一个搜索功能加缓存。需求不复杂——用户搜商品,按关键词和分类筛选,把结果缓存起来减少数据库查询。
AI 写的代码,我看了一遍,觉得没问题就 merge 了。
上线第二天,用户投诉说"搜'手机-苹果'和'手机-小米'出来一样的结果"。
排查过程挺直接的。先看调用方传的参数——第一次搜"手机-苹果"(keyword=手机, category=苹果),第二次搜"手机-小米"(keyword=手机, category=小米)。参数没问题。
再看缓存代码。AI 的实现是这样的:
// 为什么要看这段:缓存逻辑本身是对的,问题出在缓存键的设计上
function searchProducts(keyword: string, category?: string, minPrice?: number) {
const cacheKey = 'search:' + keyword
const cached = cache.get(cacheKey)
if (cached) return cached
const results = await db.query(
'SELECT * FROM products WHERE name LIKE ? AND category = ? AND price >= ?',
[`%${keyword}%`, category, minPrice]
)
cache.set(cacheKey, results, { ttl: 300 })
return results
}
// 翻车时的运行结果
用户A: searchProducts('手机', '苹果')
→ 缓存未命中,查数据库,写入缓存 key='search:手机'
→ 返回 [iPhone15, 小米14, 华为Mate60...]
用户B: searchProducts('手机', '小米')
→ 缓存命中 key='search:手机'
→ 返回 [iPhone15, 小米14, 华为Mate60...] ← 本应只返回小米14
问题找到了——缓存键只用了 keyword,没把 category 纳进去。用户搜"手机-苹果"时,缓存写入了 key='search:手机'。用户搜"手机-小米"时,缓存命中了同一个 key,返回了"手机-苹果"的结果。
但有意思的是,AI 的代码逻辑本身没问题——缓存读写、过期时间、清理策略都是对的。问题出在缓存键的粒度不够。
修复后我意识到:不是 AI 写错了,是我 review 时没问对问题。 这直接催生了后面的"三步法"。
我复盘了一下当时 review 的心理活动。我看的是:
这些全是"缓存逻辑"层面的东西。我没去问一个问题:"AI 对缓存键的粒度的假设,在我们这个场景下成立吗?"
这个问题的本质是:我的 review 注意力在"代码对不对",而不是"AI 知不知道"。
这不是我粗心。我 review 人代码也是这个习惯——看逻辑对不对、边界处理了没、异常捕获了没。这套方法 review 人代码很有效,因为人代码的 bug 就出在这几个地方。但到了 AI 代码,这套方法就不够了。
我后来想明白一件事:AI 代码的 bug 跟人代码的 bug,不是一个维度的东西。
| bug 类型 | 人代码 | AI 代码 |
|---|---|---|
| 逻辑错误 | 写错分支条件、算错索引、变量名混淆 | 同样会出现,但频率比人低 |
| 边界遗漏 | 没处理 null、没考虑空数组、没做类型校验 | 偏中等,训练数据里边界场景覆盖率决定 |
| 上下文缺失 | 很少——人知道自己在做什么项目 | 最常见——AI 不知道业务规则、不知道数据分布、不知道外部系统限制 |
| 假设偏差 | 同事会问"这个假设合理吗" | AI 不会问,它直接按"最可能"的路径写 |
人代码的 bug 集中在"写错了"这个象限。AI 代码的 bug 分两种——"写错了"和"不知道"。
"写错了"类(API 调错、语法错误、逻辑写反)跟人代码一样,按老方法 review 就能发现。但"不知道"类是 AI 特有的——AI 不知道 null 会传进来、不知道数据不满足类型定义、不知道这个 API 有调用频率限制、不知道缓存键要区分用户。
大多数人 review AI 代码时,只做了第一步(查"写错了"),没做第二步(查"不知道")。
一个简单的判断方法:如果这段代码换个有经验的同事来写,会不会写出同样的逻辑? 会 → 大概率是"写错了";不会 → 大概率是"不知道"。 不过这个观点主要适用于日常的业务逻辑代码(B 档)。对于支付、权限这类代码(C 档),AI 的"写错了"风险也不低,该逐行 review 还是得逐行。
第二弹的信任分级已经定了"哪些代码敢放权",第三弹的测试策略定了"怎么测",这一弹补上"放权之后怎么 review"。
三档不是新发明的,是在信任分级的三档框架下,补充每个档位 review 的具体方法。
A 档包括纯函数、工具函数、常量定义、脚手架代码。信任分级里说 A 档自动 merge,但前提是测试链完整。
什么叫测试链完整?类型检查(TypeScript strict mode)+ snapshot 测试到位。如果项目没有类型检查,或者没有 snapshot 测试,那 A 档也不能不 review,因为没人兜底。
我自己的做法是:review A 档代码时,不看代码本身,看两件事——
如果这两件事都 OK,直接 merge,不花时间看代码。
B 档包括业务逻辑、API 封装、数据转换。这是 AI 写代码的主力档位,也是"不知道"类 bug 最常见的地方。
review 方法:不看代码语法和逻辑,看 AI 对上下文的假设。
具体操作就是下一节的三步法。这里先给一个直觉——你在 B 档 review 时,把自己当成"这个项目的新人",而不是"reviewer"。新人会问的问题,就是你 review 要回答的问题:
C 档包括支付、权限、事务边界、数据回填。出事成本高,review 不能偷懒。
逐行看代码逻辑,跟 review 人代码一样。额外加一步:验证 AI 有没有遗漏关键场景。比如 AI 写了个支付回调处理函数,逐行看完了逻辑,还要问一句:"有没有什么场景是 AI 没考虑的?"——比如重复回调、超时回滚、部分成功。
这是我从那次翻车之后沉淀的方法,目前用下来没再漏过类似的"不知道"类 bug。
传统的 review 是"看代码"的视角,三步法切到"看假设"的视角,区别大概是这样的:
传统review: 看代码逻辑 → 看边界处理 → 看异常捕获 → merge
↓
AI review三步法: 列出假设 → 验证假设 → 修复假设
↓
核心差异:你review的不是代码,是AI的理解
逐行看代码,标注出"AI 默认了但没写在代码里"的东西。
还是用翻车那个缓存键的例子:
// AI 写的代码,逐行标注假设
function searchProducts(keyword: string, category?: string, minPrice?: number) {
// 假设1:keyword 一定有值 ← 参数有默认值吗?调用方会不会传空字符串?
const cacheKey = 'search:' + keyword
// 假设2:只用 keyword 做缓存键就够了 ← category 和 minPrice 不影响查询结果?
const cached = cache.get(cacheKey)
if (cached) return cached
// 假设3:缓存里有数据就一定是对的 ← 缓存过期时间够吗?
// 假设4:数据库一定能查到数据 ← 查不到怎么办?
const results = await db.query(...)
cache.set(cacheKey, results, { ttl: 300 })
return results
}
// 标注出来的假设列表
假设1:keyword 一定有值
假设2:只用 keyword 做缓存键就够了
假设3:缓存里有数据就一定是对的
假设4:数据库一定能查到数据
对每个假设,问三个问题:
如果某个假设不成立但暂时无法修复(比如依赖外部系统),至少要在代码里加注释标明,避免后续维护的人踩同一个坑。回到翻车案例:
| 假设 | 验证结果 | 结论 |
|---|---|---|
| keyword 一定有值 | 调用方是前端搜索框,空字符串会被前端拦截 | ✅ 成立 |
| 只用 keyword 做缓存键就够了 | 调用方会传 category 和 minPrice,不同分类返回不同数据 | ❌ 不成立 |
| 缓存里有数据就一定是对的 | 数据不频繁变更,300 秒 TTL 够用 | ✅ 成立 |
| 数据库一定能查到数据 | 查不到返回空数组,不是异常 | ✅ 成立 |
找到不成立的假设后,修复代码或补注释说明。
// 修复后的代码:把 category 和 minPrice 纳入缓存键
function searchProducts(keyword: string, category?: string, minPrice?: number) {
// 构建缓存键时,把所有影响查询结果的参数都包含进去
const cacheKey = `search:${keyword}:${category ?? 'all'}:${minPrice ?? '0'}`
const cached = cache.get(cacheKey)
if (cached) return cached
const results = await db.query(
'SELECT * FROM products WHERE name LIKE ? AND category = ? AND price >= ?',
[`%${keyword}%`, category, minPrice]
)
cache.set(cacheKey, results, { ttl: 300 })
return results
}
// 修复后运行结果
用户A: searchProducts('手机', '苹果')
→ 缓存未命中,查数据库,写入缓存 key='search:手机:苹果:0'
→ 返回 [iPhone15, 华为Mate60...]
用户B: searchProducts('手机', '小米')
→ 缓存未命中,查数据库,写入缓存 key='search:手机:小米:0'
→ 返回 [小米14, 红米Note13...]
两个结果不再互相覆盖 ✅
三步法说穿了就是"逐行标注假设 → 验证假设 → 修复假设"。但这三步做下来,比直接看代码逻辑多花 5-10 分钟,能把你从"代码对不对"的惯性里拽出来,切换到"AI 懂不懂"的视角。
这是我现在用的 review 决策表,写文章时改了改,去掉了项目敏感信息:
| 档位 | 典型代码 | review 什么 | 不 review 什么 | 耗时 | 典型翻车案例 |
|---|---|---|---|---|---|
| A | 工具函数、常量、类型定义、脚手架 | 测试链是否完整(类型检查+snapshot) | 代码逻辑本身 | 2-3 分钟 | snapshot 测试没覆盖到新文件,类型错误漏过 |
| B | 业务逻辑、API 封装、数据转换、缓存 | 上下文假设(三步法) | 语法、逻辑、代码风格 | 5-10 分钟 | 缓存键粒度不够(本文案例) |
| C | 支付、权限、事务边界、数据回填 | 逐行看代码 + 验证上下文理解 | 跳过任何东西 | 15-30 分钟 | 支付回调没处理重复通知 |
Review 检查清单(review 前扫一遍):
不是所有场景都适用。
遗留系统没有测试基础设施。 A 档不 review 的前提是类型检查和 snapshot 测试到位。如果项目没有 TypeScript strict mode,没有 Jest 配置,A 档也得逐行 review。说白了,这套策略依赖前面的防线——没有防线,review 就得加码。
纯 UI 组件不适用。 我前面说的"不 review 代码"是针对逻辑代码的。UI 组件的 review 是另一套——看布局、看交互、看状态管理,不能套用"三步法"。
"不 review 代码"不是"不 review"。 A 档和 B 档不看代码语法和逻辑细节,但还是要看代码结构——变量命名、函数拆分、模块划分。这些是代码的可维护性,跟 AI 会不会写错没关系。
回头看这个系列的四篇文章,其实都在回答同一个问题:你的验证能力决定了你能放权多少。
防线告诉你"怎么兜底",信任分级告诉你"哪些代码敢放",测试策略告诉你"写完了怎么测",review 告诉你"你怎么审"。
你的验证能力越强,你能放权给 AI 的就越多。这个逻辑跟 AI 本身没关系——跟人的工程能力有关系。