把 AI Evals 放进 CI:从人工感觉到回归门禁

0阅读13分钟

一次让我栽跟头的上线

去年秋天,我把一个 Prompt 的措辞改了三处——把「请你」换成了「请」,删掉一句多余的开场白,觉得这样更干净。改完跑了几轮手工测试,核心场景都没问题,直接合进主分支。第二天早上,客服同事找到我:有一批用户的退款请求,AI 不再主动确认订单号了,直接跳到「已为您处理」。问题不在主路径上,藏在一个我之前手工测试没覆盖到的分支条件里。Prompt 措辞微调,模型的输出分布跟着偏了。

这件事让我彻底放弃了一种幻想:「改动小,看一眼就行。」LLM 不是传统函数,它不会因为一行 typo 报 500,它会悄悄漂移,而且漂移的样子看起来「也说得通」。传统单元测试抓不住这种退化,人工抽查覆盖面又太窄。我们需要一套能跟着每次 PR 自动跑的质量门禁——这就是 AI Evals 进 CI 要解决的事情。

为什么 AI 输出需要不一样的测试策略

传统软件的输出是确定的:同样的输入,同样的代码路径,同样的结果。单元测试写一次,能稳定跑很久。LLM 应用打破了这个假设,至少引入了三层不确定性:

第一层是采样随机性。即使 temperature 设成 0,不同模型版本、不同时间点的推理结果仍可能不同。模型升级后,同一个 Prompt 的输出分布会发生偏移,这不是 bug,是模型本身的统计特性。

第二层是评估模糊性。传统测试的 assert 是精确匹配——字符串相等、数值大于某个阈值。AI 输出的「好坏」往往是语义层面的:回答是否切题、事实是否准确、语气是否合适。这些判断没有唯一正确答案。

第三层是变更连锁反应。Prompt 改动、模型版本升级、RAG 检索策略调整、工具调用逻辑变化,任何一个变量的修改都可能让原本正常的输出发生退化,而且退化通常不是整体变差,而是某些特定场景悄悄失灵。

这意味着 AI 质量保障不能只靠一组固定的 assert。它需要一套分层评估机制:先用确定性检查过滤掉结构错误,再用启发式方法评估语义质量,最后用 LLM-as-a-judge 甚至人工复核处理主观维度。把这套机制嵌进 CI,每次变更都自动跑一遍,退化的发现点就能从「用户投诉」前移到「PR 评审阶段」。

三层评估架构:从快到慢、从便宜到贵

把 eval 放进 CI 的第一个挑战是成本和速度。如果对每条样例都调用最强模型做 LLM-as-a-judge 评分,一个 200 条的测试集可能跑一次就要几十美元和半小时。CI 等不起,账单也扛不住。

务实的做法是分层过滤,让大多数样例在低成本层就能通过或失败,只有边界情况才进入高成本评估。

┌─────────────────────────────────────────────────────┐
│  Layer 1:确定性检查(毫秒级,零 API 成本)          │
│  · JSON Schema 校验                                 │
│  · 正则匹配关键字段                                  │
│  · 输出长度约束                                      │
│  · 禁止词/敏感词过滤                                 │
├─────────────────────────────────────────────────────┤
│  Layer 2:启发式评分(百毫秒级,低成本 embedding)    │
│  · 语义相似度(embedding cosine similarity)          │
│  · ROUGE / BLEU 等传统 NLP 指标                      │
│  · 检索召回率(RAG 场景)                             │
├─────────────────────────────────────────────────────┤
│  Layer 3:LLM-as-a-judge(秒级,较高成本)           │
│  · 用独立模型按评分 rubric 打分                       │
│  · temperature 设为 0,保证评分可复现                  │
│  · 多维度:准确性、完整性、语气、安全性                │
├─────────────────────────────────────────────────────┤
│  Layer 4:人工复核(分钟到天级,最高成本)            │
│  · 高风险场景的最终兜底                               │
│  · LLM-as-a-judge 评分不确定时的仲裁                  │
│  · 新增失败样例回流到 golden dataset                  │
└─────────────────────────────────────────────────────┘

每层的职责不同。Layer 1 负责拦住「硬伤」——JSON 解析失败、输出为空、包含禁用词,这类问题不需要任何模型判断,毫秒级就能得出结论。Layer 2 用 embedding 或传统 NLP 指标做语义级别的粗筛,成本只有 LLM 调用的几十分之一。Layer 3 才是真正的 LLM-as-a-judge,处理那些需要「理解」才能判断的维度。Layer 4 是兜底,处理高风险决策和评分争议。

