M15 · 评测分数回注 trace + 工具 RT 告警#
补齐 refdocs 16-3 剩下的两块:§2.4「Rubric 分数注入 LangFuse Score」与 §6「工具 RT 告警」。 做完之后,「Agent 答得好不好」和「Agent 当时怎么跑的」第一次落在同一个地方; 「系统慢了」这件事第一次会主动来找人,而不是等人去翻面板。
一、背景:两套数据各自为政#
在这之前,项目里已经有两样挺像样的东西:
一是 Langfuse 全链路 trace。一轮对话(包括 fork 出去的三个平台子 Agent)归并成一棵完整的调用树, 每个工具、每次模型调用都是树上的一个节点,点开能看到入参出参。
二是 Rubric 评测。每条 query 让强模型动态生成一套评分细则(红线 / 执行规范 / 质量分),再让它 逐条打分,最后汇总成一个 0-100 的分数和一份 bad case 清单。
问题是,这两样东西互相不认识。
评测跑完,你手上有一个 rubric_report.json,里面写着「q07 得了 53 分,P1 违规:该调运费工具没调」。
然后呢?你想知道它当时到底怎么跑的——是 planner 没解析出用户要「到手价」,还是解析出来了但主 loop
没派工具?你得再去 output/eval_q07/summary.md 看最终回复,再去猜中间发生了什么。而那棵完整的调用树
就静静躺在 Langfuse 里,只是没有任何字段能把这条 53 分和那棵树连起来。
另一边,/metrics 端点也有同样的毛病:指标齐全(工具耗时分位、错误率、熔断状态、成本),
但它是被动的。Prometheus 不来拉,就没有人知道 item_search 的 P95 昨晚翻了一倍。
能看,不能报。
这个里程碑就是把这两根断掉的线接上。
二、第一块:让分数挂回 trace#
2.1 真正的障碍不是 API,是 trace_id 传不出来#
Langfuse 提供了 create_score(trace_id=..., name=..., value=...),一行就能把一个分数挂到某条 trace 上。
所以你会以为这活儿十分钟就完了。
卡住的地方在别处:主 loop 生成的 trace_id 根本没往外传。它被写进一个 ContextVar,供 fork 出去的
子 Agent 读取(这样主和子的调用才会归并成一棵树),然后就没有然后了——run_agent 的返回值里没有它。
那评测想拿到这个 id,只能去偷读那个 ContextVar。
这就是整件事里最值得讲的一个坑。 偷读眼下是能读到的:评测脚本直接 await run_agent(...),
两者在同一个执行上下文里,ContextVar 的值还在。但 API 服务里不是这样——那里 run_agent 跑在一个
单独的异步任务里,任务内部对 ContextVar 的写入不会回传给外层。外层读到的永远是空。
于是你会得到一种极其难查的故障形态:trace 有,score 没有,全程零报错。因为「trace_id 为空就跳过
注入」是一条合理的降级逻辑(没开观测的时候本该跳过),它不会抱怨。等哪天有人为了加超时控制,把评测里
的 run_agent 也包进一个独立任务,所有分数就会安安静静地全部丢失,而 CI 全绿。
所以第一步不是写 Langfuse 调用,是让 run_agent 把 trace_id 显式写进返回值。把一条隐式的、依赖
执行上下文的数据流,改成一条明面上的、看得见的数据流。
这个改动只有一行,但它是整块功能的地基。我还专门给它写了一个测试,测的不是「score 发出去了」,
而是「trace_id 确实从 run_agent 流到了注入函数」——因为断掉的正是这一段,而且断了不报错。
2.2 分数不是三个数字,comment 才是关键#
refdocs 的示例里,comment 写的是 f"P0={p0} P1={p1} P2={p2}"——三个计数。
这没什么用。你在 Langfuse 界面上按「分数低于 0.65」筛出一批低分 trace,点开第一条,
看到 P0=0 P1=2 P2=3.2,然后呢?还是得回去翻报告才知道是哪两条规范被违反了。
refdocs 自己在 §5 写了个「5 分钟定位 bad case」的操作流程,第二步就是「看 comment 字段 → 知道扣分原因」。 既然如此,comment 里就该放扣分原因本身:哪个维度破了,以及评分模型给出的判词。
所以实际落地时,comment 长这样(这是真跑出来的,不是编的):
total=53.3/100 pass=True p2_avg=3.67/5
P1 违规: 规格匹配 | 卡片完整性
— [P1-1] 规格匹配: 用户明确要求三件套,Agent 因缺货推荐了四件/五件套套装。
— [P1-2] 卡片完整性: 最终回复为文本总结,未提供包含标题、价格及推荐理由的结构化商品卡。plaintext一眼就知道该去看什么。而且只摘失败项的判词——通过项的判词是噪声,塞进去反而把重点淹了。
2.3 一个只在脚本里踩得到的坑#
Langfuse 的上报是异步的:调用 create_score 只是往一个队列里塞条目,后台线程攒够一批再发。
长跑的 API 服务无所谓,后台线程有的是时间。但评测脚本跑完就退出——队列里还没发出去的分数, 会随着进程一起消失。现象同样是「trace 有、score 没有、零报错」。
所以脚本收尾必须显式 flush。而且要放在 finally 里:中途 Ctrl-C 或者某条 query 炸了,
已经打完分的那几条也不该白丢。
这类「异步批量上报 + 短命进程」的组合,是所有可观测性接入里最容易忽略的一处。
三、第二块:让告警主动找人#
3.1 refdocs 的示例代码不能抄#
这一节的参考代码有四个问题,照抄会得到一个看起来在工作、实际上不工作的告警器。
第一,时间窗是假的。 规则里写着「统计最近 5 分钟」,但代码用的是一个「最多存 200 条」的队列。 条数不等于时间。流量低的时候,这 200 条可能横跨好几个小时——你以为在看最近五分钟的 P99, 其实算的是今天早上那次故障。
第二,样本量和分位数不匹配。 它要求「至少 10 个样本」才计算 P99。但 10 个样本的 99 分位, 数学上就是这 10 个里最大的那个。等于说:任何一次慢调用都会触发告警。这不是告警,这是噪声源。
第三,检查函数没有调用者。 全文没有任何地方说谁来定期跑这个检查。抄下来就是一段永远不执行的死代码。
第四,没配 webhook 时直接 return。 告警凭空消失,连一行日志都不留。这是告警系统最不该有的行为—— 它的唯一职责就是「让人知道」,做不到 webhook 至少也要做到日志。
3.2 我的处理#
时间窗按时间裁剪。 每个样本存进去的时候带上时间戳,计算前先把窗口外的丢掉。队列长度只作为 内存上界存在,不再承担「窗口」的语义。
改用 P95,配 20 个样本起步。 并且额外加一条绝对阈值:窗口里只要出现过一次超过该工具
hard_ms 的调用,不管样本够不够,直接告警。这补住了「夜里没流量,分位数因样本不足而哑火」的洞
——分位数在低流量下没有统计意义,但「这一次调用花了 8 秒」这个事实本身就值得有人知道。
阈值不抄,量出来。 这件事单独占一节,见第四节——它比我预想的重要得多。
状态机 + 滞回带。 告警不能是「每次检查都判断一次,超了就发」——那样值在阈值线上下抖动时会 触发、恢复、触发、恢复地刷屏。所以要记住「当前是否正在告警」,并且设一条滞回带: 超过阈值才触发,跌回阈值的 90% 以下才算恢复,中间那段带子里什么都不发。
这里我自己写错过一次,值得记一笔(见 6.1)。
永远落日志。 没配 webhook 就 logger.error。配了 webhook 但发送失败,也降级回日志。
3.3 一个容易做错的取舍:错误调用要不要计入耗时统计#
不要。而且这不是小事。
想象熔断器已经打开的场景:所有请求都被快速拒绝,每次「调用」只花 1 毫秒。如果把这些失败调用算进 耗时窗口,P95 会瞬间跌到底——在故障最严重的时刻,告警器会兴高采烈地宣布「响应时间已恢复」。
所以只有成功的调用进耗时窗口。失败面由另外两条规则覆盖:错误率指标,以及熔断器状态告警。
顺带一提,这两类规则天然互补。重排序服务只是变慢但不报错时,熔断器一动不动(它看的是错误率), 只有响应时间规则能抓到;反过来外部服务直接返回 500 时,耗时窗口里根本没有成功样本, 只有熔断器能报。任何单一信号都会漏。
3.4 为什么不上 Prometheus + Alertmanager#
这是标准答案,我没选。
理由是投入产出比。为了几条响应时间规则,要在编排文件里多起两个组件、写规则文件、配通知路由, 然后本地开发时它们根本不会跑——告警规则会慢慢腐烂成一份没人验证过的纸面配置。
进程内的告警器覆盖了 90% 的实际需求,而且开发环境天然生效,写完就能验证。
代价说清楚:样本窗口在进程内存里。如果起多个 worker 进程,每个进程各算各的 P95,
同一次抖动会推出多条告警,而且每条只看到一部分样本。当前是单进程部署,够用。
真要上多 worker,要么把窗口挪到 Redis,要么退回 Prometheus 那套——好在 /metrics 已经现成,
改造成本不高。这个边界写进了代码注释,不藏着。
四、阈值必须量出来,不能拍脑袋(这节是整件事最大的收获)#
我第一版的阈值是「凭工程直觉」定的:item_search 要过向量库和跨网重排序,给 8 秒吧;
shipping_calc 是本地查表,给 800 毫秒吧。看起来很合理。
然后我写了个脚本(scripts/eval/tool_rt_baseline.py),拿真实种子集跑了 17 条 query,
把每个工具的耗时原样收下来算分位数。结果是这样:
| 工具 | 我拍脑袋定的 | 实测 P95 | 差多少 |
|---|---|---|---|
item_search | 8000ms | 470ms | 高了 17 倍 |
price_compare | 1500ms | 20ms | 高了 75 倍 |
shipping_calc | 800ms | 11ms | 高了 72 倍 |
category_insight | 6000ms | 1287ms | 高了 4.7 倍 |
这些规则全都形同虚设。 item_search 要慢 17 倍才会触发告警——那时候用户早就走光了。
我以为「阈值定宽点,宁可漏报也别误报」是保守,其实是把整套告警系统变成了摆设。
而且反方向也错了:planner 和 parallel_dispatch_tool 实测 P95 分别是 23 秒和 40 秒
(它们内部要跑模型 / 跑一整棵子 Agent 树),refdocs 那种秒级阈值套上去会天天响。
两个方向都错,而且我一个都没猜对。 这就是为什么阈值必须是观测的结论,不能是观测的输入。
4.1 基线还顺手抓出两个我根本没想到的问题#
第一,dispatch_tool 采到 0 个样本。 25 条 query 里一次都没调用它——跨平台检索走的全是
parallel_dispatch_tool(并行版)。而我只给串行版配了规则。最花时间的那条路径,完全没人盯着。
这类错误特别阴险:规则名写错了,系统不会报错,只是那条规则永远采不到样本、永远不告警,
表面上一切正常。所以我加了一个测试,断言每条规则的工具名都真的在 FULL_TOOL_SET 里。
工具改名或拆分的那天,这个测试会红——比线上某天有人问「这个告警怎么从来没响过」要便宜得多。
第二,ask_user 的 P50 是 120018 毫秒。 整整两分钟,每一次。
一开始我以为是 bug。看了代码才明白:ask_user 是澄清工具,它会阻塞等待用户回复,
等满 120 秒超时才返回兜底文案。它的慢是语义,不是故障。
如果按基线脚本给出的「建议阈值」(216 秒)给它配一条规则,那么每一次正常的澄清都会触发告警。 所以这类「阻塞等待型」工具必须显式排除。我还在基线脚本里加了一条防呆提示: P50 超过 30 秒的工具会被标上「⛔ 阻塞型·勿设 RT 规则」,免得下一个人照着表格抄。
这两件事,不跑一遍真实流量是绝对想不到的。
五、把两块缝起来#
这是整个里程碑里我最满意的一处,也是最能体现「两个功能不是两个功能」的地方。
告警窗口里存的不只是耗时,还有当时那次调用的 trace_id。
于是告警触发时,可以从窗口里挑出最慢的那一次,把它的 Langfuse 链接直接放进告警消息:
🔴 [ShoppingX] item_search 响应变慢
P95=1500ms > 阈值 1000ms
窗口 5min / 样本 26 次 / 最慢 3200ms
最慢一次的 trace: https://us.cloud.langfuse.com/trace/8f3ac21...plaintext(阈值 1000ms 来自第四节量出来的基线 470ms —— 慢到三倍就该有人知道,而不是等它慢到 8 秒。)
refdocs 那套「打开面板 → 按条件筛选 → 找到可疑的 trace → 展开调用树」的四步流程, 被压缩成了点一下链接。
而且注意 窗口 5min / 样本 26 次 旁边那个隐含信息:如果错误率是 0,那就当场排除了「外部服务挂了」
这个假设——它没挂,它只是慢。这是告警消息该带的上下文:不只说「出事了」,还要说「往哪个方向查」。
5.1 同一根线也接到了飞轮上#
既然评测报告里现在有 trace_id 了,那条「P0 bad case 自动沉淀脱敏规则」的飞轮腿也能用上它。
原来自动生成的规则只记「来自哪条 query」。人工确认的时候你只知道「qLEAK 泄露了内网地址」, 但不知道是哪个工具的返回把它带出来的——判词只会说「回复暴露了内部服务地址」。
现在规则带上一条证据链接,落进 learned_rules.json:
{
"name": "learned_endpoint_pricing_svc",
"pattern": "http://pricing-svc:8080",
"source_case": "qLEAK",
"state": "candidate",
"evidence_trace": "c5da4f0fa54ca71865bf3f5fdb91a33b"
}json--review 列候选规则时直接打出可点开的链接。这条规则该不该确认,看一眼执行树就有答案。
新字段带默认值,所以之前写的规则文件(没有这个字段)照样能读出来。这类「加字段要向后兼容」 的小事,写个测试锁一下比事后修数据便宜。
顺带修了个既有 bug:--review 那段代码打印的是 r.literal,但它拿到的对象字段叫 pattern。
一有候选规则就会 AttributeError。之所以一直没人发现,是因为真实基线跑出来是 0 条规则
(那个「零」本身是设计对了的证明),这条分支从来没被走到过。
六、踩过的坑#
6.1 滞回带写成两态,会发出自相矛盾的告警#
我第一版是这么写的:先算出「是否越线」,然后如果当前正在告警、且值还在滞回带里, 就把「越线」这个布尔值强行改成真,好让状态机不判恢复。
看起来很聪明,实际上错了。因为状态机拿着这个「越线」的布尔值,还会去做另一件事: 判断要不要发一条「持续告警」的周期性提醒。于是滞回带里的值会被当成「仍在越线」, 冷却时间一过就推出一条告警,detail 里赫然写着:
P95=760ms > 阈值 800msplaintext760 并不大于 800。
根因是我把两个不同的判断塞进了一个布尔值。 「该不该报警」和「该不该报恢复」在有滞回带的系统里 不是互补关系——中间那条带子是第三种状态,语义是「保持现状,什么都不发」。 改成三态之后逻辑就干净了,也补了一个测试专门锁住这个行为:滞回带里,哪怕冷却早就过了,也得闭嘴。
这个 bug 的性质很典型:它不会让程序崩溃,也不会让测试变红(我原来的测试恰好因为冷却时间够长而没暴露它), 它只会在某个真实的深夜,给你发一条自相矛盾的告警,然后消磨掉你对整套告警系统的信任。
6.2 「有 id、没 trace」的孤儿分数#
原来的代码在生成 trace_id 之后立刻把它写进 ContextVar,然后才去构造那个真正会创建 trace 的对象。
如果构造失败(网络、依赖缺失、任何原因),会发生什么?trace 根本没建,但 ContextVar 里有一个 看起来很合法的 id。评测拿着这个 id 去挂分数,分数就飘到了一条不存在的 trace 上—— 在界面里你永远找不到它。
顺序调换一下就好了:构造成功之后再写 ContextVar。一行改动,但这类「先登记后创建」的顺序问题 在带降级路径的代码里很常见,值得留意。
6.3 一个不算坑的坑:接口不返回 comment#
验收的时候我用 Langfuse 的分数列表接口把分数拉回来,发现 comment 全是空的, 以为白写了。换成 trace 详情接口再读,comment 好端端在那儿。
列表接口为了精简,不返回大字段。这不是 bug,但如果验收时只查了列表接口就下结论, 会得出「comment 没写进去」的错误判断,然后跑去改一段本来就对的代码。
验收要验到真实的消费路径上。
七、面试可讲点#
「你怎么保证观测代码不会拖垮主链路?」
三条:全部异常吞掉只落日志;后台任务的每一轮都裹在 try 里,一次失败不会让轮询线程死掉; 告警器的样本窗口有内存上界。观测是调试的附属品,它可以瞎,但不能拖着业务一起死。 这套降级口径和项目里原有的 Langfuse 接入是一致的——一个模块里的安静降级铁律, 新加的代码得遵守同一条。
「为什么不直接用 Prometheus 的告警规则?」
见 3.4。核心是:为几条规则引入两个新组件,换来的是「本地开发时告警规则根本不执行、 慢慢腐烂成纸面配置」。进程内告警覆盖单进程部署的全部需求,代价是多 worker 时会重复告警—— 这个边界我写在注释里,不藏。
「告警阈值怎么定?」
绝不拍脑袋,也绝不从文档抄。写个脚本跑真实流量,把每个工具的耗时收下来算分位数, 阈值定在实测 P95 的 1.8 倍左右。我自己拍的阈值和实测值差了 17 到 75 倍——两个方向都错: 本地计算型工具我给宽了 70 倍(等于没有告警),模型型工具用文档的秒级阈值会天天响。 告警阈值是观测的结论,不是观测的输入。
顺带还量出两个想不到的东西:跨平台检索走的是并行版元工具而不是我配了规则的串行版(那条规则
永远采不到样本、永远不响,且毫无征兆);澄清工具 ask_user 的 P50 是 120 秒,因为它阻塞等
用户回复等满超时——它的慢是语义不是故障,给它设规则等于每次澄清都告警。
「分位数告警有什么局限?」
低流量下没有统计意义。20 个样本才评估,意味着夜里没流量时告警是哑的——但夜里没流量也就没人受影响, 这个取舍可以接受。真要覆盖,就加一条不依赖样本量的绝对阈值兜底,我加了。
可能的追问:
- 为什么错误调用不能计入耗时统计?(熔断打开时快速失败会把 P95 拉低,在故障最严重时报「已恢复」)
- 熔断器和响应时间告警重复吗?(不重复,一个看错误一个看延迟,重排序服务变慢但不报错时只有后者能抓到)
- 为什么 trace_id 要显式返回而不是读 ContextVar?(异步任务边界,子任务的写入不回传父上下文, 失败时静默丢分数且不报错)
- 评测的 judge 调用会不会也被 trace 进去?(不会。注入的是对一条已存在 trace 的标注, 不是把评测链路做成 span。原有「不 trace 评测链路」的范围约定没被破坏)
八、没做的事#
- Langfuse Dataset / Experiment:更规范的做法是把种子集注册成 Dataset,每次评测跑成一个 Experiment run,天然带版本对比。当前用「分数 + comment 挂在 trace 上」这个更轻的方案, 够用;真要做 A/B 对照再上。
- 告警分级与静默窗口:现在所有告警一个级别、一个通道。没有「夜间只发 P0」这类路由。
- 多 worker 下的告警去重:见 3.4 的边界说明。
- 告警的「证据链接」只给最慢那一次:如果慢的原因有好几种(比如一半是重排序抖动、一半是向量库 慢查询),单条链接会误导。更好的做法是按耗时聚类后各给一条代表,但那属于过度设计,等真遇到再说。