Voice Agent 的“慢”不能只看一个总耗时,更不能听到用户抱怨后就先换 LLM。
我从用户最后一帧语音到达服务端开始,到首个音频包进入播放器时间轴结束,设置 t0-t8 九个事件。这样一次对话到底慢在 VAD、ASR、RAG、LLM 首 token、文本分句、TTS 首包、网络还是播放器,可以直接从瀑布图里看出来。

本文提供一套可运行的 Python Demo:

  • 使用 time.perf_counter_ns() 记录单调高精度时间;
  • trace_id 串起同一轮对话;
  • t0-t8 的顺序做强校验,避免埋点错位;
  • 内置 baseline、慢 VAD、慢 LLM、慢 TTS 四种场景;
  • 连续运行后计算各阶段 p50、p95 和瓶颈命中次数;
  • 提供 FastAPI 接口和浏览器瀑布图。

先说明测试边界:Demo 里的 ASR、LLM、TTS 都由 asyncio.sleep() 模拟。本文实测数据用于验证埋点、统计和定位逻辑,不代表任何模型、云服务或线上 Voice Agent 的真实性能

适合阅读本文的情况:

  • 日志里只有“本轮耗时 2.3 秒”,不知道时间花在哪;
  • LLM 首 token 很快,用户仍觉得 Agent 迟钝;
  • TTS 已经返回首包,前端却迟迟没有声音;
  • 平均延迟看起来正常,偶发慢请求仍然很多;
  • 准备把 Voice Agent 接入 OpenTelemetry 或 Prometheus。

一、为什么“接口总耗时”定位不了 Voice Agent

常见日志是这样的:

2026-07-01 10:00:00 request start
2026-07-01 10:00:02 request done, latency=2138ms

这条日志只能证明用户等了约 2.1 秒,不能回答下面的问题:

  • 用户说完以后,VAD 等了多久才敢判定结束?
  • ASR 是增量识别慢,还是生成 final 文本慢?
  • RAG 检索是否阻塞了 LLM 请求?
  • LLM 首 token 快不快?
  • 首 token 出来以后,多久才凑成一段可送入 TTS 的文本?
  • TTS 首包到了以后,网络和播放预缓冲又花了多久?

同样是 2 秒,可能有完全不同的根因:

链路 A:VAD 等了 700 ms,LLM 只用了 250 ms
链路 B:VAD 只等 180 ms,LLM 首 token 用了 800 ms
链路 C:LLM 很快,TTS 首包却用了 600 ms
链路 D:服务端都正常,客户端预缓冲和队列用了 500 ms

如果只看总耗时,这四种情况很容易得到同一个错误动作:换模型。


二、先把 t0-t8 定义清楚

2.1 t0 不要从“用户开始说话”算

本文把 t0 定义为:

t0 = 用户最后一帧语音到达服务端

不是用户开口,也不是整通电话开始。

原因很简单:用户说了 12 秒,不代表 Agent 响应慢了 12 秒。我们要测的是用户说完以后,系统多久开始回答。

2.2 九个事件

时间点 事件 要回答的问题
t0 用户最后一帧语音到达服务端 响应计时从哪里开始
t1 VAD 确认说话结束 端点检测等了多久
t2 ASR final 文本就绪 ASR 收尾用了多久
t3 RAG 完成并发出 LLM 请求 检索与编排是否阻塞
t4 LLM 返回首个 token LLM TTFT 多久
t5 首段可播文本送入 TTS 首 token 后又等了多久
t6 TTS 返回首个音频包 TTS 首包多久
t7 客户端收到首个音频包 音频传输多久
t8 播放器排期首个音频包 真正进入播放链路多久

对应八段耗时:

端点检测       = t1 - t0
ASR 收尾       = t2 - t1
编排/RAG       = t3 - t2
LLM 首 token   = t4 - t3
首段文本聚合    = t5 - t4
TTS 首包       = t6 - t5
音频传输       = t7 - t6
播放预缓冲      = t8 - t7

用户侧首音频延迟 = t8 - t0

这套定义里有两个我认为很值得单独保留的时间点。

第一个是 t4。它只表示 LLM 返回了首 token,不表示这段文本已经适合朗读。

