Chico Notes
研究与调研

Voice Agent 如何把端到端延迟控制在 500ms

从用户语音结束到客户端首段有效语音播放,拆解 End-of-Turn、ASR、LLM、TTS、网络与播放延迟,并用流式、推测执行和取消把响应压到 500ms 量级。

持续修订的工程笔记

证据快照:本文核对时间为 2026-08-05。文中实现细节优先依据 OpenAI Realtime VADLiveKit Turn-takingDeepgram FluxGemini Live API 官方文档。厂商延迟数字的硬件、网络、语言、音频格式和计时边界不同,不能直接横向比较。

摘要

Voice Agent 的低延迟不是“换一个更快的模型”就能得到。真正决定体验的是:何时判断用户说完、各阶段能否并行、错误推测能否立即取消,以及客户端什么时候真正播放首段有效语音。500ms 是一套端到端系统工程目标,不是单模型指标。

读者将学到什么

读完本文,你应该能回答五个问题:

  1. Voice Agent 的“500ms”究竟从哪里计时,到哪里结束。
  2. 为什么端点检测往往比 LLM 本身更影响体感。
  3. 如何通过流式处理、推测执行和可取消状态机缩短关键路径。
  4. 怎样处理打断、误打断、工具调用和播放历史不一致。
  5. 如何建立能定位尾延迟的 Trace、指标和验收方法。

1. 先定义 500ms:不要用一个错误指标优化系统

很多团队会展示以下数字:

  • ASR 返回第一段文本需要 120ms;
  • LLM 首 Token 需要 90ms;
  • TTS 首包需要 80ms。

这些数字都可能是真的,但把它们相加仍然无法说明用户等待了多久。实际体验中,系统还在等待用户停顿、等待 ASR 提交最终文本、排队、跨地域传输、缓存音频,以及等待客户端播放器积累足够的数据。

本文把主指标定义为:

turn_latency =
client_first_meaningful_audio_playout_at
- user_speech_end_at

也就是:

用户最后一个有效语音样本结束,到客户端真正开始播放首段有语义的 Agent 语音。

这里有两个限制。

第一,终点必须是客户端播放,而不是服务端拿到 TTS 首字节。服务端拿到音频后,仍可能经历下行网络、解码、抖动缓冲和播放器队列。

第二,首段语音必须有语义。预先播放“嗯”“好的”“请稍等”可以让仪表盘很好看,却未必让任务更快,甚至会增加打断和不信任。只有当短句真实承载确认、澄清或答案信息时,才应该计入首个有效语音。

生产系统至少要同时保留四类延迟:

指标起点终点用途
End-to-first-playout用户语音结束客户端首段有效语音播放核心交互体感
Speech-start-to-first-playout用户开始说话客户端首段有效语音播放衡量完整一轮时间,但受用户句长影响
Interrupt-to-silence检测到用户打断Agent 音频真正停止衡量打断能力
Task-complete latency用户语音结束工具执行与最终结果完成衡量任务完成,而不是“先开口”

人类对话的轮次交接通常发生在数百毫秒量级。Stivers 等人对十种语言的研究显示,不同语言都倾向于减少停顿和重叠,只是在具体时长上存在文化差异。[1] 因此,Voice Agent 即使回答内容正确,只要每轮都出现一秒以上的空白,也很容易被感知为“机器在等”。


2. 延迟的真正公式:关键是重叠,而不是简单相加

把用户说完之后的路径拆开,可以得到:

L_e2e =
  L_endpoint
+ L_asr_commit
+ L_context
+ L_llm_ttft
+ L_tts_first_audio
+ L_downlink
+ L_playout
- L_overlap

其中:

  • L_endpoint:判断用户是否结束当前轮次;
  • L_asr_commit:从结束判断到获得可提交文本;
  • L_context:读取短期状态、Memory、RAG 路由和 Prompt 组装;
  • L_llm_ttft:LLM 从请求到首个可用于 TTS 的 Token;
  • L_tts_first_audio:从首段可合成文本到音频首包;
  • L_downlink:音频到客户端的传输;
  • L_playout:解码、抖动缓冲和真正播放;
  • L_overlap:通过流式、并行和推测执行隐藏掉的时间。

真正重要的是最后一项。

如果严格串行执行:

等待静音
→ 提交 ASR
→ 组装上下文
→ 请求 LLM
→ 等完整句子
→ 请求 TTS
→ 等完整音频
→ 发给客户端

即使每个模型都不慢,整体仍然会超过一秒。

低延迟链路应该接近:

用户说话时持续 ASR
→ 疑似说完时推测启动 LLM
→ LLM 一产生可合成短语就流给 TTS
→ TTS 音频边生成边下发
→ 客户端小缓冲播放
→ 用户继续说话时取消整条推测链路

2.1 一个可执行的 500ms 预算

下面不是厂商 Benchmark,而是一份用于设计和压测的目标预算:

阶段建议目标说明
端点确认80–150ms依赖语义 End-of-Turn 或高置信推测;纯静音阈值通常更慢
ASR 提交20–50ms大部分识别已在用户说话期间完成
上下文与路由10–30ms热缓存、短 Prompt、工具意图快速判断
LLM 首段可合成文本80–150ms不是只看首 Token,还要看何时形成稳定短语
TTS 首段音频70–120ms长连接、流式输入、避免等待整句
下行与客户端播放40–80ms同地域、持续连接、小而自适应的播放缓冲
串行总计300–580msp90 要稳定低于 500ms,通常还需要重叠执行

这份预算揭示了一个事实:500ms 不是靠某一环节做到 20ms,而是不能让任何环节无故等待。


3. 最大的误区:把 VAD 当成 End-of-Turn

VAD(Voice Activity Detection,语音活动检测)回答的是:

现在有没有人在发声?

而 End-of-Turn(轮次结束判断)回答的是:

用户表达是否已经结束,Agent 现在回答会不会打断他?

两者不是同一个问题。

用户可能在这些位置短暂停顿:

  • “我想订一张……明天下午去上海的票。”
  • “这个接口在超时以后……是不是还会重试?”
  • “你先比较 A 和 B,嗯……再考虑成本。”

纯静音规则无法知道停顿是句末还是思考。把静音阈值设成 200ms,Agent 会经常抢话;设成 800ms,系统又会显得迟钝。

当前几套主流实现已经不再把“静音结束”当成唯一答案:

  • OpenAI Realtime API 区分 server_vadsemantic_vad。后者根据用户表达是否完整决定等待时间,并提供 lowmediumhighauto 四档 eagerness;在会话模式中还可以分别控制是否自动创建响应、是否打断当前响应。[2]
  • LiveKit 当前推荐直接编码音频的 Turn Detector。完整 v1 由 LiveKit Inference 提供,v1-mini 可在本地 CPU 运行;它同时利用语义和声学线索,不依赖 Transcript。启用音频 Turn Detector 后,默认 Endpointing 区间会从 0.5–3.0 秒缩短为 0.3–2.5 秒,但仍保留超时兜底。[13]
  • Deepgram Flux 在 /v2/listen 中把 StartOfTurnEagerEndOfTurnTurnResumedEndOfTurn 作为模型事件。官方口径是在默认配置下 End-of-Turn p50 约 260ms;启用 eager_eot_threshold 后,可以先启动 LLM,若随后收到 TurnResumed 再取消。官方同时建议以约 80ms 音频块发送给 Flux。[5][14]

这些实现名称不同,但都在解决同一个矛盾:

为了更快,系统需要在完全确定之前开始工作;为了不抢话,它又必须允许撤销。

3.1 推荐的分层判断

生产环境不要只依赖单一阈值。可以组合:

  1. 声学信号:静音长度、能量、噪声、说话人变化;
  2. ASR 信号:标点、最后词时间、文本稳定度、识别置信度;
  3. 语义信号:句法是否完整、是否仍有悬挂条件、问题是否完成;
  4. 会话信号:用户过去的停顿习惯、语言、设备、场景;
  5. 业务信号:命令是否已包含必要槽位,例如日期、对象和动作。

端点检测的输出也不应该只有布尔值,而应该包含:

{
  "state": "eager_end",
  "confidence": 0.76,
  "reason": ["silence_140ms", "syntactic_complete", "stable_partial"],
  "can_speculate": true,
  "can_play": false
}

can_speculatecan_play 必须分开。前者允许后台开始算,后者决定是否可以让用户听见。


4. 一套面向 500ms 的生产架构

架构中最重要的不是模型,而是 Turn Orchestrator。它要承担四个职责:

  • 合并 VAD、ASR 和语义端点信号;
  • 给每次生成分配单调递增的 generation_id
  • 管理推测任务的提交和取消;
  • 保证用户听到的音频、转录历史和 LLM 上下文一致。

没有这个控制层,系统很容易出现:旧轮次的 TTS 在新轮次开始后继续下发、被打断的内容仍写进历史、工具结果回到已经失效的请求等问题。


5. 关键时序:先算,但不要过早承诺

