VAD 到 STT 到 LLM 到 TTS:四个线程、三道队列

2026-08-09

大部分人读 speech-to-speech 的 README,读到「VAD → STT → LLM → TTS」这一行就以为看懂了:不就是四个模型串起来嘛。但真正决定这个项目行为的,是 README 里紧跟着的那半句——每个组件跑在自己的线程里,用队列相连

这半句不是实现细节,而是整套设计的地基。它解释了为什么这个项目要发明一个世代计数器,为什么 response.created 事件发得比你想的晚,为什么一堆毫秒参数管的其实不是延迟。这篇就把这条流水线按线程和队列拆开讲一遍,目标是:下次你遇到问题,能自己判断该去看哪一段,而不是在四个模型里瞎猜。

(先说清底线:本文全部内容来自仓库源码与官方文档口径。我们没有 pip install 过它,没有启动过服务,也没有对着麦克风说过一句话。文中所有毫秒值都是参数默认值,不是任何实测结果。)

四段各自在干什么

按 README 的 “How it works” 一节,这条级联是这样分工的:

职责(README 原文转述)默认后端
VADSilero VAD v5,检测语音边界与轮次切换内置,全平台
STT转写用户这一轮,可选实时局部转写Parakeet TDT(nvidia/parakeet-tdt-0.6b-v3
LLM生成回复,流式输出文本与工具调用OpenAI 兼容 API(responses-api
TTS合成音频并流式送回客户端Qwen3-TTS(Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice

每一级都有多个可互换后端,用 --stt--llm_backend--tts 三个参数选。README 明说代码是为「易于修改」设计的。

这里有个反直觉的地方值得先点破:项目描述写的是 “Build local voice agents with open-source models”,但 README 给出的默认等价命令里,--llm_backend responses-api--model_name gpt-5.4-mini——开箱即用的配置里,只有 STT 和 TTS 在本地,LLM 那一环默认打的是云端 OpenAI。想要真正全本地,得自己把 --responses_api_base_url 指到本地的 vLLM 或 llama.cpp 上去。这不是项目在忽悠,README 的 “Fully Local” 一节把怎么改写得很清楚,只是「默认 ≠ 本地」这件事,很多人是装完之后才发现的。

三道队列:数据到底在哪几个地方交接

仓库里 src/speech_to_speech/api/openai_realtime/README.md 这份架构文档给了一条六步数据流。把它按队列重新排一遍,四段之间的接缝就全露出来了:

第一道:recv_audio_chunks_queue(客户端 → VAD)

客户端发 input_audio_buffer.append,内容是 base64 PCM。RealtimeService 解码、重采样到 16 kHz、切成 512 采样点的块,塞进这道队列交给 VAD。注意这一步已经做了格式归一——不管你从哪种传输进来,进 VAD 的都是统一采样率的定长块。

第二道:text_output_queue(控制面与文本面)

这道队列最杂,也最关键。VAD 检测到语音边界,在这里发 speech_started / speech_stopped;STT 的输出经 TranscriptionNotifier 在这里发 transcription.delta / transcription.completed;LLM 产出文本与可选的工具调用之后,LMOutputProcessor 把输出劈成两路,其中 {"type": "assistant_text", "text": ..., "tools": [...]} 这一路也进这道队列。

「劈两路」是理解整套事件流的钥匙:干净文本给 TTS 去合成,助手文字和工具调用走 text_output_queue。所以在协议层面,助手说的话和助手发出的声音是两条事件流——前者是 response.output_audio_transcript.delta,后者是 response.output_audio.delta。你在客户端看到文字和听到声音不同步,先想想这是两条流,而不是某个模型出了问题。

第三道:send_audio_chunks_queue(TTS → 客户端)

TTS 把 PCM 块写进这里。路由器的异步 _send_loop 同时抽干这道队列和 text_output_queue,把 PCM 编码成 response.output_audio.delta 事件,把内部消息翻译成协议事件。

这个 _send_loop 是唯一的出口。所有面向客户端的东西都从这一个地方出去,这个设计决定了下一节要讲的取消机制能不能成立。

顺带一提第六步:session.update 事件会深合并进一个共享的 Pydantic 模型 RuntimeConfig,由 VAD(回合检测阈值)、LLM(instructions、tools)、TTS(voice)在处理时读取。也就是说改配置不需要重启,是处理时现读的。

并发带来的麻烦,和它的解法

四段跑在四个线程里,好处是显然的:STT 还在转上一句的时候,VAD 已经在听下一句了。代价也同样明显——当用户中途插话,已经在跑的那些线程该怎么收场?

这个项目的答案不是「设个取消标志再等一等」,而是一个 CancelScope 对象,管两样东西:

  • 世代计数器 cancel_scope.generation。流水线线程(LLM、TTS)在每个响应开始时捕获当前世代,并在每个流式 token 上检查 cancel_scope.is_stale(gen)。调用 cancel() 时世代递增,所有更早的世代立刻过期。架构文档对这一点的原话是 no timing games required——不需要玩时序把戏。
  • 丢弃标志 cancel_scope.discarding。由 cancel() 置位,被 _send_loop 检查,用来丢掉「在 cancel()response_done() 之间抵达的、来自被取代世代」的输出。

它替换的是旧的双信号模式(一个 cancel_response Event 加一个 discard_stale_output 布尔值)。为什么非换不可?因为在多线程 + 队列的结构里,「取消」这个动作发出去的瞬间,队列里还躺着一堆上一轮的音频块。你没法靠时间窗判断哪些该丢——世代号可以,它是单调的、自解释的。

这里还有一条兜底规则特别值得记:来自当前世代的输出永远放行。文档专门解释了原因,举的例子是「一个被取代的推测轮次,它的 TTS 从来没发出过 __RESPONSE_DONE__ 哨兵」——清除信号丢了,如果没有这条兜底,新响应会被残留的丢弃窗口静默吞掉。这是并发系统里最难查的那类故障。

另外两个标识符也建议记住,排查时会用到:in_response(响应处于活跃)和 response_pending(模型请求已排队但还没有任何输出)。在打断被允许的前提下(turn_detection.interrupt_response,经 RuntimeConfig.interrupt_response_enabled 读取,默认 true),这两种状态都会触发取消;而在没有活跃响应时不会cancel_scope.cancel()——这是防止在没有 __RESPONSE_DONE__ 来清除的情况下把丢弃守卫置位。

那些毫秒参数,买的是误判成本

VAD 这一段的参数(定义在 src/speech_to_speech/arguments_classes/vad_arguments.py)经常被误读成「延迟旋钮」。挑几个和四段接缝直接相关的看:

参数默认值管什么
--min_silence_ms64用于切分语音的最小静音间隔
--min_speech_ms384被视为有效语音的最小语音段长度
--min_speech_continuation_ms192接续一个可重开轮次所需的活跃语音长度
--speech_pad_ms500VAD 触发前保留并前置拼上的音频长度
--speculative_reopen_ms800软结束轮次保持可重开的基础窗口
--smart_turn_max_wait_ms2000Smart Turn 判不完整时的宽限期
--unanswered_reopen_ms7000对「还没有任何助手输出」的轮次的兜底上限

这些值绝不能相加。 它们是流水线自己引入的、可配置的等待,不包含 STT 推理、LLM 首 token、TTS 首包这三段真正吃算力的时间。那三段取决于你的模型和硬件,README 没给任何指标,我们也没有任何测量数据。把这几个默认值加起来当成端到端首响应时间,得到的是一个凭空造出来的数。

那它们买的是什么?是误判成本。Silero 判定「说完了」之后,STT 和 LLM 的工作可以推测性地先开始——省时间,但用户只是停顿的话就白干了。Smart Turn(--smart_turn 默认 True,用的是外部模型 pipecat-ai/smart-turn-v3 的 CPU ONNX 检查点)就是给这个「白干」上闸:判为完整的轮次立刻开工,提交前用 800 ms 的窗口;判为不完整的先晾 --smart_turn_incomplete_delay_ms(默认 600)再启动 STT / LLM,输出继续被 2 秒的 smart_turn_max_wait_ms 门控,而这段 600 ms 是跑在 2 秒之内的。任一段延迟内语音恢复,轮次被重开为一个更新的修订版,上一修订版的工作在到达用户之前就被丢弃。

迟滞那一对(384 / 192)也是同一个思路:开新轮或抢话代价大,门槛保持 min_speech_ms;接续一个已经存在的可重开轮次风险小,只要求 min_speech_continuation_ms。这个参数被硬钳制在 [100, min_speech_ms] 区间——你设一个大于 384 的值不会生效,这一点 help 文本里写着,但很容易被忽略。

三个重开窗口之间还有两条约束关系,调参前必须知道:unanswered_reopen_ms 低于 speculative_reopen_ms 时无效;启用 Smart Turn 时它被钳制到至少 smart_turn_max_wait_ms

拿这张架构图怎么定位问题

讲机制的意义在于能做判断。给几条按队列边界倒推的路子:

现象落在哪一段? 先看事件。有 input_audio_buffer.speech_started 说明音频进到了 VAD;有 conversation.item.input_audio_transcription.completed 说明 STT 出结果了;有 response.output_audio_transcript.delta 说明 LLM 在出文本;有 response.output_audio.delta 说明 TTS 在出音。哪一类事件断在半路,问题就在那道队列的上游。

别把 response.created 当成「开始思考」。 架构文档写得很明确:它是在第一个出站音频块时发出的(响应进入 in_progress),不是请求发起时。你要在 UI 上做「正在思考」的态,得另找依据。

改了参数没反应? README 里有条容易踩的行为:CLI 只为选中的后端构造配置,未激活后端的已知选项仍然被接受,但会带警告地忽略,JSON 配置同理。也就是说你把某个后端专属参数写在了别的后端下面,程序不会报错停下,只会警告一声照跑。想确认某个组合到底认哪些参数,用 speech-to-speech serve -h;要看另一种组合,把选择器放在 -h 前面

speech-to-speech serve --stt mlx-audio-whisper -h

多个会话卡住? 每个会话从池里认领一个队列驱动的 PipelineUnit,里面包含该会话的 RealtimeService、轮次追踪器与取消状态。池子大小由模块级参数 --num_pipelines 决定,默认只有 1。这个默认值意味着开箱状态下并不是为多路并发准备的。

要判断该换哪一段的后端? README 自己给了定性判断:LLM 是流水线里计算最重、延迟最高的组件,一次大模型前向就可能主导端到端响应时间。这是文档观点,不是我们的测量结论;但它至少告诉你,优化次序应该从 LLM 那一环开始想,而不是先去抠 VAD 的毫秒数。

服务端与暴露方式

serve 命令默认绑定 127.0.0.1,要暴露到网络必须显式传 --host 0.0.0.0local 命令永远绑环回,并把自带客户端连到 ws://127.0.0.1:<port>/v1/realtime。默认服务地址是 ws://localhost:8765/v1/realtime。仓库的 docker compose up 会同时起一个跑 Gemma 4 的 llama.cpp 服务和实时服务,暴露 80808765 两个端口——这两个端口别记反,8765 是 Realtime,8080 是 compose 里的 llama.cpp。

涉及暴露就必须原样带出官方的安全声明。README 在讲 --enable_llm_proxy 时写得毫不含糊:服务端自己不做任何认证,也不做任何限流,只在受信网络上启用该代理,或者把服务部署在一个自己掌管访问控制的网关后面。这句话对 --host 0.0.0.0 同样是提醒。我们没有部署过这个服务,也不会给出「这样配置就安全了」的结论——访问控制得由你自己的环境负责。

最后

这个项目在 GitHub 上标注 Apache-2.0,2026-08-09 的快照是 11893 star(star 数不说明质量、稳定性或是否适合你的场景,只是个时间锚点)。README 里说它作为数千台 Reachy Mini 机器人的对话后端在生产环境运行——这是文档的陈述,不是性能指标。

真正值得从这个项目里带走的,不是某个默认值,而是那套结构:四段职责单一的组件、三道明确的队列边界、一个单调递增的世代号。哪怕你不用它的模型,这个骨架也是可以照着搭的。

延伸阅读


本文依据 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?报名体系课或加入会员,照着学、照着用。