上一篇的批量分诊用起来很爽,但它有个断点:得有个人想起来,把日志和截图复制、粘贴、发给 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 能不能做",问"它做错了能不能一秒撤回"。
先把最小闭环跑通:测试红了 → 自动读失败报告 → 大模型四分类 → 在 PR 下贴一条分诊评论。 这一版只靠文本报告,不依赖截图,先保证 100% 能跑;规则兜底、flaky 隔离都是后面的增量。
GitHub Actions 是 GitHub 自带的流水线:你在仓库放一个 .github/workflows/ci.yml,以后每次 push / 开 PR,它就自动租一台临时虚拟机,按 YAML 里的步骤执行。我们这里跑两个 job:
test:装环境、跑 pytest、产出 report.xml;失败就把报告存成 artifact;ai-triage:只有 test 红了才触发,下载报告、调模型分诊、把结果贴到 PR 评论区。它被刻意设成"失败也不影响流水线"——AI 挂了,测试结论照旧。
先说选型原则,别被"接 AI"三个字带偏到最贵的模型上:
openai 库,地址、模型名、Key 全走环境变量,换模型只改三个变量、一行代码都不用动。常见可选(模型名迭代很快,以各家控制台当时的"小模型档"为准):
| 你的情况 | 提供方 | 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。
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
.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
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}),请按原流程人工查看。")
在 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:
LLM_API_KEY
为什么拆开放?地址和模型名不是秘密、要能在日志和代码里方便看到,放 Variables;Key 是机密、会被打码,放 Secrets。
tests/test_smoke.py 里放 assert 1 == 2;pytest --junitxml=report.xml
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
triage.md,能看到一条分类表格,说明模型链路通了;在 PR 分支上再提交一个 assert 1 == 2,等 Actions 跑完。预期:test 变红、ai-triage 跟着执行、PR 下方出现那条 AI 分诊评论(把它判成"真缺陷/脚本问题"都算链路正常)。看到评论 → 删掉临时测试 → 流水线转绿,闭环验收完成。
成本上不用有压力:只有红灯才调一次,又是小模型,一次基本是几分钱甚至更低的量级,具体以各家官网定价为准。
没出现评论?按这张清单排障:
test 是不是真红了?全绿时分诊按设计不触发;triage-result 产物、不会发评论;ai-triage 这个 job 的日志,看脚本是不是提示 LLM_BASE_URL / LLM_MODEL / LLM_API_KEY 没配,或 Key 余额不足;permissions 里有 issues: write(普通 PR 评论走 issues 接口),再到仓库 Settings → Actions → General → Workflow permissions 选成 Read and write permissions;triage.md 里是报错:确认所选模型/档位支持 response_format 的 JSON 模式(不行就换一家,或去掉该参数、只靠提示词约束重试);triage.md 且不会让流水线失败,日志里能看到降级原因。用 GitLab / Jenkins 的对照:思路一模一样——在"测试任务失败"后挂一个触发:GitLab CI 里是 when: on_failure 的一个 job 调同一段 Python,再用 GitLab API 在 MR 留言;Jenkins 是在构建后置步骤里用 webhook/post { failure {} } 调脚本、调对应评论接口。ai_triage.py 本身一行不用改。
文本报告跑顺了,再按需要加这两样,不必第一天就上:
test-results/)也放进 upload-artifact,ai-triage 下载后,改用支持图片的多模态小模型档(如 Gemini Flash),用 OpenAI 兼容的多模态消息格式把图和文本一起发。截图怎么取、模型怎么读,沿用上一篇的做法,这里只多了"artifact 跨 job 传递"一步。ai_triage.py 输出的 suggestion 里按模块拼上负责人的 GitHub 用户名(配合 CODEOWNERS),评论正文里出现 @用户名 就会触发通知。建议只在"真缺陷 + 高置信"时 @,否则红灯一多就是全员告警轰炸。第二章的最小版是"所有失败都问一次模型",简单但每条都花 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 先看、人复核";进流水线后更稳的顺序是"硬规则兜底 → 模型断疑案 → 人只拍板真缺陷和低置信"。
分诊稳定后,再对确认是 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 还有两个动作,顺手说清,都属于"可逆、可审计"那一档:
pytest --count=3(需 pytest-repeat)或直接触发一次重跑,把"首跑红、重跑绿"这个最直接的 flaky 证据附在评论里,再决定是否隔离——注意它只是取证,不是第二篇强调过的"retry 到绿就放行",绿灯结果不进门禁判断。CODEOWNERS 里对应模块的人;它是草稿不是正式工单,等人确认后才转正式。配套纪律比技术重要:被隔离的用例自动建卡、定 owner、给修复 SLA(比如 14 天),到期没修就退役重写,没有例外;新增用例进主套前先在 burn-in lane 用 pytest-repeat 的 --count 多跑几轮(需 pip install pytest-repeat),把新 flaky 挡在门外;每周自动出一份 flaky 率 / 隔离存量 / 修复数的趋势报告。
说死一条边界:quarantine 是"把已知噪声移出主门禁"的可逆运维动作,不是"这次算它 flaky,代码放行"。 真缺陷靠第三章那条"定位器变了就封顶"的规则,永远进不了隔离区。
再往前就是这两年很热的"self-healing CI":AI 读错误 → 直接改代码 → 提 PR 甚至自动合(GitLab Duo Fix Pipeline、各类修复 Agent 都在做)。GitHub 上也有现成的托管 Action,比如 Anthropic 官方的 claude-code-action,配好就能在失败 PR 上自动分析、给结构化结论甚至提修复,省掉自研脚本——不想自己维护第二章那套的团队可以直接用,但同样建议把"自动合并"关掉。
不管自研还是用现成工具,都建议默认关、只提 PR、人审才合。原因是自动程序修复领域反复确认的补丁过拟合(patch overfitting):补丁能让眼前几个测试变绿,但可能是改测试迁就代码、只压住表象,语义上根本错了。让它自动合,等于拿生产赌模型这次没偷懒。真要开放,至少四条护栏:
背后是和前两篇一脉相承、不可让渡的原则——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 一红就重跑、重跑绿了就合入"的团队。