1. 语音 Agent 的延迟预算
文本 Agent 可以容忍 2~3 秒响应,语音不行。人类对话的轮换间隔(turn-taking gap)平均约 200ms,超过 800ms 就会让人明显感到"对方在思考"。
把端到端延迟拆成可测量的分段:
| 环节 | 目标 | 常见实现 | 备注 |
|---|---|---|---|
| 采集 + 编码 | 20~40 ms | WebRTC Opus 20ms 帧 | 固定开销 |
| VAD 端点判定 | 100~300 ms | Silero VAD | 需等静音阈值 |
| 流式 ASR(部分结果) | 150~300 ms | Whisper-streaming / Paraformer | 边说边出 |
| LLM 首 token(TTFT) | 200~500 ms | 流式 + KV 复用 | 最大变量 |
| TTS 首帧 | 100~200 ms | CosyVoice / ElevenLabs Flash | 流式合成 |
| 传输 + 播放缓冲 | 60~120 ms | jitter buffer | 可调 |
总预算 < 900ms 是体感可接受线,< 500ms 才算"自然"。可见 LLM 的 TTFT 占了近一半,必须做流式与增量处理。流式链路的通用工程手段见 /llm-streaming-realtime/。
2. 级联式 vs 端到端全双工
2.1 级联式(Cascade)
麦克风 → VAD → ASR → LLM → TTS → 扬声器
优点:每一环可替换、可观测、可单独优化;支持任意 LLM 与工具调用。
缺点:延迟是各段之和;ASR 的错误会传递;无法处理副语言信息(语气、情绪、笑声)。
2.2 端到端语音模型(Speech-to-Speech)
麦克风 → 音频 Tokenizer → 多模态 LLM → 音频解码 → 扬声器
优点:延迟低(单模型一次前向),能保留语气与情绪,天然支持全双工。
缺点:可控性差、难插工具调用、成本高、调试困难。
2.3 选型建议
| 场景 | 推荐 |
|---|---|
| 客服 / 业务系统(需查库、调工具) | 级联式 |
| 情感陪伴 / 实时闲聊 | 端到端 |
| 混合(工具调用 + 低延迟) | 级联 + 流式优化 + 语义 VAD |
多数生产系统走级联式,因为工具调用与可观测性是硬需求。
3. VAD 与端点判定
VAD(Voice Activity Detection)决定"用户说完了没有",它的策略直接决定延迟与误打断率。
3.1 Silero VAD 实战
import torch
import numpy as np
class SileroVAD:
def __init__(self, threshold: float = 0.5, sr: int = 16000):
self.model, _ = torch.hub.load(
repo_or_dir="snakers4/silero-vad", model="silero_vad"
)
self.threshold = threshold
self.sr = sr
self.reset()
def reset(self):
self.model.reset_states()
self.speech_ms = 0
self.silence_ms = 0
def process(self, chunk: np.ndarray, chunk_ms: int = 32) -> str:
"""输入 32ms 音频块,返回状态:silence / speech / end。"""
prob = self.model(torch.from_numpy(chunk), self.sr).item()
if prob > self.threshold:
self.speech_ms += chunk_ms
self.silence_ms = 0
return "speech" if self.speech_ms > 96 else "silence"
self.silence_ms += chunk_ms
# 静音超过 500ms 且此前有语音,判定说完
if self.silence_ms > 500 and self.speech_ms > 200:
self.reset()
return "end"
return "silence"
关键参数:
| 参数 | 推荐值 | 影响 |
|---|---|---|
| 帧长 | 32 ms | 越小越灵敏,CPU 开销越高 |
| 起始阈值 | 96 ms 语音 | 过滤咳嗽/键盘声 |
| 结束静音 | 400~700 ms | 越小越快,但易截断停顿 |
| 概率阈值 | 0.5 | 嘈杂环境可提到 0.6~0.7 |
3.2 语义端点判定(Semantic VAD)
固定静音阈值有个死结:用户说完"帮我查一下明天北京的天气"后停顿 600ms 是正常的,但说"嗯……“后停顿 600ms 是思考。用 LLM 判断句子是否完整,可以动态调整等待时长。
async def is_turn_complete(partial_text: str) -> bool:
"""用极小的分类模型判断是否说完。"""
prompt = f"判断下面这句话是否语义完整,只回答 yes 或 no:\n{partial_text}"
ans = await small_llm.complete(prompt, max_tokens=2, temperature=0)
return ans.strip().lower().startswith("y")
实践中组合使用:静音 400ms 作为硬下限,若语义不完整则延长到 1200ms。
4. 流式 ASR
4.1 两种流式策略
| 策略 | 说明 | 延迟 | 精度 |
|---|---|---|---|
| 真流式(streaming) | 模型内部维护状态,逐块出词 | 低 | 中 |
| 伪流式(chunked) | 每 1~2s 切一段独立识别 | 中 | 高 |
| 双通道(双流) | 部分结果给 UI,最终结果给 LLM | 低 | 高 |
推荐双通道:UI 上展示快速但可能不准的 partial 结果,而送给 LLM 的必须等 ASR 的 final 结果,避免因识别错误导致 LLM 答非所问。
class StreamingASR:
def __init__(self, model):
self.model = model
self.buffer = []
async def feed(self, audio_chunk: np.ndarray):
self.buffer.append(audio_chunk)
# 每 200ms 出一次部分结果
partial = self.model.transcribe(np.concatenate(self.buffer), partial=True)
yield {"type": "partial", "text": partial}
async def finalize(self):
text = self.model.transcribe(np.concatenate(self.buffer), partial=False)
self.buffer.clear()
return {"type": "final", "text": text}
4.2 热词与领域适配
语音 Agent 常见问题:产品名、型号、专有名词被识别错。两个手段:
- 热词表(hotwords):给解码器加偏置,把"Qwen"识别成"千问"而不是"千万”。
- 上下文提示(prompt/initial_prompt):把领域词汇塞进解码 prompt。
result = asr.transcribe(
audio,
initial_prompt="对话涉及的技术名词:Qwen、vLLM、KV Cache、LoRA。",
hotwords=["Qwen", "vLLM", "LoRA"],
)
5. LLM 推理的低延迟要点
语音场景下 LLM 的要求与文本不同:TTFT 比吞吐重要。
5.1 首 token 优化手段
| 手段 | 效果 |
|---|---|
| 关闭 thinking / reasoning | 省 500ms~数秒 |
| 系统提示词前缀缓存 | TTFT 降 30~60% |
| 限制 max_tokens(语音回答宜短) | 总时长降 |
| 小模型 + 路由(简单问题走小模型) | TTFT 降一半 |
| 投机解码(Speculative Decoding) | 吞吐升,TTFT 略升 |
5.2 语音回答要短
语音是线性的,用户无法"跳读"。经验:单轮回答控制在 23 句、60120 字,超过就分段并允许打断。
VOICE_SYSTEM_PROMPT = """你是语音助手。规则:
1. 回答必须口语化、简短,控制在 2~3 句以内。
2. 不要使用 Markdown、列表、代码块——它们无法被朗读。
3. 数字用中文读法表达("三点五"而非"3.5")。
4. 需要长内容时,先给结论并询问是否展开。
"""
5.3 分句流式送给 TTS
不要把 LLM 的完整回答等完再合成,而是按标点切句,逐句送入 TTS。
import re
SENT_END = re.compile(r"[。!?!?;;]")
async def llm_to_tts(llm_stream, tts):
buf = ""
async for token in llm_stream:
buf += token
# 遇到句末标点且长度够,立刻合成
if SENT_END.search(token) and len(buf) >= 8:
await tts.speak(buf)
buf = ""
if buf.strip():
await tts.speak(buf)
这一步通常能把"首帧出声"时间从 1.5s 降到 600ms 以内。
6. 流式 TTS
6.1 首帧延迟是唯一指标
TTS 的总合成时间不重要,首帧(first chunk)延迟才决定体感。
| 方案 | 首帧延迟 | 质量 | 成本 |
|---|---|---|---|
| 传统拼接(Tacotron+HiFiGAN) | 200~400 ms | 中 | 低 |
| 流式神经 TTS(CosyVoice) | 150~300 ms | 高 | 中 |
| 商用 API(ElevenLabs Flash) | 100~200 ms | 很高 | 高 |
| 端侧 TTS(Piper) | 50~150 ms | 中 | 极低 |
6.2 分块播放与背压
TTS 输出的是音频流,需要一边收一边播。用 AudioWorklet 或服务端流式 HTTP 都可以。
import httpx
async def stream_tts(text: str, out_queue):
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST", "http://tts:8000/synthesize",
json={"text": text, "format": "pcm_s16le", "sample_rate": 24000},
) as resp:
async for chunk in resp.aiter_bytes(4096):
await out_queue.put(chunk) # 立即推给播放器
播放端要预留 jitter buffer,同时避免缓冲过大导致打断延迟。经验值:缓冲 100~200ms。
7. 打断(Barge-in)与全双工
打断是语音 Agent 与"录音→识别→播放"最大的体验差异点。
7.1 打断的三个动作
用户开始说话时,系统必须在 100ms 内完成:
- 停止播放:清空播放缓冲,立刻静音。
- 取消生成:中断 LLM 流与 TTS 合成(避免浪费算力)。
- 重置上下文:丢弃未播完的回答,但保留已播出的部分作为对话历史。
class VoiceSession:
def __init__(self):
self.state = "idle" # idle / listening / thinking / speaking
self.playback_queue = asyncio.Queue()
self.llm_task: asyncio.Task | None = None
async def on_speech_start(self):
if self.state == "speaking":
await self.barge_in()
async def barge_in(self):
# 1. 清空播放缓冲
while not self.playback_queue.empty():
self.playback_queue.get_nowait()
# 2. 取消 LLM / TTS 任务
if self.llm_task and not self.llm_task.done():
self.llm_task.cancel()
# 3. 记录已播出的文本到历史
self.history.append({"role": "assistant",
"content": self.spoken_text, "truncated": True})
self.state = "listening"
7.2 回声消除(AEC)
全双工下麦克风会拾取扬声器声音,导致 Agent 自己打断自己。必须做 AEC(Acoustic Echo Cancellation):
- 浏览器端:
getUserMedia({audio: {echoCancellation: true}})已内置,通常够用。 - 服务端:用 WebRTC 的 AEC3 或 SpeexDSP。
- 兜底:播放期间把 VAD 阈值调高,或用"仅在自己不说话时听"的半双工降级。
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
sampleRate: 16000,
},
});
8. 传输链路选型
| 方案 | 延迟 | 抗丢包 | 复杂度 | 适用 |
|---|---|---|---|---|
| WebRTC | 极低(<100ms) | 强(FEC/重传) | 高 | 生产级语音 |
| WebSocket + Opus | 低(100~200ms) | 弱(TCP 队头阻塞) | 中 | 快速原型 |
| WebSocket + PCM | 中 | 弱 | 低 | 局域网 / 调试 |
| HTTP SSE(单向) | 中 | 弱 | 低 | 仅下行 TTS |
关键认知:TCP 上的 WebSocket 遇到丢包会队头阻塞,导致音频卡顿。生产环境优先 WebRTC;WebSocket 方案适合内网或原型。SSE 的机制与取舍见 SSE 实时通信 。
8.1 编解码与带宽
| 格式 | 采样率 | 码率 | 带宽(双向) |
|---|---|---|---|
| PCM s16le | 16 kHz | 256 kbps | 512 kbps |
| Opus | 16 kHz | 24 kbps | 48 kbps |
| Opus(音乐) | 48 kHz | 64~128 kbps | 128~256 kbps |
语音场景 Opus 24kbps 已足够,比 PCM 省 10 倍带宽。音频编解码的更多细节见 语音与音频处理 。
9. 观测与工程实践
9.1 必须埋点的指标
| 指标 | 说明 | 目标 |
|---|---|---|
vad_end_latency | 静音判定耗时 | < 600 ms |
asr_final_latency | 最终识别耗时 | < 400 ms |
llm_ttft | 首 token 延迟 | < 500 ms |
tts_first_chunk | TTS 首帧 | < 250 ms |
e2e_response_latency | 用户说完到首声 | < 900 ms |
barge_in_stop_latency | 打断到静音 | < 150 ms |
interruption_rate | 误打断比例 | < 5% |
9.2 常见坑位
- ASR 部分结果抖动:UI 上文字反复跳动,用"稳定前缀"策略(只展示两次一致的部分)。
- TTS 音色漂移:分段合成导致音色/语速不一致,需在每段带上相同的 speaker embedding 与韵律参数。
- 长回答无法打断:如果 LLM 一次性生成完再送 TTS,打断只能丢掉后半段,浪费算力;必须分句流式。
- 静音阈值一刀切:不同用户语速差异大,可按历史说话节奏自适应调整。
- 忽略移动网络抖动:4G/5G 抖动可达数百毫秒,jitter buffer 要能动态伸缩。
10. 部署形态与容量规划
10.1 三种部署形态
| 形态 | 组件位置 | 延迟 | 适用 |
|---|---|---|---|
| 全云 | VAD/ASR/LLM/TTS 全在服务端 | 中(+网络 RTT) | 通用 |
| 边缘 + 云 | VAD/TTS 在边缘,LLM 在云 | 低 | 跨国/弱网 |
| 全端侧 | 全部在浏览器/手机 | 极低 | 隐私敏感、离线 |
端侧形态依赖小模型(Whisper-tiny / Piper / Qwen-0.5B),能力有限但隐私与延迟最优。混合形态是当前主流:端侧做 VAD 与播放,云端做识别与推理。
10.2 单机容量估算
语音会话是长连接 + 持续小流量,容量瓶颈往往在并发会话数而非 QPS。
单会话资源占用(服务端):
VAD: ~1% 单核
ASR: 流式模型,约 0.3~0.5 GPU 等效(并发批处理可摊薄)
LLM: 与其他会话共享批处理,按 token 计
TTS: 流式合成,约 0.2 GPU 等效
单张 A100 (80G) 参考容量:
级联式,7B 模型,平均回答 80 token:
~40~80 并发会话(受 LLM 批处理与 TTS 显存共同限制)
估算时要按平均会话时长 × 并发峰值算 GPU 秒,而不是按请求数。语音会话通常持续 1~5 分钟,且长尾明显(有人挂机不说话),需要空闲会话回收:
IDLE_TIMEOUT_S = 90
async def reap_idle_sessions(sessions: dict):
now = time.monotonic()
for sid, sess in list(sessions.items()):
if now - sess.last_activity > IDLE_TIMEOUT_S and sess.state == "idle":
await sess.close() # 释放 ASR/TTS 资源
del sessions[sid]
10.3 降级策略
语音链路的每一环都可能超时,必须定义降级顺序:
- LLM 超时(> 3s)→ 播放"让我想想",继续等待,而非报错。
- TTS 超时 → 回退到传统 TTS 或纯文字回复。
- ASR 置信度低 → 主动复述确认(“您是想说……吗?")。
- 网络抖动 → 提高 jitter buffer,降低采样率与码率。
async def safe_tts(text: str, primary, fallback):
try:
return await asyncio.wait_for(primary.synthesize(text), timeout=1.5)
except asyncio.TimeoutError:
return await fallback.synthesize(text)
10.4 成本结构
| 环节 | 成本占比(典型) | 优化手段 |
|---|---|---|
| LLM 推理 | 50~70% | 小模型路由、前缀缓存、缩短回答 |
| ASR | 10~20% | 端侧 VAD 前置、只在有语音时识别 |
| TTS | 10~20% | 缓存固定话术、分句复用 |
| 传输 | < 5% | Opus 编码 |
最有效的降本手段是缩短回答——语音回答本来就该短,这既是体验优化也是成本优化。
小结
实时语音 Agent 的体验由延迟与打断两个维度决定,而不是模型能力。
工程上的默认架构:Silero VAD + 语义端点判定 + 双通道流式 ASR + 小模型路由的流式 LLM + 分句流式 TTS + WebRTC 传输,并实现 100ms 内完成的 barge-in。多模态语音合成的模型侧细节见 /llm-multimodal-speech-synthesis/。
优化顺序建议:先把端到端延迟压到 900ms 以内(TTFT 与 TTS 首帧收益最大),再打磨打断体验,最后才考虑换更强的模型。用户对"快"的敏感度远高于对"聪明"的敏感度。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。