第二个是 t5。如果模型先返回“根”“据”“您”“的”几个碎片,TTS 不能每个 token 发一次请求。程序往往要等到标点、最小长度或超时条件满足,才能得到第一段可播文本。

所以:

LLM 首 token 快 ≠ Voice Agent 能马上开口
TTS 首包快      ≠ 用户已经听到声音

三、完整链路图

用户最后一帧语音
        |
        v
t0  audio_last_frame
        |
        |  endpointing
        v
t1  VAD end-of-turn
        |
        |  ASR finalize
        v
t2  ASR final text
        |
        |  orchestration / RAG
        v
t3  LLM request
        |
        |  LLM TTFT
        v
t4  first token
        |
        |  text buffering / segmentation
        v
t5  first speakable chunk -> TTS
        |
        |  TTS first packet
        v
t6  first PCM/Opus packet
        |
        |  transport
        v
t7  client receives audio
        |
        |  playback prebuffer
        v
t8  player schedules audio

生产链路可能并不是完全串行。例如流式 ASR 可以在用户还没说完时提前产生 partial 文本,RAG 也可能与其他编排并发。遇到并行阶段时,不能把所有 span duration 简单相加,应该看真正决定 t8关键路径

本文 Demo 刻意采用串行模拟,先把每个时间点和统计逻辑讲清楚。


四、运行环境与目录

本文实际运行环境:

项目 版本
操作系统 macOS,Apple Silicon
Python 3.12.13
FastAPI 0.115.12
Uvicorn 0.34.3
计时函数 time.perf_counter_ns()

项目目录:

voice-agent-latency-lab/
├── timeline.py          # t0-t8 事件和阶段定义
├── pipeline.py          # 四种可控延迟场景
├── benchmark.py         # p50/p95 和瀑布图
├── app.py               # FastAPI 接口
├── test_latency.py      # 单元测试
├── requirements.txt
└── static/
    └── index.html       # 浏览器可视化

requirements.txt

fastapi==0.115.12
uvicorn[standard]==0.34.3

启动:

python3.12 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app:app --host 127.0.0.1 --port 8000

打开:

http://127.0.0.1:8000

五、为什么用 perf_counter_ns,而不是 time.time

测持续时间时,我不建议直接用:

start = time.time()

系统时间可能因为校时而变化,而且浮点秒在长时间运行后会损失一部分精度。

Python 官方文档对 perf_counter() 的定义是:使用可用于测量短时长的高分辨率性能计数器,只应使用两次调用的差值;perf_counter_ns() 返回整数纳秒,可以避免浮点精度损失。

本文所有事件都记录:

time.perf_counter_ns()

转换为毫秒时再除:

duration_ms = (end_ns - start_ns) / 1_000_000

不要把 perf_counter_ns() 的原始值当成日期。它的参考原点没有业务意义,只有差值有意义。


六、实现 t0-t8 时间线

timeline.py 做四件事:

  1. 固定 t0-t8 名称和顺序;
  2. 每次 mark 使用高分辨率单调时钟;
  3. 计算事件累计时间和阶段增量;
  4. 生成符合 W3C traceparent 形状的 trace ID 与 parent ID。
from __future__ import annotations

import secrets
import time
from dataclasses import dataclass, field
from typing import Any


EVENTS = (
    ("t0", "用户最后一帧语音到达服务端"),
    ("t1", "VAD 确认说话结束"),
    ("t2", "ASR final 文本就绪"),
    ("t3", "RAG 完成并发出 LLM 请求"),
    ("t4", "LLM 返回首个 token"),
    ("t5", "首段可播文本送入 TTS"),
    ("t6", "TTS 返回首个音频包"),
    ("t7", "客户端收到首个音频包"),
    ("t8", "播放器排期首个音频包"),
)

STAGES = (
    ("endpointing", "端点检测", "t0", "t1"),
    ("asr_finalize", "ASR 收尾", "t1", "t2"),
    ("orchestration_rag", "编排/RAG", "t2", "t3"),
    ("llm_ttft", "LLM 首 token", "t3", "t4"),
    ("text_buffering", "首段文本聚合", "t4", "t5"),
    ("tts_first_packet", "TTS 首包", "t5", "t6"),
    ("transport", "音频传输", "t6", "t7"),
    ("playback_buffer", "播放预缓冲", "t7", "t8"),
)

