语音对话的延迟预算怎么拆:哪几段是配置,哪几段是算力

2026-08-09

做语音代理的人几乎都会遇到同一句反馈:「我说完了,它半天不吭声。」

这句话没法直接排查,因为”半天”这段时间里发生的事情性质完全不同。有一部分是流水线自己规定要等的——参数里写着多少毫秒就等多少毫秒,你改个数字它就变;另一部分是模型在算——STT 在转写、LLM 在出第一个 token、TTS 在合第一个包,这部分跟你的硬件和模型绑死,改配置一点用没有。

把这两类混在一起,就会出现最常见的两种误操作:明明是显卡跑不动,却在那里调 VAD 阈值;或者明明是等待窗口设得保守,却去换更小的模型。所以这篇只干一件事——把一次语音回合切开,标清楚每一段归谁管

依据是 huggingface/speech-to-speech 仓库(2026-08-09 快照下 star 11893)里 src/speech_to_speech/arguments_classes/ 的参数默认值,以及 src/speech_to_speech/api/openai_realtime/README.md 这份 Realtime Engine 架构文档。先把话说死:下文出现的所有毫秒数都是参数默认值,不是任何测量结果。我们没有 pip install 过它,没有启动过服务,也没有对着麦克风说过一句话。

一次回合大致经过哪几段

按 README 的「How it works」,这条流水线是 VAD → STT → LLM → TTS 四段级联,每个组件跑在自己的线程里,用队列相连。从”用户闭嘴”到”用户听见第一个音”,中间串着这么几件事:

  1. VAD 判定这段静音够长了,语音段结束(受 --min_silence_ms 影响)
  2. Smart Turn 判定这一轮是不是真说完了,并按判定结果引入相应的等待
  3. STT 把这一轮转写出来
  4. LLM 吐出第一个 token
  5. TTS 合出第一个音频包
  6. 音频通过 WebSocket 或 WebRTC 传到客户端

第 1、2 段是配置段,仓库里有明确默认值;第 3、4、5 段是算力段,取决于你选的模型和跑它的机器,仓库没给任何数据,我们也没有;第 6 段取决于你的网络和传输方式。

配置段:能查到默认值的那几个数字

先把与回合时序直接相关的默认值列出来,全部来自 vad_arguments.py

参数默认值管什么
--min_silence_ms64用于切分语音的最小静音间隔
--min_speech_ms384被视为有效语音的最小语音段长度
--min_speech_continuation_ms192接续一个可重开轮次所需的活跃语音长度
--speech_pad_ms500VAD 触发前保留并前置拼到语音段上的音频长度
--speculative_reopen_ms800软结束的 Realtime 轮次保持可重开的时长
--smart_turn_incomplete_delay_ms600判为不完整后,延迟 STT 与 LLM 处理的时长
--smart_turn_max_wait_ms2000判为不完整时的推测重开宽限期
--unanswered_reopen_ms7000对”尚无任何助手输出”的推测轮次重开时长的兜底上限

另外两个不带 _ms 但同样影响判定的:--thresh 默认 0.6(VAD 触发阈值,越高越要求高置信度才判定为语音),--smart_turn_threshold 默认 0.5(Smart Turn 判定”轮次完整”的概率阈值,按 help 的语义,越高越倾向在含糊停顿处继续等待)。

顺带提醒一个单位陷阱:--realtime_processing_pause 默认 0.5,单位是,不是毫秒。这一栏里只有带 _ms 后缀的才是毫秒。

★★ 这几个毫秒数买的不是延迟,是误判成本

这是全篇最想让你带走的一句话。

按 README 的「Smart Turn endpointing」一节,Silero VAD 判定语音段结束之后,STT 和 LLM 的工作可以推测性地先开始——不等确认,先干。Smart Turn v3.2(来自 pipecat-ai/smart-turn-v3 的 CPU ONNX 模型,不是这个仓库自己训的)用当前轮次的内容与韵律来校验 Silero 那个”说完了”的判断,然后分两条路走:

  • 判为完整:立刻开始处理,在提交输出前使用 --speculative_reopen_ms(默认 800 ms)这个窗口
  • 判为不完整:先等 --smart_turn_incomplete_delay_ms(默认 600 ms)再启动 STT / LLM,其输出继续被 --smart_turn_max_wait_ms(默认 2 秒)门控

关键在第三条:这两段延迟里任意一段内语音恢复了,现有轮次会被重开为一个更新的修订版,累积的音频重新发出,上一修订版的工作在到达用户之前就被丢弃