实际运行中,一个 PR 的 eval 大概 60-70% 的样例在 Layer 1 就能得出结论,20-25% 在 Layer 2 解决,只有 10-15% 需要进入 Layer 3。这样整体成本可以控制在一个可接受的范围内。

CI 集成模式对比

把 eval 接入 CI 有几种不同的路径,适用的团队规模和工具链各不相同。

维度原生 GitHub Actions专用平台(Braintrust 等)开源自建(Promptfoo 等)
配置方式YAML workflow 手写SDK + 平台 UIYAML 配置 + CLI
结果展示Actions 日志 / PR comment平台仪表盘 + PR 自动评论自建报告页 / HTML 导出
实验追踪无,需自行实现内置,自动关联 git commit依赖本地缓存或自建
成本仅 LLM API 费用平台订阅费 + API 费用基础设施成本 + API 费用
适用场景小团队、快速起步中等规模、需要协作有运维能力、数据敏感
安全测试需自行集成部分平台内置红队测试开源红队插件可用

我见过三个团队用三种完全不同的方式落地,效果都不错,关键是匹配团队现状。

案例一:五人团队用 GitHub Actions 快速起步

一个做客服 AI 的小团队,最初全靠创始人手动测。他们的第一步非常简单:在仓库里维护一个 evals/golden.json,里面 50 条高价值测试用例,每条包含输入、期望输出类型和评分规则。GitHub Actions workflow 在每次 PR 修改 prompts/ 目录下文件时自动触发,用一个 Python 脚本跑全部样例,评分结果输出为 JUnit XML 格式,GitHub 原生就能识别。

# .github/workflows/eval.yml — 最简 CI eval 配置
name: AI Evals
on:
  pull_request:
    paths:
      - 'prompts/**'
      - 'evals/**'
 
jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install -r evals/requirements.txt
      - name: Run golden dataset evals
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: python evals/run_evals.py --dataset evals/golden.json --threshold 0.85

这个脚本的核心逻辑很直接:跑完所有样例后,计算平均分,低于阈值就返回非零退出码,pipeline 失败。

# evals/run_evals.py — 核心评分逻辑(简化)
import json, sys, openai
 
def run_single_eval(case: dict) -> float:
    response = openai.chat.completions.create(
        model="gpt-4.1-mini",
        temperature=0,
        messages=[
            {"role": "system", "content": load_prompt(case["prompt_version"])},
            {"role": "user", "content": case["input"]}
        ]
    )
    output = response.choices[0].message.content
    return score_output(output, case["expected"], case["rubric"])
 
def main():
    dataset = json.load(open(sys.argv[--dataset]))
    results = [run_single_eval(case) for case in dataset]
    mean_score = sum(results) / len(results)
    print(f"Mean score: {mean_score:.3f}")
    # 低于阈值则 pipeline 失败
    sys.exit(0 if mean_score > 0.85 else 1)

就这么粗糙的配置,上线第一个月就拦住了三次退化。其中一次是有人把 system prompt 里一句「如果不确定,请告诉用户你需要更多信息」删掉了,改成了更简洁的「直接回答」。eval 平均分从 0.91 掉到 0.78,因为模型在不确定场景下开始编造答案。

案例二:中等规模团队用 Braintrust 做实验追踪

一个 20 人的 AI 产品团队,Prompt 迭代频率高,经常同时跑好几组实验。他们用 Braintrust 作为 eval 平台,最大的收益不是 CI 门禁本身,而是实验追踪——每次 eval 运行都自动关联 git commit,可以回溯任何一个 commit 对应的评分变化。

Braintrust 的 GitHub Action 会在 PR 下自动发评论,列出每个测试用例的得分变化。评审者不需要打开额外页面,直接在 PR 里就能看到哪些用例变好了、哪些变差了。

# 使用 Braintrust 的 CI 集成
- name: Run Braintrust evals
  uses: braintrustdata/braintrust-eval-action@v2
  with:
    project: customer-support-ai
    dataset: golden-v3
    # 与 main 分支基线对比,回归超过 5% 则失败
    regression_threshold: 0.05
  env:
    BRAINTRUST_API_KEY: ${{ secrets.BRAINTRUST_API_KEY }}

