实时语音 Agent:全双工对话与低延迟链路

拆解实时语音 Agent 的完整低延迟链路:VAD 端点检测、流式 ASR、LLM 首 token 优化、流式 TTS 与打断(Barge-in)实现,给出端到端延迟预算表、WebRTC 与 WebSocket 传输选型、全双工状态机代码与容量规划。

1. 语音 Agent 的延迟预算

文本 Agent 可以容忍 2~3 秒响应,语音不行。人类对话的轮换间隔(turn-taking gap)平均约 200ms,超过 800ms 就会让人明显感到"对方在思考"。

把端到端延迟拆成可测量的分段:

环节目标常见实现备注
采集 + 编码20~40 msWebRTC Opus 20ms 帧固定开销
VAD 端点判定100~300 msSilero VAD需等静音阈值
流式 ASR(部分结果)150~300 msWhisper-streaming / Paraformer边说边出
LLM 首 token(TTFT)200~500 ms流式 + KV 复用最大变量
TTS 首帧100~200 msCosyVoice / ElevenLabs Flash流式合成
传输 + 播放缓冲60~120 msjitter 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 常见问题:产品名、型号、专有名词被识别错。两个手段:

  1. 热词表(hotwords):给解码器加偏置,把"Qwen"识别成"千问"而不是"千万”。
  2. 上下文提示(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 内完成:

  1. 停止播放:清空播放缓冲,立刻静音。
  2. 取消生成:中断 LLM 流与 TTS 合成(避免浪费算力)。
  3. 重置上下文:丢弃未播完的回答,但保留已播出的部分作为对话历史。
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 s16le16 kHz256 kbps512 kbps
Opus16 kHz24 kbps48 kbps
Opus(音乐)48 kHz64~128 kbps128~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_chunkTTS 首帧< 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 降级策略

语音链路的每一环都可能超时,必须定义降级顺序:

  1. LLM 超时(> 3s)→ 播放"让我想想",继续等待,而非报错。
  2. TTS 超时 → 回退到传统 TTS 或纯文字回复。
  3. ASR 置信度低 → 主动复述确认(“您是想说……吗?")。
  4. 网络抖动 → 提高 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%小模型路由、前缀缓存、缩短回答
ASR10~20%端侧 VAD 前置、只在有语音时识别
TTS10~20%缓存固定话术、分句复用
传输< 5%Opus 编码

最有效的降本手段是缩短回答——语音回答本来就该短,这既是体验优化也是成本优化。

小结

实时语音 Agent 的体验由延迟与打断两个维度决定,而不是模型能力。

工程上的默认架构:Silero VAD + 语义端点判定 + 双通道流式 ASR + 小模型路由的流式 LLM + 分句流式 TTS + WebRTC 传输,并实现 100ms 内完成的 barge-in。多模态语音合成的模型侧细节见 /llm-multimodal-speech-synthesis/。

优化顺序建议:先把端到端延迟压到 900ms 以内(TTFT 与 TTS 首帧收益最大),再打磨打断体验,最后才考虑换更强的模型。用户对"快"的敏感度远高于对"聪明"的敏感度。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「llm」更多文章

  1. 端侧推理:移动端与浏览器部署
  2. RAG 评估体系:召回、忠实度与自动化指标
  3. 长上下文优化:注意力稀疏化与 KV Cache 管理