从一条追踪得出所有指标 — 质量·失败率·SLO·关卡
目标
记录流水线每一轮的跨度,从同一份原始数据中计算延迟分布、WER、回答质量和失败率,用 SLO 判定,然后制作阻止退化的发布门禁。
为什么重要
只看平均延迟和错误率,就看不到“通过了依据检查的错误回答”和“悄悄顺利处理掉的错误”。指标必须从同一份原始数据中计算,数字才能吻合;而门禁必须放进该被阻止的东西试过,才能被信任。本实验使用基线流水线 voicekit.pipeline.Pipeline——与模块 5、8 同一条主线(确认 → 改写与检索 → 阈值 → JSON 回答 → 数字检查 → 提取 → TTS),用 run(wav, qid, context, span=기록함수)(占位符为记录函数)把各阶段的跨度告知出来,并可以用 faults={...} 故意让某个阶段失败。评分器会从你的跨度记录中重新计算所有汇总数字并核对。
步骤
- 把 12 条查询作为 12 条跟踪(根 turn + 各阶段跨度)写入
/root/voice/eval/spans.jsonl。 - 把各阶段跨度长度的 p50、p95 写入
/root/voice/eval/stages.json。 - 用 asr 跨度中的文字和正确答案原文,把逐查询 WER 和整体 WER 写入
/root/voice/eval/wer.json。 - 用标准答案表,把事实准确率、拒答准确率和依据一致率写入
/root/voice/eval/quality.json。 - 分别对一条查询注入 ASR 空文本、LLM 超时、TTS 失败后重新运行,生成
/root/voice/eval/spans_faults.jsonl,以及统计了错误轮次和用户失败的/root/voice/eval/failures.json。 - 确定 SLO 目标,并生成与测量值比较的
/root/voice/eval/slo.json。 - 创建比较基线与当前指标、出现退化时以退出码 1 结束的
/root/voice/eval/gate.py。 - 生成这一版的指标
/root/voice/eval/metrics.json,并生成用基线版本(/opt/lab/fixtures/voice/eval/baseline.json)运行了门禁的/root/voice/eval/report.json。
参考
- 一行跨度:
{"trace_id": 질의 id, "span_id": …, "parent_id": 뿌리의 span_id(뿌리는 null), "name": …, "start_ms": …, "end_ms": …, "status": "ok"|"error"|…, "attrs": {…}}(占位符依次为查询 id、根的 span_id(根为 null))。把say、source、first_audio_ms放在根 turn 的 attrs 中,把这一轮的结果(ok · refused · degraded · reprompt · failed)放在 status 中。 - 标准答案表:
/opt/lab/fixtures/voice/rag/queries.jsonl的answerable、facts,正确答案原文:/opt/lab/fixtures/voice/queries/refs.tsv。 - 百分位数使用最近秩(排序后第 ⌈p/100·n⌉ 个值)。
- 常见错误:把拒答了的有答案问题从事实准确率的分母中去掉(分母是所有有答案的问题),把出错的轮次全部算作用户失败,把门禁的延迟容差设为绝对值。
- 文档:OpenTelemetry — Traces · Google SRE Book — Service Level Objectives
一轮 = 一条跟踪
在 voice-llm up 之后,用 Pipeline() 运行 12 条查询(按 /opt/lab/fixtures/voice/rag/queries.jsonl 的顺序,追问的 context 是前一条查询的最终文字),收集通过 span 钩子收到的各阶段跨度,并为每一轮添加根跨度 turn(start 为 0,end = 子跨度结束时间的最大值,status = 这一轮的结果,attrs 中包含 say、source、mode、first_audio_ms、errors),每行一条写入 /root/voice/eval/spans.jsonl。
span 钩子以 span(name, start_ms, end_ms, status, attrs) 的形式被调用。先确定根的 span_id,再把它用在子跨度的 parent_id 上。uuid.uuid4().hex 是简单的 id。
各阶段的分布
把 spans.jsonl 中的子跨度(不含根)按名称归集,把长度(end − start)的 n、p50_ms、p95_ms(最近秩)写入 /root/voice/eval/stages.json。
p95 最长的阶段,就是最常让用户等待的地方。要看的不是平均值,而是分布的尾部。
与服务相似的数据上的 WER
用 asr 跨度的 attrs.text 和 /opt/lab/fixtures/voice/queries/refs.tsv 中的原文,把逐查询 WER 写在 per_query 中,把错误之和 ÷ 单词数之和写在 wer 中,生成 /root/voice/eval/wer.json(小写、整理标点,保留撇号)。
可以直接拿模块 3 的 wer.py 来用。请与同一个模型在 LibriSpeech 上只错 1% 多一点的情况比较一下。
回答质量——依据一致不等于准确
用根跨度和标准答案表,把 fact_accuracy(以所有有答案的问题为分母,没有拒答、且 say 中包含 facts 之一就算命中——忽略大小写)、refusal_accuracy(没有答案的问题中 status 为 refused 的比例)、grounded_rate(未拒答的回答中,say 里的数字全部都在 source 文档里的比例)写入 /root/voice/eval/quality.json。
把三个数字并排放在一起,就能看到依据一致率很高,事实准确率也可能很低。数字用 re.findall(r"\d+(?::\d+)?", …) 提取。
区分错误与用户失败
用 Pipeline(faults={"asr_empty": {"q09"}, "llm_timeout": {"q03"}, "tts_error": {"q07"}}) 重新运行第 01 步,写入 /root/voice/eval/spans_faults.jsonl,并在 /root/voice/eval/failures.json 中写入 turns、error_turns(子跨度中有 status 为 error 的轮次数)、user_failures(根 status 为 failed 或 reprompt 的轮次数)、user_failure_rate、by_type。
LLM 挂掉的那一轮,会读出检索到的段落中的句子(degraded),所以用户能听到答案——是错误,但不是失败。TTS 挂掉时,用户听到的是沉默。
用 SLO 来判定
生成 /root/voice/eval/slo.json:在 objectives 中确定 p95_first_audio_ms、fact_accuracy_min(0.5 以上)、user_failure_rate_max(0.1 以下),在 measured 中写入 spans.jsonl 根跨度的 first_audio_ms p95、quality.json 的 fact_accuracy、failures.json 的 user_failure_rate,在 pass 中写入这三项是否达标。
目标要按用户的体验来写。失败率来自注入了失败的运行——看注入的失败是否破坏目标,是这一步的要点。即使出现未达标,评分也只看判定是否正确。
阻止退化的门禁
创建以 python3 gate.py 기준.json 현재.json(占位符依次为基线文件和当前文件)方式调用的 /root/voice/eval/gate.py。如果 wer 上升超过 0.02,或 fact_accuracy 下降超过 0.10,或 p95_first_audio_ms 比基线上升超过 20%,或 user_failure_rate 上升超过 0.05,就打印原因并以退出码 1 结束,否则以 0 结束。评分器会用隐藏的基线与当前组合做双向测试。
为每个指标建一张(名称,变差的方向,容差,是否按比例)的表,规则就一目了然。变好的,无论好了多少都算通过。
把这一版放进门禁
在 /root/voice/eval/metrics.json 中写入 wer(wer.json)、fact_accuracy(quality.json)、p95_first_audio_ms(slo.json 的 measured)、user_failure_rate(因为是平时运行,所以是 0.0),并生成 /root/voice/eval/report.json,其中 gate_exit 放 python3 gate.py /opt/lab/fixtures/voice/eval/baseline.json metrics.json 的退出码,gate_output 放输出的行。
基线版本是“上一次发布(虚构)”的指标。如果门禁阻止了,输出中会写明是因为哪个指标——是否发布那一版,由人来决定。