这里存在三个不同的“提交点”:

  1. 计算提交:允许 LLM 开始生成;
  2. 音频生成提交:允许 TTS 开始合成;
  3. 用户可见提交:允许音频进入播放队列。

越靠前越激进,延迟越低,浪费和误判风险也越高。常见的稳妥策略是:

  • 中等置信度:只提前跑 LLM;
  • 高置信度:提前跑 LLM 和 TTS,但暂不播放;
  • 确认结束:将匹配当前文本版本的音频提交给播放器。

LiveKit 当前默认开启 Preemptive Generation,但默认只提前运行 LLM;preemptive_tts 默认关闭。官方还以 10 秒限制长语音的预生成,并将每轮最大预生成尝试数设为 3。开启预生成 TTS 可以继续降低延迟,但 Transcript 变化或用户恢复说话时会浪费更多计算,因此必须同时记录取消率和浪费成本。[4]


6. 五个最有效的工程手段

6.1 使用持续双向连接,并把服务放在同一延迟域

每轮重新建立 HTTP、TLS、鉴权和模型连接,通常比优化 Prompt 更浪费时间。实时链路应保持:

  • 客户端到媒体边缘的长连接;
  • 媒体边缘到 ASR 的持续流;
  • Orchestrator 到 LLM/TTS 的连接池或长连接;
  • 尽量同地域部署 ASR、LLM、TTS、Redis 和业务服务。

浏览器实时音频通常优先考虑 WebRTC,因为它已有 RTP、拥塞控制、抖动缓冲、丢包处理和设备能力。WebSocket 仍适合服务到服务或可控客户端,但音频封包、播放时钟、重连和拥塞策略要自己负责。

音频发送粒度与模型输入粒度也要分开:

  • 客户端可以每 20ms 发送一个 Opus/PCM 帧;
  • Edge 可以按 40–80ms 批量喂给 ASR;
  • 不要为了减少请求次数积累 200–500ms 才发送一次。

Opus 支持 2.5、5、10、20、40 和 60ms 帧长;更长帧会增加算法和丢包影响,20ms 是常见的实时折中。[10]

需要注意“客户端帧长”和“Provider 推荐输入块”并不是同一个参数。例如 Flux 官方建议应用以约 80ms 块写入 /v2/listen,但这不代表浏览器必须积累 80ms 才向 Edge 发送;Edge 可以持续接收更细帧,再按 Provider 的吞吐与延迟特征重新打包。[14]

6.2 所有阶段都流式,但不要把“有流”误当“低延迟”

至少需要以下流:

音频帧流
→ ASR partial/final 流
→ LLM Token/文本短语流
→ TTS 音频流
→ 客户端播放流

常见反模式是某一层虽然支持 streaming,业务代码却把它重新聚合:

  • 等 ASR 整句 final 才调用 LLM;
  • 等 LLM 输出完整回答才调用 TTS;
  • 等 TTS 生成完整 WAV 才返回;
  • 客户端收到音频后积累一秒才播放。

LLM 到 TTS 之间还需要“可合成短语切分”。逐 Token 喂给 TTS 可能导致发音不稳定;等完整句号又可能太慢。可以按以下边界切分:

  • 标点;
  • 语义完整的短语;
  • 最大字符数;
  • 最大等待时间;
  • TTS 对数字、英文缩写和特殊符号的规范化结果。

目标不是最早拿到一个 Token,而是最早形成一段不会马上被改写、能够自然朗读的文本。

6.3 把 End-of-Turn 做成可学习、可自适应的策略

固定 silence_ms=500 适合作为保底,不适合作为唯一策略。

系统可以按场景动态调整:

  • 用户连续快速问答:降低等待;
  • 用户在口述长需求:提高等待;
  • 中文、英文和代码口述使用不同模型或参数;
  • 噪声高、多人说话时提高声学置信门槛;
  • 用户近期多次“被抢话”时自动增加最小等待;
  • 用户频繁短句时提高推测执行比例。

OpenAI Semantic VAD 暴露 eagerness;LiveKit 支持动态 Endpointing、自适应 Interruption 与音频 Turn Detector;Gemini Live 的 Automatic Activity Detection 允许配置起止灵敏度、前缀填充与 silenceDurationMs,并默认让新的用户活动打断模型响应。对于长口述、Push-to-Talk 或明确的业务提交,Gemini 也允许关闭自动检测,由客户端显式发送 ActivityStart / ActivityEnd。[2][4][6][13][16] 这些参数不应该只在上线前手工调一次,而应该进入实验和观测体系。

