AI 代码的验证:一次"测试全绿、上线就崩"之后的改造

本文摘要AI 代码的验证:一次"测试全绿、上线就崩"之后的改造本文来源: 哪吒三太子(公众号:哪吒三太子) 原文链接: https://mp.weixin.qq.com/s/WLDzX9TqViI8Uk_6zamPZg 发布时间: 2026-09-17 08:00作者: 哪吒三太子  发布时间: 2026-09-17 08:00摘要:单元测试全绿、覆盖率 92%、CI 一路通过,合并三天后线上炸了。复盘发...

AI 代码的验证:一次"测试全绿、上线就崩"之后的改造

本文来源: 哪吒三太子(公众号:哪吒三太子)
原文链接: https://mp.weixin.qq.com/s/WLDzX9TqViI8Uk_6zamPZg
发布时间: 2026-09-17 08:00

AI 代码的验证:一次"测试全绿、上线就崩"之后的改造

作者: 哪吒三太子  发布时间: 2026-09-17 08:00


摘要:单元测试全绿、覆盖率 92%、CI 一路通过,合并三天后线上炸了。复盘发现,测试里有一条断言写的是 assert result is not None——它跑了那行代码,却没验证结果。AI 写代码之后,这样的"假通过"正在变多。

一个全绿的 PR,三天后炸了

上周 review 一个 PR:AI 写的,300 多行,单元测试全绿,覆盖率 92%,CI 一路通过。我点了合并。

三天后,线上炸了。

复盘时找到的原因有点讽刺——那段代码里有个边界判断被写反了,而测试里恰好有一条断言写的是 assert result is not None。它执行了那行代码,让覆盖率变绿了,但对"结果对不对"什么都没做。

这不是个例。有团队实测过一个 Python 模块:行覆盖率 100%,变异得分(mutation score)只有 4%。测试把每一行都跑过一遍,却没有一行真正验证结果。

再看两组数字:

• AI 生成的 PR 合并率约 32.7%,人写的约 84.5%——大量 AI 代码卡在队列里没人敢合

• 约 60% 的 AI 相关故障属于"静默失败":测试过了,评审也过了,在生产某个边缘 case 上安静地崩

这三个事实指向同一件事:AI 没有让"写代码"变难,它让"判断这段代码能不能上线"变难了。

我们后来把验证拆成了三道关卡,分别对应三个问题:人审不动、验证可能是假的、要验的量太大。

第一道关卡:机器先审,人做判断

它解决什么问题

AI 一次改动 P75 超过 400 行,没人能逐行看完;31.3% 的 PR 是零评审直接合并的。

解法不是让人更努力地看,而是让机器先做一轮过滤,人只看机器看不了的部分——跨切面改动、业务正确性、架构一致性。

工具与地址

PR-Agent,Apache 2.0 开源:

• 官方仓库:https://github.com/The-PR-Agent/pr-agent

• Docker 镜像:pragent/pr-agent(v0.34.2 之后的新命名空间,旧的 codiumai/pr-agent 已冻结)

• 支持 GitHub / GitLab / Bitbucket / Azure DevOps / Gitea

• 模型可换:OpenAI / Claude / Gemini / DeepSeek / OpenRouter / Ollama

网上大量教程还指向 Codium-ai/pr-agent,项目已移交社区维护,照旧教程配置容易拉到过期地址。

15 分钟接入

1. 准备 Key。 任选一家(OpenAI / Anthropic / DeepSeek),进入仓库 Settings → Secrets and variables → Actions,新建 Secret OPENAI_KEY

2. 新建 .github/workflows/pr-agent.yml:

name: PR Agent

on:

pull_request:

types: [opened, reopened, ready_for_review]

issue_comment:

types: [created]

jobs:

pr_agent_job:

不加这行,机器人评论会触发机器人,无限循环

if: ${{ github.event.sender.type != 'Bot' }}

runs-on: ubuntu-latest

permissions:

issues: write

pull-requests: write

contents: write

steps:

  • name: PR Agent action step

uses: the-pr-agent/pr-agent@main

env:

GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

OPENAI_KEY: ${{ secrets.OPENAI_KEY }}

换模型只需改 env:

Claude

ANTHROPIC.KEY: ${{ secrets.ANTHROPIC_KEY }}

CONFIG.AI_PROVIDER: "anthropic"

CONFIG.MODEL: "claude-sonnet-4-6"

DeepSeek(成本更低)

OPENAI.KEY: ${{ secrets.DEEPSEEK_API_KEY }}

OPENAI.API_BASE: "https://api.deepseek.com/v1"

CONFIG.MODEL: "deepseek/deepseek-chat"

3. 提交 PR。 之后每次开 PR,CI 自动跑。

4. 在 PR 评论区下命令,这是它的主要交互方式:

/review     # 全面审查:安全、逻辑、风格

/describe   # 自动生成 PR 标题、摘要、变更类型、标签

