AI Agent 实战避坑 05|AI 版 TDD:Eval-Driven Development 完全指南
一个 Reviewer Agent 上线初期效果不错。两周后有人改了 prompt 里几个措辞,没跑任何验证就合并了。又过了一周,另一个人加了两条新的检查规则。再过一周,模型供应商默默升级了一个小版本。
一个月后团队发现 review 通过率从 85% 跌到了 60%。但没人知道是这三个变更中的哪一个导致的------因为没有任何自动化的回归检测。
传统软件有 TDD(Test-Driven Development)防回归。AI 系统需要 EDD(Eval-Driven Development)。
TDD 和 EDD 的类比
如果你有 TDD 经验,理解 EDD 会非常快:
TDD(传统软件) EDD(AI 系统)
────────────── ──────────────
先写测试用例 先写 eval 用例
跑测试 → 红灯 跑 eval → 低分
写代码让测试通过 调 prompt/pipeline
跑测试 → 绿灯 跑 eval → 高分
重构时跑回归测试 改 prompt 时跑回归 eval
全绿 = 没有退化 分数 ≥ 基线 = 没有退化
核心区别在一点:TDD 的测试结果是二元的(PASS/FAIL),EDD 的 eval 结果是分数制的。
因为 AI 的输出是非确定性的------同一个输入跑 10 次可能得到 10 个不同的输出。你不能要求"输出必须和标准答案完全一致",只能要求"输出必须满足一组条件,满足率足够高"。
Eval 三要素
一个最小可用的 eval 体系需要三样东西:
1. 测试集:一组有代表性的输入-输出对
python
eval_cases = [
{
"input": "一个有 SQL 注入漏洞的代码片段",
"expected": "至少一条 finding 包含 'SQL injection' 或 'SQL 注入'",
"category": "security",
"difficulty": "easy"
},
{
"input": "一个有 race condition 的并发代码",
"expected": "至少一条 finding 提到并发/竞态/锁",
"category": "concurrency",
"difficulty": "hard"
},
# ... 35 个 case
]
测试集的设计原则:
- 覆盖核心场景:不需要 1000 个 case,但必须覆盖最重要的 10-15 个场景类型
- 有简单有困难:easy case 用来检测严重退化,hard case 用来检测能力上限
- 有 gold case:3-5 个"标准答案"级别的 case,任何改动后这些必须通过
2. 评估函数:怎么判断输出"足够好"
这是 EDD 和 TDD 差异最大的地方。传统测试是 assertEqual(output, expected),AI eval 需要更灵活的判断。
三种评估策略,从硬到软:
python
# 策略 1:确定性检查(最硬,不依赖 AI)
def check_format(output):
"""输出必须是合法 JSON,且包含 findings 数组"""
data = json.loads(output)
assert "findings" in data
assert len(data["findings"]) > 0
for f in data["findings"]:
assert "file" in f and "line" in f and "issue" in f
# 策略 2:关键词/模式匹配(中等)
def check_content(output, case):
"""输出必须包含预期的关键信息"""
if case["category"] == "security":
keywords = ["SQL injection", "XSS", "注入", "漏洞"]
assert any(kw in output for kw in keywords)
# 策略 3:AI 评估 AI(最软,用另一个模型打分)
def ai_judge(output, case):
"""让一个独立的模型评估输出质量"""
score = judge_model.evaluate(
criteria="输出是否正确识别了代码中的安全问题",
output=output,
reference=case["expected"]
)
assert score >= 0.7
推荐组合:策略 1 做门槛(不过就 FAIL),策略 2 做核心(覆盖主要场景),策略 3 做补充(处理难以硬编码的语义判断)。尽量少用策略 3------用 AI 评估 AI 会引入新的不确定性。
3. 基线:改动前的分数
基线记录:
日期: 2026-03-15
prompt 版本: v2.3
模型: claude-sonnet-4-6
eval 分数:
format_check: 35/35 (100%)
content_check: 30/35 (86%)
gold_case: 3/3 (100%)
overall: 68/73 (93%)
每次改动后跑 eval,对比基线。如果分数下降超过阈值(比如 5%),就阻断这次变更。
非确定性回归测试:同一个 case 跑 N 次
这是 AI eval 独有的难题。传统测试跑一次就够了------确定性系统每次输出一样。AI 系统需要多次运行取统计值。
Case #7: "检测未关闭的数据库连接"
跑 10 次的结果:
Run 1: ✅ PASS --- 找到了未关闭连接
Run 2: ✅ PASS --- 找到了未关闭连接
Run 3: ❌ FAIL --- 漏掉了,只报了格式问题
Run 4: ✅ PASS
Run 5: ✅ PASS
Run 6: ✅ PASS
Run 7: ❌ FAIL --- 报了但描述不准确
Run 8: ✅ PASS
Run 9: ✅ PASS
Run 10: ✅ PASS
通过率:8/10 = 80%
怎么判断这个 80% 是"正常"还是"退化了"?
判断标准:对比基线时期的通过率
基线(prompt v2.3 时期):Case #7 的 10 次通过率 = 90%
当前(prompt v2.4 改动后):Case #7 的 10 次通过率 = 80%
下降了 10% → 超过 5% 阈值 → 标记为疑似退化,需要调查
关键点:
- 单次 FAIL 不算退化,概率性系统允许偶尔失败
- 通过率的显著下降才算退化,需要统计比较
- gold case 例外:gold case 的标准是 100%,任何一次 FAIL 都需要调查
实战:从 0 搭建一个 Reviewer Agent 的 Eval
Step 1:收集初始测试集
不需要一开始就有 100 个 case。从 3 个 gold case 开始:
python
gold_cases = [
{
"id": "gold_1",
"name": "明显的空指针",
"input": open("test_data/null_pointer.py").read(),
"must_find": ["NoneType", "空指针", "null check"],
"must_not_find": ["性能", "风格"], # 不应该报无关问题
},
{
"id": "gold_2",
"name": "SQL 注入漏洞",
"input": open("test_data/sql_injection.py").read(),
"must_find": ["SQL", "injection", "参数化"],
},
{
"id": "gold_3",
"name": "无问题的干净代码",
"input": open("test_data/clean_code.py").read(),
"expected_findings": 0, # 不应该误报
},
]
gold case 的设计有一个容易忽略的点:必须包含一个"无问题"的 case。否则你只在测"能不能找到 bug",没在测"会不会误报"。
Step 2:定义评估函数
python
def evaluate_review(output, case):
results = {}
# 格式检查(硬门槛)
try:
data = json.loads(output)
results["format"] = "PASS"
except:
results["format"] = "FAIL"
return results # 格式都不对,后面不用看了
# 内容检查
findings_text = " ".join(f["issue"] for f in data.get("findings", []))
if "must_find" in case:
found = any(kw.lower() in findings_text.lower() for kw in case["must_find"])
results["detection"] = "PASS" if found else "FAIL"
if "must_not_find" in case:
false_alarm = any(kw.lower() in findings_text.lower() for kw in case["must_not_find"])
results["false_positive"] = "FAIL" if false_alarm else "PASS"
if "expected_findings" in case:
results["count"] = "PASS" if len(data["findings"]) == case["expected_findings"] else "FAIL"
return results
Step 3:跑基线
bash
python run_eval.py --prompt v1.0 --runs 5 --output baseline.json
Eval Results (prompt v1.0, 5 runs per case):
─────────────────────────────────────────────
Case Format Detection FP-Check Avg
gold_1 5/5 5/5 4/5 93%
gold_2 5/5 4/5 5/5 93%
gold_3 5/5 n/a 5/5 100%
─────────────────────────────────────────────
Overall: 95% ← 这就是你的基线
Step 4:改 prompt 后跑回归
场景:有人想把 prompt 里 "找出所有缺陷" 改成 "找出最严重的 3 个缺陷"
跑 eval:
─────────────────────────────────────────────
Case Format Detection FP-Check Avg
gold_1 5/5 5/5 5/5 100% ↑
gold_2 5/5 2/5 5/5 80% ↓↓
gold_3 5/5 n/a 5/5 100%
─────────────────────────────────────────────
Overall: 90% ← 比基线 95% 低了 5%
详细分析:
gold_2 的 detection 从 4/5 降到 2/5
原因:限制"最严重的 3 个"后,SQL 注入有时不被认为是"最严重"的
结论:这次 prompt 变更引入了回归,不应合并
没有 eval,这个退化要等线上用户投诉才能发现。有了 eval,改完 prompt 5 分钟就能知道。
Step 5:持续扩展测试集
每次线上发现一个 eval 没覆盖到的问题,就加一个 case:
线上事件:Reviewer 漏掉了一个死锁问题
→ 加一个 case: test_data/deadlock.py
→ 加到 eval suite
→ 确认当前 prompt 在这个 case 上的通过率
→ 如果通过率低 → 优化 prompt → 再跑 eval → 确认不退化
这就是 EDD 的飞轮:线上问题 → 新 eval case → prompt 优化 → eval 验证 → 上线。
和 CI/CD 集成
AI 系统的 CI 不是 PASS/FAIL 二元,而是分数制:
yaml
# .github/workflows/ai-eval.yml
ai-eval:
runs-on: ubuntu-latest
steps:
- name: Run eval suite
run: python run_eval.py --runs 3 --output eval_result.json
- name: Check regression
run: |
python check_regression.py \
--baseline baseline.json \
--current eval_result.json \
--threshold 5 # 允许 5% 波动
python
# check_regression.py
def check(baseline, current, threshold):
baseline_score = baseline["overall_score"]
current_score = current["overall_score"]
diff = baseline_score - current_score
if diff > threshold:
print(f"REGRESSION: score dropped {diff}% ({baseline_score}% → {current_score}%)")
sys.exit(1) # 阻断合并
# gold case 必须 100%
for case in current["gold_cases"]:
if case["pass_rate"] < 1.0:
print(f"GOLD CASE FAIL: {case['name']}")
sys.exit(1)
print(f"PASS: score {current_score}% (baseline {baseline_score}%, diff {diff}%)")
常见误区
误区 1:"eval 太贵了,每次跑几十次 API 调用"
算一笔账:一次完整 eval(35 个 case × 3 次 × Sonnet)≈ 35 × 3 × 0.07 ≈ 7.35。一次线上退化被用户发现后的排查修复成本 ≈ 2 人天 ≈ $800+。eval 是最便宜的保险。
误区 2:"AI 输出不确定,所以没法做回归测试"
不是测"输出是否相同",是测"输出是否满足条件集"。10 次跑出 10 个不同的输出,但只要 10 次都通过所有检查 → 没有退化。
误区 3:"eval 写完就不用改了"
eval 本身也需要维护。模型能力提升后,原来的 hard case 可能变成 easy case,需要补充更难的;线上新发现的失败模式需要补充新 case。eval 是活的,不是一次性工程。
传统 TDD 防的是确定性 bug,EDD 防的是概率性退化。没有 eval 的 AI 系统,和没有测试的传统软件一样------改一行代码都心惊胆战。