Agent · 评估与可观测(Evals & Tracing)

Eval 回答"好不好"并防变坏, Trace 回答"慢/贵在哪" — 两者共用同一套数据, badcase 回流成闭环

左: 评估闭环 — 数据集→运行→判分→门禁, badcase 回流; 右: Trace 瀑布 — 每个 span 带 token 与成本 评估闭环 Eval Loop 评测数据集 50+ 条: happy + 难例 + badcase eval runner 温度=0 · 固定数据 · 可复现 judge 判分 规则/代码执行/LLM-as-judge 回归门禁 低于基线 → 拦截合并 闭环: 线上点踩 → 还原成用例 → 进数据集 → 修复后永不复发 事故现场: "看起来对"就上线 两个月前改坏的 prompt 一直在产线上裸奔, 没人发现 一次任务执行的 Trace 瀑布 (OTel GenAI 约定) span 0s 1s 2s 3s agent.run — 3.4s · $0.041 llm 1.0s tool llm 1.6s (重试 +0.9s) llm 1.2s 瓶颈一目了然: step2 的 llm 1.6s — 失败重试白吃 +0.9s tokens: in 21k / out 2.3k · cost $0.041 · cache 命中 87% 成本/任务 均值 $0.041 · 预算熔断 延迟 P99 3.4s → 告警线 8s 工具错误率 0.7% · 按工具下钻 没有 trace 的 agent 故障 = 开盲盒; 有 trace = 30 秒定位到具体步骤 判分器光谱 — 从确定性到主观性, 按任务选段位 ① 精确匹配/断言 抽取类: 答案对不对, 零成本 ② 代码执行 生成的代码跑通 + 用例通过 ③ rubric 分维度打分 忠实/完整/格式/简洁 各 1-5 分 ④ LLM-as-judge 与人工一致率 ≥0.85 才上岗 judge 与被测禁用同一个模型(自我偏好); judge 的提示词与阈值同样进版本管理 事故现场: 阈值拍脑袋 0.6 → 全绿放行。先校准: judge vs 人工 50 条, 一致率达标再上岗 回归门禁: pass_rate 低于基线 1 个百分点 → CI 直接拦截合并, 不靠人眼把关

闭环第一(机制视角)

  • • 数据集 → 运行 → 判分 → 门禁, 四步成环
  • • badcase 回流 = 评测集自动长大
  • • "看起来对"不是验收, 基线对比才是

判分器光谱(行为视角)

  • • 能用确定判分就不上 judge
  • • judge 必须与人工校准, 禁止自评
  • • 幻觉/忠实度一票否决, 其余维度加权

可观测(生产价值)

  • • 每个 span 带模型/token/成本属性
  • • 30 秒定位: 慢在哪个 step、贵在哪个 tool
  • • 成本/延迟/错误率三告警, 按任务下钻

💡 一句话理解

Agent 的输出是概率性的: 同一份代码今天跑对、明天可能跑偏——所以它没法像传统软件那样"测试过了就永远对", 只能靠持续体检。Eval 是体检报告: 用固定题库定期测, 分数跌了就拦住上线; Trace 是病历: 每次任务留下带时间的检查记录, 慢在哪贵在哪一翻便知。两者要连成一个圈: 线上翻车案例(病历)沉淀成新考题(题库), 让同样的病第二次发作前就被拦下。

🧠 必知必会 必考 & 必会

Eval 先于 vibe check
"我看输出挺对"不可持续: prompt 改一行, 十个隐藏能力同时变。评估集把"挺对"变成可比的数字。
vibe:  "感觉还行"            # → 无基线, 改坏无人知
eval:  pass_rate 93% (基线 92%)  # → 每次改动有账可查
评估类型
单元(单步决策)、端到端(整任务)、回归(防变坏)、在线(AB/人工抽检)。覆盖面从小到大, 缺回归必退化。
unit  # → 路由/解析逻辑, mock LLM 快跑
e2e   # → 全任务完成率
regression # → 改动前后同题对比, CI 门禁
数据集构建
三源合一: 真实线上案例、手工设计的难例、历史 badcase。只有 happy path 的数据集是自我安慰。
happy ×  20  # 常规流程
hard  ×  20  # 歧义/超长/多约束/对抗
bad   ×  10  # 线上翻车复现 — 最珍贵
判分器光谱
从确定到主观四段位: 精确断言、代码执行、rubric 打分、LLM-as-judge。能用左边就不上右边——便宜且稳。
extract  # → 精确匹配, 0 成本
codegen  # → 跑测试用例
openQA   # → rubric / judge
LLM-as-judge 校准
judge 本身是概率的: 先拿 50 条人工标注测一致率, 达标(≥0.85)才上岗; 换模型/改提示词要重新校准。
agree = judge vs human / 50
# → 0.86 上岗; 0.6 的 judge = 掷硬币
可复现性
评估必须稳定: 温度 0、固定数据集版本、固定模型版本。flaky 的评估比没有评估更糟。
agent.run(case, temperature=0)
# 错: 温度 1.0 跑 eval → 每次分数随机抖动, 无法对比
回归门禁
把 eval 挂进 CI: 通过率低于基线 1 个百分点直接拦截合并。人眼把关在 50 条用例面前必失守。
if report.pass_rate < baseline - 0.01:
    sys.exit(1)          # → CI 红灯, 合并被拦
