logo

实时语音 Agent:延迟预算与打断处理

Published on

实时语音 Agent:延迟预算与打断处理

文字聊天里,模型晚两秒回答,用户可能顺手刷一下手机。语音里不是这样。电话那头安静两秒,用户会先怀疑网络断了;系统还没说完,用户已经开始说下一句,结果两边同时发声,像两个互不认识的人在抢麦克风。

我做实时语音实验时,最开始只看“完整回答用了几秒”。这个指标很容易让人误判:总耗时 4 秒并不一定难受,如果 300 毫秒就开始播放自然的过渡语,用户可能觉得系统反应很快;反过来,总耗时 2 秒但一直没有声音,体验依然像卡死。

实时语音 Agent 的核心不是把文字聊天加上麦克风,而是把一条长链路切成多个可以并行、可以取消、可以恢复的流:声音进入后尽快判断用户是否说完,文本还没完全结束时就开始准备回答,回答产生一个片段就交给语音合成,同时还要随时接受用户打断。

一、先画出完整音频链路

一条常见的语音 Agent 管线如下:

麦克风
音频编码 / 降噪
VAD:判断有没有人在说话
ASR:语音转文字
对话状态与工具调用
LLM 流式生成
TTS:文字转语音
扬声器播放

这不是一条必须严格串行的流水线。ASR 可以持续输出 partial transcript,LLM 可以在确认一句话结束后开始生成,TTS 可以按句子或短语切片合成。越早把第一段可播放音频送到客户端,用户越早得到反馈。

但并行也带来状态问题:如果用户在 LLM 生成到一半时打断,已经排队的 TTS 音频要丢掉,正在播放的音频要停止,未完成的工具调用是否取消也要有明确规则。实时系统最怕“旧任务的结果晚到”,然后覆盖新状态。

二、用延迟预算替代一句“要快一点”

可以先给一次交互设一个目标预算:

用户停止说话到开始播放声音:≤ 800ms
VAD 结束判断:100ms
ASR 最终确认:150ms
LLM 首个可播片段:350ms
TTS 首个音频片段:200ms
网络与播放缓冲:100ms

这不是所有产品都必须达到的数字,但它能迫使团队面对瓶颈。假如 ASR 最终结果就用了 600ms,后面所有环节再怎么优化也无法达到目标;假如 LLM 已经很快,TTS 却要等整段文本完成,问题就在流式切分。

系统要分别记录:

type VoiceTrace = {
  traceId: string
  speechStartAt: number
  speechEndAt: number
  vadFinalAt: number
  asrFinalAt: number
  llmFirstTokenAt: number
  ttsFirstAudioAt: number
  playbackStartAt: number
  interruptedAt?: number
  completedAt?: number
}

不要只看平均值。P50 可能很漂亮,P95 却让一部分用户一直等待。实时语音要关注 P95/P99、网络类型、设备型号、语言和是否调用工具,因为这些因素会显著影响尾延迟。

三、VAD 决定系统什么时候接话

Voice Activity Detection 的工作看似简单:有人声就开始录,没有人声就结束。但真实对话里有停顿、吸气、思考声和背景噪音。结束得太早,用户一句话会被切成两段;结束得太晚,系统会迟迟不接话。

可以把 VAD 做成带状态的状态机:

IDLE
  └─ 检测到人声 → SPEAKING
SPEAKING
  ├─ 短暂静音 → MAYBE_END
  └─ 持续人声 → SPEAKING
MAYBE_END
  ├─ 恢复人声 → SPEAKING
  └─ 超过结束阈值 → ENDED

结束阈值不能固定适合所有人。安静环境、嘈杂环境、不同语言和不同说话速度都需要调参。短暂静音时不要立刻请求模型,可以先等待一个很短的 hangover 时间;但用户明确说“好了”“就这样”时,又可以通过语义信号提前结束。

VAD 还要和播放状态协同。系统正在说话时,麦克风仍然应该监听用户的声音,否则无法自然打断。可以使用回声消除降低扬声器声音被麦克风再次识别的问题,但不能把所有重叠声音都当成噪音过滤掉。

四、ASR 的 partial 结果不能直接当最终事实

实时 ASR 通常会先给出不稳定的 partial transcript,再给出 final transcript:

partial: 我想查一下
partial: 我想查一下退款
partial: 我想查一下退款进度
final:   我想查一下退款进度。

partial 适合更新界面或提前准备,但不能直接触发高风险工具调用。模型可以根据 partial 预热检索,却要等 final 或明确的确认信号后再提交退款、发送消息等动作。

