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

37 阅读6分钟

工智能测试CI/CD

摘要:单元测试全绿、覆盖率 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_target pull_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 门禁

.github/workflows/mutation.yml,只对核心模块触发(全仓跑会慢到不可接受):

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

mutmut html 会标出哪些行没被真正验证。把对应变异体直接贴给 AI:

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

【原始代码】
<code>

【变异后】
<diff>

请只做一件事:写出一条能杀死这个变异体的 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 时代失效了。当写代码趋近于免费,决定"这段代码能不能上线"的判断力,就成了整条交付链上最贵的能力。

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

出处:公众号《AI 代码的验证:一次测试全绿、上线就崩之后的改造》,转载请注明。