跳到主要内容
Agent Eval

第8章:CI/CD 与回归测试 —— 把 Eval 变成 Pre-merge Gate

GitHub Actions + DeepEval 完整 workflow,性能门禁规则,A/B 测试,Canary 灰度,Production drift 检测,Eval-as-Code

CI/CD GitHub Actions DeepEval Regression Testing Canary Drift Detection

写好 benchmark 后,如果只是手动跑一次再提交 PR,等于把 eval 从工程体系里割裂出去——3 个月后没人再跑,benchmark 烂在仓库里。真正生产级的 agent 团队把 eval 做成 pre-merge gate:每个 PR 自动跑、回归就 block、生产持续监控、drift 自动告警。本章讲清这套 CI/CD 工程模式,给一份完整的 GitHub Actions + DeepEval workflow 示例,以及 production drift 检测代码。

📑 目录


1. 为什么 Eval 必须进 CI/CD

1.1 三种”伪 eval”症状

症状 1:Eval 是孤岛
  团队某个工程师手动跑了一次,数字写到 wiki,没人再看

症状 2:Eval 滞后
  PR 都合并了才发现 benchmark 退化,回滚成本高

症状 3:Eval 漂移
  生产数据分布慢慢变,offline benchmark 还是 6 个月前的题

1.2 把 Eval 变成”基础设施”

维度没 CI eval有 CI eval
反馈周期几周-几月几分钟
回归发现上线后用户提交时 block
责任归属模糊提交人
数据沉淀散乱leaderboard / wandb 持续追踪
防 regression完全靠记忆自动

🌟 生产 agent 的 eval 应该和单元测试一样自然——每个 PR 都跑,跑不过 block。


2. CI eval 三层结构

借鉴模块八第 2 章的评测金字塔,CI 也分层:

        ┌────────────────────────┐
        │  E2E Eval(每天 / nightly) │
        │  跑 GAIA / SWE-bench Lite  │
        └────────────────────────┘
        
        ┌────────────────────────┐
        │  Integration Eval(每个 PR) │
        │  跑自建 benchmark 子集 100 题 │
        └────────────────────────┘
        
        ┌────────────────────────┐
        │  Unit Eval(每个 commit)  │
        │  跑 50 条快测试           │
        └────────────────────────┘

2.1 频率对照

频率时长成本
Unit每个 commit< 5min$1-5
Integration每个 PR< 30min$10-50
E2Enightly / 每候选 release1-3h$50-300

2.2 设计原则

  • Unit 层永远绿色:几乎不允许跑挂
  • Integration 层是 gate:回归 > 5% block PR
  • E2E 层是监控:不 block,但报警 + 给候选 release 评分

3. GitHub Actions + DeepEval 完整 workflow

3.1 目录结构

agent-project/
├── .github/
│   └── workflows/
│       ├── eval-unit.yml         # 每个 commit
│       ├── eval-integration.yml  # 每个 PR
│       └── eval-nightly.yml      # 每天
├── eval/
│   ├── unit/
│   │   └── test_basic.py         # DeepEval 单元测试
│   ├── integration/
│   │   ├── benchmark.json        # 自建 100 题
│   │   └── run.py
│   ├── e2e/
│   │   ├── gaia_subset.py
│   │   └── swe_bench_lite.py
│   └── compare.py                # 对比 baseline
├── baselines/
│   └── current.json              # 当前 baseline scores
└── src/
    └── agent.py

3.2 eval-integration.yml(核心)

name: Integration Eval

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  integration_eval:
    runs-on: ubuntu-latest
    timeout-minutes: 45
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      
      - name: Cache deps
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: pip-${{ hashFiles('eval/requirements.txt') }}
      
      - run: pip install -r eval/requirements.txt
      
      - name: Run integration benchmark
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: |
          python eval/integration/run.py \
            --benchmark eval/integration/benchmark.json \
            --output result.json
      
      - name: Compare against baseline
        id: compare
        run: |
          python eval/compare.py \
            --baseline baselines/current.json \
            --new result.json \
            --threshold 0.05 \
            --output report.md
      
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: eval-report
          path: |
            result.json
            report.md
      
      - name: Comment on PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const report = fs.readFileSync('report.md', 'utf8');
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: report,
            });
      
      - name: Block merge on regression
        if: steps.compare.outputs.regression == 'true'
        run: |
          echo "::error::Eval regression > 5%, blocking merge"
          exit 1

3.3 compare.py(基线对比)

"""对比新 eval 结果与 baseline,检测回归。"""
import json
import argparse