6.4 推测执行必须配套取消传播

推测执行不是“提前请求一下 LLM”,而是一条有生命周期的事务。

每一轮至少要带:

session_id
turn_id
generation_id
transcript_version
cancel_token
deadline

取消信号要传到:

  • LLM 流;
  • TTS 流;
  • 工具调用;
  • 下行音频队列;
  • 客户端播放器;
  • 历史提交逻辑。

否则即使服务端停止 LLM,已经排队的音频仍可能继续播放。

还要防止 ABA 问题:用户恢复说话后又很快停下,新一轮的文本可能与旧版本看起来相同,但它们不是同一个生成。最终提交时,应校验 generation_idtranscript_version/hash,而不是只比较字符串。

6.5 把“先开口”和“完成任务”拆成两条路径

工具调用、数据库查询和 RAG 可能需要 300ms 到数秒。如果把它们都放在首句之前,500ms 很难稳定实现;如果用无意义的填充语掩盖,又是在优化假指标。

更合理的做法是区分:

  • Fast path:能直接回答、能确认约束、能提出必要澄清;
  • Slow path:工具、搜索、长推理、生成复杂结果。

两阶段响应只有在第一阶段真实有用时才成立。例如:

  • 有用:“你问的是昨天那笔订单,对吗?我正在读取它的物流状态。”
  • 无用:“好的,请稍等一下。”

对于只需要短事实的问题,Fast path 应直接给答案;对于工具任务,第一段可以确认对象、范围或下一步,同时异步执行工具。Google Gemini Live 文档展示了打断取消与函数调用的会话机制;MoshiRAG 一类研究系统也尝试让检索与对话前端异步协作。[6][9]


7. 最小实现:一个可取消的 Turn Coordinator

下面是简化的 Python 伪代码。它没有包含重试、持久化、鉴权和媒体协议,但展示了三个核心点:版本化、推测执行和提交门。

import asyncio
import contextlib
import hashlib
from dataclasses import dataclass, field
from typing import AsyncIterator, Optional


def text_hash(text: str) -> str:
    return hashlib.sha1(text.strip().encode("utf-8")).hexdigest()


@dataclass
class Generation:
    generation_id: int
    transcript_version: int
    transcript_hash: str
    llm_task: Optional[asyncio.Task] = None
    tts_task: Optional[asyncio.Task] = None
    committed: bool = False
    audio_queue: asyncio.Queue[bytes] = field(
        default_factory=asyncio.Queue
    )


class TurnCoordinator:
    def __init__(self, llm, tts, player):
        self.llm = llm
        self.tts = tts
        self.player = player

        self.partial = ""
        self.transcript_version = 0
        self.next_generation_id = 1
        self.active: Optional[Generation] = None
        self.lock = asyncio.Lock()

    async def on_partial(
        self,
        text: str,
        eou_confidence: float,
    ) -> None:
        async with self.lock:
            if text != self.partial:
                self.partial = text
                self.transcript_version += 1

            # 中等置信度:提前运行 LLM,但先不播放。
            if (
                eou_confidence >= 0.70
                and self.active is None
                and len(text.strip()) > 0
            ):
                await self._start_speculation(preemptive_tts=False)

    async def on_eager_end(self, confidence: float) -> None:
        async with self.lock:
            if confidence >= 0.88 and self.active is None:
                # 高置信度可以提前生成 TTS,仍需经过 commit gate。
                await self._start_speculation(preemptive_tts=True)

    async def on_turn_resumed(self) -> None:
        async with self.lock:
            await self._cancel_active(reason="turn_resumed")

    async def on_turn_confirmed(self, final_text: str) -> None:
        async with self.lock:
            final_hash = text_hash(final_text)

            reusable = (
                self.active is not None
                and self.active.transcript_version
                    == self.transcript_version
                and self.active.transcript_hash == final_hash
            )

            if not reusable:
                await self._cancel_active(reason="transcript_changed")
                self.partial = final_text
                self.transcript_version += 1
                await self._start_speculation(preemptive_tts=True)

            assert self.active is not None
            self.active.committed = True
            asyncio.create_task(self._drain_audio(self.active))

    async def on_barge_in(self) -> None:
        async with self.lock:
            await self._cancel_active(reason="barge_in")
            await self.player.stop_and_clear()

            # 生产实现还需要把会话历史截断到“用户实际听到”的位置。
            await self.player.report_played_range()

    async def _start_speculation(
        self,
        preemptive_tts: bool,
    ) -> None:
        generation = Generation(
            generation_id=self.next_generation_id,
            transcript_version=self.transcript_version,
            transcript_hash=text_hash(self.partial),
        )
        self.next_generation_id += 1
        self.active = generation

        generation.llm_task = asyncio.create_task(
            self._run_generation(
                generation,
                transcript=self.partial,
                preemptive_tts=preemptive_tts,
            )
        )

    async def _run_generation(
        self,
        generation: Generation,
        transcript: str,
        preemptive_tts: bool,
    ) -> None:
        text_chunks: AsyncIterator[str] = self.llm.stream(transcript)

        async for phrase in text_chunks:
            if self.active is not generation:
                return

            if preemptive_tts or generation.committed:
                async for audio in self.tts.stream(phrase):
                    if self.active is not generation:
                        return
                    await generation.audio_queue.put(audio)

    async def _drain_audio(self, generation: Generation) -> None:
        while self.active is generation and generation.committed:
            audio = await generation.audio_queue.get()
            await self.player.write(
                audio,
                generation_id=generation.generation_id,
            )

    async def _cancel_active(self, reason: str) -> None:
        generation = self.active
        self.active = None
        if generation is None:
            return

        for task in (generation.llm_task, generation.tts_task):
            if task is not None:
                task.cancel()
                with contextlib.suppress(asyncio.CancelledError):
                    await task

        while not generation.audio_queue.empty():
            generation.audio_queue.get_nowait()

        await self.llm.cancel(generation.generation_id, reason=reason)
        await self.tts.cancel(generation.generation_id, reason=reason)

