AI 代码评审:自查清单与幻觉 API 识别
让 AI 评审代码,最大的价值不是「帮你发现 bug」,而是把一份稳定的清单复核一遍——空指针、错误处理、边界、并发、资源释放、注入、脱敏。它偶尔会漏,但很少全漏。前提是你把它放在审查者位置,而不是作者位置。
角色分工:审查者不是作者
同一个模型,刚写完函数就让它「检查一下」,它倾向于复述自己的思路;换一个会话、只喂 diff,它才像审查者。有效的提示词长这样:
角色:资深 Go 后端评审者,只评审,不改写。
范围:只评审下面这段 diff,不评价文件其他部分。
输出:按 [阻塞/建议/可选] 三档分级,每条含 文件:行号、问题、触发条件、最小修复方向
禁止:泛泛的风格问题(命名、注释)、未被 diff 改动到的历史问题。
三条经验:只给 diff 加必要上下文,别让它在几千行文件里自由发挥;要求给出触发条件,「这里可能有问题」不是结论,「当 user 为 nil 且缓存命中时 panic」才是;要求分级,否则你会收到 30 条同权重建议,无法判断先做哪个。
评审清单
| 类别 | 具体关注点 | Go 常见形态 |
|---|---|---|
| 空指针 | nil map/slice 写入、nil 接口、可选字段解引用 | var m map[string]int; m[k]=1 直接 panic |
| 错误处理 | 是否吞错、是否包装、是否区分可重试 | _ = f()、用 %v 而非 %w、未特判 sql.ErrNoRows |
| 边界 | 空输入、零值、超长、正好等于阈值 | s[0] 未判空、for i := 0; i <= len(s); i++ |
| 并发 | 共享变量、锁粒度、goroutine 泄漏、闭包捕获 | 循环里 go func(){ use(i) }()、errgroup 无退出条件 |
| 资源释放 | 行集、文件、锁、事务是否必然释放 | rows.Close() 在 err 分支被跳过 |
| SQL 注入 | 拼接参数、动态表名与排序字段 | fmt.Sprintf("... WHERE name='%s'", name) |
| 日志脱敏 | 手机号、身份证、token、密码入日志 | log.Printf("req=%+v", req) 打印整个请求结构体 |
下面这段代码能同时命中四类问题:
// 有问题的写法
func (r *OrderRepo) ByUser(ctx context.Context, uid int64, orderBy string) ([]Order, error) {
q := "SELECT id, order_no, amount FROM orders WHERE user_id = " + strconv.FormatInt(uid, 10) +
" ORDER BY " + orderBy // 1) 排序字段拼接,注入点
rows, err := r.db.QueryContext(ctx, q)
if err != nil {
return nil, nil // 2) 吞错,调用方拿到 nil 错误却无数据
}
defer rows.Close()
var out []Order
for rows.Next() {
var o Order
rows.Scan(&o.ID, &o.OrderNo, &o.Amount) // 3) 忽略 Scan 错误
out = append(out, o)
}
return out, nil // 4) 未检查 rows.Err()
}
正确方向是:占位符传值、动态标识符走白名单映射、错误用 %w 包装、Scan 与 Err() 都要检查。
// 修好后的写法
var allowedOrderBy = map[string]string{
"created": "created_at DESC",
"amount": "amount DESC",
}
func (r *OrderRepo) ByUser(ctx context.Context, uid int64, key string) ([]Order, error) {
orderBy, ok := allowedOrderBy[key] // 只允许白名单里的排序字段
if !ok {
return nil, fmt.Errorf("invalid order key: %q", key)
}
q := "SELECT id, order_no, amount FROM orders WHERE user_id = ? ORDER BY " + orderBy
rows, err := r.db.QueryContext(ctx, q, uid)
if err != nil {
return nil, fmt.Errorf("query orders: %w", err)
}
defer rows.Close()
var out []Order
for rows.Next() {
var o Order
if err := rows.Scan(&o.ID, &o.OrderNo, &o.Amount); err != nil {
return nil, fmt.Errorf("scan order: %w", err)
}
out = append(out, o)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate orders: %w", err)
}
return out, nil
}
幻觉 API:识别与验证
AI 最危险的输出不是明显的逻辑错误,而是不存在的函数、参数、配置项——名字很像真的,语法也对,只是编译不过或静默不生效。
| 形态 | 例子 | 识别信号 |
|---|---|---|
| 版本错位 | 建议用 slices.Contains、errors.Join,项目还在 Go 1.19 | 拼写正确但 undefined,先查该 API 的最低版本 |
| 参数错位 | 记混 http.Server 字段名或 sql.DB 方法签名 | 参数顺序、个数与文档不符 |
| 拼接函数名 | db.QueryRowContextAll、strings.TrimPrefixAll | 名字符合惯例但标准库里不存在 |
| 配置项幻觉 | skip-grant-table(正确写法是 skip-grant-tables) | 官方文档搜不到,只在某篇博客里出现过 |
| 假出处 | 引用不存在的 issue 编号、含混的「某版本之后支持」 | 在官方 changelog 里定位不到 |
验证成本很低,养成本能即可:
go doc strings.CutPrefix # 存在吗?签名对吗?
go doc database/sql.DB.QueryContext
go build ./... && go vet ./... # 编译与静态检查是第一道闸
go list -m -versions github.com/gin-gonic/gin
规则很简单:没有编译通过、没有 go doc 确认、没有官方文档出处的 API,不进代码。配置项同理,先在官方文档里搜到再改配置文件。
检查清单
| 检查项 | 通过标准 |
|---|---|
| 评审范围 | 只评审本次 diff,历史问题另开单 |
| 空值路径 | 每个解引用点有 nil 判断或上游保证 |
| 错误路径 | 没有 _ = 吞错,包装保留错误链,可重试错误可区分 |
| 并发安全 | 有 -race 跑过的证据,goroutine 有退出条件 |
| 资源释放 | 行集、文件、锁、事务在 err 分支也释放 |
| 注入面 | 外部输入走占位符,动态标识符走白名单 |
| 日志内容 | 无 token、密码、完整手机号等敏感字段 |
| API 真实性 | 新增函数与配置项均有官方文档或 go doc 依据 |
常见坑
- 同一会话既写又审:模型会维护「我刚写的没错」的立场,换会话、只喂 diff 效果明显更好。
- 把建议当阻塞:AI 一次给很多条,全盘接受会拖慢迭代;先修阻塞项,建议项评估后再做。
- 只看结论不看触发条件:没有触发条件的问题无法验证,也写不出覆盖它的测试。
- 忽略它漏报的类别:模型对业务语义不敏感(额度、状态机、幂等性),这部分必须人工过一遍。
- 为让幻觉 API 编译而乱改调用方:把调用方改成迁就一个不存在的接口,损失远大于直接换掉它。
小结:AI 评审的价值在于稳定复核一份清单——空指针、错误处理、边界、并发、资源释放、注入、脱敏;让它当审查者、只给 diff、要求分档并给出触发条件,比让它当作者更有效。同时把幻觉 API 当常态对待:每个新增函数与配置项,都要经过 go doc、编译和官方文档三重确认才落进仓库。