OTel GenAI 约定
OpenTelemetry 的 gen_ai 语义约定: span 上统一挂模型/token/参数属性, 任何 APM 后端都能读懂。
span.set_attribute("gen_ai.request.model", model)
span.set_attribute("gen_ai.usage.input_tokens", n)
# 一套约定, Jaeger/Tempo/Datadog 通吃
span 粒度
agent.run → step → llm/tool/retrieval/guard 四类子 span。粒度到"每次模型调用/每个工具", 才能定位到具体环节。
agent.run (3.4s)
├─ step1: llm 1.0s + tool.search 0.4s
├─ step2: llm 1.6s   # → 重试 +0.9s, 瓶颈
成本归因
每个 span 记 token 与费用, 聚合出"每任务/每用户/每 agent"成本。缓存命中部分按缓存价计。
cost = (fresh*in_price + cached*in_price*0.1
        + out*out_price) / 1e6
# → 月底账单能精确归因到每一次调用
badcase 回流
用户点踩 → 还原当时输入/上下文/输出 → 变成新评测用例。闭环让"同类问题永不复发"从口号变成机制。
thumbs_down → rebuild_case → dataset.add
# → 修复 PR 必须让新用例转绿, 否则不许合并
失败归类
失败按签名聚类: 工具错/格式错/幻觉/超时。先修占比最大的模式, 而不是随机打地鼠。
Counter({'hallucination': 14, 'tool_error:search': 9})
# → 本周主攻幻觉: 加引用强制 + faithfulness 门禁

🏭 生产实战 real world

场景 1 · 单元测试: 行为断言不靠人眼

agent 的决策逻辑(先查再答)可以用脚本化 LLM 做确定性断言, CI 里 3 秒跑完。

class FakeLLM:
    def __init__(self, scripted):
        self.script = list(scripted)      # 预设响应序列
    def create(self, **kw):
        return self.script.pop(0)

def test_agent_searches_before_answer():
    agent = Agent(llm=FakeLLM([tool_call("search"),
                               text("退款 3 个工作日到账")]))
    out = agent.run("退款几天到账")
    assert out.tools_used == ["search"]  # 行为: 必须先查

场景 2 · 数据集 + runner: 评测即代码

用例放 YAML 进仓库, runner 统一执行, 报告落 JSON 留基线。

# evals/dataset.yaml: 50+ 条(happy/难例/badcase 三源)
cases = yaml.safe_load(open("evals/dataset.yaml"))

async def run_eval(agent) -> Report:
    rows = []
    for c in cases:
        out = await agent.run(c["task"], temperature=0)  # 可复现
        rows.append(judge(c, out))       # 判分器统一入口
    return Report(rows)   # → pass_rate / 各维度均分 / 耗时

场景 3 · rubric 判分: 分维度 + 一票否决

开放式回答没法精确匹配。四个维度打分, 忠实度不达标直接判负。

JUDGE_PROMPT = """你是评审员, 按四个维度打 1-5 分并给理由:
① 事实忠实: 是否只基于给出的资料
② 完整性: 是否覆盖问题各部分
③ 格式: 是否符合要求的 JSON 结构
④ 简洁: 无废话
输出 JSON: {"faith": n, "complete": n, "format": n, "brief": n}"""

score = json.loads(judge_llm(JUDGE_PROMPT, answer, ctx))
assert score["faith"] >= 4, "忠实度不达标, 一票否决"

场景 4 · judge 校准: 先对答案再上岗

新 judge 上岗前, 拿 50 条人工标注算一致率; 换模型或改提示词后重跑。

agree = 0
for case, human in human_labeled[:50]:
    agree += (judge(case) == human)
rate = agree / 50
assert rate >= 0.85, f"judge 一致率 {rate}, 不允许上岗"
# → 0.86 通过; 之后每月抽检一次防漂移

场景 5 · OTel 接入: gen_ai 语义约定