def load_scores(path):
    with open(path) as f:
        return json.load(f)["scores"]

def compare(baseline, new, threshold=0.05):
    issues = []
    improvements = []
    for metric, base_val in baseline.items():
        new_val = new.get(metric)
        if new_val is None:
            continue
        delta = new_val - base_val
        rel_delta = delta / base_val if base_val > 0 else delta
        
        if rel_delta < -threshold:
            issues.append((metric, base_val, new_val, rel_delta))
        elif rel_delta > 0.02:
            improvements.append((metric, base_val, new_val, rel_delta))
    
    return issues, improvements

def render_report(issues, improvements):
    lines = ["# 📊 Eval Report\n"]
    if issues:
        lines.append("## ⚠️ Regressions\n")
        lines.append("| Metric | Baseline | New | Δ |")
        lines.append("|--------|----------|-----|---|")
        for m, b, n, d in issues:
            lines.append(f"| {m} | {b:.3f} | {n:.3f} | {d:+.1%} |")
    if improvements:
        lines.append("\n## ✅ Improvements\n")
        for m, b, n, d in improvements:
            lines.append(f"- **{m}**: {b:.3f}{n:.3f} ({d:+.1%})")
    if not issues and not improvements:
        lines.append("\n✅ 与基线持平")
    return "\n".join(lines)

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--baseline", required=True)
    parser.add_argument("--new", required=True)
    parser.add_argument("--threshold", type=float, default=0.05)
    parser.add_argument("--output", required=True)
    args = parser.parse_args()
    
    baseline = load_scores(args.baseline)
    new = load_scores(args.new)
    issues, improvements = compare(baseline, new, args.threshold)
    
    report = render_report(issues, improvements)
    with open(args.output, "w") as f:
        f.write(report)
    
    # 输出 GitHub Actions 标志
    print(f"::set-output name=regression::{'true' if issues else 'false'}")
    
    if issues:
        exit(1)

4. 性能门禁规则

4.1 三档阈值

严重度触发行动
🔴 Block主指标(capability)回归 > 5%直接 block merge
🟠 Warn次要指标回归 > 10%(cost / latency)warn,需 reviewer 批
🟡 Comment任何指标变化 > 2%PR 评论说明,不 block

4.2 多指标加权

COMPOSITE_SCORE_WEIGHTS = {
    "capability": 0.5,
    "reliability_pass3": 0.2,
    "safety_refusal": 0.1,
    "cost_per_task": -0.1,    # 越低越好
    "latency_p95": -0.1,
}

def composite_score(metrics):
    return sum(metrics[k] * w for k, w in COMPOSITE_SCORE_WEIGHTS.items())

只看 composite score 容易掩盖局部退化,多看每个维度

4.3 baseline 怎么更新

# 只有合并到 main 后,baseline 自动更新
on:
  push:
    branches: [main]

jobs:
  update_baseline:
    if: github.event_name == 'push'
    steps:
      - run: cp result.json baselines/current.json
      - run: |
          git config user.email "actions@github.com"
          git config user.name "GH Actions"
          git add baselines/current.json
          git commit -m "Update eval baseline [skip ci]"
          git push

5. A/B 测试与 Canary

5.1 A/B 设计

不只看离线 benchmark,线上 A/B 才反映真实业务:

50% 流量 → Agent v1(对照)
50% 流量 → Agent v2(实验)
跑 1-2 周
对比 KPI:CSAT、退订率、客单价、转化率

5.2 Canary 灰度

新 release 上线分阶段:

T0: 5% 流量 → 监控 1 小时
T1: 25% 流量 → 监控 1 天
T2: 50% 流量 → 监控 3 天
T3: 100% 流量

任何一个阶段触发告警 → 自动回滚到上一阶段。

5.3 Canary 触发回滚条件

CANARY_GATES = {
    "error_rate": "< 0.5x baseline",      # 错误不能比对照高 50%
    "p95_latency": "< 1.2x baseline",     # latency 不能恶化 20%
    "cost_per_task": "< 1.3x baseline",   # cost 不能涨 30%
    "user_satisfaction": "> 0.95x",        # 满意度跌不超过 5%
}

def canary_check(experiment_metrics, baseline_metrics):
    for k, rule in CANARY_GATES.items():
        if not rule_passes(experiment_metrics[k], baseline_metrics[k], rule):
            return False, f"{k} gate failed"
    return True, "OK"

6. Production Drift 检测

6.1 三类 drift

