《AI Agent 工程实战》12.3 CI 质量门:分数下降即拦截

评测跑完只打印一张表,不会拦住任何东西。本节把评测变成质量门:与基线比对,总体指标掉超阈值、或某条样本由 PASS 变 FAIL,就让流水线 exit 1。真跑先验证「与基线持平」退出码 0,再注入「关掉引用产出」的退化使质量门失败退出码 1,打印逐样本差异,最后给出 GitHub Actions 接入形态(注明未在本机执行)。

12.3 CI 质量门:分数下降即拦截

12.1 有了评测集与评分器,12.2 让评测变成确定性的离线回放。但到现在为止,评测跑完只是打印一张表——它把「成功率 90%」写进日志,然后流水线继续往前走,该合并合并、该发布发布。一张没人拦的表,等于没有评测。 要让评测真正起作用,得把它接成一道门:分数掉了就 fail,让坏版本根本进不来。

本节把 ResearchCopilot 推进到「质量门禁」:跑评测、与基线比对、总分掉超阈值或任一样本由 PASS 变 FAIL 即 exit 1;真跑验证「持平通过、退化拦截」两种结果,并给出 GitHub Actions 接入形态。

12.3.1 从「打印一张表」到「拦住一个版本」

先分清「评测」和「质量门」的区别,它们是同一套数据的两种用法:

维度评测(12.1/12.2)质量门(本节)
产出一张得分表一个退出码
回答的问题「这版表现如何」「这版能不能进」
消费方式人看CI 自动判断
失败后果无(只是日志)流水线红、合并被拦

关键差别在退出码。评测脚本无论跑出什么结果都 return 0,因为它的任务是「报告」;质量门脚本则必须根据结果返回非零,因为它的任务是「裁决」。CI 只认退出码——0 放行、非 0 拦截。所以质量门的第一件事,就是把「人看的表」翻译成「机器认的码」。

这也决定了质量门的两个基本要求:判据要明确(掉多少算掉)、结论要可复现(同样的输入必须给同样的码)。含糊的判据会让门时红时绿,团队很快就会学会绕过它。

还有一点容易被忽视:门要有牙齿,但也要有出口。牙齿指的是它必须真的能拦住——一个从没红过的门,要么是完美的代码库(不可能),要么是判据形同虚设。出口指的是当门误报时(比如某条样本本来就是已知的长期失败),要有一条显式、可审计的豁免路径(白名单 + 理由),而不是让人把阈值偷偷调大。没有牙齿的门是装饰,没有出口的门会被绕过——两种都会让质量门失效,只是失效的方式不同。

门的位置也要想清楚:它卡在合并前,而不是发布后。发布后才发现的退化,代价是线上事故;合并前拦下的退化,代价只是作者多改几行。这也是为什么质量门必须便宜到能每次提交都跑——贵到跑不起的门,最后一定会被人关掉。

12.3.2 基线的维护

质量门要回答「分数掉了没有」,就得先定义「原来的分数」——这就是基线(baseline)。基线是一份存档的评测结果:总体指标 + 每条样本的明细。

{
  "overall": {"success": 0.9, "cite_acc": 0.5},
  "samples": {
    "q001-rag-def": {"success": 1.0, "cite_acc": 1.0, "ok": true},
    "q006-no-cite": {"success": 1.0, "cite_acc": 0.0, "ok": false}
  }
}

基线怎么维护,有三条规矩,缺一不可:

规矩做法违反的后果
存哪随代码进版本控制(如 evals/baseline.json)基线只在某人机器上,别人跑不了门
何时更新只在有意的质量变化后更新,且要写理由基线被随手刷新,门永远不响
谁能更新更新基线是显式动作,进 PR、被评审悄悄改基线 = 悄悄放宽标准

第二条最容易破功。设想一个常见场景:你提交了一个改动,质量门红了。此时最省事的「修复」不是改代码,而是把基线刷成当前结果——红立刻变绿。这就是把温度计砸了来退烧。规矩要堵的就是这个:更新基线必须带理由,比如「评测集新增 3 条样本,基线需重算」或「本次是有意提升引用率,旧基线已过时」。没有理由的基线刷新,评审一律打回。

一句话:基线是质量门的锚,锚一动,门就失效。宁可让门偶尔误报(再手动确认),也不允许基线被无理由地漂移。

12.3.3 为什么不能只看均值

最直觉的判据是「总体成功率掉了没有」。但只看均值会漏掉最危险的一类退化:某一类问题整体崩了,但被其它类的提升对冲,总体均值纹丝不动。

举个具体例子。假设评测集里 5 条是「检索类」、5 条是「计算类」。某次改动把检索的引用逻辑写坏了,5 条检索全从 PASS 掉成 FAIL;同时顺手优化了计算路径,让原本失败的 5 条计算全变 PASS。总体成功率 50% → 50%,一点没变——只看均值的质量门会放行这个版本,但它其实把整个检索能力打废了。

所以质量门的判据必须双层:

  • 总体层:成功率、引用准确率等指标跌幅超过阈值(本节取 2%)即 fail。
  • 样本层:任一条样本由 PASS 变 FAIL 即 fail——不看均值,逐条看。

