AI 代码审查召回率实测:20 个骗过单元测试的变异体,抓到 13 个

上一篇写了 AI 代码审查的精确率:1919 行 Java,报 18 条,8 条真、8 条假、2 条待定,精确率 50%。

那篇只回答了"报得准不准"。这篇回答另一个问题:它会不会漏。

方法不是"找几个人评一评感受",是往代码里注入已知缺陷,让它盲审,数命中率。下面把设计、结果、漏检原因和成本全部摊开,方法可以照抄。

结论

  • 注入 23 个变异体,3 个被现有单元测试抓住并淘汰,20 个进入盲测
  • 盲测命中 13 个,召回率 65%
  • 并发类 4/4 全中;安全 3/4,空值、异常、资源各 2/4
  • 漏掉的 7 个里,5 个是"删掉或换掉一个防御步骤"
  • 20 次盲测约 165 万 token,单次 5 万--15.4 万

为什么不测真实缺陷

最理想的语料是线上事故和人工评审记录。拿不到:项目是个人工程,25 个 commit 全是自己提交,没有一条评审意见,也就没有 ground truth。

退而求其次,用变异测试(mutation testing)的思路:自己注入缺陷,自己当裁判。 代价是变异体的分布和真实缺陷不同,结论只能说明"对哪类问题敏感",不等于真实漏检率。这一点后面还会再提一次。

实验对象

AI Agent Lab,一个 Spring AI 练习工程。Java 17、Spring Boot 4.1.1、Spring AI 2.0.1、DeepSeek、Spring MVC + SSE、Caffeine、ONNX Runtime。src/main/java 34 个文件、1919 行,34 个单元测试全绿。

基线快照打 tag review-baseline(commit c5477f4)。

实验设计

1. 变异体:5 类 × 4 个

五类缺陷,每类 4 个,每条只改一处,且必须能在本项目里自然落地:

类别 例子 候选落点
null-boundary 空集合取首元素、越界读、拆箱 NPE RagService 的 topK、LexicalReranker 的空候选
concurrency 非线程安全集合、检查后执行、共享可变状态 BoundedChatMemoryRepository、ModelFileCache
exception 吞异常、错误码映射错、catch 过宽 ApiExceptionHandler、ChatService
resource 流未关闭、SSE 未释放、关闭顺序错 ChatController、RagService
security 日志打印 PII / 签名 URL、输入未校验 ChatController、ModelFileCache

硬约束:变异体只放 src/main/java,不改测试;一条一个分支 exp/mutants/M-XX,从 review-baseline 拉出,不合并回 main。

2. 用测试筛掉"太容易"的

每个变异体应用后跑 ./mvnw test。测试变红即作废,在同类补一个更隐蔽的。

结果:23 个里 3 个被抓住,全部在安全类。

mutant 改法 为什么被抓住
M-18 error 事件发送 error.getMessage() 而不是通用文案 有断言校验对外错误文案
M-20 去掉 ConversationIds 的 hasText 判断 有空会话 ID 用例
M-20R 路径拼接越界 有路径穿越用例

补了两条替补:M-18R(日志输出全量订单表)、M-20B(流式入口打印用户消息)。

现有测试唯一能主动抓住的,是有现成断言的那几条。 进入盲测的 20 个,正是测试覆盖不到的地方,AI 在这里补位才有意义。

3. 盲测协议

  1. 用固定种子(20261027)把 20 个变异体打乱成 B-01...B-20,映射表单独存,审查时不可见。
  2. 审查输入只包含被变异的文件,加上它直接依赖的接口签名;不给分支名、commit message、改法说明。
  3. 审查 prompt 冻结在 v1,与上一篇基线审查完全一致,不加"这里有没有 bug"之类的暗示。
  4. 模型固定(claude-sonnet),中途不换。
  5. 先全部注入、打乱,再统一审查------不做一条审一条,避免后一次受前一次影响。
  6. 判定完成后再对照映射表,记录 caught_by_ai。

一个必须说明的缺口:CLI 不暴露温度参数,原计划对同一模块跑 3 次量化方差,这次没做。所以下面没有重复运行的一致性数据。

结果

逐条判定

blind mutant 类别 变异 命中
B-01 M-03 null-boundary 删除空查询保护 ✗
B-02 M-14 resource 先发布 session 后发布 tokenizer ✓
B-03 M-10 exception 可重试错误 503 → 502 ✗
B-04 M-13 resource 不关闭知识库输入流 ✓
B-05 M-06 concurrency 先发布空向量库 ✓
B-06 M-11 exception 非 200 分支不清理 .part ✓
B-07 M-18R security 日志输出全量订单表 ✓
B-08 M-09 exception catch 扩大到 Exception ✓
B-09 M-15 resource close() 先置空再关闭 ✗
B-10 M-08 concurrency 删除锁内二次检查 ✓
B-11 M-12 exception 去掉 IllegalStateException 捕获 ✗
B-12 M-01 null-boundary 删除空候选提前返回 ✗
B-13 M-04 null-boundary days >= 0 → days > 0 ✓
B-14 M-19 security 日志追加完整签名 uri ✗
B-15 M-16 resource 删除 onCompletion 清理 ✗
B-16 M-17 security chat() 打印用户消息 ✓
B-17 M-07 concurrency 删除失败退避读取 ✓
B-18 M-20B security 流式入口打印用户消息 ✓
B-19 M-02 null-boundary position <= candidates.size() ✓
B-20 M-05 concurrency 存入调用方可变列表 ✓

按类别