与手写脚本最大的区别在于对比机制。手写脚本只能判断「这次得分是否高于绝对阈值」,Braintrust 能判断「这次得分相比基线下降了多少」。这对 Prompt 迭代特别重要——有时候绝对分数看起来还行,但相比上一个版本已经悄悄退化了。

案例三:数据敏感场景用 Promptfoo 自建

一个金融领域的团队,合规要求所有评测数据不能离开自有基础设施。他们选了 Promptfoo,一个开源的 eval 框架,用 YAML 定义测试套件,所有数据存在自己的 S3 里。

# promptfooconfig.yaml — 金融场景 eval 配置
prompts:
  - file://prompts/financial_advisor.yaml
providers:
  - id: openai:gpt-4.1
    config:
      temperature: 0
tests:
  - vars:
      question: 我有 50 万存款,应该怎么配置?
    assert:
      - type: contains-json
      - type: llm-rubric
        value: 回答必须包含风险提示,不得给出具体收益率承诺
      - type: not-contains
        value: 保证收益、稳赚不赔
    threshold: 0.9

Promptfoo 的好处是配置声明化,产品和业务同事也能看懂测试用例。红队测试功能是另一个加分项——可以自动生成 prompt injection 和 jailbreak 用例,检查模型在对抗场景下的安全性。

评估流程全景

把整个 CI eval 流程画出来,大致是这样的:

流程图画布 · 115%
Mermaid 流程图加载中...

这个流程有几个关键设计点。首先,路径过滤——只有改动了 prompts 或模型配置的 PR 才触发 eval,避免无谓的 API 调用。其次,分层执行——Layer 1 失败就立刻停下,不浪费后续层的时间。第三,失败报告不是简单的 pass/fail,而是附带每个用例的输入、输出、期望和评分细节,让开发者能快速定位问题。最后,人工复核环节把争议案例的处理闭环,而不是让评分偏差反复阻塞 pipeline。

评分方法对比

不同评分方法的适用场景、成本和可靠性差异很大。选错了方法,要么评分不准,要么成本失控。

评分方法适用场景成本可靠性典型延迟
精确匹配格式化输出、分类标签极低高(但覆盖窄)<1ms
正则/关键词禁止词检测、必填字段极低<1ms
ROUGE / BLEU摘要、翻译等参考文本对比中(与人类判断相关性一般)~10ms
Embedding 相似度语义等价判断中高~50ms
LLM-as-a-judge开放式问答、语气、准确性中高(需校准)1-5s
人工评审高风险决策、评分争议极高最高(但难以规模化)分钟到天

LLM-as-a-judge 是目前处理开放式输出最实用的方法,但它不是万能药。Galtea 的实践指南提到一个关键数字:LLM judge 与人类专家的 Pearson 相关系数需要达到 0.7 以上,评分才有实际参考价值。低于这个阈值,judge 本身的判断就和人类不一致,用它做的门禁反而会引入噪声。

校准 judge 的方法也不复杂:先收集 50-100 条由领域专家打过分的样例,让 LLM judge 对同样的样例评分,然后比较两者的 Pearson 相关系数。如果相关性不够,调整 judge 的评分 prompt——通常需要更明确地定义每个分数档位的标准,减少模糊地带。

评分器设计对比

不同评分器设计的代码结构和适用场景也不同。以下是三种常见模式的对比。

# 模式一:硬编码规则评分 —— 快但脆弱
def rule_scorer(output: str, expected: dict) -> float:
    score = 0.0
    if expected["required_field"] in output:
        score += 0.3
    if len(output) <= expected["max_length"]:
        score += 0.2
    if not any(bad_word in output for bad_word in expected["banned_words"]):
        score += 0.2
    if json.loads(output).get("status") == "success":
        score += 0.3
    return score
# 优点:零成本,毫秒级
# 缺点:规则写死,覆盖面窄,语义变化抓不到
# 模式二:Embedding 相似度评分 —— 语义级,低成本
from openai import OpenAI
from numpy import dot
from numpy.linalg import norm
 
client = OpenAI()
 