样本层是防止「对冲式退化」的关键。它把「均值没掉」这个假象戳破:只要有一条曾经能过的样本现在过不了,就说明某条真实能力退化了,无论总体数字多好看。这也呼应了卷一 16.3 强调的「别把平均值当成全部」——均值是给人看趋势的,逐条才是给门做裁决的。

阈值本身也要选好。2% 是个经验值:太小会被噪声频繁误触,太大又拦不住真实退化。更重要的是阈值要写进配置、有注释,而不是散在代码里的魔法数——不然「为什么是 2%」半年后没人说得清。

12.3.4 真跑:质量门脚本

质量门脚本做三件事:跑评测、与基线比对、返回退出码。核心是比对逻辑:

DROP_LIMIT = 0.02          # 总体指标允许的最大跌幅

def main() -> int:
    baseline = json.load(open("baseline.json", encoding="utf-8"))
    samples = run_eval(transform)              # transform 用于注入退化
    now, base = summarize(samples), baseline["overall"]
    reasons = []
    for cid, cur in samples.items():           # 样本层:逐条看
        b = baseline["samples"][cid]
        if b["ok"] and not cur["ok"]:
            reasons.append(f"{cid} 由 PASS 变 FAIL")
    for key in ("success", "cite_acc"):        # 总体层:看跌幅
        if base[key] - now[key] > DROP_LIMIT:
            reasons.append(f"{key} 跌幅 {base[key] - now[key]:.1%} 超阈值 {DROP_LIMIT:.0%}")
    if reasons:
        print("质量门:FAIL")
        for r in reasons:
            print("  -", r)
        return 1                               # 非零 -> CI 拦截
    print("质量门:PASS(与基线持平)")
    return 0

脚本有三种模式,日常只在 CI 里跑 check:

python -m evals.gate baseline   # 生成/刷新基线(人工动作,需理由)
python -m evals.gate check      # 与基线比对,CI 每次提交跑
python -m evals.gate regress    # 注入退化,用来验证门本身有牙齿

baseline 是人工动作——它只在评测集变更或有意的质量提升后执行,绝不进 CI(否则基线会被每次提交自动刷新,门就永远不响)。regress 则是对门本身的测试:一个质量门如果从来没红过,你无法确定它是「代码真好」还是「判据失效」。用它定期自检,保证门真的有牙齿。

先跑「与基线持平」的情况——同样的评测、同样的基线,应当原样通过:

总体  success 90.0% -> 90.0%   cite_acc 50.0% -> 50.0%
样本                      基线      当前   变化
q001-rag-def          1.00    1.00
q002-pgvector         1.00    1.00
q003-hnsw             1.00    1.00
q004-rag-vs           1.00    1.00
q005-quota            1.00    1.00
q006-no-cite          0.00    0.00
q007-empty            0.00    0.00
q008-bad-cite         0.00    0.00
q009-unsupported      0.00    0.00
q010-no-tool          0.00    0.00
质量门:PASS(与基线持平)
退出码=0

退出码=0 就是放行信号。注意这里没有一条样本翻颜色,总体两个指标也逐字不变——这正是「持平」该有的样子。质量门对「不变」是宽容的:它不要求每一版都变好,只要求不许变差。这一点很重要——如果把门设计成「必须进步」,团队会为了凑指标去做无意义的「优化」,反而扭曲了动机。

12.3.5 真跑:注入退化,质量门失败

现在注入一个真实会发生的退化:关掉引用产出。ResearchCopilot 的报告里,引用是硬要求,如果某次改动让流水线不再给答案附引用(比如渲染层漏了引用拼接),报告就成了「无来源的断言」。用 transform 模拟这个退化——把终答里的 [doc-xxx] 全部抹掉:

CITE_RE = re.compile(r"\s*\[doc-[a-z0-9-]+\]")
transform = lambda f: CITE_RE.sub("", f)      # 模拟「引用产出被关掉」

真跑输出:

总体  success 90.0% -> 90.0%   cite_acc 50.0% -> 0.0%
样本                      基线      当前   变化
q001-rag-def          1.00    0.00   退化
q002-pgvector         1.00    0.00   退化
q003-hnsw             1.00    0.00   退化
q004-rag-vs           1.00    0.00   退化
q005-quota            1.00    0.00   退化
q006-no-cite          0.00    0.00
q007-empty            0.00    0.00
q008-bad-cite         0.00    0.00
q009-unsupported      0.00    0.00
q010-no-tool          0.00    0.00
质量门:FAIL
  - q001-rag-def 由 PASS 变 FAIL
  - q002-pgvector 由 PASS 变 FAIL
  - q003-hnsw 由 PASS 变 FAIL
  - q004-rag-vs 由 PASS 变 FAIL
  - q005-quota 由 PASS 变 FAIL
  - cite_acc 跌幅 50.0% 超阈值 2%
退出码=1

这次质量门拦住了这个版本,而且拦得有理有据:退出码=1 让 CI 变红,明细里列了 6 条理由——5 条是样本层(曾经能过、现在过不了),1 条是总体层(引用准确率跌了 50 个百分点,远超 2% 阈值)。