类别 命中 / 总数 召回率
concurrency 4 / 4 100%
security 3 / 4 75%
null-boundary 2 / 4 50%
exception 2 / 4 50%
resource 2 / 4 50%
合计 13 / 20 65%

漏检分析

7 条漏检,逐条看。

1. B-12 删除空候选提前返回(RagService:99)

原来候选为空时提前返回,变异体删掉这段,空检索结果会继续走 LLM,答案凭空生成。AI 完全没提。

2. B-01 删除空查询保护(LexicalReranker:33)

空查询集导致除零,分数变成 NaN,排序结果随机。没提。

3. B-15 删除 onCompletion 清理(ChatController:64)

客户端断开后订阅不释放,模型继续生成。没提。

4. B-11 去掉 IllegalStateException 捕获(ChatController:156)

连接已关闭时抛出的 IllegalStateException 不再被吞,会往外抛。这一条最典型:AI 讨论了同一个方法的另一个问题(send 失败后没有 complete),说明方法读完了,只是没看出少了一个 catch 类型。

5. B-09 close() 先置空再关闭(CrossEncoderReranker:97)

并发请求在关闭窗口内拿到 null 或已关闭对象。注意:这条和本轮命中的 B-05(先发布空向量库)是同一个形状,一次中、一次漏------说明模式识别不是稳定规则。

6. B-03 状态码 503 → 502(ApiExceptionHandler:56)

可重试错误被映射成不可重试。代码里两个数都合法,看不出"错",错在客户端重试策略,那层不在输入里。

7. B-14 日志追加完整 uri(ModelFileCache:125)

签名参数进日志。另外三处日志泄露是新增整行打印,全部命中;往一行已有日志里加一个参数,漏了。

归纳

  • 漏检的 7 条里,5 条是删掉或换掉一个防御步骤:B-01、B-09、B-11、B-12、B-15。代码本身完全合法,没有一处"看起来不对"。
  • 命中的几乎都是"有形状"的东西:锁的位置、volatile、set 和 add 的先后、新增的日志行、收得过宽的 catch。
  • 它审的是写出来的代码,不是没写出来的代码。

另一个反例是数值改动:B-13(>= → >)命中了,因为数值被翻译成了语义("0 天被判成已过去");B-03(503 → 502)没中,因为字面差异不构成错。线索是语义,不是数值。

成本

指标 值
盲测 token 合计 约 165 万
单次 token 区间 5 万 -- 15.4 万
单次成本与文件大小的相关性 弱

比基线审查(10 次运行、58.7 万 token)贵将近三倍,原因是运行次数翻倍,且每次都带完整上下文。贵的还是读文件的轮次,不是代码行数。

局限

  1. 单项目、单人判定:一个 2000 行规模的项目,判定由作者一人完成,主观性无法消除。
  2. 变异体是人为构造的:分布与真实缺陷不同。65% 只说明"对这类注入缺陷的敏感度",不等于真实漏检率。
  3. 样本量小:20 个,单个命中就是 5 个百分点,不要把 65% 当成精确值。
  4. 两类指标语料不同 :上一轮的 50% 精确率来自真实代码,这一轮的 65% 召回率来自人工注入,不能合成 F1。
  5. 没有重复运行:单次运行,无稳定性数据。

工程建议

  1. 给 diff,不要只给文件。 删除型缺陷在 diff 里表现为"少了几行",可见;给整个文件,那几行根本不存在。
  2. 别指望它补"少写"的检查。 判空、finally、清理回调、异常分支,要靠测试和 diff review 兜底。
  3. 用它补测试难写的部分。 并发、资源、异常路径测试成本高,AI 命中率最高------这次并发 4/4。
  4. 只采纳能落到行号、触发条件、后果的发现。 说不出来的当噪音。
  5. 先让测试筛一遍变异体。 测试能抓的交给测试,AI 只处理测试的盲区,这样两边的账都算得清。

复现线索

  • 基线:tag review-baseline,commit c5477f4
  • 变异体:23 个分支 exp/mutants/M-01...M-20B,一条一个改法
  • prompt:冻结的 review-prompt-v1.md,核心三条纪律------只报能指到行号的、写清触发条件与后果、需要未知代码才能判断的标 needs_context 不要假设
  • 盲测:固定种子打乱编号,输入只给被变异文件 + 直接依赖接口,判定后再对映射表
  • 成本记录:每次运行的输入/输出 token 与耗时

完整记录(含 prompt、原始输出 JSON、逐条判定理由)在项目 docs/experiments/ 下。


数据与判定由作者人工核对。这是一个个人项目的单次实验,样本量和语料都有明显边界,结论请按自己的工程形态打折使用。

相关推荐
浮链序4 小时前
别让 AI 客服把用户当猴耍:百来行 Python 把「转人工」做成一条高可用链路
后端
属于自己的天空4 小时前
Claude Code 插件完全指南:官方市场怎么用,怎么自己写(v2.1.269+)
后端
Tokenfree5 小时前
给大模型调用加一个熔断器:60 行代码,三个阈值
后端
Jason_chen5 小时前
Linux 进程调度器深入解析与实时性优化指南(基于 Linux 5.10+)
后端
136096757235 小时前
三个 Agent 干同一个活,职责边界到底该怎么划
后端
房西杭5 小时前
GM 指令:修改玩家属性
后端
胡写代码5 小时前
删掉的数据还占着唯一键:逻辑删除 + 唯一索引的四次取舍
spring boot·后端
IT_陈寒5 小时前
React的useEffect依赖数组把我坑惨了
前端·人工智能·后端
用户8356290780515 小时前
Python 调整 Excel 工作表显示设置
后端·python