于是逻辑就清楚了。抢先开工能省时间,但用户只是喘了口气的话,这份工就白干了——而”白干”在语音场景里不是省不省电的问题,是可能把半句话播给用户听。Smart Turn 干的事,是给这份白干上一个成本可控的闸:有把握说完了就少等一点(800 ms 的提交窗),没把握就先晾 600 ms 再开工、最多晾 2 秒。

所以调这几个参数时,你要问的不是”能不能更快”,而是”我更怕哪种错”

  • 用户说话带长停顿(念数字、报地址、边想边说),被切一半更难受 → 按 help 语义,--smart_turn_threshold 调高会更倾向于继续等待
  • 场景是短促指令、催单式对话,宁可偶尔切早 → 反方向
  • 想彻底不要这层判定:Smart Turn 默认是开的(源码里 smart_turn: bool = True),关掉要传 --no_smart_turn

具体调到多少,仓库没给推荐值,我们也不会瞎编一个。

三个重开窗口的层级关系

--speculative_reopen_ms(800)、--unanswered_reopen_ms(7000)、--smart_turn_max_wait_ms(2000)这三个经常被当成三个平行旋钮,其实它们之间有约束:

  • --unanswered_reopen_ms 低于 --speculative_reopen_ms 时无效
  • 启用 Smart Turn 时,--unanswered_reopen_ms 被钳制到至少 --smart_turn_max_wait_ms,让轮次在整个宽限期内都保持可重开

这就是判断依据:如果你把 --unanswered_reopen_ms 调到一个很小的值却发现”没反应”,别怀疑是不是没生效——按文档语义,它本来就会被这两条规则托上去。7000 这个默认值的定位是兜底上限,管的是”还没有任何助手输出”的那类轮次,不是常规路径上每次都要等的时间。

同类的钳制还有一处。--min_speech_continuation_ms(默认 192)被硬钳制在 [100, min_speech_ms] 区间内,也就是说你设一个大于 --min_speech_ms 的值不会生效。这个参数与 --min_speech_ms(默认 384)的分工是:开新轮次、抢话,始终要求满 384 ms 的活跃语音;接续一个还在重开窗口里、尚未提交的软结束轮次,只要求 192 ms。门槛做低一半的理由很实在——接续只是把同一轮延长,风险小;开新轮或打断助手说话代价大,门槛就得保持高。默认与 help 里明写的推荐搭配就是 192 配 384。

还有一个容易被忽略的:--min_silence_ms 默认才 64 ms,切得相当碎。仓库为此准备了 --short_segment_merge_ms(默认 0,即关闭),开启后连续的、每段都短于 --min_speech_ms 的 VAD 段会被暂存缝合,help 明说它在 --min_silence_ms 取值很低时有用;但活跃语音短于 100 ms 的碎片永远不会被暂存

算力段:仓库自己都说这是大头

README 在「LLM Backends」一节的定性很直接:LLM 是流水线里计算最重、延迟最高的组件,一次大模型前向就可能主导端到端响应时间,所以按硬件与延迟预算选后端很重要。这是文档观点,不是我们的测量。

顺着这个观点,仓库给了一个明确的权衡动作:--responses_api_reasoning_effort none,用于在”chat-template 标志无效”的服务商上关掉推理,README 给的示例注释就写着这是为了低语音延迟。道理不难懂——模型多想几秒,用户听到的就是干等。

还有一个默认值值得点破:开箱即用的默认配置里,--llm_backendresponses-api--model_namegpt-5.4-mini。项目描述写的是 “Build local voice agents with open-source models”,但默认只有 STT 和 TTS 在本地,LLM 那一环打的是云端。这意味着你的默认延迟预算里天然含着一次公网往返。要把这段收回本地,README 给的最省事做法是把 LLM 放到独立的 llama.cpp 进程,再用 --responses_api_base_url "http://127.0.0.1:8080/v1" 指过去。

至于 STT 和 TTS 这两段花多少时间——默认 STT 是 Parakeet TDT(CUDA / CPU 上是 nvidia/parakeet-tdt-0.6b-v3,Apple Silicon 上走 MLX 变体),默认 TTS 是 Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice,我们手上关于它们的硬事实只有模型名里自带的参数量。推理耗时我们一个数都没有,也不打算给区间。

★★ 为什么这些毫秒绝对不能相加

看到上面那张表,很多人第一反应是把 64 + 384 + 800 + 600 加一加,宣布”端到端约多少毫秒”。这个结论是错的,而且错得挺严重。理由有三条:

第一,它们压根不是同一条路径上的串联项。 完整轮次和不完整轮次是两条互斥的分支,800 和 600/2000 不会同时发生在一次回合里。--unanswered_reopen_ms 管的是特定状态下的兜底上限,正常提交的轮次根本走不到它。