这段代码还缺一个重要能力:按播放进度截断历史。假设 Agent 生成了 20 秒回答,用户在第 3 秒打断,那么下一轮上下文不能假设用户已经听完 20 秒,也不能完全删掉这条回答。需要记录文本片段与音频时间的映射,并只把实际播放部分写进会话事实。

OpenAI Realtime 在 WebRTC 与 SIP 模式下能够由服务端管理输出缓冲,并自动截断未播放音频;使用 WebSocket 时,客户端必须立即停止本地播放、记录实际播放时长,并发送 conversation.item.truncate 删除未播放部分。OpenAI Agents SDK 进一步提供 audio_interruptedRealtimePlaybackTracker,让电话等远端播放场景按真实播放进度更新历史,而不是假设生成的音频已经全部被听到。[3][15]

Gemini Live 默认在检测到新的用户活动时中断当前输出;客户端同样需要清空尚未播放的本地音频队列。[6][16] 这类“已生成、已发送、已播放、已提交”的边界,是全双工对话最容易被忽视的数据一致性问题。


8. 级联、端到端语音模型,还是混合架构

维度ASR → LLM → TTS 级联端到端 Speech-to-Speech混合架构
延迟下限受多阶段串行影响,但可通过流式和推测降低理论上更低,模型可直接处理双向音频快速语音路径低延迟,工具路径保留文本控制
文本可控性高,Prompt、审核、工具协议清晰依赖模型暴露的中间表示中等到高
情绪、韵律、重叠容易在 ASR 文本化时损失通常更自然关键场景走原生语音
工具/RAG容易接入和审计需要模型或控制层支持工具走结构化文本通道
可观测性每阶段容易打点内部延迟更难拆需要统一 Trace
故障替换ASR、LLM、TTS 可独立降级模型耦合更强控制面复杂
适用场景知识问答、客服、业务操作陪伴、角色对话、自然闲聊对自然度和业务可靠性都要求高的系统

Kyutai 的 Moshi 是端到端方向的代表:官方仓库给出的口径是理论延迟 160ms,在特定 L4 环境下实际可低至约 200ms。[7][8] 这说明端到端模型能显著压缩传统流水线,但不代表任意公网、任意设备和工具场景都能达到同样数字。

对于需要知识引用、权限、工具审计和稳定结构化输出的生产 Voice Agent,级联或混合架构仍然更容易治理。更现实的目标不是押注一种形态,而是把 Turn State、取消、播放提交和 Trace 设计成独立控制面,使底层模型可以替换。


9. 生产环境最常见的失败模式