每次 LLM 调用一个 span, 属性按社区约定命名, 任意 APM 直接可用。

with tracer.start_as_current_span("llm.call") as span:
    span.set_attribute("gen_ai.system", "anthropic")
    span.set_attribute("gen_ai.request.model", "claude-sonnet-4-5")
    span.set_attribute("gen_ai.usage.input_tokens", u.input_tokens)
    span.set_attribute("gen_ai.usage.output_tokens", u.output_tokens)
    span.set_attribute("gen_ai.request.temperature", 0.2)
# trace_id 贯穿: 用户报障 → 一条 trace 还原全程

场景 6 · 成本归因: 缓存价单算

账单对不上? 缓存命中的输入价格不同。精确到每个任务的费用函数。

PRICES = {"claude-sonnet-4-5": {"in": 3.0, "out": 15.0}}  # $/Mtok

def cost_of(model: str, usage) -> float:
    p = PRICES[model]
    cached = usage.cache_read_input_tokens
    fresh  = usage.input_tokens - cached
    return (fresh * p["in"] + cached * p["in"] * 0.1
            + usage.output_tokens * p["out"]) / 1_000_000
# → 按任务/用户/agent 维度聚合, 账单精确归因

场景 7 · CI 回归门禁: 分数跌了就拦

eval 接进 CI, 低于基线 1 个百分点直接退出非零, 合并被拦截。

# .github/workflows/eval-gate.yml
- name: eval gate
  run: python -m evals.run --baseline evals/baseline.json

# evals/run.py 内部:
if report.pass_rate < baseline.pass_rate - 0.01:
    print(f"回归: {report.pass_rate:.2%} < {baseline.pass_rate:.2%}")
    sys.exit(1)                      # → CI 红灯

场景 8 · badcase 回流: 点踩变考题

用户点踩后, 自动还原现场存成用例。修复 PR 必须让新用例转绿。

async def on_feedback(msg_id: str, thumbs: str):
    if thumbs != "down":
        return
    case = await rebuild_case(msg_id)   # 输入/上下文/输出全还原
    await dataset.add({"id": f"bad-{msg_id}", **case})
# → 评测集自动长大; 同类翻车的复发率是闭环的 KPI

场景 9 · A/B: 两版 prompt 同题对比

改 prompt 靠感觉? 同一批数据集跑两版, 数字说话, 权衡延迟后灰度。

r_old = run_eval(agent, dataset, system=SYS_v14)
r_new = run_eval(agent, dataset, system=SYS_v15)
report.compare(r_old, r_new)
# → v15: pass_rate 91→94, 但 p50 延迟 +120ms
# 决策: 收益大于代价 → 灰度 10% 观察线上反馈

场景 10 · 失败聚类: 先修大头

失败不是均匀分布的。按签名聚类, 每周主攻占比最高的失败模式。

from collections import Counter

def cluster(failures) -> Counter:
    sig = lambda f: f"{f.type}:{f.tool or "-"}"
    return Counter(map(sig, failures))
# → Counter({'hallucination:-': 14, 'tool_error:search': 9})
# 本周主攻幻觉: 上引用强制 + faithfulness 门禁

⚠️ 编码注意与常见坑 pitfalls

