上一篇的批量分诊用起来很爽,但它有个断点:得有个人想起来,把日志和截图复制、粘贴、发给 AI。 人一忙、一到周五、一放假,流程就退回老样子——CI 红了,重跑;绿了,合入。

要真正把"翻红灯"的人力干掉,得让分诊长在流水线里:测试一红,下一个动作不是等人来看,而是 AI 自动把"这是真缺陷、flaky、环境还是脚本问题,证据在哪,建议谁处理"贴到 PR 评论区。你早上打开 PR,结论已经在那儿了。

这一篇是照着做就能跑通的落地指南:用 GitHub Actions + Python,接任意一个兼容 OpenAI 接口的模型,约半小时接完。模型怎么选,见 2.2 节对照表;用 GitLab CI / Jenkins 的,骨架完全一样,文末给了对照。

但开工前先立一条从第一天就不能越的线:AI 可以自动地"看"和"说",但不能自己决定"这次红灯算了,放行"。

一、先分级:自动化的边界沿"可逆性"画

接 CI 最容易犯的错,是一看大模型能写代码,就想一步到位"红灯→AI 自己改→自己合"。先按动作的风险和可逆性分三级,从最低风险那级开始,用时间换信任。后面几章就是把这三级逐个落地:

级别 AI 自动做什么 风险 什么时候开
L1 只看只说 失败自动归因、分类,把证据和置信度贴到 PR 几乎为零,只读不写 第一天就开(本篇第二章直接做)
L2 有界、可逆动作 给确认是 flaky 的打标隔离、重跑确认、建草稿缺陷单 动作可审计、可一键撤回 L1 稳定跑几周后(第四章)
L3 改代码 自动改脚本/产品代码、提 PR,甚至自动合 可能改错还把测试弄绿,进了主干 默认关闭,必须人审(第五章)

一句话记住:别问"AI 能不能做",问"它做错了能不能一秒撤回"。
在这里插入图片描述

二、照着做:约半小时把 L1 接完(GitHub Actions + 任意兼容模型)

先把最小闭环跑通:测试红了 → 自动读失败报告 → 大模型四分类 → 在 PR 下贴一条分诊评论。 这一版只靠文本报告,不依赖截图,先保证 100% 能跑;规则兜底、flaky 隔离都是后面的增量。

2.1 它是怎么转起来的(先建立全貌,再动手)

GitHub Actions 是 GitHub 自带的流水线:你在仓库放一个 .github/workflows/ci.yml,以后每次 push / 开 PR,它就自动租一台临时虚拟机,按 YAML 里的步骤执行。我们这里跑两个 job:

  1. test:装环境、跑 pytest、产出 report.xml;失败就把报告存成 artifact;
  2. ai-triage:只有 test 红了才触发,下载报告、调模型分诊、把结果贴到 PR 评论区。它被刻意设成"失败也不影响流水线"——AI 挂了,测试结论照旧。 在这里插入图片描述

2.2 先选模型(30 秒):这活儿用"小模型快速档",不是旗舰

先说选型原则,别被"接 AI"三个字带偏到最贵的模型上:

常见可选(模型名迭代很快,以各家控制台当时的"小模型档"为准):

你的情况 提供方 LLM_BASE_URL LLM_MODEL 取值
国内直连、便宜、注册即用,最省事的起步 DeepSeek https://api.deepseek.com deepseek-chat
已在用 OpenAI / 要海外服务 OpenAI https://api.openai.com/v1 控制台里的 mini 档模型名
希望顺带读失败截图(多模态)、或在 Google 生态 Google Gemini https://generativelanguage.googleapis.com/v1beta/openai/ 控制台里的 Flash 档模型名
数据不能出公司 自建/私有部署 内网网关地址 部署的模型名

这篇的截图和步骤以"最省事的起步项"填值演示,但默认值不写死在代码里。能稳定连 Anthropic 的团队,直接用 Haiku 档(claude-haiku-5-5)也是同类选择;如果连脚本都不想自己维护,第五章会提到 GitHub 上现成的托管 Action。

2.3 准备好三样东西