明细里那 5 条「由 PASS 变 FAIL」尤其有价值:它们把「哪个能力坏了」直接指了出来——全是需要引用的检索类样本。如果只有总体层那一行「cite_acc 跌幅 50%」,作者还得自己去猜是哪几条中招;逐样本明细省掉了这一步,让修复动作从「翻日志找原因」变成「直接看清单」。质量门不只是裁决,它顺带给出了下一步该干什么。

注意 success 那一栏一点没动(都是 90%)。如果这个门只看成功率,它会放行这个把引用能力整个打废的版本。这正是 12.3.3 说的「只看均值会漏掉整类退化」在本机的实证——样本层和引用指标一起看,才拦得住。这也解释了为什么 12.1 要把引用准确率单独做成一个评分器:它正是这条退化唯一会触动的指标。

两次运行的退出码摆在一起,就是质量门的全部契约:

场景总体样本层退出码结果
与基线持平不变无翻色0放行
关掉引用产出引用 -50%5 条退化1拦截

12.3.6 接入 GitHub Actions(未在本机执行)

质量门在本地是一个脚本,在 CI 里就是流水线里的一个 step。下面给出接入形态——注意:本节未在本机执行这份 workflow,本机只跑了上面的脚本本身;workflow 需要 GitHub 托管环境才能运行。

name: eval-gate
on: [pull_request]
jobs:
  quality-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.14"
      - run: pip install -r requirements.txt
      - name: 离线评测质量门
        run: python -m evals.gate check     # 退出码非零 -> 该 step 失败 -> PR 被拦

三个要点。其一,CI 里只跑 check,绝不跑 record——录制要真实额度、要网络、要授权,只能人手动做,不能进流水线(12.2 已论证)。其二,基线随代码一起 checkout,所以门在不同机器上判据一致。其三,失败时把明细贴进 PR 评论(本节略去这步),让作者一眼看到是哪几条退化——门的作用是「拦住」,但真正让人去修的是「说清为什么红」。

真实项目里还会给门加一些工程细节:把评测报告存成 CI artifact 供下载、对同一 PR 的多次推送做去重评论、给「已知的长期失败样本」打白名单(避免它们每次都被重复报警)。这些都建立在同一个核心之上:评测结果 -> 与基线比对 -> 退出码。

12.3.7 真实环境 / 真实模型下会怎样

真实环境里质量门有三个额外要考虑的点:

  • 噪声与阈值。假模型下分数是确定的,同一版本永远同样的分;真实模型有随机性,分数会抖。这时阈值不能定得太死,要么对每条样本多次取平均,要么把阈值放到能吸收噪声的水平。别让门被模型噪声频繁误触,否则团队会失去对它的信任。
  • 基线要跟着评测集走。评测集加样本时,基线必须重算并说明理由(12.3.2)。CI 里可以加一个校验:如果评测集文件变了但基线没变,直接报警——防止「加了样本却不更新基线」导致的假红或假绿。
  • 真实模型评测不进每次提交。真实调用贵且慢,所以逐次提交的门用夹具回放(12.2),真实模型评测只在发布前跑一次做验收。门和验收用的是同一套 Case 与评分器,只是模型后端不同。

本节验收清单:

  • 质量门有明确退出码:0 放行、非 0 拦截
  • 判据双层:总体指标跌幅阈值 + 逐样本 PASS→FAIL
  • 基线进版本控制,更新基线是显式动作且有理由
  • CI 里只跑 check,record 需人工授权
  • 阈值写进配置并注释,不是散落的魔法数
  • 退化能被门拦住,且明细说清是哪几条

12.3.8 小结

  • 评测负责「报告」,质量门负责「裁决」——裁决的载体是退出码。
  • 判据必须双层:总体跌幅阈值 + 逐样本 PASS→FAIL,后者专防「对冲式退化」。
  • 只看均值会漏掉整类退化:本机实测中成功率 90% 不变,但引用率从 50% 掉到 0,只有逐条看才拦得住。
  • 基线是门的锚:进版本控制、更新要理由、不许悄悄刷新。
  • CI 里只跑回放与 check,录制需人工授权;真实模型评测只在发布前做验收。
  • 门的作用是「拦住」,明细的作用是「说清为什么红」——两者缺一不可。

到这里,ResearchCopilot 有了一套完整的质量闭环:评测集量好坏(12.1)、夹具保确定(12.2)、质量门做裁决(12.3)。但这套闭环只覆盖「已知的、被写成样本的行为」。线上还会发生样本没覆盖的事故——用户看到一次慢得离谱的响应、某个工具静默重试了三次、某个租户的配额莫名超了。要发现这些样本之外的问题,需要把服务本身变得可观测。下一章 13.1 结构化日志与追踪:OTel GenAI 语义约定埋点 就从这里接上。

阅读导航:上一节:12.2 离线回放与夹具:假模型 + 录制样本确定性回归 · 下一节:13.1 结构化日志与追踪:OTel GenAI 语义约定埋点 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距