失败模式表现根因修复方向
过早结束用户轮次Agent 抢话、频繁被用户纠正只看短静音;忽略句法和历史停顿语义 EOU;动态阈值;统计 false-end
结束判断太慢每轮出现明显空白固定长静音;等待 final 后才开始Eager EOU;预生成 LLM;降低无效缓冲
回应基于旧 partialAgent 回答了用户未说完的半句话推测任务未按版本取消transcript version + generation id
误打断咳嗽、回声、“嗯嗯”让 Agent 停止AEC/VAD 不稳;任何语音都算打断自适应 interruption;词数/持续时间;误打断恢复
用户打断后 Agent 仍在说播放器已有大量排队音频只取消服务端,未清客户端队列端到端 cancel;播放器按 generation 清队列
历史与听感不一致下一轮引用用户没听到的内容把完整生成写入历史按已播放音频范围提交历史
TTS 首包快但首句慢首字节到达,播放器很久才出声音频切片过小/过大;播放器高水位稳定短语切分;自适应水位
工具拖慢所有问题简单问答也等数据库或 RAG路由在关键路径;工具串行快慢路径;并行检索;超时和降级
平均值很好,用户仍抱怨少数轮次卡 2–5 秒冷启动、重连、跨区、队列和 GC看 p90/p99;按维度拆分
网络正常但播放延迟高服务端 Trace 很快,用户仍听得慢抖动缓冲、解码和设备调度采集 WebRTC 与客户端 playout 指标

尤其要注意回声。Agent 播放的声音可能重新进入麦克风,被 VAD 当成用户打断。AEC(Acoustic Echo Cancellation)不是音质装饰,而是全双工状态机的前置条件。多人会议又不同:强力 Voice Isolation 可能把真正的第二位发言者也滤掉,需要按单人/多人场景选择噪声策略。


10. 可观测性:必须把一轮对话画成 Trace

每轮至少记录以下时间戳:

audio_capture_started_at
user_speech_started_at
endpoint_candidate_at
user_speech_ended_at
endpoint_confirmed_at
asr_partial_first_at
asr_final_at
context_ready_at
llm_requested_at
llm_first_token_at
llm_first_speakable_phrase_at
tts_requested_at
tts_first_audio_at
server_audio_sent_at
client_audio_received_at
client_first_playout_at
interrupt_detected_at
client_audio_stopped_at
turn_completed_at

然后计算:

endpoint_ms =
  endpoint_confirmed_at - user_speech_ended_at

asr_commit_ms =
  asr_final_at - endpoint_confirmed_at

llm_ttft_ms =
  llm_first_token_at - llm_requested_at

llm_speakable_ms =
  llm_first_speakable_phrase_at - llm_requested_at

tts_first_audio_ms =
  tts_first_audio_at - tts_requested_at

server_to_playout_ms =
  client_first_playout_at - server_audio_sent_at

turn_e2e_ms =
  client_first_playout_at - user_speech_ended_at

barge_in_stop_ms =
  client_audio_stopped_at - interrupt_detected_at

llm_ttft_msllm_speakable_ms 要分开。模型很早输出一个标点或不完整词,并不能立刻交给 TTS。

10.1 不只看 p50

至少观察:

  • p50:典型体验;
  • p90:大部分用户是否稳定;
  • p99:重连、冷启动、排队和长尾;
  • 超过 500ms、800ms、1500ms 的轮次占比。

并按以下维度切分:

region
client_network_type
device / browser
language
ASR / LLM / TTS model
tool vs no_tool
RAG vs no_RAG
short_turn vs long_turn
first_turn vs warm_session
interrupted vs uninterrupted

如果只看全局平均值,移动网络的播放问题可能被办公室 Wi-Fi 掩盖,中文长停顿可能被英文短指令掩盖,工具调用也会和普通闲聊混在一起。

10.2 网络与播放指标

WebRTC Stats 标准提供 RTT、丢包、Jitter、jitterBufferDelayjitterBufferEmittedCountestimatedPlayoutTimestamp 和 Concealment 等指标;平均抖动缓冲时长可由累计 delay 除以 emitted count 计算。[11]

推荐同时上报:

current_rtt
packets_lost
jitter
avg_jitter_buffer_delay
concealed_samples
audio_queue_depth_ms
decoder_time_ms
playout_underrun_count

服务端模型很快,而 audio_queue_depth_ms 长期在 400ms,用户仍然只会觉得 Agent 慢。

10.3 延迟之外的质量指标

低延迟优化必须有质量护栏:

  • false_end_rate:用户继续说话但已被判定结束;
  • missed_end_rate:用户明显说完却迟迟不关闭轮次;
  • false_interruption_rate:噪声或附和造成误打断;
  • turn_resumed_rate:Eager End 后用户恢复说话;
  • speculation_cancel_rate:推测生成被取消比例;
  • wasted_llm_tokenswasted_tts_audio_ms
  • history_playout_mismatch_rate
  • 首句正确率与完整任务成功率。

只把 p90 从 650ms 降到 420ms,却让抢话率翻倍,不是成功。


