适用范围:中文电话客服、AI 语音客服、Voice Agent 中的流式 ASR 链路。
运行环境:Python 3.11;核心示例仅依赖标准库,单元测试使用 pytest。
本文提供一套可复制的埋点口径、日志结构和统计脚本;不提供未经真实业务链路验证的"平均延迟"数据。
结论
ASR 的"快不快",不能只看服务端返回一条最终文本用了多久。
在 Voice Agent 中,用户首先感受到的是系统什么时候开始"听懂了"------也就是第一段有效 partial(中间识别结果)何时出现;工程上还要知道最终结果何时稳定,以便触发 LLM、工具调用或下一轮状态流转。
一套足够实用的 ASR 埋点可以先从四个时刻开始:
text
t0:本轮用户有效语音的第一帧进入待发送队列
t1:该帧实际写入 ASR 流
t2:收到第一条非空 partial
t3:收到本轮 final
由此至少计算五个指标:
text
audio_queue_ms = t1 - t0 # 客户端/网关排队与发送前等待
asr_first_partial_ms = t2 - t1 # ASR 服务收到音频后的首条有效中间结果
first_text_e2e_ms = t2 - t0 # 用户体感上的首次"听懂"时间
asr_final_ms = t3 - t0 # 从有效语音开始到最终结果的总时间
partial_to_final_ms = t3 - t2 # 中间结果到最终稳定结果的等待
最容易被忽略的一点是:t3 并不只代表模型推理速度。它经常同时受用户是否停说、VAD 判停阈值、分段策略、网络抖动和 ASR 的 final 策略影响。若把所有问题都归因于"模型慢",后续优化通常会走偏。
本文只讨论 ASR t0-t3 。LLM 首 token、TTS 首包和实际播放开始,应在后续全链路 t0-t8 中另外记录,不能混进这一组指标。
目录
- [一、先把 t0-t3 的边界说清楚](#一、先把 t0-t3 的边界说清楚)
- 二、为什么首字延迟与最终识别延迟必须分开看
- 三、埋点应该放在哪些位置
- [四、可运行的 t0-t3 埋点代码](#四、可运行的 t0-t3 埋点代码)
- [五、按场景汇总 P50、P95 的脚本](#五、按场景汇总 P50、P95 的脚本)
- [六、真实电话环境应该怎样做 A/B 测试](#六、真实电话环境应该怎样做 A/B 测试)
- 七、看到异常延迟后,如何定位
- 八、FAQ
一、先把 t0-t3 的边界说清楚
四个时间点都必须绑定到同一轮用户 utterance(一次连续表达),并写入同一个 turn_id。否则同一通电话中的多轮音频、重连或插话会混在一起,统计结果没有解释价值。
流式 ASR 语音网关/客户端 用户语音 流式 ASR 语音网关/客户端 用户语音 #mermaid-svg-oMmFCpfBbfyHnbXC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oMmFCpfBbfyHnbXC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oMmFCpfBbfyHnbXC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oMmFCpfBbfyHnbXC .error-icon{fill:#552222;}#mermaid-svg-oMmFCpfBbfyHnbXC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oMmFCpfBbfyHnbXC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oMmFCpfBbfyHnbXC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oMmFCpfBbfyHnbXC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oMmFCpfBbfyHnbXC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oMmFCpfBbfyHnbXC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oMmFCpfBbfyHnbXC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oMmFCpfBbfyHnbXC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oMmFCpfBbfyHnbXC .marker.cross{stroke:#333333;}#mermaid-svg-oMmFCpfBbfyHnbXC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oMmFCpfBbfyHnbXC p{margin:0;}#mermaid-svg-oMmFCpfBbfyHnbXC .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-oMmFCpfBbfyHnbXC text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-oMmFCpfBbfyHnbXC .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-oMmFCpfBbfyHnbXC .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-oMmFCpfBbfyHnbXC .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-oMmFCpfBbfyHnbXC .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-oMmFCpfBbfyHnbXC #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-oMmFCpfBbfyHnbXC .sequenceNumber{fill:white;}#mermaid-svg-oMmFCpfBbfyHnbXC #sequencenumber{fill:#333;}#mermaid-svg-oMmFCpfBbfyHnbXC #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-oMmFCpfBbfyHnbXC .messageText{fill:#333;stroke:none;}#mermaid-svg-oMmFCpfBbfyHnbXC .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-oMmFCpfBbfyHnbXC .labelText,#mermaid-svg-oMmFCpfBbfyHnbXC .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-oMmFCpfBbfyHnbXC .loopText,#mermaid-svg-oMmFCpfBbfyHnbXC .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-oMmFCpfBbfyHnbXC .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-oMmFCpfBbfyHnbXC .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-oMmFCpfBbfyHnbXC .noteText,#mermaid-svg-oMmFCpfBbfyHnbXC .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-oMmFCpfBbfyHnbXC .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-oMmFCpfBbfyHnbXC .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-oMmFCpfBbfyHnbXC .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-oMmFCpfBbfyHnbXC .actorPopupMenu{position:absolute;}#mermaid-svg-oMmFCpfBbfyHnbXC .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-oMmFCpfBbfyHnbXC .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-oMmFCpfBbfyHnbXC .actor-man circle,#mermaid-svg-oMmFCpfBbfyHnbXC line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-oMmFCpfBbfyHnbXC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} t0:进入发送队列 t1:实际写入 ASR 流 t2:第一条非空 partial t3:本轮 final 第一帧有效语音 写入音频帧 partial:"我要查..." final:"我要查询订单状态"
1. t0:不是录音开始,而是"有效语音"进入发送队列
如果一通电话在静音中保持连接数分钟,把录音会话开始当 t0,会把大量无意义静音算进延迟。推荐将 t0 定义为:VAD 判断本轮用户开始说话后,第一帧有效语音进入 ASR 待发送队列的时刻。
这一定义也要求在日志里保留 VAD 版本和阈值。不同端点策略会改变一轮 utterance 的边界,从而改变 t0 和 t3。
2. t1:必须是"实际写入",而不是"准备发送"
音频帧可能在浏览器、媒体网关、异步队列或重试队列里等待。只在 enqueue() 时记时间,无法区分队列拥塞和 ASR 服务变慢。
因此 t1 应落在真正执行 asr_stream.send(audio_frame) 成功之后,或在可观测的网络层确认写入之后。它衡量的是从本端检测到用户开口,到音频真正离开本端的等待。
3. t2:第一条"非空且可用"的 partial
有的 ASR SDK 会先回一个空事件、连接状态事件,甚至只有置信度却没有文本。它们不应该作为 t2。
本文将 t2 定义为:本轮第一条 kind == "partial" 且 text.strip() 非空的识别事件。若服务只返回 final、不返回 partial,日志应明确写 t2 = null,而不是用 t3 替代 t2。
4. t3:收到 final,不等于用户刚刚说完
t3 是 ASR 返回 final 的时刻。它包括服务端解码和 final 决策的等待,但也可能包含本地/服务端 VAD 等待用户停说的时间。为了理解这个差异,生产日志最好额外保留:
text
speech_end_detected_at # VAD 推断用户说完的时刻,可选
final_reason # endpoint / manual_flush / timeout / provider_final
这样,后续可以区分"用户说完后 ASR 迟迟不 final"和"VAD 迟迟不认为用户说完"。
二、为什么首字延迟与最终识别延迟必须分开看
用户体验与后续业务逻辑依赖的不是同一件事。
| 指标 | 主要影响 | 常见误判 |
|---|---|---|
first_text_e2e_ms |
用户是否感觉系统已开始理解 | 只看 final,忽略前端一直没有反馈 |
asr_first_partial_ms |
ASR 流式响应能力 | 把音频队列拥塞误判成模型慢 |
asr_final_ms |
LLM/工具调用何时可安全开始 | 把用户长句、停顿都算成服务端延迟 |
partial_to_final_ms |
partial 何时变为可提交文本 | 只调模型参数,不检查 endpoint/VAD |
举一个只用于解释口径的例子:两条链路的 t3-t0 都是 1 秒,但 A 在 200ms 就返回了可读 partial,B 到 950ms 才有第一段文本。对用户来说,两者的"响应感"完全不同;对是否可以尽早做意图预测,也完全不同。
所以报表至少应分开呈现 first_text_e2e_ms 与 asr_final_ms。不能用一个"ASR 平均耗时"代替全部判断。
三、埋点应该放在哪些位置
下面的链路图适用于浏览器 WebRTC、SIP 电话网关或服务端媒体流。具体 SDK 名称可以替换,但时间点语义不应变。
#mermaid-svg-iTBv5RMeP2GOOlgL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-iTBv5RMeP2GOOlgL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iTBv5RMeP2GOOlgL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iTBv5RMeP2GOOlgL .error-icon{fill:#552222;}#mermaid-svg-iTBv5RMeP2GOOlgL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iTBv5RMeP2GOOlgL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iTBv5RMeP2GOOlgL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iTBv5RMeP2GOOlgL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iTBv5RMeP2GOOlgL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iTBv5RMeP2GOOlgL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iTBv5RMeP2GOOlgL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iTBv5RMeP2GOOlgL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iTBv5RMeP2GOOlgL .marker.cross{stroke:#333333;}#mermaid-svg-iTBv5RMeP2GOOlgL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iTBv5RMeP2GOOlgL p{margin:0;}#mermaid-svg-iTBv5RMeP2GOOlgL .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-iTBv5RMeP2GOOlgL .cluster-label text{fill:#333;}#mermaid-svg-iTBv5RMeP2GOOlgL .cluster-label span{color:#333;}#mermaid-svg-iTBv5RMeP2GOOlgL .cluster-label span p{background-color:transparent;}#mermaid-svg-iTBv5RMeP2GOOlgL .label text,#mermaid-svg-iTBv5RMeP2GOOlgL span{fill:#333;color:#333;}#mermaid-svg-iTBv5RMeP2GOOlgL .node rect,#mermaid-svg-iTBv5RMeP2GOOlgL .node circle,#mermaid-svg-iTBv5RMeP2GOOlgL .node ellipse,#mermaid-svg-iTBv5RMeP2GOOlgL .node polygon,#mermaid-svg-iTBv5RMeP2GOOlgL .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-iTBv5RMeP2GOOlgL .rough-node .label text,#mermaid-svg-iTBv5RMeP2GOOlgL .node .label text,#mermaid-svg-iTBv5RMeP2GOOlgL .image-shape .label,#mermaid-svg-iTBv5RMeP2GOOlgL .icon-shape .label{text-anchor:middle;}#mermaid-svg-iTBv5RMeP2GOOlgL .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-iTBv5RMeP2GOOlgL .rough-node .label,#mermaid-svg-iTBv5RMeP2GOOlgL .node .label,#mermaid-svg-iTBv5RMeP2GOOlgL .image-shape .label,#mermaid-svg-iTBv5RMeP2GOOlgL .icon-shape .label{text-align:center;}#mermaid-svg-iTBv5RMeP2GOOlgL .node.clickable{cursor:pointer;}#mermaid-svg-iTBv5RMeP2GOOlgL .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-iTBv5RMeP2GOOlgL .arrowheadPath{fill:#333333;}#mermaid-svg-iTBv5RMeP2GOOlgL .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-iTBv5RMeP2GOOlgL .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-iTBv5RMeP2GOOlgL .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iTBv5RMeP2GOOlgL .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-iTBv5RMeP2GOOlgL .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iTBv5RMeP2GOOlgL .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-iTBv5RMeP2GOOlgL .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-iTBv5RMeP2GOOlgL .cluster text{fill:#333;}#mermaid-svg-iTBv5RMeP2GOOlgL .cluster span{color:#333;}#mermaid-svg-iTBv5RMeP2GOOlgL div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-iTBv5RMeP2GOOlgL .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-iTBv5RMeP2GOOlgL rect.text{fill:none;stroke-width:0;}#mermaid-svg-iTBv5RMeP2GOOlgL .icon-shape,#mermaid-svg-iTBv5RMeP2GOOlgL .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iTBv5RMeP2GOOlgL .icon-shape p,#mermaid-svg-iTBv5RMeP2GOOlgL .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-iTBv5RMeP2GOOlgL .icon-shape .label rect,#mermaid-svg-iTBv5RMeP2GOOlgL .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iTBv5RMeP2GOOlgL .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-iTBv5RMeP2GOOlgL .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-iTBv5RMeP2GOOlgL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} VAD 识别有效语音
音频帧进入发送队列 t0
网关/客户端发送音频
写入 ASR 流 t1
ASR partial
第一条非空文本 t2
ASR final
最终文本 t3
写入结构化延迟日志
建议一轮日志至少包含这些字段:
json
{
"turn_id": "call-8f3a-turn-04",
"scene": "telephone_8k",
"asr_provider": "<provider-name>",
"vad_version": "<version>",
"frame_ms": 20,
"t0_ns": 0,
"t1_ns": 0,
"t2_ns": 0,
"t3_ns": 0,
"final_reason": "endpoint",
"first_text_e2e_ms": 0.0,
"asr_final_ms": 0.0
}
生产环境不要把完整原始语音、手机号、订单号等敏感内容直接写入延迟日志。turn_id 应使用可追溯但不含个人身份的信息;如需结合文本排障,应另走脱敏、权限和留存策略。
Python 的 time.perf_counter_ns() 适合测量同一进程内的耗时差:它是高分辨率、单调递增的计时器,应该计算差值而不是把它当成可跨机器比对的绝对时间。Python time 文档
四、可运行的 t0-t3 埋点代码
下面的示例把 ASR SDK 抽象为两个接口:send_audio() 负责实际写入音频,events 持续产出 ASR 事件。接入任意一家 SDK 时,只需要把这两处替换为真实调用;时间点和日志结构保持不变。
1. 核心埋点:asr_timing.py
python
from __future__ import annotations
import asyncio
import json
import time
from dataclasses import dataclass, field
from typing import AsyncIterator, Awaitable, Callable
MARK_ORDER = ("t0", "t1", "t2", "t3")
@dataclass
class ASREvent:
kind: str # "partial" or "final"
text: str
final_reason: str | None = None
@dataclass
class TurnTrace:
turn_id: str
scene: str
clock_ns: Callable[[], int] = time.perf_counter_ns
marks_ns: dict[str, int] = field(default_factory=dict)
final_reason: str | None = None
def mark(self, name: str) -> None:
if name not in MARK_ORDER:
raise ValueError(f"unknown mark: {name}")
if name in self.marks_ns:
return # 同一个时间点只记录首次,避免后续事件覆盖
self.marks_ns[name] = self.clock_ns()
def delta_ms(self, start: str, end: str) -> float | None:
start_ns = self.marks_ns.get(start)
end_ns = self.marks_ns.get(end)
if start_ns is None or end_ns is None:
return None
return round((end_ns - start_ns) / 1_000_000, 3)
def to_log(self) -> dict[str, object]:
t0, t1, t2, t3 = (self.marks_ns.get(key) for key in MARK_ORDER)
return {
"turn_id": self.turn_id,
"scene": self.scene,
"t0_ns": t0,
"t1_ns": t1,
"t2_ns": t2,
"t3_ns": t3,
"audio_queue_ms": self.delta_ms("t0", "t1"),
"asr_first_partial_ms": self.delta_ms("t1", "t2"),
"first_text_e2e_ms": self.delta_ms("t0", "t2"),
"asr_final_ms": self.delta_ms("t0", "t3"),
"partial_to_final_ms": self.delta_ms("t2", "t3"),
"final_reason": self.final_reason,
}
async def measure_turn(
trace: TurnTrace,
send_audio: Callable[[], Awaitable[None]],
events: AsyncIterator[ASREvent],
) -> dict[str, object]:
"""记录一次 utterance 的 t0-t3。
调用方应在 VAD 判定本轮第一帧有效语音后立即调用本函数。
send_audio() 内部必须在实际写入 ASR 流之后才返回。
"""
trace.mark("t0")
await send_audio()
trace.mark("t1")
async for event in events:
if event.kind == "partial" and event.text.strip():
trace.mark("t2")
elif event.kind == "final":
trace.mark("t3")
trace.final_reason = event.final_reason
break
if "t3" not in trace.marks_ns:
raise RuntimeError("ASR stream ended before a final event")
return trace.to_log()
# 以下仅用于验证埋点流程,不代表真实 ASR 时延。
async def demo_sender() -> None:
await asyncio.sleep(0)
async def demo_events() -> AsyncIterator[ASREvent]:
yield ASREvent(kind="partial", text="我要查")
yield ASREvent(kind="partial", text="我要查询订单")
yield ASREvent(
kind="final",
text="我要查询订单状态",
final_reason="endpoint",
)
async def main() -> None:
trace = TurnTrace(turn_id="demo-turn-001", scene="local_demo")
result = await measure_turn(trace, demo_sender, demo_events())
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
asyncio.run(main())
运行:
bash
python asr_timing.py
预期会得到以下结构的日志。具体毫秒值取决于本机调度,不能把 demo 的输出当成服务性能结论。
json
{
"turn_id": "demo-turn-001",
"scene": "local_demo",
"t0_ns": 0,
"t1_ns": 0,
"t2_ns": 0,
"t3_ns": 0,
"audio_queue_ms": 0.0,
"asr_first_partial_ms": 0.0,
"first_text_e2e_ms": 0.0,
"asr_final_ms": 0.0,
"partial_to_final_ms": 0.0,
"final_reason": "endpoint"
}
2. 用可控时钟测试指标口径:tests/test_asr_timing.py
python
from asr_timing import TurnTrace
class FakeClock:
def __init__(self, values: list[int]) -> None:
self.values = iter(values)
def __call__(self) -> int:
return next(self.values)
def test_t0_t3_metrics() -> None:
# 单位为纳秒:t0=0ms, t1=20ms, t2=180ms, t3=600ms
trace = TurnTrace(
turn_id="test-turn",
scene="telephone_8k",
clock_ns=FakeClock([0, 20_000_000, 180_000_000, 600_000_000]),
)
for name in ("t0", "t1", "t2", "t3"):
trace.mark(name)
result = trace.to_log()
assert result["audio_queue_ms"] == 20.0
assert result["asr_first_partial_ms"] == 160.0
assert result["first_text_e2e_ms"] == 180.0
assert result["asr_final_ms"] == 600.0
assert result["partial_to_final_ms"] == 420.0
def test_missing_partial_is_not_replaced_by_final() -> None:
trace = TurnTrace(
turn_id="test-no-partial",
scene="telephone_8k",
clock_ns=FakeClock([0, 10_000_000, 500_000_000]),
)
for name in ("t0", "t1", "t3"):
trace.mark(name)
result = trace.to_log()
assert result["first_text_e2e_ms"] is None
assert result["partial_to_final_ms"] is None
assert result["asr_final_ms"] == 500.0
运行:
bash
python -m pytest -q
预期结果:
text
2 passed
这两个测试特意覆盖一个常见错误:服务没有返回 partial 时,不能为了让报表"完整"而把 t2 伪造为 t3。那会把"没有流式中间结果"隐藏掉。
五、按场景汇总 P50、P95 的脚本
单轮日志只能定位个案。上线后应按网络、音频通道、VAD 配置、区域或 ASR 版本分组看分位数。平均值很容易被少量极慢或极快样本掩盖;P50 反映典型体验,P95 更适合暴露长尾卡顿。
下面脚本读取 JSONL 日志,按 scene 汇总 first_text_e2e_ms 和 asr_final_ms。它使用最近秩定义计算 P95,并在输出中写清样本量;样本量很小时,不应据此下性能结论。
summarize_latency.py
python
from __future__ import annotations
import json
import math
import sys
from collections import defaultdict
from pathlib import Path
METRICS = ("first_text_e2e_ms", "asr_final_ms", "partial_to_final_ms")
def nearest_rank(values: list[float], percentile: float) -> float:
if not values:
raise ValueError("values must not be empty")
ordered = sorted(values)
index = max(0, math.ceil(percentile * len(ordered)) - 1)
return ordered[index]
def summarize(values: list[float]) -> dict[str, float | int]:
ordered = sorted(values)
middle = len(ordered) // 2
if len(ordered) % 2:
p50 = ordered[middle]
else:
p50 = (ordered[middle - 1] + ordered[middle]) / 2
return {
"n": len(ordered),
"p50_ms": round(p50, 3),
"p95_ms": round(nearest_rank(ordered, 0.95), 3),
"max_ms": round(ordered[-1], 3),
}
def main(path: str) -> None:
grouped: dict[str, dict[str, list[float]]] = defaultdict(
lambda: defaultdict(list)
)
for line in Path(path).read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
record = json.loads(line)
scene = record.get("scene", "unknown")
for metric in METRICS:
value = record.get(metric)
if isinstance(value, (int, float)):
grouped[scene][metric].append(float(value))
for scene, metrics in sorted(grouped.items()):
print(f"\\n[{scene}]")
for metric in METRICS:
values = metrics.get(metric, [])
if values:
print(metric, json.dumps(summarize(values), ensure_ascii=False))
else:
print(metric, "no valid samples")
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("usage: python summarize_latency.py logs.jsonl")
main(sys.argv[1])
调用方式:
bash
python summarize_latency.py asr_latency.jsonl
Python 标准库也提供了分位数相关工具,但无论使用哪种算法,都应在团队指标文档中写清口径,避免不同看板的 P95 不可比。Python statistics.quantiles
六、真实电话环境应该怎样做 A/B 测试
不要在一次测试里同时换 ASR 模型、VAD 阈值、音频帧长和网关区域。那样即使指标变化,也无法知道是哪一项造成的。
建议先固定一条可复现的测试链路:
#mermaid-svg-8us8I7TONEZmWqbI{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8us8I7TONEZmWqbI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8us8I7TONEZmWqbI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8us8I7TONEZmWqbI .error-icon{fill:#552222;}#mermaid-svg-8us8I7TONEZmWqbI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8us8I7TONEZmWqbI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8us8I7TONEZmWqbI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8us8I7TONEZmWqbI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8us8I7TONEZmWqbI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8us8I7TONEZmWqbI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8us8I7TONEZmWqbI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8us8I7TONEZmWqbI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8us8I7TONEZmWqbI .marker.cross{stroke:#333333;}#mermaid-svg-8us8I7TONEZmWqbI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8us8I7TONEZmWqbI p{margin:0;}#mermaid-svg-8us8I7TONEZmWqbI .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8us8I7TONEZmWqbI .cluster-label text{fill:#333;}#mermaid-svg-8us8I7TONEZmWqbI .cluster-label span{color:#333;}#mermaid-svg-8us8I7TONEZmWqbI .cluster-label span p{background-color:transparent;}#mermaid-svg-8us8I7TONEZmWqbI .label text,#mermaid-svg-8us8I7TONEZmWqbI span{fill:#333;color:#333;}#mermaid-svg-8us8I7TONEZmWqbI .node rect,#mermaid-svg-8us8I7TONEZmWqbI .node circle,#mermaid-svg-8us8I7TONEZmWqbI .node ellipse,#mermaid-svg-8us8I7TONEZmWqbI .node polygon,#mermaid-svg-8us8I7TONEZmWqbI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8us8I7TONEZmWqbI .rough-node .label text,#mermaid-svg-8us8I7TONEZmWqbI .node .label text,#mermaid-svg-8us8I7TONEZmWqbI .image-shape .label,#mermaid-svg-8us8I7TONEZmWqbI .icon-shape .label{text-anchor:middle;}#mermaid-svg-8us8I7TONEZmWqbI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8us8I7TONEZmWqbI .rough-node .label,#mermaid-svg-8us8I7TONEZmWqbI .node .label,#mermaid-svg-8us8I7TONEZmWqbI .image-shape .label,#mermaid-svg-8us8I7TONEZmWqbI .icon-shape .label{text-align:center;}#mermaid-svg-8us8I7TONEZmWqbI .node.clickable{cursor:pointer;}#mermaid-svg-8us8I7TONEZmWqbI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8us8I7TONEZmWqbI .arrowheadPath{fill:#333333;}#mermaid-svg-8us8I7TONEZmWqbI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8us8I7TONEZmWqbI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8us8I7TONEZmWqbI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8us8I7TONEZmWqbI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8us8I7TONEZmWqbI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8us8I7TONEZmWqbI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8us8I7TONEZmWqbI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8us8I7TONEZmWqbI .cluster text{fill:#333;}#mermaid-svg-8us8I7TONEZmWqbI .cluster span{color:#333;}#mermaid-svg-8us8I7TONEZmWqbI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8us8I7TONEZmWqbI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8us8I7TONEZmWqbI rect.text{fill:none;stroke-width:0;}#mermaid-svg-8us8I7TONEZmWqbI .icon-shape,#mermaid-svg-8us8I7TONEZmWqbI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8us8I7TONEZmWqbI .icon-shape p,#mermaid-svg-8us8I7TONEZmWqbI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8us8I7TONEZmWqbI .icon-shape .label rect,#mermaid-svg-8us8I7TONEZmWqbI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8us8I7TONEZmWqbI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8us8I7TONEZmWqbI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8us8I7TONEZmWqbI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 脱敏测试语料
固定音频通道:电话 8k 或 WebRTC 16k
固定网络与网关区域
记录 t0-t3 与 final_reason
按场景汇总 P50/P95
只改一个变量
帧长
VAD 静音阈值
热词/解码配置
部署区域或 ASR 版本
建议的测试分层
| 维度 | 至少要区分什么 | 目的 |
|---|---|---|
| 音频通道 | telephone_8k、webrtc_16k |
避免把窄带损伤误判为模型退化 |
| 语句类型 | 短指令、长描述、数字字段、地址字段 | 观察句长和业务字段的影响 |
| 噪声 | 低、中、高,使用统一标注规范 | 找到真实环境边界 |
| 说话方式 | 标准普通话、常见口音、快语速 | 验证长尾体验,不做个人能力判断 |
| 配置版本 | VAD、帧长、热词、模型、网关区域 | 保证差异可归因 |
一个变量、一个假设
例如测试 VAD 静音阈值时,应该写成可以被证伪的假设:
text
假设:缩短静音阈值可能降低 asr_final_ms,
但可能增加数字串或长句被错误切段的风险。
因此 A/B 结果不能只看 asr_final_ms,还要同时复核:
partial_to_final_ms是否缩短;- 关键字段完全匹配率是否下降;
final_reason中 timeout 或异常切段是否增加;- P95 是否改善,还是只有 P50 好看。
这正是工程实测与"演示时听起来快"之间的区别。
七、看到异常延迟后,如何定位
| 现象 | 优先检查 | 常见原因 |
|---|---|---|
audio_queue_ms 高 |
发送队列深度、事件循环阻塞、网关回压 | 音频帧堆积、网络写入未及时执行 |
asr_first_partial_ms 高,队列正常 |
SDK 流式配置、网络 RTT、ASR 区域 | 未启用 streaming、首包路径远、服务限流 |
partial_to_final_ms 高 |
VAD 静音阈值、句末判停、final_reason |
用户停顿被误判为未结束、服务端 endpoint 保守 |
| P50 正常、P95 很高 | 重连、并发、GC、区域网络、限流 | 少量长尾请求拉高用户投诉 |
t2 常为 null |
SDK 事件类型、partial 开关、日志过滤 | 实际没有中间结果,或被错误丢弃 |
先看"时间点缺失",再看数值高低
延迟排障常犯的错误,是看到一个数字就直接调参数。实际上,以下缺失状态本身就是问题:
text
t1 缺失:音频没有确认写入,不能讨论 ASR 响应
t2 缺失:没有流式文本,不能声称首字很快
t3 缺失:本轮没有收到 final,应按异常链路单独统计
final_reason 缺失:无法区分 endpoint、timeout 与服务端策略
先把异常链路从正常样本中分出来,再计算 P50/P95;否则"少报日志"会把延迟看板伪装得很好看。
八、FAQ
1. 为什么不用 time.time()?
同一进程内的耗时统计更适合使用单调、高分辨率的 time.perf_counter_ns()。墙上时间会受系统校时影响;而 perf_counter_ns() 用于计算时间差,且以整数纳秒返回,避免中间浮点误差。Python time 文档
2. 网关和 ASR 客户端不在同一台机器,t0-t3 还能直接相减吗?
不能直接把不同机器的单调时钟做差。最稳妥的做法是让 t0-t3 在同一个负责 ASR 会话的进程中记录;若必须跨进程,需要额外的时钟同步方案,并把网络边界时间单独记录。
3. 没有 partial 的 ASR 服务,还能做 Voice Agent 吗?
可以,但要诚实记录它没有 t2。系统仍可等待 final 后调用 LLM;只是无法把"正在听懂"的能力前置,也难以用 partial 进行早期意图提示或界面反馈。不能用 t3 伪装成 t2。
4. 要不要为了更快 final 把 VAD 阈值调得很短?
先看真实语料。阈值变短可能降低 asr_final_ms,也可能截断地址、订单号、否定词或长句。必须同时看字段正确率、异常切段数和 P95,不能只追一个更小的数。
5. 这套 t0-t3 和后续 Voice Agent 全链路 t0-t8 有什么关系?
t0-t3 是全链路的 ASR 子区间。后续可以在 t3 之后继续记录 LLM 首 token、TTS 首包和实际播放开始,但不要反过来把 LLM/TTS 时间混入 ASR 指标。这样才能回答"慢在听、想,还是说"。
小结
要把 ASR 延迟测清楚,先不要问"平均多少毫秒",而要先确认四个时间点到底代表什么:音频什么时候进入队列、什么时候真正写入 ASR、什么时候有第一段可用文本、什么时候得到最终结果。
对中文客服而言,t2 决定响应感,t3 决定下一步业务编排何时可靠启动;两者之间的差值,则常常暴露 VAD、端点和流式策略问题。把 t0-t3、场景、配置版本和 final_reason 统一写入结构化日志,才能让一次"感觉慢"变成可复现、可定位、可验证的工程问题。
下一篇会进入流式 TTS:当 ASR 已经足够快,Voice Agent 又该怎样降低语音合成首包、分句和播放队列带来的等待。