类型表现
Input drift用户 query 分布变了(节日 / 新需求)
Output drift同样 input 模型回答变了(模型升级 / prompt 改)
Performance driftaccuracy / cost 慢慢变差(数据老化)

6.2 检测方法

① Embedding distribution shift

from scipy.spatial.distance import jensenshannon

# 每天对比"今天用户 query embedding 分布" vs "上周"
today_embeddings = get_embeddings(today_queries)
last_week_embeddings = get_embeddings(last_week_queries)

js_divergence = jensenshannon(
    histogram(today_embeddings),
    histogram(last_week_embeddings),
)
if js_divergence > 0.3:
    alert("Input distribution drift detected")

② Online A/B 持续比对

把 5% 流量永久打到”控制版本”——任何性能差异自动告警。

③ Synthetic canary

每小时跑一组固定的 100 个 synthetic query,看输出是否漂移:

SYNTHETIC_QUERIES = ["...", "...", ...]   # 固定不变

def hourly_canary():
    outputs = [agent.run(q) for q in SYNTHETIC_QUERIES]
    # 与历史输出 embedding 比对
    drift_score = compare_to_history(outputs)
    if drift_score > 0.4:
        alert("Output drift detected")

6.3 整合到 Phoenix / LangSmith

模块六第 8 章讲过的 OTel 观察平台都内置 drift detection:

# Phoenix
import phoenix as px
df = px.get_traces_df(time_range="last_24h")
drift = px.evals.detect_drift(df, baseline_df=last_week_df)

7. Eval-as-Code:Git 管理 eval definition

7.1 思想

eval 不是工具配置,是代码——和 agent 代码同一个 repo,同一套 review 流程。

agent-project/
├── src/agent.py              ← code
├── eval/benchmark.json       ← eval definition
└── baselines/current.json    ← reference
git log eval/
# 你能看到 eval 怎么演化的
# Q1 2026:加了 50 道 customer service 题
# Q2 2026:删除老化的 20 道,加 30 道新 SKU 相关题

7.2 优势

维度工具配置式Eval-as-Code
Review工具内点点点PR review
版本化没有 / 弱Git history
协作跨团队难code review 习惯
复现时间一变就丢git checkout 任意版本
文档散在 wiki一份 README

7.3 Eval 定义的标准 schema

# eval/customer_service_v3.yaml
metadata:
  name: customer_service_v3
  version: 2026-05-07
  author: Eval Team
  scope: 客服 agent 主线场景
  size: 500
  refresh_due: 2026-08-07

dimensions:
  - name: capability
    weight: 0.5
    verifier: rule_based.action_match
  - name: reliability
    weight: 0.2
    verifier: pass^3
  - name: safety
    weight: 0.2
    verifier: rule_based.no_pii_leakage
  - name: cost
    weight: 0.05
  - name: latency
    weight: 0.05

cases:
  - id: cs_001
    input: "..."
    expected_action_sequence: [...]
    forbidden_actions: [transfer_human]
    sla:
      ttft: 1000
      e2e: 30000

  - id: cs_002
    ...

7.4 自动 eval doc 生成

# 从 yaml 自动生成 markdown docs
def generate_eval_docs(yaml_path):
    config = yaml.load(open(yaml_path))
    return f"""
# {config['metadata']['name']}

- Version: {config['metadata']['version']}
- Cases: {len(config['cases'])}
- Refresh due: {config['metadata']['refresh_due']}

## Dimensions
{render_dim_table(config['dimensions'])}
"""

✅ 自我检验清单

  • 三种伪 eval 症状:能列出”孤岛 / 滞后 / 漂移”
  • CI eval 三层:能默写 unit / integration / e2e 各自频率和时长
  • GitHub Actions workflow:能写一份完整 yml 含 trigger / steps / PR comment
  • compare.py:能写一段自动对比 baseline 的代码
  • 三档阈值:能默写 Block / Warn / Comment 的触发条件
  • Composite score:能解释为什么”只看 composite 不够”
  • Baseline 自动更新:能写 push 到 main 时自动更新 baseline 的 yaml
  • Canary gates:能列出至少 4 个回滚触发条件
  • 三类 drift:能区分 input / output / performance drift
  • Synthetic canary:能解释为什么”固定 query 持续跑”能检测 drift
  • Eval-as-Code:能列出与”工具配置式”的 5 个差异
  • YAML schema:能写一份 customer_service eval 的标准 yaml

📚 参考资料

CI/CD 模式

Drift 检测

Canary / 灰度

  • OpenAI o-series 灰度发布实践:OpenAI 官方
  • Anthropic Claude 灰度策略

Eval-as-Code