第8章:CI/CD 与回归测试 —— 把 Eval 变成 Pre-merge Gate
GitHub Actions + DeepEval 完整 workflow,性能门禁规则,A/B 测试,Canary 灰度,Production drift 检测,Eval-as-Code
写好 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
- 2. CI eval 三层结构
- 3. GitHub Actions + DeepEval 完整 workflow
- 4. 性能门禁规则
- 5. A/B 测试与 Canary
- 6. Production Drift 检测
- 7. Eval-as-Code:Git 管理 eval definition
- 自我检验清单
- 参考资料
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 |
| E2E | nightly / 每候选 release | 1-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 drift | accuracy / 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 模式
- DeepEval CI/CD:deepeval.com/docs/getting-started
- GitHub Actions LLM eval examples:awesome list
Drift 检测
- Phoenix Drift Detection:arize.com/docs/phoenix
- Embedding Drift Detection 综述论文
Canary / 灰度
- OpenAI o-series 灰度发布实践:OpenAI 官方
- Anthropic Claude 灰度策略
Eval-as-Code
- Promptfoo YAML config:promptfoo.dev/docs
- OpenAI Evals YAML 范式:github.com/openai/evals