Voice Agent 如何把端到端延迟控制在 500ms
从用户语音结束到客户端首段有效语音播放,拆解 End-of-Turn、ASR、LLM、TTS、网络与播放延迟,并用流式、推测执行和取消把响应压到 500ms 量级。
证据快照:本文核对时间为 2026-08-05。文中实现细节优先依据 OpenAI Realtime VAD、LiveKit Turn-taking、Deepgram Flux 与 Gemini Live API 官方文档。厂商延迟数字的硬件、网络、语言、音频格式和计时边界不同,不能直接横向比较。
摘要
Voice Agent 的低延迟不是“换一个更快的模型”就能得到。真正决定体验的是:何时判断用户说完、各阶段能否并行、错误推测能否立即取消,以及客户端什么时候真正播放首段有效语音。500ms 是一套端到端系统工程目标,不是单模型指标。
读者将学到什么
读完本文,你应该能回答五个问题:
- Voice Agent 的“500ms”究竟从哪里计时,到哪里结束。
- 为什么端点检测往往比 LLM 本身更影响体感。
- 如何通过流式处理、推测执行和可取消状态机缩短关键路径。
- 怎样处理打断、误打断、工具调用和播放历史不一致。
- 如何建立能定位尾延迟的 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–580ms | p90 要稳定低于 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_vad与semantic_vad。后者根据用户表达是否完整决定等待时间,并提供low、medium、high与auto四档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中把StartOfTurn、EagerEndOfTurn、TurnResumed与EndOfTurn作为模型事件。官方口径是在默认配置下 End-of-Turn p50 约 260ms;启用eager_eot_threshold后,可以先启动 LLM,若随后收到TurnResumed再取消。官方同时建议以约 80ms 音频块发送给 Flux。[5][14]
这些实现名称不同,但都在解决同一个矛盾:
为了更快,系统需要在完全确定之前开始工作;为了不抢话,它又必须允许撤销。
3.1 推荐的分层判断
生产环境不要只依赖单一阈值。可以组合:
- 声学信号:静音长度、能量、噪声、说话人变化;
- ASR 信号:标点、最后词时间、文本稳定度、识别置信度;
- 语义信号:句法是否完整、是否仍有悬挂条件、问题是否完成;
- 会话信号:用户过去的停顿习惯、语言、设备、场景;
- 业务信号:命令是否已包含必要槽位,例如日期、对象和动作。
端点检测的输出也不应该只有布尔值,而应该包含:
{
"state": "eager_end",
"confidence": 0.76,
"reason": ["silence_140ms", "syntactic_complete", "stable_partial"],
"can_speculate": true,
"can_play": false
}can_speculate 和 can_play 必须分开。前者允许后台开始算,后者决定是否可以让用户听见。
4. 一套面向 500ms 的生产架构
架构中最重要的不是模型,而是 Turn Orchestrator。它要承担四个职责:
- 合并 VAD、ASR 和语义端点信号;
- 给每次生成分配单调递增的
generation_id; - 管理推测任务的提交和取消;
- 保证用户听到的音频、转录历史和 LLM 上下文一致。
没有这个控制层,系统很容易出现:旧轮次的 TTS 在新轮次开始后继续下发、被打断的内容仍写进历史、工具结果回到已经失效的请求等问题。
5. 关键时序:先算,但不要过早承诺
这里存在三个不同的“提交点”:
- 计算提交:允许 LLM 开始生成;
- 音频生成提交:允许 TTS 开始合成;
- 用户可见提交:允许音频进入播放队列。
越靠前越激进,延迟越低,浪费和误判风险也越高。常见的稳妥策略是:
- 中等置信度:只提前跑 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_id 和 transcript_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_interrupted 和 RealtimePlaybackTracker,让电话等远端播放场景按真实播放进度更新历史,而不是假设生成的音频已经全部被听到。[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;降低无效缓冲 |
| 回应基于旧 partial | Agent 回答了用户未说完的半句话 | 推测任务未按版本取消 | 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_atllm_ttft_ms 和 llm_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、jitterBufferDelay、jitterBufferEmittedCount、estimatedPlayoutTimestamp 和 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_tokens、wasted_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 量级,最关键的不是找到一个“最快模型”,而是重新设计轮次的关键路径:
- 用客户端首个有效语音播放定义端到端延迟;
- 把 VAD、End-of-Turn 和业务提交拆开;
- 在不确定阶段允许推测,在用户继续说话时立即撤销;
- 让 ASR、LLM、TTS、网络和播放真正流式并重叠;
- 用 generation、版本和播放进度维持打断后的一致性;
- 同时观测延迟、抢话、误打断、成本和任务成功率。
最终,低延迟 Voice Agent 更像一个实时分布式系统,而不是三个模型 API 的顺序调用。模型决定能力上限,Turn Orchestrator、媒体链路和可观测性决定用户每天实际感受到的速度。
参考资料
-
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
-
OpenAI, Realtime API — Voice activity detection (VAD) https://developers.openai.com/api/docs/guides/realtime-vad
-
OpenAI, Realtime conversations https://developers.openai.com/api/docs/guides/realtime-conversations
-
LiveKit, Turn-taking tuning https://docs.livekit.io/agents/logic/turns/tuning/
-
Deepgram, Migrating from Nova-3 to Flux https://developers.deepgram.com/docs/flux/nova-3-migration
-
Google AI for Developers, Live API capabilities guide https://ai.google.dev/gemini-api/docs/live-api/capabilities
-
Kyutai Labs, Moshi — GitHub repository https://github.com/kyutai-labs/moshi
-
Défossez et al., Moshi: a speech-text foundation model for real-time dialogue https://arxiv.org/abs/2410.00037
-
Kyutai Labs, MoshiRAG — asynchronous retrieval for spoken dialogue https://github.com/kyutai-labs/moshi-rag
-
IETF, RFC 6716: Definition of the Opus Audio Codec https://www.rfc-editor.org/rfc/rfc6716
-
W3C, Identifiers for WebRTC’s Statistics API https://www.w3.org/TR/webrtc-stats/
-
Silero, Silero VAD https://github.com/snakers4/silero-vad
-
LiveKit, Audio Turn Detector https://docs.livekit.io/agents/logic/turns/turn-detector/
-
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
-
OpenAI Agents SDK, Realtime Guide — Interruptions and Playback Tracking https://openai.github.io/openai-agents-python/realtime/guide/
-
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 继续交流。