EVENT_LABELS = dict(EVENTS)
EVENT_NAMES = tuple(name for name, _ in EVENTS)


def _nonzero_hex(byte_count: int) -> str:
    while True:
        value = secrets.token_hex(byte_count)
        if any(char != "0" for char in value):
            return value


@dataclass
class Timeline:
    scenario: str
    trace_id: str = field(default_factory=lambda: _nonzero_hex(16))
    parent_id: str = field(default_factory=lambda: _nonzero_hex(8))
    _events_ns: dict[str, int] = field(default_factory=dict, init=False, repr=False)

    @property
    def traceparent(self) -> str:
        return f"00-{self.trace_id}-{self.parent_id}-01"

    def mark(self, event_name: str) -> None:
        expected_index = len(self._events_ns)
        if expected_index >= len(EVENT_NAMES):
            raise ValueError("timeline 已经记录完 t0-t8")

        expected_name = EVENT_NAMES[expected_index]
        if event_name != expected_name:
            raise ValueError(
                f"事件顺序错误:当前应记录 {expected_name},收到 {event_name}"
            )

        self._events_ns[event_name] = time.perf_counter_ns()

    def as_dict(
        self,
        configured_delays_ms: dict[str, float] | None = None,
    ) -> dict[str, Any]:
        missing = [name for name in EVENT_NAMES if name not in self._events_ns]
        if missing:
            raise ValueError(f"timeline 缺少事件:{', '.join(missing)}")

        origin_ns = self._events_ns["t0"]
        events = []
        previous_ns = origin_ns

        for name, label in EVENTS:
            current_ns = self._events_ns[name]
            events.append(
                {
                    "name": name,
                    "label": label,
                    "elapsed_ms": ns_to_ms(current_ns - origin_ns),
                    "delta_ms": ns_to_ms(current_ns - previous_ns),
                }
            )
            previous_ns = current_ns

        stages = []
        for key, label, start_name, end_name in STAGES:
            duration_ms = ns_to_ms(
                self._events_ns[end_name] - self._events_ns[start_name]
            )
            stages.append(
                {
                    "key": key,
                    "label": label,
                    "start": start_name,
                    "end": end_name,
                    "duration_ms": duration_ms,
                    "configured_ms": (
                        configured_delays_ms.get(key)
                        if configured_delays_ms is not None
                        else None
                    ),
                }
            )

        bottleneck = max(stages, key=lambda stage: stage["duration_ms"])
        return {
            "scenario": self.scenario,
            "trace_id": self.trace_id,
            "traceparent": self.traceparent,
            "events": events,
            "stages": stages,
            "total_ms": events[-1]["elapsed_ms"],
            "bottleneck": {
                "key": bottleneck["key"],
                "label": bottleneck["label"],
                "duration_ms": bottleneck["duration_ms"],
            },
        }


def ns_to_ms(value_ns: int) -> float:
    return round(value_ns / 1_000_000, 3)

6.1 为什么要强校验事件顺序

埋点系统最怕“有数据,但定义已经乱了”。

例如某次重构后,代码先 mark 了 t4,随后才补 t3。如果程序照样收下这两条日志,后续可能出现负耗时或错位归因。

本文要求:

expected_name = EVENT_NAMES[len(self._events_ns)]

下一条不是预期事件就直接报错。Demo 阶段宁愿尽早暴露问题,也不要悄悄生成错误指标。


七、构造四种可控慢点

真实线上链路不适合拿来反复“故意变慢”。为了验证瓶颈定位是否正确,我设置四种合成场景:

baseline       各阶段相对均衡
slow_endpoint  只把端点检测从 180 ms 调到 550 ms
slow_llm       只把 LLM TTFT 从 260 ms 调到 650 ms
slow_tts       只把 TTS 首包从 180 ms 调到 520 ms

pipeline.py

from __future__ import annotations

import asyncio
import random
from dataclasses import dataclass

from timeline import EVENT_NAMES, STAGES, Timeline


@dataclass(frozen=True)
class Scenario:
    name: str
    description: str
    delays_ms: dict[str, float]
    jitter_ms: float = 8.0


BASELINE_DELAYS = {
    "endpointing": 180.0,
    "asr_finalize": 90.0,
    "orchestration_rag": 60.0,
    "llm_ttft": 260.0,
    "text_buffering": 120.0,
    "tts_first_packet": 180.0,
    "transport": 25.0,
    "playback_buffer": 70.0,
}


def with_override(**overrides: float) -> dict[str, float]:
    delays = BASELINE_DELAYS.copy()
    delays.update(overrides)
    return delays


SCENARIOS = {
    "baseline": Scenario(
        name="baseline",
        description="各环节均衡的基线链路",
        delays_ms=BASELINE_DELAYS,
    ),
    "slow_endpoint": Scenario(
        name="slow_endpoint",
        description="VAD 端点检测等待过长",
        delays_ms=with_override(endpointing=550.0),
    ),
    "slow_llm": Scenario(
        name="slow_llm",
        description="LLM 首 token 明显变慢",
        delays_ms=with_override(llm_ttft=650.0),
    ),
    "slow_tts": Scenario(
        name="slow_tts",
        description="TTS 首个音频包明显变慢",
        delays_ms=with_override(tts_first_packet=520.0),
    ),
}


async def run_trace(
    scenario_name: str,
    *,
    seed: int | None = None,
    scale: float = 1.0,
) -> dict:
    if scenario_name not in SCENARIOS:
        available = ", ".join(SCENARIOS)
        raise ValueError(f"未知场景 {scenario_name},可选:{available}")
    if scale < 0:
        raise ValueError("scale 不能小于 0")

    scenario = SCENARIOS[scenario_name]
    randomizer = random.Random(seed)
    timeline = Timeline(scenario=scenario_name)
    timeline.mark("t0")

    for stage_index, (stage_key, _, _, _) in enumerate(STAGES, start=1):
        configured_ms = scenario.delays_ms[stage_key]
        jitter_ms = randomizer.uniform(-scenario.jitter_ms, scenario.jitter_ms)
        actual_delay_ms = max(0.0, configured_ms + jitter_ms)
        await asyncio.sleep(actual_delay_ms / 1_000 * scale)
        timeline.mark(EVENT_NAMES[stage_index])

    result = timeline.as_dict(scenario.delays_ms)
    result["description"] = scenario.description
    result["measurement_scope"] = (
        "synthetic-demo-only:本地 asyncio 延迟模拟,不代表任何厂商性能"
    )
    return result

每次延迟带有固定范围的抖动,并通过 seed 复现。这样既不会每次完全一样,也不会因为随机数失控而难以对照。


八、不要只算平均值:输出 p50、p95 和瓶颈次数

平均值会被少量极慢或极快请求拉动。Voice Agent 更适合至少同时看:

p50:典型用户大约会遇到什么速度
p95:较慢的 5% 请求大约慢到什么程度
p99:线上量足够大时,观察长尾

Demo 只连续运行 5 次,用来做 sanity check,不足以形成生产 SLA。线上应把直方图持续采集在真实流量中。

统计核心代码:

import statistics
from collections import Counter


def percentile(values: list[float], percent: int) -> float:
    if len(values) == 1:
        return round(values[0], 3)

    quantiles = statistics.quantiles(
        values,
        n=100,
        method="inclusive",
    )
    return round(quantiles[percent - 1], 3)


def summarize(traces: list[dict]) -> dict:
    total_values = [trace["total_ms"] for trace in traces]
    stage_keys = [stage["key"] for stage in traces[0]["stages"]]
    stage_summary = {}

    for stage_key in stage_keys:
        values = [
            next(
                stage["duration_ms"]
                for stage in trace["stages"]
                if stage["key"] == stage_key
            )
            for trace in traces
        ]
        stage_summary[stage_key] = {
            "p50_ms": round(statistics.median(values), 3),
            "p95_ms": percentile(values, 95),
        }

    bottleneck_counts = Counter(
        trace["bottleneck"]["key"]
        for trace in traces
    )
    return {
        "scenario": traces[0]["scenario"],
        "runs": len(traces),
        "total_p50_ms": round(statistics.median(total_values), 3),
        "total_p95_ms": percentile(total_values, 95),
        "bottleneck_counts": dict(bottleneck_counts),
        "stages": stage_summary,
        "measurement_scope": traces[0]["measurement_scope"],
    }

为什么还要统计“瓶颈命中次数”?