def embedding_scorer(output: str, expected: dict) -> float:
    output_emb = client.embeddings.create(
        input=output, model="text-embedding-3-small"
    ).data[0].embedding
    expected_emb = client.embeddings.create(
        input=expected["reference_answer"], model="text-embedding-3-small"
    ).data[0].embedding
    similarity = dot(output_emb, expected_emb) / (
        norm(output_emb) * norm(expected_emb)
    )
    return similarity
# 优点:能捕捉语义等价,成本低(约 $0.02 / 1M tokens)
# 缺点:只衡量相似度,不判断事实准确性
# 模式三:LLM-as-a-judge 评分 —— 灵活但需校准
JUDGE_PROMPT = """你是一个评分员。请根据以下 rubric 对回答打分(1-5 分):
 
5 分:回答准确、完整,语气适当,无安全问题
4 分:基本准确,有小的遗漏但不影响理解
3 分:部分准确,关键信息有缺失
2 分:明显错误或答非所问
1 分:完全不可用或包含有害内容
 
用户问题:{question}
模型回答:{answer}
参考答案:{reference}
 
请严格按以下 JSON 格式输出:
{{"score": <分数>, "reason": "<一句话理由>"}}"""
 
def judge_scorer(output: str, expected: dict) -> float:
    response = client.chat.completions.create(
        model="gpt-4.1-mini",
        temperature=0,
        messages=[{
            "role": "user",
            "content": JUDGE_PROMPT.format(
                question=expected["question"],
                answer=output,
                reference=expected["reference_answer"]
            )
        }]
    )
    result = json.loads(response.choices[0].message.content)
    return result["score"] / 5.0
# 优点:能处理开放式评估,覆盖主观维度
# 缺点:成本较高,需要定期校准,存在位置偏差和冗长偏差

三种模式不是互斥的。实际项目中,我推荐组合使用:先用规则评分过滤硬伤,再用 embedding 相似度做语义粗筛,最后对关键场景用 LLM-as-a-judge 做精细评估。

Golden Dataset 维护策略

Golden dataset 是整个 eval 体系的地基。dataset 质量不行,评分再精确也没用——垃圾进,垃圾出。

维护维度推荐做法常见错误
初始规模50-100 条高价值真实案例一上来追求 1000+ 条,质量参差不齐
来源生产日志 + 历史 bug + 边界用例只靠团队脑补,缺少真实场景
标注标准单一领域专家做二分类(通过/不通过)多人标注不统一,Likert 量表过度细化
更新频率每个迭代周期从生产反馈中补充建完就不管,半年后与实际分布脱节
版本管理与 prompt 一起纳入 git 版本控制dataset 散落在文档和表格里,无法追溯
场景覆盖主路径 + 边界 + 安全对抗 + 拒答场景只覆盖 happy path

一个常见的误区是追求 dataset 的「大而全」。实际上,50 条精心挑选、标注清晰的用例,比 500 条随手凑数的用例有用得多。优先覆盖这几类场景:用户最常见的请求(主路径)、曾经出过 bug 的输入(回归保护)、对抗性输入(安全兜底)、以及模型应该拒绝回答的场景(合规保护)。

成本控制与波动处理

CI eval 的成本主要来自 LLM API 调用。如果不加控制,一个 200 条的测试集每次 PR 都跑一次,一个月的 API 费用可以轻松跑到几百甚至上千美元。

# 成本控制配置示例
eval_config:
  # 分层执行,减少高成本调用
  layers:
    deterministic:
      enabled: true
      timeout_ms: 500
    heuristic:
      enabled: true
      min_similarity: 0.75  # 低于此值直接失败,不调 LLM
    llm_judge:
      enabled: true
      model: gpt-4.1-mini   # 用小模型做 judge,不必用最强模型
      temperature: 0          # 保证评分可复现
      max_concurrent: 5       # 并发控制
 
  # 缓存策略
  cache:
    enabled: true
    directory: .eval-cache
    ttl_hours: 24             # 相同输入 24 小时内复用结果
 
  # 采样策略
  sampling:
    full_run: false           # PR 阶段不全量跑
    changed_prompt_cases: true # 只跑受影响的用例
    smoke_test_count: 20      # 每次 PR 跑 20 条烟雾测试
    full_run_schedule: daily  # 全量跑留给每日定时任务

几个实用的成本控制手段:

第一,缓存 API 响应。同一个输入在 24 小时内不太会因为外部变化而改变输出,缓存可以大幅减少重复调用。Promptfoo 的 .promptfoo/cache 就是这个思路。