11. 推测执行的成本与控制

推测执行会消耗额外资源:

  • 被取消的 LLM Token;
  • 未播放的 TTS 音频;
  • 更多并发连接;
  • 工具请求的取消与幂等成本;
  • 更复杂的 Trace 和状态管理。

可以建立分级策略:

EOU confidence < 0.70
  → 不推测

0.70 ≤ confidence < 0.88
  → 只预生成 LLM

confidence ≥ 0.88
  且短句、无工具、高稳定 partial
  → 预生成 LLM + TTS

任何 transcript_version 改变
  → 立即取消旧 generation

达到最大推测次数或预算
  → 等待最终确认

进一步设置:

  • 每轮最多 1–3 次推测;
  • 限制推测输出 Token;
  • 长口述不做预生成 TTS;
  • 工具调用在明确 commit 前只做可取消的只读操作;
  • 写操作必须等轮次确认和参数校验;
  • 记录“节省的延迟”和“浪费的成本”,而不是默认推测一定值得。

LiveKit 文档也提醒,预生成并不总能降低最终指标,需要通过观测确认它是否真正有效。[4]


12. 建议的状态机

状态机应持久化到日志,但不一定每个瞬态都写数据库。生产上通常把:

  • 会话、轮次、已提交消息作为长期事实;
  • 推测 generation、音频队列、取消 Token 作为内存或 Redis 瞬态;
  • Trace 和事件写入可观测系统;
  • 工具写操作使用独立幂等键。

这样既能恢复业务状态,又不会让每个 20ms 音频帧都进入事务数据库。


13. 什么时候不应该强求 500ms

500ms 是交互目标,不是所有任务的唯一目标。

以下场景更应该优先保证完整性:

  • 医疗、金融、合同等需要准确复述和确认的任务;
  • 用户在嘈杂环境或多人交叉发言;
  • 长篇口述、会议记录和代码听写;
  • 需要多步工具调用、权限确认或不可逆写操作;
  • 网络条件差,低缓冲会频繁卡顿;
  • 模型需要长推理才能避免明显错误。

更合适的产品设计是分层 SLO:

简单问答 / 指令确认:
  p90 首个有效语音 ≤ 500ms

需要工具的任务:
  p90 首个真实进度反馈 ≤ 700ms
  最终任务完成按工具类型设 SLO

高风险任务:
  不用抢答换速度
  必须复述关键参数并等待用户确认

延迟、正确性、可打断性和成本是四个不同维度。不要用一个数字把它们压扁。


14. 实践检查清单

指标

  • 500ms 的起点和终点已经统一定义。
  • 终点使用客户端首段有效语音播放,而不是服务端首包。
  • 同时记录 p50、p90、p99 和超阈值比例。
  • 工具轮次与非工具轮次分开统计。
  • 延迟指标有抢话率、误打断率和任务成功率护栏。

媒体与网络

  • 客户端使用持续实时连接,不按轮次重建。
  • 音频帧发送粒度没有引入数百毫秒聚合等待。
  • ASR、LLM、TTS 和业务服务尽量在同一延迟域。
  • 客户端能上报真实 playout、队列和 WebRTC 指标。
  • AEC、噪声和多人场景经过独立测试。

Turn 管理

  • VAD 与 End-of-Turn 概念分开。
  • 端点判断使用声学、文本和语义多信号。
  • Eager End 可以触发推测,Turn Resumed 可以取消。
  • 每次生成都有 generation_id 和 transcript_version。
  • 取消能传播到 LLM、TTS、工具、网络和播放器。
  • 打断后历史只提交用户实际听到的部分。

推理与 TTS

  • LLM 首 Token 与首个可合成短语分别计时。
  • 文本按稳定短语流给 TTS,不等完整回答。
  • TTS 首包到客户端播放之间有独立指标。
  • 预生成 TTS 有置信门槛和成本上限。
  • 固定填充语不被当作有效答案优化指标。

可靠性

  • 工具写操作必须等轮次和参数确认。
  • 旧 generation 的迟到事件不会污染新一轮。
  • 冷启动、重连、超时和区域故障有降级策略。
  • 压测包含长停顿、附和、咳嗽、回声、打断和弱网。
  • 每次参数调整都能从 Trace 验证收益与副作用。

总结

