闪电智能Voice Agent:ASR 首字延迟和最终识别延迟怎么测?t0-t3 埋点实战

适用范围:中文电话客服、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 的边界说清楚

四个时间点都必须绑定到同一轮用户 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_msasr_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_msasr_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_8kwebrtc_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 又该怎样降低语音合成首包、分句和播放队列带来的等待。

相关推荐
冬哥聊AI1 小时前
百万行代码里跑Claude Code?三面这题80%的人答不上来
人工智能
甄同学1 小时前
第二十三篇:Tasks系统,Claude Code如何统一管理7种后台任务
人工智能
独隅1 小时前
IntelliJ IDEA 接入多种AI大模型插件终极指南(2026.1 企业合规版)
java·人工智能·intellij-idea
有Li1 小时前
EvoMDT:用于多癌种结构化临床决策的自进化多智能体系统文献速递/医学智能体前沿
人工智能·学习·分类·文献·医学生
小刘BlandNew1 小时前
AI核心概念大串联
人工智能
墨染天姬1 小时前
【AI】自驱动智能体
人工智能
D2aZXN3FhrDa7e2122 小时前
佛山乐从低预算实体店如何选择?看美诚AI自动化获客方案
运维·人工智能·自动化·佛山美诚科技有限公司
林泽毅2 小时前
PyTRIO:当强化学习不再需要本地GPU
人工智能·python·深度学习·机器学习
颜酱2 小时前
# 02 | 搭骨架:用 LangGraph 编排 12 步工作流(思路)
前端·人工智能·后端