/improve    # 逐行改进建议,输出可直接 commit 的 diff

/ask 这段并发逻辑在高并发下会不会有问题?

每条命令一次 LLM 调用,约 30 秒出结果,单个 PR 成本几分钱。

本地先试也可以:

pip install pr-agent

export OPENAI_KEY=your_key_here

pr-agent --pr_url https://github.com/owner/repo/pull/123 review

决定成败的一步:让它别什么都评论

多数团队失败不是模型不行,而是它什么都要评论,最后被开发者整体忽略——连同有价值的意见一起。

仓库根目录新建 .pr_agent.toml:

[config]

model = "gpt-4o"

response_language = "zh-CN"     # 中文团队务必打开,输出变中文

temperature = 0.1                # 越低越稳定

[pr_reviewer]

require_score_review = true      # 工作量评估(S/M/L/XL)

require_security_review = true   # 专项安全检查

require_tests_review = true      # 是否配套写了测试

num_code_suggestions = 4         # 限制条数,避免刷屏

inline_code_comments = true      # 行内评论,精确到行

extra_instructions = """

本项目重点关注:

  1. 并发问题(goroutine 泄露、竞态、锁粒度)
  2. 错误处理是否吞掉异常
  3. 数据库操作是否有事务边界
  4. 对外接口的输入校验

以下情况禁止评论:

  • 纯命名与代码风格偏好
  • 不影响正确性的重构建议
  • 没有明确改法的空泛建议

"""

[ignore]

glob = [".lock", ".min.js", "dist/", ".generated.", "docs/", "*.md"]

那段"禁止评论"必须写。[ignore] 同样关键——排除 lock 文件与构建产物,是降噪最有效的一招。

三个容易踩的坑

1. 别为跑通 fork PR 换成 pull_request_targetpull_request 事件对 fork PR 默认不暴露 secrets,这是 GitHub 的安全设计;换成 pull_request_target 等于把 Key 交给任何外部提交者。fork PR 由维护者手动触发 /review

2. 机器人触发死循环。issue_comment 用于评论交互,但必须加 if: github.event.sender.type != 'Bot'

3. 确认只发 diff 不发明文全库。 敏感仓库优先自托管 + 内网模型。该项目曾因凭证暴露问题(#2445)临时禁用过 /help_docs

第二道关卡:验证"验证"本身

第一道关卡保证有人看代码,这一道保证"通过"是真的

覆盖率为什么会骗人

行覆盖只统计"这行有没有被执行",完全不关心"有没有断言它的结果"。AI 特别容易写出只调用不断言的测试,让数字好看、CI 变绿,然后什么都不保护。

变异测试就是用来戳破这个的:工具自动往代码里注入微小错误(变异体),再跑测试。

• 测试报错 → 变异体被杀死 → 这条测试真的在保护代码

• 测试依然通过 → 变异体存活 → 这里缺一条有效断言

常见注入:操作符替换(> 变 >=+ 变 -)、返回值改写、删掉判断分支。

工具

语言工具安装
Pythonmutmutpip install mutmut
JS/TSStrykernpm i -D @stryker-mutator/core
JavaPITMaven / Gradle 插件

三条命令

1) 先出覆盖率,再只变异"被测试覆盖到的行"

   没被覆盖的行变异了也抓不到,纯浪费时间

pytest --cov=src --cov-report=xml

mutmut run --paths-to-mutate=src/ --use-coverage --no-progress

2) 并行执行(套件 10 秒内跑完时,4 进程通常提速 2-3 倍)

mutmut run --paths-to-mutate=src/ --processes 4

3) 看结果 / 只看存活 / 出 HTML 报告

mutmut results

mutmut results --survived

mutmut html        # html/index.html,红色行就是薄弱点

mutmut 状态存在 .mutmut-cache(SQLite),跨分支持久化,已杀死的变异体不重复跑——这是它能进 CI 的前提。

做成 CI 门禁

name: Mutation Gate

on:

pull_request:

paths:

  • 'src/core/**'
  • 'src/payment/**'

jobs:

mutation:

runs-on: ubuntu-latest

steps:

  • uses: actions/checkout@v4
  • uses: actions/setup-python@v5

with:

python-version: '3.12'

  • run: pip install pytest pytest-cov mutmut
  • name: Coverage first

run: pytest --cov=src --cov-report=xml

  • name: Run mutation

run: mutmut run --paths-to-mutate=src/core/ --use-coverage --processes 4 --no-progress

  • name: Gate

run: |

不同 mutmut 版本输出字段略有差异,首次请先本地跑一次确认

mutmut results | tee mutation-report.txt

SCORE=$(grep -oP 'Mutation score\s:?\s\K[0-9]+' mutation-report.txt || true)

echo "mutation_score=${SCORE:-unknown}"

if [ -n "$SCORE" ] && [ "$SCORE" -lt 80 ]; then

echo "FAIL: mutation score ${SCORE}% < 80%"

exit 1

