speech-to-speech 是什么:一条能换掉每一段的语音代理流水线
如果你只想知道 speech-to-speech 是什么,一句话:它是一条语音代理流水线,把 VAD、STT、LLM、TTS 四段串起来,对外暴露一个兼容 OpenAI Realtime 的 WebSocket 接口,而这四段里的每一段都能用一个命令行参数换掉。
仓库是 github.com/huggingface/speech-to-speech,GitHub 上的一句话描述原文是 “Build local voice agents with open-source models”,仓库标注 Apache-2.0,主语言 Python,2026-08-09 这天的快照是 11893 star、1463 fork、136 个 open issue。star 数只说明有人在看,不说明它适合你,下面这些结构性的东西才决定它适不适合。
一、四段级联,四条线程,中间是队列
README 的 “How it works” 把流水线拆成四级:
- VAD——用 Silero VAD v5 检测语音边界与轮次切换;
- STT——转写用户这一轮,可选实时局部转写;
- LLM——生成回复,流式输出文本与工具调用;
- TTS——合成音频并流式送回客户端。
关键不在这四个名词,而在它们的连接方式:每个组件跑在自己的线程里,用队列相连。这句话是理解后面一切行为的前提。因为四条线程是并发推进的,所以「用户忽然插话了,正在生成的那一段该怎么办」不是一个能靠 if 解决的问题——仓库为此专门做了一个带世代计数的 CancelScope,流水线线程在每个流式 token 上检查 cancel_scope.is_stale(gen),cancel() 时世代递增让旧世代立刻过期,另有一个 discarding 标志负责丢掉「在 cancel() 与 response_done() 之间抵达」的残留输出。如果你把这套东西理解成「四个模块顺序调用」,看到 __RESPONSE_DONE__ 哨兵这类设计时就会一头雾水。
源码目录也是按这四段分的:src/speech_to_speech/ 下 96 个 Python 文件,VAD/、STT/、TTS/、LLM/ 各自成组,服务端全部塞在 api/openai_realtime/ 里,参数定义单独放在 arguments_classes/ 的 18 个 dataclass 文件里,一共 131 个 CLI 参数。参数多到这个量级,本身就是「每段可替换」的代价。
本节延伸
二、「能换掉每一段」具体换的是什么
换的入口只有三个参数:--stt、--llm_backend、--tts。README 的 Supported Components 表给了各级可选后端:STT 侧除了默认的 Parakeet TDT,还有走 Transformers 的 Whisper、Faster Whisper、Apple Silicon 上的 Lightning Whisper MLX 与 MLX Audio Whisper、以及走 FunASR 的 Paraformer;TTS 侧除了默认的 Qwen3-TTS,还有 Kokoro-82M、Pocket TTS、ChatTTS、MMS TTS;LLM 侧是 OpenAI 兼容 API(responses-api 与 chat-completions 两个后端)、本地 transformers、以及 macOS 上的 mlx-lm。有些后端内置,有些要装 extra,比如 pip install "speech-to-speech[faster-whisper]"、pip install "speech-to-speech[paraformer]"。
这里有个会咬人的行为,README 单独写明了:CLI 只为选中的后端构造配置,未激活后端的已知选项仍然被接受,但会带警告地忽略,JSON 配置里多余的非激活后端键同理。翻译成人话——你把 Kokoro 的参数写在了跑 Qwen3-TTS 的命令里,程序不会报错停下,只会警告一声照跑,而你以为自己改的那个东西根本没生效。排查这类「参数改了没反应」时,第一件事是回去确认这个参数属不属于你当前激活的后端。
想看某个组合的真实默认值,README 给的办法是 speech-to-speech serve -h;要看另一种组合的参数,选择器要放在 -h 前面,例如 speech-to-speech serve --stt mlx-audio-whisper -h。
本节延伸
三、三个开箱即用的反直觉
第一个:项目叫 local voice agents,但默认的 LLM 走的是云端。 README 给出了默认配置的等价完整命令,里面写着 --llm_backend responses-api、--model_name gpt-5.4-mini,前面还要 export OPENAI_API_KEY=...。也就是说 pip install speech-to-speech 之后直接 speech-to-speech serve,本地跑的只有 STT 和 TTS,LLM 那一环打的是 OpenAI 的 Responses API。要真正全本地,得自己换——README 的 Fully Local 一节给的做法是另起一个 llama.cpp 进程,再用 --responses_api_base_url "http://127.0.0.1:8080/v1"、--responses_api_api_key "" 把 LLM 指过去。密钥一律用环境变量占位,正文里、脚本里都别写死。
第二个:默认 STT 不支持中文。 README 的多语言矩阵写得很清楚,默认 STT nvidia/parakeet-tdt-0.6b-v3 覆盖的是 25 种欧洲语言。想做中文语音代理,必须换 STT,选 Whisper 系或 Paraformer(README 说 Paraformer 默认那个检查点偏中文)。README 给的中文示例是 --stt whisper-mlx --stt_model_name large-v3 --language zh。另外 --language 默认是 en,要做语言切换传 --language auto;--enable_lang_prompt 默认 False,README 的判断依据是大模型通常能从上下文推断语言,但对小模型这条显式指令可能有帮助。
第三个:--num_pipelines 默认是 1。 服务端每个会话从池里认领一个 PipelineUnit,池子默认只有一个单元——默认状态下只承载一个并发会话。你要做多用户部署,这是第一个要动的参数。它属于模块级参数(module_arguments.py);和它常常一起改的 --host(默认 127.0.0.1)与 --port(默认 8765)则来自服务端参数文件 realtime_server_arguments.py——两组参数不在一个文件里,翻源码时别找错地方。
本节延伸
四、那些毫秒数字,买的不是延迟,是误判成本
VAD 那层一共 19 个参数,其中大半带 _ms 后缀,也有不是毫秒的(触发阈值 --thresh 默认 0.6,采样率 --sample_rate 默认 16000)。那一串毫秒数很容易让人误以为是性能指标。它们不是。
--min_silence_ms 默认 64,是切分语音的最小静音间隔;--min_speech_ms 默认 384,是被视为有效语音的最小段长;--speech_pad_ms 默认 500,是触发前保留并前置拼上去的音频。真正体现设计思路的是这一组:
| 参数 | 默认 | 管什么 |
|---|---|---|
--min_speech_continuation_ms | 192 ms | 接续一个可重开轮次所需的活跃语音时长 |
--speculative_reopen_ms | 800 ms | 软结束轮次保持可重开的基础窗口 |
--smart_turn_max_wait_ms | 2000 ms | Smart Turn 判轮次不完整时的宽限期 |
--smart_turn_incomplete_delay_ms | 600 ms | 判不完整后延迟启动 STT/LLM 的时长 |
--unanswered_reopen_ms | 7000 ms | 对「尚未有任何助手输出」的轮次的兜底上限 |
Silero 判定「用户说完了」之后,STT 和 LLM 可以推测性地先开始干活——省时间,但用户如果只是停顿,这活就白干了。Smart Turn v3.2(--smart_turn 默认 True,关掉用 --no_smart_turn,阈值 --smart_turn_threshold 默认 0.5)做的就是给「白干」上一个成本可控的闸:判为完整的轮次立刻开工,提交前留 800 ms 的重开窗;判为不完整的先晾 600 ms 再启动昂贵计算,其输出继续被 2 秒宽限期门控。任一段延迟内语音恢复,轮次会被重开为一个更新的修订版,上一版的工作在到达用户之前就被丢弃。
同样的思路也体现在迟滞设计上:开新轮次和抢话要求满 384 ms 的活跃语音,而接续一个还没提交的旧轮次只要 192 ms——因为接续的风险小,只是把同一轮延长;开新轮或打断助手代价大,门槛就保持高。这个参数被硬钳制在 [100, min_speech_ms] 区间,你设一个比 min_speech_ms 还大的值不会生效。--unanswered_reopen_ms 也有约束:低于 speculative_reopen_ms 时无效,启用 Smart Turn 时被钳制到 smart_turn_max_wait_ms。
必须说死的一点:上面这些毫秒值全部是流水线自己引入的、可配置的等待,不包含 STT 推理、LLM 首 token、TTS 首包这三段真正吃算力的时间。把它们加起来去得出一个「端到端延迟是多少毫秒」的结论,是彻底错误的读法。那三段取决于你的模型和硬件,本文没有任何测量数据。README 自己的定性判断是 LLM 是流水线里计算最重、延迟最高的组件,所以它才专门给了 --responses_api_reasoning_effort none 这种「为压语音延迟而关推理」的旋钮——这是 README 的定性判断,本站没有任何测量数据可以佐证或反驳它。
顺带一个单位陷阱:--realtime_processing_pause 默认 0.5,单位是秒,其余带 _ms 后缀的才是毫秒。
本节延伸
五、对外那一层:为什么是 Realtime 协议
服务端是一个 FastAPI/uvicorn 应用,同时暴露 WebSocket 与 WebRTC 两种传输(WebRTC 需要装 speech-to-speech[webrtc] extra)。选择兼容 OpenAI Realtime 的意义在于:客户端侧的事件语义是现成的,session.update 改配置、response.create 触发生成、response.cancel 取消,服务端回 response.output_audio.delta 送音频块、response.output_audio_transcript.delta 送助手转写增量。
两个细节值得记住。一是 session.update 的配置是深合并进一个共享的 RuntimeConfig,由 VAD、LLM、TTS 在处理时读取——不是重启生效,是运行中现读。二是 response.created 不是在请求发起时发的,是在第一个出站音频块出来时才发,你写客户端「正在思考」状态时得考虑这个时机差。助手转写这条流还有个迁移点:以前消费块级 done 的客户端要把实时渲染改到 delta 上,把 done 只当作定稿。
local 命令则把服务和自带的麦克风客户端组合在一个进程里,走环回。三个命令的分工是:serve 起服务,talk --url <完整 realtime url> 起自带客户端连上去说话,local 两者合一。顺便提醒,--mode 已废弃且很快会停止工作,网上不少旧教程还在用它——迁移期内 --mode realtime 等价于 serve、--mode local 等价于 local,都会打警告,其余取值已被移除。
本节延伸
六、你该从哪一环开始改
按处境倒推,比按参数表选省事得多:
- 要中文——第一件事换 STT,其它先别动;
- 要全本地——第一件事换 LLM 后端,因为默认那一环在云上;
- 要多人同时用——第一件事调
--num_pipelines; - 只是想先看看它长什么样——什么都别改,README 的 Quickstart 就三条命令,装完
speech-to-speech serve,另开一个终端speech-to-speech talk --url ws://127.0.0.1:8765/v1/realtime。
有两类情况这套东西可能不适合你。一是你只要「一段音频转成一段文字」,那你要的是 STT 库,不是一条带轮次管理和打断处理的流水线,这一整套复杂度是为对话准备的。二是你打算把它直接开到公网上:serve 默认绑 127.0.0.1,要暴露必须显式传 --host 0.0.0.0;而 --enable_llm_proxy(默认关闭)会把配置好的远端 LLM 也作为普通 OpenAI 兼容端点暴露出来,README 的原话是服务端自己不做任何认证,也不做任何限流,只应在受信网络上启用,或者把服务部署在一个自己掌管访问控制的网关后面。这句声明本文原样带出,不做任何「这样配就安全了」的引申。
至于装的时候会遇到什么坑——Qwen3-TTS 的 CUDA wheel 版本错配、DeepFilterNet 与 Pocket TTS 的 numpy 版本互斥,README 各自开了小节,那是另一篇的事。
本节延伸
- serve、talk、local:三个命令分别在什么场景
- —enable_llm_proxy:不认证、不限流,官方自己说的
- Qwen3-TTS 装不上:那个 wheel 默认盯的是 CUDA 12.8
专题全部内容
本专题共 40 篇,按下面七组读。想直接动手的从「安装与部署」开始,想搞清楚它为什么这么设计的从「VAD 与回合判定」和「工具调用与打断」开始。
架构与概览
四个线程三道队列,这是理解后面一切延迟与打断行为的前提。
- VAD 到 STT 到 LLM 到 TTS:四个线程、三道队列
- 名字里写着 local,默认配置的 LLM 却在云上
- 从一段 base64 PCM 到一段合成语音:六步数据流
- 96 个 Python 文件怎么读:按目录找到你要改的地方
--num_pipelines默认是 1:多人用之前必须先动它
安装与部署
两个真会卡住人的坑都在这组:CUDA wheel 版本与 numpy 版本互斥。
- 从 pip install 到第一次 serve:默认装了些什么
- Qwen3-TTS 装不上:那个 wheel 默认盯的是 CUDA 12.8
- DeepFilterNet 要 numpy 小于 2,Pocket TTS 要大于等于 2
- 七个 pip extras:哪些该装,哪些别碰
- docker compose up 起来的是两个服务,不是一个
- 断网也能跑:HF_HUB_OFFLINE=1 之前要先做的事
命令与配置
三个命令的分工、14 个默认参数,以及那个不报错的坑。
- serve、talk、local:三个命令分别在什么场景
- 一条 serve 背后的 14 个默认参数,逐个说清楚
- 老教程里的 —mode 为什么跑不通了
--mac-optimal-settings做了什么,又被什么覆盖- 参数写错了不报错:非激活后端的选项只会带个警告被忽略
- 默认绑 127.0.0.1 是有道理的:改成 0.0.0.0 之前想清楚
VAD 与回合判定
那些毫秒数买的不是延迟,是误判成本——这一组讲透这句话。
- 19 个 VAD 参数逐个讲:默认值背后的意图
- Smart Turn:先干活再决定要不要认账
- 384 与 192:为什么接续一轮的门槛只有开新轮的一半
- 800、2000、7000:三个重开窗口的层级与钳制关系
- 切得太碎怎么办:—short_segment_merge_ms 的补救逻辑
- 两个长得很像的实时转写开关,别调错
- 语音对话的延迟预算怎么拆:哪几段是配置,哪几段是算力
Realtime 协议与传输
要对着这个 API 做客户端或设备,从这组开始。
- 5 个上行、15 个下行:Realtime 事件表怎么读
- 老客户端要改:转写从 done 迁到 delta
- WebRTC 模式的三处不一样,尤其那个被拒绝的事件
- 什么情况下你非得自己架一台 TURN
- —enable_llm_proxy:不认证、不限流,官方自己说的
- session.update 深合并进 RuntimeConfig:配置是处理时现读的
工具调用与打断
这一组的并发取消范式可以直接搬进你自己的项目。
后端选型
中文场景务必先看「默认 STT 只覆盖 25 种欧洲语言」那篇。
本文依据 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 的实际输出为准。
许可条款请以官方 LICENSE 原文为准,本文不构成法律意见。各模型权重另有各自许可,请以各模型页面为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。