坑 1 · vibe check 验收 — 症状: "我看没问题", 两周后隐性退化. 原因: 无评测集无基线. 正解: 50 条数据集 + 基线对比。
# 错: 人工看 3 个例子就上线
# 对: pass_rate 93% vs 基线 92% → 数字验收
坑 2 · judge 偏好长答案 — 症状: 冗长回答得分高, 用户嫌啰嗦. 原因: judge 提示没约束"简洁"维度. 正解: rubric 加简洁维度 + 长度惩罚。
# 错: "给这个回答打 1-5 分"(默认偏爱详细)
# 对: 分维度打分, brief ≤3 分即扣总分
坑 3 · 数据集只有 happy path — 症状: eval 全绿, 线上翻车. 原因: 难例/对抗/badcase 缺席. 正解: 三源配比 + 定期补充。
# 错: 20 条"正常提问"全过
# 对: happy 20 + hard 20 + badcase 10
坑 4 · judge 自评 — 症状: 分数虚高永远通过. 原因: judge 与被测同模型(自我偏好). 正解: judge 换不同家族模型。
# 错: 用 gpt-x 生成又用 gpt-x 判
# 对: 生成用 A 家, judge 用 B 家 + 人工抽检
坑 5 · eval 无版本管理 — 症状: 分数涨了不知道是改好了还是考题变简单了. 原因: 数据集/判分器随手改. 正解: 数据集与 judge 进 git, 变更留痕。
# 错: dataset 本地随便加题
# 对: dataset.yaml + judge prompt 走 PR 评审
坑 6 · 温度不定 flaky — 症状: 同一版本两次跑分差 8 个点. 原因: 采样随机未固定. 正解: temperature=0 + 固定数据版本。
# 错: eval 时 temperature=1.0
# 对: eval 强制 temperature=0, 差异才可信
坑 7 · 只测单轮 — 症状: 单步全对, 多轮任务一塌糊涂. 原因: 没有端到端用例. 正解: 单元 + e2e 双层, e2e 含多轮交互。
# 错: 只测"路由对不对"
# 对: e2e 用例: 10 轮工单处理全流程
坑 8 · trace 断链 — 症状: 跨服务查 trace 对不上. 原因: trace_id 没随调用传递. 正解: 上下文传播(traceparent 头)贯穿所有服务。
# 错: 每个 service 自己起 trace_id
# 对: propagate_context() + W3C traceparent 头
坑 9 · 成本不归因 — 症状: 月底账单翻倍说不清是谁花的. 原因: 只看总量. 正解: span 记 token + 按 task/user/agent 聚合。
# 错: 月账单 $4200, 归因靠猜
# 对: group by agent → researcher 占 62% → 定点优化
坑 10 · 回归不拦截 — 症状: 明明 eval 红了, 代码照样合并. 原因: 门禁只是"报告"不是"闸门". 正解: CI exit 1 阻断合并。
# 错: eval 结果发到群里无人看
# 对: sys.exit(1) → branch protection 拦截
坑 11 · 考题泄漏进提示 — 症状: eval 分数虚高, 线上一塌糊涂. 原因: 评测用例内容被抄进 few-shot/提示词. 正解: 数据集与提示词隔离评审, 抽查重叠。
# 错: few-shot 例子直接来自评测集
# 对: 例子与用例分仓库管理 + 相似度巡检
坑 12 · 阈值拍脑袋 — 症状: 0.6 全绿放行, 实际一半是错的. 原因: 阈值没校准. 正解: 先跑人工一致率再定阈值。
# 错: judge_score > 0.6 即通过
# 对: 校准分布 → 取"误放率 <5%"的分位数做阈值
坑 13 · 忽略延迟回归 — 症状: 质量涨 2 分, 延迟翻倍没人管. 原因: eval 只看正确率. 正解: 报告带 p50/p99 与成本, 同入门禁。
# 错: 只比 pass_rate
# 对: pass_rate + p99 + cost/任务 三指标门禁
坑 14 · 线上无人工抽检 — 症状: eval 与真实分布脱节三个月. 原因: 无线上复核. 正解: 每周随机抽 50 条人工评, 回流校准。
# 错: 上线后再没人看过真实输出
# 对: 周抽 50 条 → 人工打标 → 对比 judge 漂移
坑 15 · 10 条用例下结论 — 症状: 改 A/B 结果随机反转. 原因: 样本太小, 噪声淹没信号. 正解: ≥50 条起步, 差异 <3 个点不下结论。
# 错: 10 条跑一遍, 9 比 8 就宣布新版更好
# 对: 50+ 条 + 差异显著性意识
坑 16 · judge 与业务脱节 — 症状: judge 满分的回答用户不买账. 原因: rubric 是通用模板没含业务规则. 正解: rubric 里写进业务硬约束。
# 错: 通用 rubric: 相关/完整/流畅 三维度
# 对: + 业务项: 引用工单号 / 金额两位小数 / 带时区
坑 17 · 只看均值 — 症状: 均分 4.2 不错, 但 15% 的 1 分案例全是幻觉. 原因: 均值掩盖尾部. 正解: 看分布与最差 5%。
# 错: 只盯 mean=4.2
# 对: p05 与 1 分占比一并出门禁
坑 18 · badcase 不回流 — 症状: 同样翻车每月来一遍. 原因: 复盘完就完. 正解: 点踩→用例→修复→转绿 闭环。
# 错: 修完即忘
# 对: dataset.add(bad_case) + 修复 PR 必须转绿
坑 19 · mock 过头 — 症状: eval 全绿, 真实 API 一接全崩. 原因: 全 mock 无真实依赖. 正解: 分层——单测 mock, e2e 打真实沙箱。
# 错: e2e 也全 mock, 连 schema 都不对
# 对: e2e 用真实模型 + 沙箱环境
坑 20 · 日志无结构 — 症状: 排障全靠 grep 文本. 原因: 自由文本日志. 正解: JSON 结构化日志 + trace_id 字段。
# 错: print("step 3 done, cost 0.02")
# 对: log.info(json.dumps({"step": 3, "cost": 0.02, "trace": tid}))