什么情况下你非得自己架一台 TURN

2026-08-09

先说结论方向:绝大多数人一开始纠结的 ICE、STUN、TURN,在 speech-to-speech 这套流水线里其实只跟一条传输路径有关。走错路的人比配错参数的人多得多,所以第一步不是查 NAT,是先确认自己到底在哪条路上。

第一步:确认你真的在 WebRTC 这条路上

speech-to-speech 的实时服务是一个 FastAPI / uvicorn 服务,同时暴露 WebSocketWebRTC 两种传输,两种传输共用同一套事件协议,也从同一个池里认领会话所用的 PipelineUnit

WebSocket 那条路是一条普通的长连接:客户端把音频以 base64 PCM 通过 input_audio_buffer.append 事件发上去,服务端解码、重采样到 16 kHz、切成 512 采样点的块喂给 VAD。这条路上不存在打洞问题,也就谈不上 ICE。

WebRTC 那条路才需要 ICE。判定动作有三个,任意一个成立就说明你确实在 WebRTC 模式:

  1. 安装时用的是 pip install "speech-to-speech[webrtc]"——WebRTC 传输需要这个 extra,默认安装并不带。
  2. 客户端建连方式是 POST /v1/realtime/callsContent-Type: application/sdp,发的是一个 SDP offer。
  3. 你在会话里发了 input_audio_buffer.append,服务端拒绝了它并返回 invalid_event_for_transport。这个错误码本身就是判定依据:WebRTC 模式下音频走媒体轨,不走事件通道,客户端的写法还停在 WebSocket 的习惯上。

还有一处时机差异值得记住:WebRTC 模式下 session.created 是在数据通道打开时发送的,而不是连接建立时。写客户端状态机的人如果按 WebSocket 的时序去等它,会觉得服务端”没反应”。

第二步:搞清楚不配 ICE 时的默认行为

如果不设置环境变量 SPEECH_TO_SPEECH_ICE_SERVERS,服务端用的是 aiortc 的默认配置:host candidates 加 Google STUN。

这两样东西解决的都是”我在哪”的问题,而不是”包能不能过去”的问题。host candidates 是服务端自己网卡上的地址,STUN 帮它发现自己在出口 NAT 上的映射地址。两者都只是把候选地址报给对端,媒体流最终仍然要求双方能把 UDP 包直接送到对面。

所以下面这几种情况,默认值大概率就够用,别急着架东西:

  • 服务端和客户端在同一台机器上,或同一个局域网段里;
  • 服务端有公网 IP,且防火墙/安全组把 UDP 放通了;
  • 客户端是普通家庭宽带,没有被强约束的出口策略。

第三步:官方点名的两种”必须上 TURN”的形态

事实卡对应的官方文档在这里说得很直白:客户端无法直连服务端的部署——对称 NAT、没有暴露 UDP 的容器——需要 TURN 服务器。

这两种形态怎么对号入座:

没暴露 UDP 的容器是最常见也最容易被忽略的一种。很多人把服务塞进容器时只映射了 HTTP/WebSocket 用的那个 TCP 端口(默认 8765),信令一切正常,SDP 也换成功了,就是听不到声音——因为媒体那条 UDP 通路压根没有从宿主机进到容器里。这种情况下先别怀疑模型和音频参数,去看端口映射。

对称 NAT 则通常出在服务端或客户端所处的网络出口上。它的特征是:对不同目标地址会分配不同的映射端口,于是 STUN 探出来的那个地址对第三方并不成立,候选地址报了也白报。

TURN 与 STUN 的区别落到工程上就一句话:STUN 只在建连阶段被问一次,TURN 要在整个通话期间替你中继媒体流。这意味着带宽和费用是持续消耗的,这也是”能直连就别架”的现实理由。(这一段属于 WebRTC 的通用网络常识,不是该项目文档的内容。)

第四步:配置怎么写

ICE 服务器通过环境变量传入,值是一个 JSON 列表:

export SPEECH_TO_SPEECH_ICE_SERVERS='[{"urls": "stun:stun.example.com:3478"}, {"urls": "turn:turn.example.com", "username": "u", "credential": "c"}]'

逐项解释这三个键为什么在这:

  • urls:服务器地址,STUN 条目写 stun: 前缀,TURN 条目写 turn: 前缀。上面这条 STUN 示例带了端口 3478,TURN 示例没写端口。
  • username / credential:TURN 的访问凭据。STUN 那条目里没有这两个键——这也是判断”我到底配没配上 TURN”的最直观标志:列表里只有 stun: 条目,就是没配 TURN。
  • 列表可以同时放 STUN 与 TURN 条目,官方给的示例就是两者并列的写法。

一条把 extra、ICE 配置和启动串起来的完整示例:

pip install "speech-to-speech[webrtc]"

export SPEECH_TO_SPEECH_ICE_SERVERS='[{"urls": "stun:stun.example.com:3478"}, {"urls": "turn:turn.example.com", "username": "u", "credential": "c"}]'
export OPENAI_API_KEY=<YOUR_API_KEY>

speech-to-speech serve --host 0.0.0.0 --port 8765

其中 --host 默认是 127.0.0.1,只有显式传 0.0.0.0 才会把服务暴露到网络上;--port 默认就是 8765,写出来只是为了让这条命令自解释。以上为按官方参数语义组合的示例,未逐项实测,以官方文档与 speech-to-speech serve -h 的实际输出为准。

关于 --host 0.0.0.0 必须说清楚的一件事:官方文档写得很明确——服务端自己不做任何认证,也不做任何限流;只应该在受信网络上启用,或者把服务部署在一个自己掌管访问控制的网关后面。TURN 解决的是”包能不能到”,它一点也不解决”谁能连”。这两件事千万别混在一起想。

Windows 侧的写法差异:上面的 export 是 Linux/macOS 与 Git Bash 的语法。PowerShell 里对应写成 $env:SPEECH_TO_SPEECH_ICE_SERVERS = '...',外层用单引号包住整段 JSON,让里面的双引号原样保留。这属于 shell 层面的通用差异,不是该项目文档的内容,但它是本站读者最容易在这一步卡住的地方——JSON 的双引号被 shell 吃掉之后,报错信息通常离真正原因很远。

第五步:产出物与验收点

配完之后,有依据可查的产物只有这么几处,其余的日志文案、界面提示我们没有依据,就不替你想象:

  • 客户端 POST 一个 SDP offer 到 /v1/realtime/calls,会收到 SDP answer,状态码 201,并带 Location: /v1/realtime/calls/{call_id} 响应头。
  • 之后音频走 RTP 媒体轨,编码是 Opus、48 kHz,与流水线内部的 16 kHz 之间由有状态重采样器互转;发送侧按 20 ms 一帧的节奏推,空闲时发静音。
  • 所有 JSON 事件走 oai-events 数据通道,协议与 WebSocket 模式相同。

按这个顺序验收,能把故障切成三段,不至于一上来就怀疑 NAT:

  1. 信令段:SDP 交换有没有拿到 201 和 Location 头。拿不到说明问题在 HTTP 层或 extra 没装,跟 ICE 完全无关,配再多 TURN 也没用。
  2. 数据通道段session.created 有没有在数据通道打开后到达。到了就说明事件通路是通的。
  3. 媒体段:对着麦克风说话之后,看有没有 input_audio_buffer.speech_started 这类事件从数据通道回来。这个事件是 VAD 检测到语音才发的——它出现,就证明入站媒体真的送达了服务端;它一直不来而前两段都正常,才轮到怀疑 UDP 通路和 TURN。

另外有一个特别容易被误判成网络问题的坑:--num_pipelines 默认是 1。它的 help 原文说的是最大并发 WebSocket 会话数等于该值,更多的连接会被拒绝;而 WebRTC 会话是从同一个池里认领 pipeline unit 的。所以两个人一起测的时候,第二个人连不上,很可能只是池子被占满了,跟 NAT、TURN 一点关系都没有。改这个值之前先想清楚:help 原文说每个 unit 都有自己独立的 VAD/STT/LM/TTS handler 与各自的会话状态,池子开大就是成倍的资源占用。

什么情况下不该折腾这件事

  • 能直连就别架。服务端有公网 IP、UDP 放通、客户端网络正常,默认的 host candidates 加 STUN 就够了,架 TURN 只是给自己加一份持续的带宽账单。
  • 你走的是 WebSocket 传输。那条路上没有 ICE 这一环,把精力放在这里是白花。
  • 你在用 local 命令。它永远绑定环回,并把自带客户端连到本机的实时服务,整件事跟打洞无关。
  • 你真正要解决的是访问控制。TURN 不做鉴权、不做限流、不替你挡住任何人。这类需求得靠你自己在前面放网关,而且必须结合自身环境评估,不能指望一条环境变量。
  • 需要人工兜底的地方:TURN 凭据是有有效期和配额的,凭据本身写错通常不会在启动阶段报错,只会在建连阶段表现成”连不上”。这一点属于 TURN 的通用行为,不是该项目文档写的,排查时把它当成一个独立的怀疑对象,别和项目参数搅在一起。

延伸阅读


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