第二,路径过滤触发。只有 prompts 目录或模型配置变更才触发 eval,改个 README 不需要跑。

第三,烟雾测试替代全量。日常 PR 只跑 20 条最关键的用例,全量 200 条留给每日定时任务或发版前的全量回归。

第四,用小模型做 judge。LLM-as-a-judge 不需要用和生成模型同级别的模型。GPT-4.1-mini 做 judge 的评分结果和 GPT-4.1 差异很小,但成本只有十分之一。

第五,temperature 设为 0。评分阶段不需要创造性,确定性输出保证同样的输入每次得到同样的分数,减少评分波动。

让失败可行动

Eval 失败最让人沮丧的不是红灯本身,而是「不知道哪里出了问题」。一个设计良好的 eval 失败报告应该包含这些信息。

信息维度必须包含作用
输入内容完整的用户输入复现问题
模型输出完整的模型响应定位偏差方向
期望结果golden dataset 中的参考答案对比差距
评分详情各维度得分和 judge 理由理解失败原因
版本信息prompt 版本、模型版本、commit hash追溯变更源头
历史对比与上一次运行的分数变化判断是退化还是波动
复现命令本地重跑该用例的命令开发者自行调试

Braintrust 在这方面的做法值得参考——PR 评论里直接列出每个退化用例的输入、输出、期望和评分变化,评审者不用离开 GitHub 就能看到完整上下文。手写脚本的话,至少要做到把失败样例的输出保存为 artifact,方便下载查看。

上线检查清单

把 AI Evals 放进 CI 之前,逐项检查这些要点。

基础设施

  • CI 环境能访问 LLM API(API Key 已配置为 secret)
  • 网络出口策略允许访问模型提供商的 API endpoint
  • 超时和重试策略已配置,避免偶发网络问题导致 pipeline 误失败
  • API 调用有并发控制,不会触发速率限制

Golden Dataset

  • 至少 50 条高价值测试用例,覆盖主路径、边界和安全场景
  • 每条用例有明确的评分标准(参考答案或 rubric)
  • 标注由领域专家完成,不是随手编写
  • Dataset 与 prompt 一起纳入版本控制

评分体系

  • 分层评分已实现,确定性检查在前,LLM judge 在后
  • LLM-as-a-judge 的评分 prompt 经过校准,与人类专家 Pearson 相关 ≥ 0.7
  • 评分阈值已设定,且与团队达成共识(建议初始 0.85)
  • Judge 的 temperature 设为 0,保证评分可复现

成本控制

  • PR 阶段使用烟雾测试(20 条左右),非全量运行
  • API 响应已启用缓存,相同输入不重复调用
  • 路径过滤已配置,无关变更不触发 eval
  • 有每日/每周全量回归任务,覆盖完整 dataset

失败处理

  • 失败报告包含输入、输出、期望、评分和版本信息
  • 有明确的 owner 负责处理 eval 失败(不是无人认领的红灯)
  • 评分争议有人工复核通道,不会永久阻塞 pipeline
  • 失败样例有回流机制,确认后的退化案例加入 golden dataset

持续演进

  • 每个迭代从生产反馈中补充新用例到 golden dataset
  • 定期(月度)审查评分阈值是否需要调整
  • LLM judge 的评分质量定期重新校准(模型升级后必须重做)
  • 团队成员知道如何新增 eval 用例、如何本地重跑

写在最后

把 AI Evals 放进 CI 不是终点,它是一个持续演进的质量信号。传统的 CI 是一个门——通过就放行,不通过就阻塞。AI eval 更像是一个仪表盘,它在每次变更时告诉你质量的变化趋势:这个 Prompt 改动让回答更精准了,还是让某些场景退化了一点?这次模型升级整体表现更好,但在安全拒答上变弱了?

一开始不需要追求完美。50 条用例、一个分层评分脚本、一个 GitHub Actions workflow,这就够了。跑起来之后,团队自然会知道哪些用例需要补充、评分标准需要调整、阈值需要校准。重要的不是第一次搭建得多完善,而是让「每次变更都自动验证质量」成为习惯。

那个让我栽跟头的 Prompt 改动,后来被加进了 golden dataset。每次有人修改那个 prompt 文件,CI 都会自动验证:退款场景下,模型是否仍然会确认订单号。


参考资料

评论 0

0 / 1000