Voice Agent 要进入 500ms 量级,最关键的不是找到一个“最快模型”,而是重新设计轮次的关键路径:

  1. 用客户端首个有效语音播放定义端到端延迟;
  2. 把 VAD、End-of-Turn 和业务提交拆开;
  3. 在不确定阶段允许推测,在用户继续说话时立即撤销;
  4. 让 ASR、LLM、TTS、网络和播放真正流式并重叠;
  5. 用 generation、版本和播放进度维持打断后的一致性;
  6. 同时观测延迟、抢话、误打断、成本和任务成功率。

最终,低延迟 Voice Agent 更像一个实时分布式系统,而不是三个模型 API 的顺序调用。模型决定能力上限,Turn Orchestrator、媒体链路和可观测性决定用户每天实际感受到的速度。


参考资料

  1. Stivers et al., Universals and cultural variation in turn-taking in conversation https://www.mpi.nl/publications/item66202/universals-and-cultural-variation-turn-taking-conversation

  2. OpenAI, Realtime API — Voice activity detection (VAD) https://developers.openai.com/api/docs/guides/realtime-vad

  3. OpenAI, Realtime conversations https://developers.openai.com/api/docs/guides/realtime-conversations

  4. LiveKit, Turn-taking tuning https://docs.livekit.io/agents/logic/turns/tuning/

  5. Deepgram, Migrating from Nova-3 to Flux https://developers.deepgram.com/docs/flux/nova-3-migration

  6. Google AI for Developers, Live API capabilities guide https://ai.google.dev/gemini-api/docs/live-api/capabilities

  7. Kyutai Labs, Moshi — GitHub repository https://github.com/kyutai-labs/moshi

  8. Défossez et al., Moshi: a speech-text foundation model for real-time dialogue https://arxiv.org/abs/2410.00037

  9. Kyutai Labs, MoshiRAG — asynchronous retrieval for spoken dialogue https://github.com/kyutai-labs/moshi-rag

  10. IETF, RFC 6716: Definition of the Opus Audio Codec https://www.rfc-editor.org/rfc/rfc6716

  11. W3C, Identifiers for WebRTC’s Statistics API https://www.w3.org/TR/webrtc-stats/

  12. Silero, Silero VAD https://github.com/snakers4/silero-vad

  13. LiveKit, Audio Turn Detector https://docs.livekit.io/agents/logic/turns/turn-detector/

  14. Deepgram, Flux Quickstart, State Machine and Eager End of Turn https://developers.deepgram.com/docs/flux/quickstart https://developers.deepgram.com/docs/flux/state https://developers.deepgram.com/docs/flux/voice-agent-eager-eot

  15. OpenAI Agents SDK, Realtime Guide — Interruptions and Playback Tracking https://openai.github.io/openai-agents-python/realtime/guide/

  16. Google AI for Developers, Gemini Live API WebSocket Reference https://ai.google.dev/api/live


图片清单

1. 原创封面图

  • 文件名:voice-agent-500ms-cover.png
  • 比例:16
  • 用途:文章封面与社交分享卡片
  • Alt:实时语音在麦克风、神经网络和声波之间以低延迟流动的抽象编辑插画
  • 生成提示词:
A refined editorial illustration for a technical article about ultra-low-latency voice agents. A human voice waveform travels from a microphone through several translucent real-time processing layers, then returns as a clean synthetic voice waveform. Show parallel streams, interruption and cancellation as elegant branching paths, subtle timing pulses and a sense of speed without using clocks or racing imagery. Deep navy, warm white and restrained cyan accents, modern technical magazine style, spacious composition, 16:9, no text, no letters, no numbers, no logo, no watermark.

2. 整体架构图

  • 文件名:voice-agent-500ms-architecture.mmd
  • 用途:解释客户端、媒体边缘、Turn Orchestrator、推理和观测边界
  • Alt:面向 500ms 响应目标的 Voice Agent 流式架构
  • 形式:正文第 4 节 Mermaid Flowchart

3. 推测执行时序图

  • 文件名:voice-agent-speculative-turn-sequence.mmd
  • 用途:解释 Eager End、Turn Resumed、取消与 Commit Gate
  • Alt:Voice Agent 在用户疑似说完后预生成并在恢复说话时取消的时序
  • 形式:正文第 5 节 Mermaid Sequence Diagram

4. Turn 状态机

  • 文件名:voice-agent-turn-state-machine.mmd
  • 用途:解释 Listening、Speculating、Speaking、Interrupted 与 Recovering
  • Alt:支持推测执行和打断恢复的 Voice Agent 轮次状态机
  • 形式:正文第 12 节 Mermaid State Diagram

讨论

继续讨论这篇笔记

有问题、补充案例或不同观点,可以通过 GitHub Discussions 继续交流。

On this page