第二,这些等待与推理是重叠的,不是排队的。 推测执行的意思就是 STT/LLM 在等待窗口内已经在跑了;每个组件各自一个线程、用队列相连,几段工作是并发推进的。把并发的东西按串行相加,加出来的数没有物理含义。

第三,最要命的是——这几个数字里一个字节的模型推理时间都不含。 STT 转写、LLM 首 token、TTS 首包,这三段真正吃算力的时间取决于你的模型与硬件,仓库没给,我们更没有。用配置默认值之和冒充端到端延迟,等于把最大的一块直接抹掉了。

所以正确的口径是:这些默认值告诉你流水线愿意主动等多久,不告诉你它要算多久。

那怎么给自己的部署量出分段

不能相加,但可以分段观测——协议事件本身就是天然的分界桩。Realtime 服务端会往客户端发这些事件(以下均为架构文档列出的事件名,原样使用):

  • input_audio_buffer.speech_stopped:用户语音段结束
  • conversation.item.input_audio_transcription.completed:用户这一轮的最终转写
  • response.created在第一个出站音频块时发出
  • response.output_audio.delta:TTS 的 base64 PCM 音频块
  • response.output_audio_transcript.delta:助手转写的增量后缀

在客户端记下这几个事件的到达时刻,你就能把”用户闭嘴 → 转写落定”和”转写落定 → 第一个音出来”分开看,而不是笼统地喊慢。

这里有一个坑必须提前说破:response.created 不是在请求发起时发的,是在第一个音频块出来时才发。如果你按它来点亮”正在思考”,那个状态永远来不及显示;它实际上标的是”开始出声”这条线。同理,助手文字要实时渲染就订 response.output_audio_transcript.deltaresponse.output_audio_transcript.done 只当定稿用——文档对老客户端的迁移指引写得很明确,以前消费块级 done 的客户端必须把实时渲染改到 delta 上。

另外,重开机制意味着某些轮次的工作会被丢弃重来。架构文档里的取消机制是靠 cancel_scope.generation 这个世代计数器做的:handler 在响应开始时捕获当前世代,在每个流式 token 上检查 cancel_scope.is_stale(gen),过期就提前中止;cancel_scope.discarding 置位期间,send loop 会丢掉不属于当前世代的输出,直到世代匹配的 __RESPONSE_DONE__ 哨兵到达。理解这套机制对读延迟数据很重要——你统计到的某次”特别慢”,可能是一次被重开的轮次,前一个修订版的工作根本没到用户耳朵里。

什么情况说明问题不在配置段

最后给几条反向判据,帮你尽早停止调参:

  • 改了配置段参数,感觉毫无变化:先确认参数是不是被钳制了(--min_speech_continuation_ms[100, min_speech_ms]--unanswered_reopen_ms 的两条约束都会让改动”消失”)。另外,CLI 只为选中的后端构造配置,未激活后端的已知选项仍然被接受,但会带警告地忽略——写错了后端专属参数,程序不会报错停下,只会警告后照跑。这一条足够解释一大半”我明明改了”。
  • 只有多人同时用时才慢:那不是等待窗口的事。--num_pipelines 默认是 1,每个会话从池里认领一个 PipelineUnit,池子只有一个的时候,第二个人就是在排队。这是容量问题。
  • in_response / response_pending 这类状态长期挂着不动:按打断机制的描述,response_pending 指的是模型请求已排队但还没有任何输出。卡在这个状态上,方向应该往上游 LLM 服务去查,不是往 VAD 参数去查。
  • 你要的是中文而默认 STT 一直不对:默认 STT(Parakeet TDT)覆盖的是 25 种欧洲语言。做中文语音代理必须换 STT(Whisper 系或 Paraformer),这不是延迟问题,是能力问题。

再提一句部署侧:serve 默认绑定 127.0.0.1(默认端口 8765),要暴露到网络必须显式传 --host 0.0.0.0。如果你顺手开了 --enable_llm_proxy,README 写得很清楚:服务端自己不做任何认证,也不做任何限流,只应在受信网络上启用该代理,或者把服务部署在一个自己掌管访问控制的网关后面。这句原文照抄给你,怎么落到你自己的环境里得你自己评估。

延伸阅读


本文依据 speech-to-speech 官方仓库(github.com/huggingface/speech-to-speech)的 README、 src/speech_to_speech/arguments_classes/ 下的参数定义与 Realtime Engine 架构文档整理,核对日 2026-08-09。 本文内容为仓库源码与文档口径,我们没有安装、部署或调用过该服务,文中毫秒值均为参数默认值而非实测延迟。 参数与默认值随版本变动,请以 speech-to-speech serve -h 的实际输出为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。