如果 100 次请求里有 92 次最慢阶段都是 endpointing,结论很稳定;如果各阶段轮流成为最大值,就要继续按网络、语言、文本长度、模型和客户端类型分组,而不是仓促优化一个组件。


九、输出瀑布图

一次 baseline 运行结果:

scenario=baseline total=1009.0 ms
t0->t1 端点检测          188.3 ms  █████████
t1->t2 ASR 收尾           92.9 ms  ████
t2->t3 编排/RAG           67.1 ms  ███
t3->t4 LLM 首 token      255.6 ms  ████████████
t4->t5 首段文本聚合        122.3 ms  ██████
t5->t6 TTS 首包          185.2 ms  █████████
t6->t7 音频传输            33.8 ms  ██
t7->t8 播放预缓冲           63.8 ms  ███
bottleneck=llm_ttft (255.6 ms)

生成文本瀑布图:

def render_waterfall(trace: dict, width: int = 48) -> str:
    total_ms = trace["total_ms"]
    lines = [
        f"trace_id={trace['trace_id']}",
        f"scenario={trace['scenario']} total={total_ms:.1f} ms",
    ]

    for stage in trace["stages"]:
        bar_length = max(
            1,
            round(stage["duration_ms"] / total_ms * width),
        )
        bar = "█" * bar_length
        lines.append(
            f"{stage['start']}->{stage['end']} "
            f"{stage['label']:<10} "
            f"{stage['duration_ms']:>8.1f} ms  {bar}"
        )

    lines.append(
        "bottleneck="
        f"{trace['bottleneck']['key']} "
        f"({trace['bottleneck']['duration_ms']:.1f} ms)"
    )
    return "\n".join(lines)

运行:

python benchmark.py \
  --scenario baseline \
  --runs 5 \
  --waterfall

十、FastAPI 接口

app.py

from __future__ import annotations

from pathlib import Path

from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse

from benchmark import benchmark_scenario
from pipeline import SCENARIOS, run_trace


BASE_DIR = Path(__file__).resolve().parent
INDEX_HTML = BASE_DIR / "static" / "index.html"

app = FastAPI(title="Voice Agent t0-t8 Latency Lab")


@app.get("/")
async def index() -> FileResponse:
    return FileResponse(INDEX_HTML)


@app.get("/api/scenarios")
async def scenarios() -> dict:
    return {
        name: {
            "description": scenario.description,
            "delays_ms": scenario.delays_ms,
        }
        for name, scenario in SCENARIOS.items()
    }


@app.get("/api/trace/{scenario_name}")
async def trace(
    scenario_name: str,
    seed: int = Query(default=20260701),
) -> dict:
    if scenario_name not in SCENARIOS:
        raise HTTPException(status_code=404, detail="scenario 不存在")
    return await run_trace(scenario_name, seed=seed)


@app.get("/api/benchmark/{scenario_name}")
async def benchmark(
    scenario_name: str,
    runs: int = Query(default=5, ge=1, le=30),
    seed: int = Query(default=20260701),
) -> dict:
    if scenario_name not in SCENARIOS:
        raise HTTPException(status_code=404, detail="scenario 不存在")

    traces, summary = await benchmark_scenario(
        scenario_name,
        runs=runs,
        seed=seed,
    )
    return {
        "summary": summary,
        "sample_trace": traces[0],
    }

接口测试:

curl 'http://127.0.0.1:8000/api/trace/slow_tts?seed=20260701'

返回中可以直接看到:

{
  "scenario": "slow_tts",
  "total_ms": 1346.992,
  "bottleneck": {
    "key": "tts_first_packet",
    "label": "TTS 首包",
    "duration_ms": 524.356
  }
}

十一、浏览器瀑布图的关键代码

页面不是靠一张静态图,而是每次调用 /api/trace/{scenario} 后重新计算各阶段宽度:

async function runTrace(scenario) {
  const trace = await fetch(
    `/api/trace/${scenario}`
  ).then(response => response.json());

  renderTrace(trace);
}