文本修订也要有版本号:

type TranscriptEvent = {
  turnId: string
  revision: number
  text: string
  final: boolean
  timestamp: number
}

如果旧 revision 晚到,客户端和 Agent 状态机必须忽略它。实时系统中的消息顺序不能靠运气,所有关键事件都应该可排序、可丢弃、可重放。

五、LLM 和 TTS 要按可播放单元流式协作

把整段回答生成完再交给 TTS,通常会产生明显等待。更好的方式是按标点或语义边界切片:

LLM:退款申请已经提交。
TTS:立即合成并播放第一句

LLM:通常会在三个工作日内完成审核。
TTS:继续合成第二句

切片太小,语音会断裂,TTS 请求次数也会增加;切片太大,又失去低延迟优势。可以在句号、逗号和停顿处设置策略,并保留一小段缓冲,避免每个短片段都独立改变语调。

工具调用要单独处理。如果 Agent 判断需要查询订单,应该先用短语告诉用户“我查一下”,同时执行工具;工具返回后再生成最终答案。不要让模型长时间无声等待,也不要在工具失败时继续编造结果。

六、打断是核心能力,不是异常分支

用户打断有三种常见情况:

  1. 用户只是附和,例如“嗯”“对”。
  2. 用户补充信息,例如“我说的是上个月的订单”。
  3. 用户明确改变目标,例如“别查了,直接取消”。

系统首先要检测到打断,然后立即停止或淡出当前播放;接着取消尚未发送的 TTS 片段、标记旧生成任务失效,再把新语音作为当前 turn 处理。

async function interrupt(current: VoiceTurn, reason: string) {
  current.cancelled = true
  await tts.cancel(current.ttsRequestIds)
  await player.stop({ fadeOutMs: 40 })
  await llm.cancel(current.generationId)
  return createNextTurn({ reason })
}

取消不一定能撤回已经发给供应商的请求,所以所有异步结果都要带 turnId 或 generation ID。旧任务即使晚返回,也不能写入当前对话状态。

“嗯”这种短打断不一定意味着用户要抢回话轮,可以用 VAD、语义分类和播放位置综合判断。但在不确定时,宁可短暂停止并确认,也不要继续盖住用户的声音。自然对话首先是尊重对方说话的权利。

七、状态机比一堆布尔变量可靠

语音系统很容易出现 isListeningisSpeakingisProcessingisInterrupted 四五个布尔变量互相矛盾。建议用明确状态:

type VoiceState =
  | 'idle'
  | 'listening'
  | 'transcribing'
  | 'thinking'
  | 'speaking'
  | 'interrupted'
  | 'waiting_confirmation'
  | 'error'

每次状态转换都记录事件和原因:

speaking → interrupted:检测到用户人声,turnId=42
interrupted → listening:播放已停止,等待新 final transcript
thinking → waiting_confirmation:动作属于高风险操作

状态机的价值不是让代码看起来更正式,而是让那些“偶尔发生”的竞态条件变得可复现。没有状态记录,你只会得到一句“有时候它会自己重复说上一句”。

八、实时语音也要有隐私边界

麦克风数据比文本更敏感。系统应明确什么时候开始采集、什么时候上传、是否保存音频、ASR 文本保留多久,以及用户如何停止和删除。调试时不要默认保存完整音频,尽量记录时序指标和脱敏文本。

本地 VAD、回声消除和部分敏感信息识别可以减少不必要的上传,但端侧处理不代表数据天然安全。浏览器权限、系统录音指示、断线重连和后台录音状态都应该让用户看得见。

总结:快不是催出来的,是每一层都愿意让路

实时语音 Agent 最终拼的不是某一个模型的速度,而是整条管线有没有为“尽快反馈”和“随时打断”做设计。VAD 要及时判断,ASR 要区分 partial 和 final,LLM 要流式生成,TTS 要按可播放单元输出,播放器要能取消,旧任务结果要有版本保护。

我现在判断一个语音 Agent 是否成熟,不是听它能不能说出一段漂亮的话,而是观察三个瞬间:用户停下后它多久开始回应,用户插话时它能不能马上闭嘴,网络或工具失败时它会不会诚实地说明情况。

语音交互比文字更接近人与人的相处。一个总是抢话、装作听懂、长时间沉默的 Agent,即使模型能力很强,也很难让人愿意继续使用。把延迟拆成预算,把打断做成状态机,把不确定性和权限边界说清楚,系统才会从“能说话”走向“真的会交流”。

🤪 您也可以编辑此页: