`--num_pipelines` 默认是 1:多人用之前必须先动它
把 speech-to-speech 装好、speech-to-speech serve 起来、自己对着麦克风说两句都挺顺的时候,最容易忽略的一个参数就是 --num_pipelines。它躺在 module_arguments.py 里,默认值是 1。
默认 1 意味着什么?按仓库里这个参数的 help 说明:池里放的是若干个相互隔离的流水线实例,一个 uvicorn 服务监听 --port,把每个进来的客户端路由到下一个空闲流水线;最大并发 WebSocket 会话数就等于 num_pipelines,再多的连接会被拒绝。
所以「默认 1」不是「默认性能一般」,而是「默认只服务一个人」。你自己测的时候永远碰不到这个上限,第二个同事一连上来就碰到了。
一个 pipeline 单元里装的不是一个线程,是一整套组件
要理解为什么这个数字不能随手往大了写,得先看清楚一个「实例」的粒度。
按 Realtime Engine 的架构文档,每个会话从池里认领一个队列驱动的 PipelineUnit,这个单元里包含该会话自己的 RealtimeService、轮次追踪器和取消状态。--num_pipelines 的 help 说得更直白:每个流水线各有自己的 VAD / STT / LM / TTS handler 和对话状态。
再回忆一下这条流水线的形态——README 里描述的是 VAD → STT → LLM → TTS 四段级联,每个组件跑在自己的线程里、用队列相连。也就是说,把 --num_pipelines 从 1 改成 4,你复制的不是四个连接句柄,而是四套「VAD 加 STT 加 LLM 加 TTS」的组件与线程结构,外加四份互相隔离的对话状态。
这一点直接决定了扩容的成本结构:并发数在这里是按「整套组件」翻倍的,不是按「请求」摊薄的。至于翻倍之后机器上到底多占多少内存或显存,我们没有任何测量数据,也不打算给数字——仓库里能拿到的硬事实只有模型名字里自带的参数量(默认 STT 是 nvidia/parakeet-tdt-0.6b-v3,默认 TTS 是 Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice),由它只能推出权重体积的算术下限,实际占用必然更高,还要看后端实现。请以你自己机器上跑起来之后看到的实际占用为准。
超限之后长什么样:session_limit_reached
这是判断「我是不是撞上了这个默认值」最直接的证据。
服务端到客户端的协议事件里有一个 error 事件,架构文档举出的已知错误码里第一个就是 session_limit_reached,同时列出的还有 unknown_or_invalid_event、invalid_session_type、conversation_already_has_active_response(文档用的是「等」,所以这四个是我们手上的全部依据,不代表穷举)。当池子满了、新连接被拒绝时,你要找的就是 session_limit_reached 这一条。
由此可以推出两个很实际的工程动作:
- 客户端必须处理
error事件里的session_limit_reached,而不是把所有error一律当成「连接失败,重试」。区别在于:这个错误码不是网络抖动,重试多少次都没用,除非有人挂断。合理的产品行为是提示「当前会话已满」,而不是无限转圈。 - 排查时先看这个错误码,再怀疑网络和防火墙。如果服务本身能连、单人能说话、多人时后来者拿到的是
session_limit_reached,那问题就锁死在--num_pipelines上了,跟带宽、NAT、证书都没关系。
WebSocket 和 WebRTC 共用同一个池,不是各算一份
这条最容易想当然。先说清前提:WebRTC 传输不是默认就有的,按仓库的说明要装 speech-to-speech[webrtc] 这个 extra 才可用。
架构文档写得很清楚:WebRTC 会话是从同一个池里认领 pipeline unit 的;每个单元的 send loop 仍然是流水线输出队列的唯一消费者,把 PCM 交给传输层,由传输层按 20 ms 的 RTP 帧节奏发出。两种传输走的是同一套协议事件,差别只在音频通道——WebRTC 下 input_audio_buffer.append 会被拒绝并返回 invalid_event_for_transport,音频改走媒体轨(Opus,48 kHz),output_audio_buffer.clear 则是 WebRTC 独有的。
对容量规划的含义是:你不能按传输方式分别算并发。三个 WebSocket 客户端加两个 WebRTC 客户端,占的是五个流水线单元,不是两组各自算。做混合接入的场景尤其要注意这一点。
单元是「认领 / 释放」的,所以状态归零发生在换人时
顺着池化再往下看一层,会看到一个容易被忽略的收尾动作:打断机制里的 cancel_scope,它的 discarding 标志有三种清除时机,其中一种就是会话认领与释放时的 reset()(另两种是世代匹配的 response_done(generation),以及显式 response.create 触发的 new_response())。
这说明池化不是「开 N 个副本各跑各的」那么粗糙——单元在换人的时候会被显式复位,避免上一个会话遗留的取消状态影响到下一个人。理解这一点对写客户端有好处:会话之间的隔离是服务端保证的,你不需要自己想办法「清干净」;但反过来,你也别指望在同一个池里跨会话共享上下文。
那么,该设多大?
坦白说,仓库没有给任何容量建议,我们也没有部署过这个服务,所以下面给的是判断依据,不是推荐值。
第一步,数的是并发会话数,不是 QPS。 语音对话是长连接、独占一个流水线单元直到挂断。所以要问的问题是「同一时刻最多几个人在跟它说话」,而不是「一天有多少次调用」。演示场景里五个人轮流上前说话,只要前一个人的连接断了,1 也许就够;五个人各自挂着连接不放,那就得 5。
第二步,看 LLM 这一环在哪。 默认 --llm_backend 是 responses-api,默认模型走的是 OpenAI 的 gpt-5.4-mini——也就是说,开箱即用的配置里,最吃算力的 LLM 那一段其实不在你机器上。这种情况下抬 --num_pipelines,本机复制的主要是 VAD / STT / TTS 三段。反过来,如果你按 README 的全本地方案,把 LLM 换成本机 llama.cpp(docker compose 里那套就是 llama.cpp 在 8080、实时服务在 8765),或者用进程内的 transformers / mlx-lm 后端,扩容的成本结构就完全不同了。这是决定该不该往大了调的分岔点,也是唯一能在没有实测数据的情况下先想清楚的一层。
第三步,local 命令本来就是单人场景。 local 是把 serve 和自带的麦克风客户端在同一进程内用环回组合起来,永远绑环回地址。你在自己机器上用 local 玩,调 --num_pipelines 意义有限;真正要考虑它的是 serve。
第四步,多人用几乎一定意味着要暴露服务。 serve 默认绑 127.0.0.1,要让别人连上必须显式传 --host 0.0.0.0。这里必须原样带出仓库自己写的安全声明:这个服务端自身不做任何认证,也不做任何限流;官方的表述是只在受信网络里启用,或者把它部署在一个由你自己掌管访问控制的网关后面。同样的声明也适用于 --enable_llm_proxy(默认 False)。请把这句话当成硬约束,而不是可选建议——本文不会、也不能给出「这样配就安全了」的结论。
什么情况下动它没有用
这是本文最想让你记住的一半。
它不是延迟旋钮。 单个人说完一句话到听见回答之间的等待,跟池子大小没关系,那是另一套参数在管——VAD 侧的 --thresh(默认 0.6)、--min_silence_ms(默认 64)、--min_speech_ms(默认 384),以及 Smart Turn 那几个窗口(--speculative_reopen_ms 默认 800、--smart_turn_max_wait_ms 默认 2000)。顺便说清楚:这些毫秒值是流水线自己引入的可配置等待,不包含 STT 转写、LLM 首 token、TTS 首包这三段真正吃算力的时间,更不能把它们相加当成端到端延迟。
它也不是「侧边任务并发」的开关。 如果你的并发需求不是「更多人同时说话」,而是「一边语音对话、一边跑摘要或起标题这类文本任务」,那要看的是 --enable_llm_proxy:开启后服务把它配置的远端 LLM 也作为普通 OpenAI 兼容端点暴露出来,这类请求与语音对话并发,且不会被新的说话打断。它是无状态的(每次发完整消息列表),model 字段总会被覆写成服务端配置的 --model_name,客户端传的 API key 会被忽略,服务端持有的 key 永不到达客户端;要求远端后端,否则返回 501 并附原因。相关连接超时参数是 --llm_proxy_connect_timeout_s,默认 10.0。这里必须把仓库自己的安全声明再原样说一遍,一个字都不能少:这个服务端不做任何认证,也不做任何限流,只在受信网络上启用该代理,或者把服务部署在一个由你自己掌管访问控制的网关后面。README 举的网关例子是 s2s-endpoint 的计算副本——它只对「用 HF token 创建了会话的客户端」开放这些路径,按该 token 校验 API key,并按用户限流。
它不是「一个连接卡住了怎么办」的答案。 单元被认领后要等会话释放才回池,所以真正决定周转的是客户端有没有好好挂断。这一点属于你自己的客户端实现,跟参数无关。
别顺手混掉的两个邻居参数
module_arguments.py 里 --num_pipelines 的邻居里有一个 --enable_live_transcription,模块级默认是 True;而 VAD 侧还有一个名字很像的 --enable_realtime_transcription,默认是 False。这是两个不同的参数,分属不同文件,谈并发与开销时别把它们当成一个开关来讨论。
最后提一句坐标:这个仓库在 2026-08-09 的快照里 star 数是 11893。star 数只说明有多少人点过星,不说明它在你的场景里稳不稳、够不够用——这一点和 --num_pipelines 的默认值一样,得你自己按上面那几步去判断。
以上命令与参数均按官方参数语义引用,未逐项实测,以官方文档与 speech-to-speech serve -h 的实际输出为准。
延伸阅读
- 从一段 base64 PCM 到一段合成语音:六步数据流
- 5 个上行、15 个下行:Realtime 事件表怎么读
- session.update 深合并进 RuntimeConfig:配置是处理时现读的
本文依据 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 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。