多维评测 Eval + CI 阻断
昨天(Day 09)讲了 Agent 的「记忆」怎么让它越用越准。今天回答一个更根本的问题:怎么证明它真的准?怎么量化"这个 Agent 靠不靠谱"、怎么防止改一版 prompt 就偷偷变差?答案是像跑单元测试一样,给 Agent 跑一套"标准考卷"打分,不及格就拦合并。这是第 2 周可信底座的收官,为下周(D11 起)进入运行时打好"质量能被度量"的地基。
为什么 Agent 必须评测
传统代码有单元测试:输入固定,输出对就 PASS。但 Agent 输出的是"自然语言 + 大模型推理结果"——同一个问题两次回答可能措辞都不一样,没法简单 assert 相等。
可如果不评测,你改一句 prompt、换个模型,根本不知道 Agent 变好了还是变差了。所以框架把评测做成一等公民:准备一套"标准考卷"(golden set),给 Agent 跑一遍,从多个维度打分,不及格就拦住不让合并。代码在 packages/ai-trust-toolkit/src/ai_trust_toolkit/eval/。
AGENTS.md 里有条硬规矩:新 Agent 必须先有评测样本 + Critic,才能合并。评测不是可选项,是准入门槛。golden set 与评测样本(数据结构走读)
"标准考卷"就是一批 EvalSample,存成 JSONL 文件(每行一个 JSON 样本),放在每个 agent 的 data/eval_samples.jsonl。先看它的真实 dataclass 定义(eval/schemas.py:61):
# eval/schemas.py:61
@dataclass
class EvalSample:
id: str
description: str
input: dict[str, Any] # 初始 state 的输入(trace_id / service_name / user_question)
ground_truth: GroundTruth # 标准答案(人工标注)
mock_responses: dict[str, list[str]] | None = None # 喂给 FakeLLM 的固定回复
id = 这道题的编号;input = 把哪些字段塞进图的初始 state(相当于"题干");ground_truth = "参考答案"(下面细讲);mock_responses = 预先录好的"假大模型该怎么答"——键是 system prompt 里的关键词(如"Trace 专家"),值是该节点每次调用返回的字符串序列。有了它,评测走 FakeLLM 就能几秒跑完、每次分数完全一样。真实样本长这样(来自 apps/sre-rca-agent/data/eval_samples.jsonl 第 1 行,已折叠 mock_responses):
{
"id": "db-pool-001",
"description": "service-A 连接池打满 + 慢查询",
"input": {
"trace_id": "trace-db-pool-001",
"service_name": "service-A",
"user_question": "service-A 14:23 突然变慢,DB 连接池打满"
},
"mock_responses": {
"Trace 专家": ["{\"evidence_ids\":[\"trace-1\"], \"hint\":\"SELECT span 8.2s\", ...}"],
"Metric 专家": ["{\"evidence_ids\":[\"metric-1\"], \"hint\":\"connection_pool_usage 98%\", ...}"],
"最终综合节点": ["{\"hypotheses\":[{\"category\":\"DB_POOL_EXHAUSTED\", ...}], \"confidence\":\"高\"}"],
"质检员": ["{\"critique_passed\": true, \"rule_results\": {...}}"]
},
"ground_truth": {
"primary_category": "DB_POOL_EXHAUSTED",
"candidate_categories": ["DB_POOL_EXHAUSTED", "DB_SLOW_QUERY"],
"keywords_in_hypothesis": ["service-A", "连接池", "98%"],
"min_supporting_specialists": 3,
"overall_confidence_at_least": "高"
}
}
再看"参考答案" GroundTruth 的字段(schemas.py:21)——每个字段就是评测的一把尺子:
# eval/schemas.py:21
@dataclass
class GroundTruth:
primary_category: str = "" # 期望的第一根因 category
candidate_categories: list[str] = field(default_factory=list) # 期望进 Top-3 的集合
keywords_in_hypothesis: list[str] = field(default_factory=list) # 结论文本必须含的关键词
min_supporting_specialists: int = 2 # 至少几位专家印证
overall_confidence_at_least: str = "中" # 置信度至少几级(高>中>低)
detail: dict[str, Any] = field(default_factory=dict) # v0.6+:扩展字段桶
load_jsonl()(schemas.py:84)逐行读、跳过空行和 # 注释行,每行 json.loads 后交给 from_dict。而 GroundTruth.from_dict(schemas.py:44)里藏着关键一手:
# eval/schemas.py:44
@classmethod
def from_dict(cls, d: dict) -> GroundTruth:
detail = dict(d.get("detail") or {})
for k, v in d.items():
if k not in _KNOWN_GT_FIELDS: # 不认识的 key
detail.setdefault(k, v) # 自动收进 detail 桶
return cls(
primary_category=d.get("primary_category") or d.get("top1_category", ""), # 别名兼容
...
)
golden_patch_count、golden_hunk_count)统统塞进 detail 桶。coding scorer 自己去 ground_truth.detail 里取。代价是 detail 里的东西没有类型检查;收益是一套 schema 通吃所有 agent,样本文件格式统一。from_dict 里 primary_category 还兼容了别名 top1_category,candidate_categories 兼容 top3_categories(schemas.py:52-56)。所以你在样本里写 top1_category 也能被认出来——但团队里最好统一一种写法,否则 grep 样本时两种名字都要搜。对话型:5 维评分(FiveDimScorer)
像 sre-rca 这种"给结论"的 agent,用 FiveDimScorer(scorers.py:48)打 5 个布尔维度——每一维过没过:
top1_hit
第一根因命中标准答案
top3_hit
Top-3 里命中候选之一
confidence
置信度校准达标
specialists
足够多专家支撑
keywords
关键词命中率≥50%
现在钻进 FiveDimScorer.score()(scorers.py:54)看它逐维怎么算。第一步是从图的最终 state 里把结论抠出来:
# eval/scorers.py:60
score = EvalScore(sample_id=sample_id)
conclusion = final_state.get(self.conclusion_field) or {} # 默认取 state["conclusion"]
hypotheses = conclusion.get("hypotheses") or conclusion.get("top_root_causes") or []
然后 5 个维度一个个填。挑最关键的三维看:
# ① top1_hit —— 第一条 hypothesis 的 category 对不对(scorers.py:65)
top1_cat = _get_hypothesis_field(hypotheses[0] if hypotheses else None, "category")
score.top1_hit = top1_cat is not None and _category_match(top1_cat, ground_truth.primary_category)
# ③ confidence —— 用 {低:1,中:2,高:3} 把中文置信度变成可比的数(scorers.py:76)
actual_conf = conclusion.get("confidence") or conclusion.get("overall_confidence") or "低"
actual_rank = CONFIDENCE_RANK.get(str(actual_conf), 1)
expected_rank = CONFIDENCE_RANK.get(ground_truth.overall_confidence_at_least, 2)
score.confidence_meets = actual_rank >= expected_rank
# ⑤ keywords —— 半数以上关键词命中算过(scorers.py:89)
text_blob = (_flatten_text(hypotheses) + _flatten_text([conclusion])).lower()
if not ground_truth.keywords_in_hypothesis:
score.keywords_meets = True # 没要求关键词 → 直接算过
else:
hits = sum(1 for kw in ground_truth.keywords_in_hypothesis if kw.lower() in text_blob)
ratio = hits / len(ground_truth.keywords_in_hypothesis)
score.keywords_meets = ratio >= 0.5
_category_match 会把大小写、下划线、连字符全抹平再比(scorers.py:321),所以 "db_pool" 能匹配 "DB_POOL_EXHAUSTED"。③ 中文"高/中/低"没法直接比大小,先用字典 CONFIDENCE_RANK(scorers.py:18)翻译成 3/2/1,再比"实际 ≥ 要求"。⑤ 把结论里所有文字拍平成一个大字符串(_flatten_text),数关键词命中几个,命中一半以上就算过。_category_match(scorers.py:321)故意做成大小写不敏感 + 去下划线/连字符 + 允许子串(a in e or e in a)。好处:容忍 LLM 输出 "db pool"、样本写 "DB_POOL_EXHAUSTED" 这种格式差异,不会误判。代价:可能"过宽"——比如 "pool" 会匹配上 "DB_POOL_EXHAUSTED"。这是"宁可放过、不可错杀"的评测哲学:评测是回归护栏,不追求学术级精确,只要能稳定抓到"明显退步"即可。scorers.py:91)。也就是说样本里 keywords_in_hypothesis 留空,这一维永远绿灯——不是 bug,是"这道题不考关键词"的显式表达。别误以为"绿了就代表命中了"。coding 型:6 维加权(CodingAgentScorer)
像 spec-executor 这种"改代码"的 agent,评价标准不同——用 CodingAgentScorer(scorers.py:107)打 6 个加权浮点维度(权重之和=1.0):
| 维度 | 衡量 |
|---|---|
| tool_success | 工具调用成功率 |
| build_pass | 改完能否通过构建/测试 |
| patch_count | 补丁数量是否合理 |
| iterations | 迭代轮数(越少越好) |
| risk_distribution | 改动风险分布 |
| change_minimality | 改动是否最小化(可选用 Haiku 当裁判) |
权重是写死的常量(scorers.py:22),build_pass(能不能通过构建)占最重的 0.30——因为对 coding agent 来说"改完能跑"是硬指标:
# eval/scorers.py:22
CODING_DIM_WEIGHTS: dict[str, float] = {
"tool_success": 0.20, "build_pass": 0.30, "patch_count": 0.15,
"iterations": 0.15, "risk_distribution": 0.10, "change_minimality": 0.10,
} # sum == 1.0
# score() 里加权求和 → total(scorers.py:153)
total = round(sum(dims[k] * self.weights[k] for k in self.weights), 4)
return EvalScore(sample_id=sample_id, dim_scores=dims, total=total,
total_passed=total >= self.slo) # 默认 SLO=0.6
BaseScorer Protocol(scorers.py:37),run_eval 不关心用了哪套;EvalScore.all_passed(schemas.py:142)靠"dim_scores 是否非空"自动切换判据——非空走 total_passed,空则走 5 维全 True。change_minimality(改动是否最小化)有两种裁判模式(scorers.py:166):默认 heuristic 纯算数(实际 hunk 数 vs golden hunk 数的超出比例),或 haiku 让便宜 LLM 当裁判。而 haiku 模式最能体现工程化的谨慎——它有三层回退:
# eval/scorers.py:177 _haiku_minimality_judge
def _haiku_minimality_judge(self, state, gt) -> float:
try:
from ai_trust_toolkit.llm import get_llm
from langchain_core.messages import HumanMessage, SystemMessage
except ImportError:
return self._score_minimality_heuristic_fallback(state, gt) # 回退①:没装 langchain
if not self._minimality_prompt:
return self._score_minimality_heuristic_fallback(state, gt) # 回退②:prompt 文件缺失
llm = get_llm("haiku")
try:
resp = llm.invoke([SystemMessage(...), HumanMessage(...)])
return _extract_score_from_text(str(resp.content))
except Exception:
return self._score_minimality_heuristic_fallback(state, gt) # 回退③:LLM 调用失败
return fallback(scorers.py:182 / 190 / 213)。LLM-as-judge 是"锦上添花",绝不能因为它挂了就让整个评测崩——没装依赖、prompt 文件不在、模型调用抛异常,任一环节出问题都静默退回纯算数,评测照跑。这就是可信工程化的一贯做派:越是"可选增强"的东西,越要保证它失败时系统不受影响。run_eval:批量跑考卷
把样本、图、scorer 交给 run_eval()(runner.py:17)就能跑批:
samples = EvalSample.load_jsonl("data/eval_samples.jsonl")
report = await run_eval(graph, samples, scorer=FiveDimScorer())
report.print_summary() # 漂亮打印各维通过率
# CI 里用阈值判定,不达标就退出码非 0
if not report.meets_thresholds(EvalThresholds(top1=0.5, top3=0.7)):
raise SystemExit(1)
现在看 run_eval 的真实实现(runner.py:17)——它跟具体 agent 完全解耦,你只要把编译好的 graph 传进来。核心是这段并发跑批:
# eval/runner.py:42
semaphore = asyncio.Semaphore(concurrency) # 控制同时跑几条(默认 1=串行)
async def _run_one(idx: int, sample: EvalSample) -> EvalScore:
async with semaphore: # 拿到令牌才开跑
try:
initial = initial_state_builder(sample) # sample.input → 初始 state
config = config_builder(sample, idx) # 每条一个独立 thread_id
final = await graph.ainvoke(initial, config=config) # 真跑图
return scorer.score(sample_id=sample.id, final_state=final,
ground_truth=sample.ground_truth) # 打分
except Exception as e:
err_score = EvalScore(sample_id=sample.id)
err_score.error = f"{type(e).__name__}: {e}" # 挂了只记这条的 error
return err_score
scores = await asyncio.gather(*[_run_one(i, s) for i, s in enumerate(samples)])
gather 把 N 条样本同时发起;(2) 每条进 _run_one 前先 async with semaphore 抢令牌——令牌数 = concurrency,所以最多 N 条一起跑、多了排队,防止把下游打爆;(3) 每条被一整个 try/except 包住,一条炸了只把异常记进这条自己的 EvalScore.error,其余照跑——绝不会一条烂样本掀翻整场评测。data/eval_samples.jsonl(比如 20 条 golden 样本)+ build_rca_graph() + FiveDimScorer()→
run_eval 并发把 20 题各喂进图跑一遍、逐题打 5 维分 → 汇总输出(
report.print_summary()):top1=0.85 top3=0.95 confidence=0.80 specialists=0.90 keywords=0.70 → 对照阈值 top1≥0.5, top3≥0.7… 全过 → meets_thresholds() = True → CI 退出码 0,放行合并。concurrency 默认 1(串行)(runner.py:24)。为什么不默认拉满?因为评测最看重"结果可复现"——串行跑,节点里的共享 mock、日志顺序、资源占用都最稳定、最好排查;想加速再显式调大。默认求稳、按需提速,是评测工具的合理默认值。而且 config_builder 给每条样本一个独立 thread_id=f"eval-{id}-{idx}"(runner.py:75),并发时各自的 checkpoint 记忆互不串。阈值与 CI 阻断:不达标合不了
EvalThresholds(schemas.py:98)定义每一维的及格线,默认:top1=0.5、top3=0.7、confidence=0.7、specialists=0.7、keywords=0.6。跑完得到的 EvalReport 会先把每条样本的布尔汇总成"通过率",再逐维对照阈值:
# eval/schemas.py:172 某一维的通过率 = 这一维为 True 的样本数 / 总样本数
def rate(self, dim: str) -> float:
attr_name = {"top1":"top1_hit", "top3":"top3_hit", "confidence":"confidence_meets",
"specialists":"specialists_meets", "keywords":"keywords_meets"}[dim]
return sum(1 for s in self.scores if getattr(s, attr_name)) / len(self.scores)
# eval/schemas.py:222 任何一维通过率低于阈值 → 整体不达标
def meets_thresholds(self, thresholds: EvalThresholds) -> bool:
s = self.summary()
return all(s[dim] >= getattr(thresholds, dim)
for dim in ("top1","top3","confidence","specialists","keywords"))
rate("top1") 算的是"20 条样本里有几条 top1 命中",比如 17/20 = 0.85。meets_thresholds 用 all(...) 要求五维的通过率都过线,只要有一维(比如 keywords 掉到 0.55 < 0.6)没过,整个函数返回 False,CI 就 SystemExit(1) 拦下合并。注意它是群体通过率的门槛,不是"每条样本都必须满分"——允许个别难题失手,但整体水平不能滑坡。这个能力被接进了 CI 流水线(Day 18 细讲)——有一道专门的闸门 ci-gate-eval:
ci-gate-eval:评测不达标 → 阻止 merge
PR 里改了某个 agent,CI 自动对它重跑评测,任一维掉到阈值以下,流水线直接失败,合并按钮点不动。PR 模式只跑变更的 agent,省时间。
👶 小白:大模型每次输出都不一样,那评测分数岂不是每次跑都飘?CI 怎么可能稳定拦得住?
👨🏫 老师:好问题。秘密在样本里的 mock_responses——评测走的是 FakeLLM(Day 11),假大模型对每道题返回固定回复,所以同一份代码跑 100 遍分数完全一样。飘的是真模型,评测里被摁成了确定值。这样"分数变了"就一定是你的代码/prompt 变了,而不是模型随机抖动——CI 才敢拿它当合并闸。
👶 小白:那 PR 里改了 5 个 agent,每次都全量重跑 21 个 agent 的考卷不会很慢吗?
👨🏫 老师:不会。ci-gate-eval 在 PR 模式下只跑被改动的那几个 agent的考卷,主干才全量。加上全程 FakeLLM 不联网,几秒就出结果。
在线评测 & 回归检测
除了 CI 里用 golden set 的"离线评测",框架还有两个进阶能力:
ObservableScorer — 在线评测(scorers.py:381)
golden set 需要人工标答案,成本高、覆盖有限。ObservableScorer 不依赖标准答案(也 不调 LLM,纯统计),从线上真实调用的 envelope 信号算 5 个 SLO 维度。默认阈值写在 OBSERVABLE_SLO_DEFAULTS(scorers.py:372),看它怎么判"违约":
# eval/scorers.py:372 在线 SLO 默认阈值
OBSERVABLE_SLO_DEFAULTS = {
"success_rate": 0.85, # status="success" 占比 · 跌破触红
"critic_end_rate": 0.10, # Critic 拦下(不敢下结论)占比 · 涨过触红
"p99_latency_ms": 30000.0, # 延迟 p99 · 涨过触红
"p99_cost_usd": 0.50, # 成本 p99 · 涨过触红
"failure_no_LLM_rate": 0.02, # 预算/限流类硬失败占比
}
# eval/scorers.py:441 score_aggregate 里判一维是否违约(以成功率为例)
success_rate = status_distribution.get("success", 0.0)
if success_rate < self.slos["success_rate"]:
severity = "red" if success_rate < self.slos["success_rate"] - 0.1 else "yellow"
breaches.append(SloBreach(dim="success_rate", actual=success_rate,
threshold=self.slos["success_rate"], severity=severity))
breaches 里塞一条 SloBreach,还分 yellow(黄,轻微超) 和 red(红,超得离谱) 两档严重度。空 case 列表直接返回全 0(scorers.py:403),不会除零崩。ObservableScorer 的类注释里明确写着 "SHALL NOT 调 LLM(纯 statistics)"(scorers.py:382)。为什么定死不许?因为在线评测要对海量线上 case 跑、要频繁跑,一旦引入 LLM 判分,成本和延迟都会失控,还会把"评测"本身变成一个可能出错的推理过程。这里的取舍是:golden set 离线评测用 LLM 也行(量小),在线评测只准用确定性统计(量大)。RegressionDetector — 回归检测
对比"这次评测"和"历史基线",自动找出哪一维退步了。配合仓库里的 eval-replay skill(Day 19):给一个线上出问题的 case_id,能拉出历史输入重放、对比 5 维评分、定位是哪一维掉了。
今日小结 + 动手
🧠 第 2 周收官自测
- Agent 为什么不能用普通单测、要专门评测?(输出是自然语言,没法 assert 相等)
- golden set / ground_truth / mock_responses 各是什么?
- 对话型 5 维、coding 型 6 维分别评什么?为什么用多维而不是一个总分?
- 评测怎么进 CI 阻断合并?(meets_thresholds + ci-gate-eval)
- 离线评测和在线评测(ObservableScorer)的区别?
✋ 动手
# 1. 看 eval 模块清单
sed -n '1,61p' packages/ai-trust-toolkit/src/ai_trust_toolkit/eval/__init__.py
# 2. 看一个真实的 golden 样本文件
head -3 apps/sre-rca-agent/data/eval_samples.jsonl
# 3. 读 5 维评分器
sed -n '48,110p' packages/ai-trust-toolkit/src/ai_trust_toolkit/eval/scorers.py
# 4. 亲自跑一遍 sre-rca 的评测
uv run python apps/sre-rca-agent/scripts/run_eval.py
# 5. 跑 eval 单测
uv run pytest packages/ai-trust-toolkit/tests/test_eval.py -q
@safe_tool_result、get_llm/set_llm_factory 厂商无关注入、FakeLLM 测试基础设施,它们是前面所有"测试友好、厂商无关"的技术底座。