your-repo/
├── .github/
│ └── workflows/
│ └── ci.yml # 流水线:跑测试 + 失败触发 AI 分诊
├── scripts/
│ └── ai_triage.py # 读 report.xml,调模型,写 triage.md
├── requirements.txt # 依赖
└── tests/ # 你原有的测试
└── ...

requirements.txt:

pytest>=8
openai>=1.40

2.4 文件一:.github/workflows/ci.yml

name: ci
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - run: pip install -r requirements.txt

      # pytest 失败会让本 job 变红——这是触发分诊的信号,不要加 continue-on-error
      - name: 跑测试,产出 JUnit 报告
        run: pytest --junitxml=report.xml

      # job 红了以后默认会跳过后续步骤,所以这里必须 always(),把报告留下来
      - name: 保存失败报告
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: failure-evidence
          path: report.xml
          if-no-files-found: ignore

  ai-triage:
    needs: test
    if: always() && needs.test.result == 'failure'   # 只有测试红了才花钱调模型
    runs-on: ubuntu-latest
    permissions:
      contents: read     # checkout、下载产物所需的只读权限
      # PR 下贴普通评论用的是 issues 接口,必须给 issues: write(不是 pull-requests)
      issues: write
      pull-requests: write   # L2 做行内 review / 建草稿单时才用得到,最小化可先只留 issues

    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: failure-evidence                     # 下载到仓库根目录,得到 report.xml

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install openai

      - name: AI 分诊(出错也不阻断流水线)
        continue-on-error: true
        run: python scripts/ai_triage.py
        env:
          LLM_BASE_URL: ${{ vars.LLM_BASE_URL }}     # 模型地址(非密,放 Variables)
          LLM_MODEL:    ${{ vars.LLM_MODEL }}         # 模型名(非密,放 Variables)
          LLM_API_KEY:  ${{ secrets.LLM_API_KEY }}    # Key(密,放 Secrets)

      - name: 把分诊结果贴到 PR
        if: github.event_name == 'pull_request'     # push 到主干没有 PR,这步自动跳过
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const body = fs.readFileSync('triage.md', 'utf8');
            await github.rest.issues.createComment({
              ...context.repo,
              issue_number: context.issue.number,
              body
            });

      # 非 PR 触发(直接 push 到分支)没有评论区,把结果存成产物,可在流水线页面下载
      - name: 保存分诊结果(兜底)
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: triage-result
          path: triage.md
          if-no-files-found: ignore

2.5 文件二:scripts/ai_triage.py

它做三件事:解析 JUnit 里的失败用例 → 调模型按固定四分类输出 JSON → 渲染成一张 Markdown 表写进 triage.md。用的是通用 openai 库,模型地址和名字全部来自环境变量。

# scripts/ai_triage.py
"""CI 失败时被调用:读 report.xml,调 OpenAI 兼容模型分诊,输出 triage.md。"""
import json
import os
import xml.etree.ElementTree as ET

from openai import OpenAI

# 三个值全部来自环境变量,换模型只改 CI 里的配置、不动代码
BASE_URL = os.environ.get("LLM_BASE_URL", "")
MODEL = os.environ.get("LLM_MODEL", "")
API_KEY = os.environ.get("LLM_API_KEY", "")

REPORT_PATH = "report.xml"
OUT_PATH = "triage.md"


def write(text):
    with open(OUT_PATH, "w", encoding="utf-8") as f:
        f.write(text)


def cell(value):
    # 表格单元格清洗:吃掉竖线和换行,避免撑坏 Markdown 表
    return str(value or "").replace("|", "丨").replace("\n", " ").strip()


def parse_failures(path):
    failures = []
    root = ET.parse(path).getroot()
    for case in root.iter("testcase"):
        # <failure> 是断言失败;<error> 是 fixture/用例外的异常,红灯时同样要分诊
        node = case.find("failure")
        if node is None:
            node = case.find("error")
        if node is not None:
            failures.append({
                "name": f"{case.get('classname', '')}::{case.get('name', '')}",
                "error": (node.text or "")[:800],
            })
    return failures