fi

  • uses: actions/upload-artifact@v4

with:

name: mutation-report

path: mutation-report.txt

阈值不要一刀切,这决定能否持续:

模块类型建议门槛
核心 / 资金 / 安全相关≥ 80%
一般业务模块60-70%
适配层 / 胶水代码不设门禁,观察即可

把漏网之鱼喂回给 AI

下面是我的代码,以及它的一个变异体(注入了一个 bug),但我的测试没有发现它:

【原始代码】

【变异后】

请只做一件事:写出一条能杀死这个变异体的 pytest 测试。

要求断言具体到数值或状态,不要用 assert x is not None 这类弱断言。

不要修改业务代码。

比笼统说"帮我补测试"有效得多——给了它一个具体到不能再具体的靶子。

第三道关卡:从源头减少要验的量

前两道是"怎么验",这一道是让需要验的东西变少

PR 体积上限

最立竿见影的一条。diff 压到人类愿意看的规模,前面的评审才可能发生。

CI 中加入:改动超 150 行直接失败,要求拆分

LINES=$(git diff --shortstat origin/main...HEAD | awk '{print $4}')

echo "changed lines: $LINES"

if [ "$LINES" -gt 150 ]; then

echo "FAIL: diff too large ($LINES > 150). Split this PR."

exit 1

fi

配套政策:禁止零评审合并

复杂度与函数长度

AI 容易写出几百行的大函数,人一看就放弃 review。

pip install radon

radon cc src/ -a -s     # 圈复杂度,建议 >10(C 级以上)报警

断言密度

粗略自检每个测试文件的断言密度

pytest --collect-only -q | wc -l

grep -rc "assert" tests/ | awk -F: '$2 < 3 {print "低断言文件:", $1}'

断言数是形式,变异得分是实质——两者结合才有效。

先写判据,再让 AI 写代码

投入产出比最高的动作。动手之前,先把"什么算合格"落成判据:

写代码前先列出验收标准:

  • 功能性:输入输出映射,列出 3 个具体边界值
  • 可靠性:异常输入的预期行为,失败是否需要重试或回滚
  • 安全性:输入是否需校验,有无敏感信息落日志
  • 可维护性:圈复杂度是否超 10,单函数是否超 50 行
  • 性能:预期 QPS,是否存在 N+1 查询

列完再写代码,并把每一条写成测试。

AI 最擅长满足你写下来的那部分,最难补的是你没写的那部分。把隐含需求显式化,返工率会明显下降。

落地节奏

一次性上全套的团队基本都失败。建议这样推进:

第 1 天(30-60 分钟)

☑ 接入 PR-Agent,写好 .pr_agent.toml 的"禁止评论"清单

☑ 找 3-5 个真实 PR 跑 /review,人工统计"有用评论占比"

第 1 周

☑ 有用率低于 50% 就继续收紧 prompt,先别扩大范围

☑ CI 加 PR 体积门禁(>150 行 fail)

☑ 对一个核心模块建立变异得分基线

第 2-3 周

☑ 核心模块变异门禁上线(阈值 80%)

☑ 把 mutmut html 的红色区域当每周补测试的任务清单

☑ 每周跟踪两个指标:中位评审耗时、逃逸到生产的 bug 率

判断成败的标准很简单:两周后,团队是主动去看评审意见,还是已经装了忽略插件。

排错表

症状原因解法
评审意见被当噪音忽略什么都要评论,误报率高收紧 extra_instructions,写明"禁止评论"清单;先只开安全和缺测试两类
PR 一评论就无限循环机器人触发机器人加 if: github.event.sender.type != 'Bot'
改了配置没生效用了旧的 action 地址换成 the-pr-agent/pr-agent@main
变异测试跑几小时,CI 超时全仓跑且没用 coverage 过滤加 --use-coverage,限定 --paths-to-mutate,只对特定路径触发
变异得分长期很低补不动测试写成了"只调用不断言"用上面那段 prompt 把存活变异体喂回 AI 补断言
fork PR 的自动评审不工作安全设计,secrets 不暴露给 fork维护者手动 /review;别换 pull_request_target
团队抵触"AI 审我的代码"定位成了挑错定位成第一道过滤,人做最终判断;AI 评论不设成合并硬门槛

回到开头那个 PR。它的问题不在于 AI 写得差,而在于我们的验证体系默认了"通过"等于"正确"

这个默认在 AI 时代失效了。当写代码趋近于免费,决定"这段代码能不能上线"的判断力,就成了整条交付链上最贵的能力。

你们团队踩过"测试全绿、上线就崩"的坑吗?最后是怎么查出来的?评论区聊聊 👇


本文转载自微信公众号「哪吒三太子」,仅供学习交流使用。

觉得内容不错?我要

打赏杯咖啡或蜜雪冰城吧
微信扫一扫
微信赞赏码
支付宝扫一扫
支付宝赞赏码
评论 暂无评论
请登录后参与评论