设计一个实时语音对话的 AI 助手
一句话回答
有两种架构:级联流水线(语音识别 ASR → 大模型 → 语音合成 TTS),模块可以替换,中间有文本,方便接工具、做审核和记录;端到端语音模型直接输入输出音频,延迟更低、能保留语气,但可控性差一些。无论哪种,核心都是延迟预算:每个环节都要流式,ASR 边听边识别,模型边生成,TTS 拿到第一句就开始合成。用语音活动检测(VAD)判断用户什么时候说完;用户插话时立即停止播报、取消生成,并把历史截断到用户实际听到的部分。客户端和服务端之间的传输优先用 WebRTC,服务端之间可以用 WebSocket;还要处理弱网和断线重连。
详细解析
第一步:澄清需求
- 场景:App 里的语音助手、电话客服,还是陪伴类聊天?电话要接入电话网关,音质和延迟条件都不一样
- 延迟:假设目标是用户说完后 1 秒左右开始听到回复,作为下文延迟预算的基准
- 能力:要不要调用工具(查天气、下订单),要不要同时在屏幕上显示内容
- 语言:普通话、方言、中英混说
- 成本和隐私:按通话分钟数估算成本;录音要告知用户并取得同意,默认只保存文字记录
第二步:整体架构
| 级联流水线(ASR → LLM → TTS) | 端到端语音模型 | |
|---|---|---|
| 延迟 | 各环节延迟相加,要靠全链路流式来压缩 | 更低 |
| 语气和情绪 | 转成文字后丢失 | 能理解,也能表达 |
| 可控性 | 中间有文本,便于接工具和 RAG、做审核和记录 | 较弱,依赖模型提供的文字转写和工具调用能力 |
| 灵活性 | 每个环节可以单独选型、替换 | 绑定某个模型和供应商 |
例如 OpenAI 的 Realtime API、Google 的 Gemini Live API 都提供端到端的实时语音能力。下面以级联流水线为例:
客户端:麦克风 → 回声消除、降噪 → 本地 VAD
│ WebRTC:上行音频、下行音频,数据通道传控制事件
▼
媒体服务(接收音频流、管理播放队列、会话状态机)
├─► 流式 ASR:边听边出中间结果,判断用户说完后给出最终文本
├─► 对话服务:上下文 + 工具调用 → LLM 流式生成文本
├─► 断句:攒够一句或一个短语,就送去合成
├─► 流式 TTS:边合成边下发音频片段
└─ 打断:用户开口 → 停止播放、取消 LLM 和 TTS、截断历史
第三步:核心模块
延迟预算(示例,全部是假设值):
| 环节 | 预算 | 说明 |
|---|---|---|
| 判断用户说完(静音等待) | 300~500 毫秒 | 占比往往很大,等短了又会打断用户 |
| ASR 给出最终结果 | 100~200 毫秒 | 流式识别,大部分文字在说话过程中已经识别好了 |
| LLM 首个 token | 300~500 毫秒 | 受模型大小、输入长度、排队情况影响 |
| TTS 首个音频片段 | 100~200 毫秒 | 拿到第一句就开始合成 |
| 网络和播放缓冲 | 100 毫秒左右 | 就近部署,各个服务放在同一个地域 |
压缩延迟的手段:全链路流式;LLM 的输出按标点断句,第一句尽量短;服务之间保持长连接;用小而快的模型;系统提示保持稳定,命中提示缓存;需要调用工具时先播一句"我查一下",掩盖等待时间。
VAD 和轮次判断:VAD 判断"有没有人在说话",可以在客户端和服务端各做一次(如 Silero VAD 这类模型)。"用户说完了"不能只看静音时长,说话中间的停顿会被误判为结束。可以结合语义判断,比如"我想订一张去……"显然没说完,就多等一会儿;判断错了也要能补救,用户接着说时把两段合并成一句。
打断(barge-in):
- 客户端检测到用户开口,立即停止本地播放,这一步不能等服务端
- 服务端取消正在进行的 LLM 生成和 TTS 合成,清空待播放的音频
- 把这条回复在历史里截断到用户实际听到的位置,否则模型会以为用户听到了全部内容
- 区分插话和附和:用户说"嗯""对"不应该打断,可以要求语音持续一定时长,或者识别出实际内容后再打断
客户端必须开启回声消除,否则扬声器播放的回复被麦克风录进去,会被当成用户在说话,助手就会自己打断自己。
传输选择:
| WebRTC | WebSocket | |
|---|---|---|
| 底层 | 通常基于 UDP,有抖动缓冲、丢包补偿、码率自适应 | TCP,丢包时后面的数据要等重传,延迟会累积 |
| 浏览器能力 | 内置回声消除、降噪、Opus 编码 | 要自己采集、编码、播放 |
| 复杂度 | 需要信令和 STUN/TURN 等基础设施,或使用现成的媒体服务 | 实现简单 |
| 适合 | 客户端和服务端之间的实时音频 | 服务端之间、网络稳定的场景、原型验证 |
WebSocket 的基础见 WebSocket,它和 SSE 的取舍见流式输出为什么常用 SSE。
第四步:关键难点
对话状态管理:每个会话是一个状态机:聆听 → 思考 → 播报,被打断时回到聆听。所有事件(识别结果、生成片段、打断)都带上所属轮次的 ID,迟到的旧事件直接丢弃,避免上一轮的音频在新一轮里播出来。工具调用期间用户又开口了,要决定是取消调用,还是等结果返回后一起处理。
适合语音的输出:Prompt 要求回答简短、口语化,不输出表格、代码和 Markdown 符号;数字、单位、英文缩写在合成前做文本规范化("3.5%"读作"百分之三点五")。需要展示的结构化内容,通过数据通道同时发到屏幕上。
弱网处理:WebRTC 会根据网络状况调整码率;丢包和延迟持续偏高时,提示用户网络不佳,必要时建议切换到文字输入。断线后用会话 ID 重连,服务端保留会话状态和历史一段时间,重连后从当前轮次继续。
第五步:扩展与优化
- 成本:ASR 通常按音频时长计费,TTS 按字符数,LLM 按 token;长时间静音的音频不送去识别
- 电话渠道:通过电话网关(SIP)接入,电话音频的采样率较低,识别模型要适配
- 可观测:记录每一轮各环节的耗时,延迟问题才能定位到具体环节
代码示例
打断处理的核心逻辑(VAD、播放器、TTS 的接口各家不同,这里写成通用的形式):
// 客户端:检测到用户开口,先停止本地播放,再告诉服务端实际播放到了哪里
vad.onSpeechStart(() => {
if (!player.isPlaying) return
const playedMs = player.stop() // 返回当前回复已经播放的时长
channel.send(JSON.stringify({ type: 'interrupt', turnId: player.turnId, playedMs }))
})
// 服务端:取消生成和合成,把历史截断到用户实际听到的部分
function onInterrupt(session: Session, { turnId, playedMs }: InterruptEvent) {
if (turnId !== session.currentTurnId) return // 过期的事件直接丢弃
session.abortController.abort() // 停止 LLM 生成和 TTS 合成
const reply = session.history.at(-1)!
// TTS 返回了字级时间戳时可以精确对齐,否则按已经发送的句子粗略截断
reply.content = textSpokenBefore(reply, playedMs)
reply.interrupted = true
session.state = 'listening'
}
面试官可能追问
只用静音时长判断用户说完了,有什么问题?
阈值短了,用户思考时的停顿会被当作说完,频繁被打断;阈值长了,每轮都要多等,显得反应迟钝。改进方法:结合语义判断句子是否完整;按场景动态调整(用户在报一串数字时多等一会儿);判断错了允许补救,用户继续说时取消已经开始的回复,把两段合并处理。
级联方案和端到端方案怎么选?
需要调用工具、查知识库、严格审核、留存文字记录的业务场景(客服、办事),级联方案更可控,延迟靠全链路流式来压缩。看重自然度、情绪表达和最低延迟的场景(陪伴聊天、口语练习),端到端模型更合适。也可以混合使用:端到端模型负责对话,工具调用和关键信息通过它提供的文字转写和函数调用来处理。
为什么打断后要截断历史?不截断会怎样?
模型生成的回复可能很长,用户只听了前一半就插话了。如果历史里保留完整的回复,模型会以为用户已经知道了后半段,下一轮可能说"如我刚才所说",或者跳过用户没听到的信息。所以要按实际播放的位置截断,并标记这条回复被打断了。
怎么把延迟问题定位到具体环节?
给每一轮生成一个 ID,记录关键时间点:用户停止说话、ASR 给出最终结果、LLM 首个 token、第一句送入 TTS、首个音频片段到达客户端、开始播放。看各段耗时的分布,尤其是高分位数,再针对最慢的环节优化。客户端的时间点也要上报,网络和播放缓冲的耗时只有在客户端才能测到。
易错点
- 只优化模型的速度,忽略了等待用户说完、合成首个音频片段这些环节,它们加起来往往更长
- 打断时只停止服务端,客户端缓冲里的音频还在继续播放
- 没开回声消除,助手听到自己的声音后打断自己
- 把 Markdown、表格直接交给 TTS 朗读
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。