def main():
    missing = [n for n, v in (("LLM_BASE_URL", BASE_URL),
                              ("LLM_MODEL", MODEL),
                              ("LLM_API_KEY", API_KEY)) if not v]
    if missing:
        write("⚠️ AI 分诊跳过:未配置 " + "、".join(missing) + "。")
        return

    failures = parse_failures(REPORT_PATH)
    if not failures:
        write("测试失败但没从 report.xml 解析到失败用例,请查看完整日志。")
        return

    client = OpenAI(base_url=BASE_URL, api_key=API_KEY)

    # 显式要求只输出 JSON,并配合 response_format 让各家的 JSON 模式稳定生效
    prompt = (
        "你是 CI 失败分诊员,只能依据下面给出的失败信息判断,不许编造。\n"
        "每条失败在以下四类中选一:真缺陷 / flaky / 环境问题 / 脚本问题"
        "(脚本问题包括业务改版导致脚本不适配、以及脚本自身写错)。\n"
        "只输出一个 JSON 对象,格式为 "
        '{"items":[{"name","category","confidence","evidence","suggestion"}]};'
        "confidence 取 高/中/低;evidence 必须引用错误原文;"
        "凡是疑似真缺陷或置信度为中/低,suggestion 一律写\"需人工确认\";"
        "禁止建议用 sleep、重试、跳过用例来掩盖红灯。\n"
        f"失败用例列表:{json.dumps(failures, ensure_ascii=False)}"
    )

    resp = client.chat.completions.create(
        model=MODEL,
        messages=[{"role": "user", "content": prompt}],
        response_format={"type": "json_object"},
    )
    items = json.loads(resp.choices[0].message.content).get("items", [])

    lines = [
        "## 🤖 AI 失败分诊(仅供参考,红还是红,需人确认)",
        "",
        "| 用例 | 分类 | 置信度 | 证据 | 建议 |",
        "| --- | --- | --- | --- | --- |",
    ]
    for it in items:
        lines.append(
            f"| {cell(it.get('name'))} | **{cell(it.get('category'))}** | "
            f"{cell(it.get('confidence'))} | {cell(it.get('evidence'))} | "
            f"{cell(it.get('suggestion'))} |"
        )
    write("\n".join(lines))


if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        # 任何异常都优雅降级:分诊是锦上添花,绝不能把流水线搞炸
        write(f"⚠️ AI 分诊暂不可用({type(e).__name__}: {e}),请按原流程人工查看。")

2.6 配置三个模型参数

在 GitHub 仓库进入 Settings → Secrets and variables → Actions,这里有两个标签页:

Variables 标签页(不保密的地址和模型名)→ New repository variable,建两个:

Name Value(以 DeepSeek 起步为例,换模型照 2.2 表改)
LLM_BASE_URL https://api.deepseek.com
LLM_MODEL deepseek-chat

Secrets 标签页(密钥)→ New repository secret:

为什么拆开放?地址和模型名不是秘密、要能在日志和代码里方便看到,放 Variables;Key 是机密、会被打码,放 Secrets。

2.7 先本地验证,再推 CI(别直接赌流水线)

  1. 先临时写个必红测试,比如 tests/test_smoke.py 里放 assert 1 == 2;
  2. 本地跑出报告:
pytest --junitxml=report.xml
  1. 配上三个环境变量后跑分诊脚本(macOS/Linux 用 export;Windows PowerShell 用 $env:LLM_MODEL="deepseek-chat" 这种写法):
export LLM_BASE_URL=https://api.deepseek.com
export LLM_MODEL=deepseek-chat
export LLM_API_KEY=sk-你的key
python scripts/ai_triage.py
  1. 打开生成的 triage.md,能看到一条分类表格,说明模型链路通了;
  2. 删掉这个临时测试,把三个文件提交、推一个分支、开一个 Pull Request。

2.8 上线验收:故意制造一次红灯

在 PR 分支上再提交一个 assert 1 == 2,等 Actions 跑完。预期:test 变红、ai-triage 跟着执行、PR 下方出现那条 AI 分诊评论(把它判成"真缺陷/脚本问题"都算链路正常)。看到评论 → 删掉临时测试 → 流水线转绿,闭环验收完成。

成本上不用有压力:只有红灯才调一次,又是小模型,一次基本是几分钱甚至更低的量级,具体以各家官网定价为准。

没出现评论?按这张清单排障:

用 GitLab / Jenkins 的对照:思路一模一样——在"测试任务失败"后挂一个触发:GitLab CI 里是 when: on_failure 的一个 job 调同一段 Python,再用 GitLab API 在 MR 留言;Jenkins 是在构建后置步骤里用 webhook/post { failure {} } 调脚本、调对应评论接口。ai_triage.py 本身一行不用改。

2.9 最小闭环之后,两个按需增量

文本报告跑顺了,再按需要加这两样,不必第一天就上:

三、跑顺后再加:确定性规则先兜底,模型只啃灰色中段

第二章的最小版是"所有失败都问一次模型",简单但每条都花 token,模型偶尔也会飘。稳定用几周后,值得在调模型之前插一层纯 Python、不花钱的硬规则:拿运行历史和错误签名一算便知的案子直接判掉,只把规则判不准的灰色中段交给模型。
在这里插入图片描述

先加不依赖历史的两条(数据现成,马上能用)——在 main() 调模型前过一遍:

# 环境/基础设施的网络信号(别放裸的 timeout,元素等待超时也叫 timeout,会被误判成环境)
ENV_HINTS = ("502", "503", "504", "connection refused", "connection reset",
             "could not resolve host", "network is unreachable",
             "temporarily unavailable", "read timed out", "connect timeout")
# 脚本/代码错误的真实报错文案(以小写匹配)
SCRIPT_HINTS = ("importerror", "modulenotfounderror", "syntaxerror",
                "nameerror", "attributeerror", "typeerror",
                "no such element", "elementnotfound",
                "strict mode violation", "test expects", "did not find")

def rule_classify(failure):
    """命中明确特征就直接定类;返回 None 表示规则判不准,交给模型。"""
    text = failure["error"].lower()
    if any(h in text for h in ENV_HINTS):
        return ("环境问题", "命中环境/网络错误特征")
    if any(h in text for h in SCRIPT_HINTS):
        return ("脚本问题", "命中导入/语法/定位器错误特征")
    return None

关键词别贪多:宁可漏到灰色中段让模型判,也不要写得太宽把真缺陷直接归了类。比如 timeout 单独出现时可能是页面元素没等到(脚本/产品问题),只有带 connect、read 等网络前缀才更像环境。

再把 main() 改成:先对每条失败调 rule_classify,有结论的直接进结果表,只有返回 None 的才收集起来发给模型。这样大部分环境抖动、脚本低级错误零成本归位。

等你攒下运行历史,再补两条更强的规则(需要把每次的通过/失败结果落一张小表或接测试平台):

其中有一条保命硬规则:失败那一刻定位器正好变了,flaky 分数直接封顶,禁止判 flaky——一次真实 UI 回归,绝不能被"它平时也时好时坏"的统计印象洗白。这种"确定性打分先解决高置信、LLM 只仲裁模糊带、改状态必须人批"的分层,在 2026 年的工业实践里已被验证有效:UiPath AgentHack 2026 获奖方案 FlakeWarden 就是把确定性评分层和 LLM 分类层分开,在 150 个标注失败上约 90.7% 准确、0 次把真实回归误判成 flaky,约三分之一的失败纯靠确定性数学直接判定、压根不调模型(其准确率来自项目自标注数据集、可复现,尚未经过独立行业基准验证,数字当作参考量级即可)。

人机分工因此再调一次:手动分诊时是"AI 先看、人复核";进流水线后更稳的顺序是"硬规则兜底 → 模型断疑案 → 人只拍板真缺陷和低置信"。

四、L2:flaky 的自动闭环——打标、隔离、限期治理

分诊稳定后,再对确认是 flaky 的那批开放有界、可逆的动作。业界(Datadog Test Optimization、Trunk Flaky Tests 等)通行的生命周期是:检测 → 隔离 → 限期修或退役 → 趋势报告。在 pytest 里,隔离就是一个 marker 加两条 lane,先在 pytest.ini 注册:

[pytest]
markers =
    quarantine: 已确认 flaky、移出主门禁、在观察 lane 继续跑的用例
import pytest

@user1ne
def test_old_payment_flow():
    ...