function renderTrace(trace) {
  document.querySelector("#total").textContent =
    `${trace.total_ms.toFixed(1)} ms`;

  document.querySelector("#bottleneck").textContent =
    `${trace.bottleneck.label} ` +
    `${trace.bottleneck.duration_ms.toFixed(1)} ms`;

  waterfallRoot.replaceChildren();

  for (const stage of trace.stages) {
    const row = document.createElement("div");
    const label = document.createElement("span");
    const track = document.createElement("div");
    const bar = document.createElement("div");
    const value = document.createElement("span");

    row.className = "row";
    track.className = "track";
    bar.className = "bar";

    label.textContent =
      `${stage.start}${stage.end} ${stage.label}`;
    bar.style.width =
      `${stage.duration_ms / trace.total_ms * 100}%`;
    value.textContent =
      `${stage.duration_ms.toFixed(1)} ms`;

    track.appendChild(bar);
    row.append(label, track, value);
    waterfallRoot.appendChild(row);
  }
}

浏览器验证结果:

baseline
总延迟约 1009.2 ms
瓶颈:LLM 首 token,约 255.5 ms

slow_llm
总延迟约 1397.1 ms
瓶颈:LLM 首 token,约 645.5 ms

当只调慢 t3 -> t4,瀑布图能够把增长稳定定位到 LLM TTFT,而不是误报给 TTS 或网络。


十二、完整实测结果

12.1 单元测试

python -m unittest -v

结果:

test_percentile_single_value ... ok
test_summary ... ok
test_all_events_are_emitted ... ok
test_scenario_configuration_has_expected_bottleneck ... ok
test_rejects_out_of_order_event ... ok
test_traceparent_shape ... ok

Ran 6 tests in 0.005s
OK

12.2 baseline 连续 5 次

total p50 = 998.879 ms
total p95 = 1014.527 ms
bottleneck = llm_ttft,5/5 次

各阶段 p50:

阶段 p50
端点检测 186.584 ms
ASR 收尾 92.915 ms
编排/RAG 65.764 ms
LLM 首 token 257.914 ms
首段文本聚合 122.299 ms
TTS 首包 180.412 ms
音频传输 23.066 ms
播放预缓冲 68.957 ms

12.3 三种慢点对照

场景 总延迟 p50 总延迟 p95 主要慢点 命中
baseline 998.879 ms 1014.527 ms LLM TTFT 5/5
slow_endpoint 1371.344 ms 1383.424 ms 端点检测 5/5
slow_llm 1391.680 ms 1404.660 ms LLM TTFT 5/5
slow_tts 1341.982 ms 1354.701 ms TTS 首包 5/5

这组结果不用于比较谁更快,只验证一件事:当我们只改变一个阶段时,埋点和瓶颈算法能否指向正确位置。


十三、从实验到真实 Voice Agent,埋点应该放在哪里

13.1 t0:音频接收层

不要在 HTTP 请求进入时记录 t0。实时语音通常是一条持续存在的 WebSocket 或 WebRTC 会话,请求早已建立。

t0 应在音频接收层确定“用户最后一帧有效语音”的位置。

13.2 t1:VAD/Endpointing

t1 - t0 既受 VAD 参数影响,也受“静音多久算说完”的业务策略影响。

端点太激进会抢话,端点太保守会拖慢整条链路。这个阶段不能只追求低延迟,还要同时看:

  • 误截断率;
  • 用户补充半句话后被切断的比例;
  • 噪声下的误触发;
  • 不同语言和说话速度。

13.3 t2:ASR final

流式 ASR 往往早已有 partial 文本,但业务通常需要一个稳定版本再做意图、RAG 或下单。

建议同时记:

asr_first_partial
asr_final

本文为了保持 t0-t8 紧凑,只把 asr_final 放入主时间线。

13.4 t3、t4:LLM 请求与首 token

LLM TTFT 应从请求真正发出算起:

t4 - t3

如果把 RAG、提示词拼装和排队时间一起塞进去,就会把编排问题误认为模型问题。

13.5 t5:第一段可播文本

这是 Voice Agent 经常缺失的埋点。

LLM 很快返回首 token,但程序可能:

  • 等完整 JSON;
  • 等一句话结束;
  • 等 Markdown 清洗;
  • 等工具调用完成;
  • 等安全审核结果;
  • 等到文本超过最小长度。

这些等待全部落在:

t5 - t4

13.6 t6:TTS 首包

从第一段文本发给 TTS 到收到首个音频包:

t6 - t5

不要用整句合成完成时间替代首包。流式 Voice Agent 最关心何时能开始播,完整生成耗时则影响队列能否持续供给。

13.7 t7、t8:客户端才知道的时间

服务端不能准确猜测客户端什么时候收到包、什么时候真正排入播放器。

浏览器或 App 应回传:

{
  "trace_id": "...",
  "turn_id": 18,
  "t7_client_receive": 123.4,
  "t8_play_scheduled": 194.2,
  "queued_audio_ms": 160,
  "underrun_count": 0
}

跨机器时不要直接拿两个没有同步保证的本地时钟做绝对时间相减。更稳的做法是:

  • 每个服务记录自己 span 的持续时间;
  • 用同一个 trace context 关联;
  • 客户端单独上报本地阶段耗时;
  • 需要跨时钟比较时,明确做时钟同步和误差评估。

十四、Trace ID 怎么跨 ASR、LLM、TTS 传下去

Demo 生成:

traceparent: 00-<32位trace-id>-<16位parent-id>-01

W3C Trace Context 规范定义了 traceparenttracestate,用于在分布式系统的不同组件之间传播追踪上下文。

真实链路里,每个下游请求都应继续携带 trace context:

Voice Gateway
  `-- trace_id=abc
      |-- ASR span
      |-- RAG span
      |-- LLM span
      |-- TTS span
      `-- playback feedback span/event

如果使用 OpenTelemetry,可以把每个阶段建成 span,把 t0-t8 作为 span event。OpenTelemetry Python 官方文档也给出了创建 span、嵌套 span、添加 attribute 与 event 的手工埋点方式。

示意代码:

from opentelemetry import trace

tracer = trace.get_tracer("voice-agent")

with tracer.start_as_current_span("voice.turn") as turn_span:
    turn_span.set_attribute("voice.turn_id", turn_id)
    turn_span.add_event("t0.audio_last_frame")

    with tracer.start_as_current_span("vad.endpointing"):
        await wait_for_endpoint()
    turn_span.add_event("t1.endpoint_detected")

    with tracer.start_as_current_span("asr.finalize"):
        transcript = await asr.finalize()
    turn_span.add_event("t2.asr_final")

属性里不要直接塞完整用户录音和敏感对话文本。可以记录长度、语言、模型、状态码、错误类型等非敏感诊断信息;确实需要样本时,要有单独的脱敏、权限和留存策略。


十五、Prometheus 里怎么存

追踪适合看“一次请求为什么慢”,指标适合看“一段时间整体慢不慢”。

可以为阶段延迟建直方图:

voice_stage_latency_ms{
  stage="llm_ttft",
  model="example-model",
  region="cn-east"
}

不要把 trace_id、完整手机号、完整用户 ID 放进 metric label。每个不同 label 组合都会产生新的时间序列,高基数会迅速放大存储和查询成本。

Prometheus 官方文档区分了 histogram 与 summary:histogram 暴露分桶计数,可在服务端计算分位数,也更适合跨实例聚合;summary 直接在客户端计算分位数,但分位值不能直接跨实例聚合。

实践上可以这样分工:

Metrics:p50/p95/p99、错误率、欠载率、阶段趋势
Traces:一次慢请求的完整 t0-t8 瀑布
Logs:错误码、请求状态和必要诊断字段

十六、我实际遇到的几个埋点坑

16.1 首 token 和首句混成一个指标

日志只记:

llm_first_response_ms

但不知道是首 token 还是首段可播文本。最后 LLM 看起来很快,Voice Agent 仍然不开口。

修正:分成 t4t5

16.2 TTS 首包在服务端记了,播放器没记

服务端 200 ms 收到 PCM,不代表用户 200 ms 听到声音。包可能还在网络、前端队列或 AudioContext 预缓冲里。

修正:客户端补 t7t8

16.3 只报平均值

大多数请求 800 ms,少量请求 5 秒,平均值可能掩盖真实体验。

修正:至少看 p50、p95、p99 和超阈值比例。

16.4 不同机器直接减时间戳

ASR 服务写一个 wall-clock,TTS 服务写另一个 wall-clock,然后直接相减。机器时钟漂移后会出现负耗时或虚高。

修正:每个 span 用单调时钟测本地持续时间,再用 trace context 串联。

16.5 重试耗时被覆盖

代码重试三次,只保留最后一次成功请求的 180 ms,用户实际等了 1.4 秒。

修正:记录:

attempt_count
attempt_1_duration
backoff_duration
attempt_2_duration
total_stage_duration

16.6 把并行阶段全部相加

流式 ASR、预取 RAG、LLM 和 TTS 可能有重叠。所有阶段 duration 相加后大于用户实际等待,不一定是数据错误,可能只是并行。

修正:同时保存 span 的开始、结束和父子关系,分析关键路径。

16.7 指标名变了,仪表盘还在读旧字段

这是最朴素也最常见的问题。

修正:给埋点 schema 加版本,例如:

{
  "schema_version": "voice-latency-v1",
  "trace_id": "...",
  "events": []
}

十七、常见报错与排查

Q1:为什么 t2 - t1 是负数?

先检查是否混用了:

  • time.time()
  • perf_counter_ns()
  • 浏览器 performance.now()
  • 另一台机器的 wall-clock。

同一段持续时间必须来自同一个时钟域。

Q2:为什么 Demo 配置 180 ms,实测是 186 ms?

asyncio.sleep(0.18) 表示至少挂起约 180 ms,不保证精确在 180 ms 恢复。事件循环调度、系统负载和代码执行都会增加少量时间。本文记录的正是实际恢复时间。

Q3:p95 为什么每次有波动?

样本只有 5 次时,p95 很不稳定。Demo 只是验证统计代码。生产环境应积累足够样本,按模型、地区、客户端、语言和场景合理分组。

Q4:为什么总延迟不等于所有阶段 p50 相加?

每个阶段的 p50 可能来自不同请求,分位数不能直接相加得到总延迟分位数。应直接对每条 trace 的 t8 - t0 统计总延迟。

Q5:真实系统一定要 t0-t8 吗?

不一定。可以增加:

ASR first partial
RAG first result
LLM tool call start/end
TTS stream completed
audio playback started/ended
barge-in detected

t0-t8 是最小骨架,不是协议标准。

Q6:怎样判断该先优化哪一段?

先按真实流量统计:

  • 阶段 p50/p95/p99;
  • 该阶段成为瓶颈的比例;
  • 优化后对 t8-t0 的实际贡献;
  • 准确率、自然度、误截断等副作用。

耗时最长不等于最值得优化。某阶段虽然慢,却可能是保证质量的必要等待。

Q7:为什么 VAD 不能直接调得非常激进?

因为低延迟和不抢话是冲突目标。端点窗口缩短后,Agent 可能频繁截断停顿较长、语速较慢或在噪声中说话的用户。


十八、优化顺序:先定位,再动参数

我会按下面顺序处理一次 Voice Agent 慢请求:

1. 确认 t0、t8 定义一致
2. 找到该 trace 的最大阶段
3. 查看这一阶段在 p50/p95 是否都异常
4. 按模型、地区、语言、文本长度继续切分
5. 检查重试、排队、限流和网络
6. 只改一个变量
7. 用相同样本重新跑
8. 同时验证准确率、自然度和打断体验

不要一口气同时改 VAD、模型、分句和 TTS。总延迟即使下降,也不知道是谁起作用,更不知道质量损失来自哪里。


十九、本文的判断标准

这套埋点是否真正有用,我会看六件事:

  1. 能否从用户说完而不是用户开口开始计时;
  2. 能否区分 LLM 首 token 与首段可播文本;
  3. 能否区分 TTS 首包与客户端首播放;
  4. 能否用同一个 trace ID 串起 ASR、RAG、LLM、TTS;
  5. 能否同时输出单次瀑布和整体分位数;
  6. 能否在故意调慢某一段后准确指出该瓶颈。

如果日志只有一个 total_latency,它更像报警器;有了 t0-t8,才开始像诊断工具。


系列阅读

上一篇:

流式 TTS 怎么接入 Voice Agent?首包延迟、分句与 PCM 音频队列实战

下一篇计划:

用户打断以后,上下文怎么续?Voice Agent 会话状态管理实战

会继续拆已经生成、已经播放、用户已经确认三类内容,以及打断后哪些内容能留在上下文、哪些必须撤回。


官方资料

更多推荐