# 主门禁 lane:不跑被隔离用例,flaky 不再阻塞所有人合并
pytest -m "not quarantine"
# 观察 lane(非阻塞):继续跑它们采证,而不是删掉或假装不存在
pytest -m quarantine

表里 L2 还有两个动作,顺手说清,都属于"可逆、可审计"那一档:

配套纪律比技术重要:被隔离的用例自动建卡、定 owner、给修复 SLA(比如 14 天),到期没修就退役重写,没有例外;新增用例进主套前先在 burn-in lane 用 pytest-repeat 的 --count 多跑几轮(需 pip install pytest-repeat),把新 flaky 挡在门外;每周自动出一份 flaky 率 / 隔离存量 / 修复数的趋势报告。

说死一条边界:quarantine 是"把已知噪声移出主门禁"的可逆运维动作,不是"这次算它 flaky,代码放行"。 真缺陷靠第三章那条"定位器变了就封顶"的规则,永远进不了隔离区。

五、L3 这道线,默认关上:自动修复只提不合

再往前就是这两年很热的"self-healing CI":AI 读错误 → 直接改代码 → 提 PR 甚至自动合(GitLab Duo Fix Pipeline、各类修复 Agent 都在做)。GitHub 上也有现成的托管 Action,比如 Anthropic 官方的 claude-code-action,配好就能在失败 PR 上自动分析、给结构化结论甚至提修复,省掉自研脚本——不想自己维护第二章那套的团队可以直接用,但同样建议把"自动合并"关掉。

不管自研还是用现成工具,都建议默认关、只提 PR、人审才合。原因是自动程序修复领域反复确认的补丁过拟合(patch overfitting):补丁能让眼前几个测试变绿,但可能是改测试迁就代码、只压住表象,语义上根本错了。让它自动合,等于拿生产赌模型这次没偷懒。真要开放,至少四条护栏:

  1. 隔离复现:在独立 worktree/分支先把红稳定复现,再谈改;
  2. 范围受限:只许动证据指向的最小范围,受保护文件锁死,不执行模型生成的任意命令(动作走白名单);
  3. 反向验证:定向测试要过,且撤掉补丁必须重新变红,证明修的是因不是果;
  4. 只提不合:产出 PR/MR,人 review 通过才合并。

背后是和前两篇一脉相承、不可让渡的原则——a red test stays red(红的用例保持红):AI 不能改门禁状态、不能关测试、不能把红说成绿,只能让"你搞清为什么红"变快。

还有一条数据红线:CI 日志、trace、截图里常有真实业务数据。本文把 LLM_BASE_URL 做成可配置,就是为了让你能把它指向内网或私有部署的模型,让日志不出公司;对接外部 SaaS(包括托管 Action)前先过数据合规。

六、接进来之后,人去做什么

三步连起来看:第一篇,人定框架规矩、AI 在规矩里写脚本;第二篇,人拍板、AI 批量分诊;这一篇,人设计并看守这条自动流水线的边界和规则,AI 在一线 7×24 分诊。

测试工程师的日常随之换了:不再逐条翻日志,而是维护分类口径和阈值;只审真缺陷和低置信那一小撮;看每周根因趋势,决定框架下一步在哪块"长肌肉";review AI 提的修复 PR,决定哪根绑着的手指可以松开。从挨条红灯去救的灭火队员,变成设计自动消防系统、只在真火时到场的人。

写在最后

自动化测试成熟的标志,不是再也没有红灯,而是每一个红灯都被即时、对等地对待:真缺陷秒级到人,flaky 被隔离并限期治理,环境挂了不拉全团队陪跑,脚本坏了立刻有主。

把 AI 接进 CI,是给团队装一个不下班的分诊台。但它的权力要被刻意设计成——只有眼睛和嘴,手是绑住的;然后靠规则、灰度和时间,一根手指一根手指地松开。这不是保守,是让"自动化"本身也值得信任的唯一方式。


这是「AI for Testing 提效实战 · 执行自动化(三)」。从"在框架里写脚本",到"批量分诊红灯",再到"把分诊变成流水线的一部分",执行这一段的人机分工基本闭环了。

如果这篇对你有启发,欢迎转给那个"CI 一红就重跑、重跑绿了就合入"的团队。


↙↙↙阅读原文可查看相关链接,并与作者交流