WebRTC 模式的三处不一样,尤其那个被拒绝的事件
speech-to-speech(github.com/huggingface/speech-to-speech,GitHub 元数据 2026-08-09 快照 11893 star)的 serve 命令,一个 FastAPI / uvicorn 服务同时暴露 WebSocket 与 WebRTC 两种传输。仓库内那份 Realtime Engine 架构文档对这件事的定性是:走 WebRTC 时,所有 JSON 事件走 oai-events 数据通道,协议与 WebSocket 模式相同。
「协议相同」这句话最容易骗人。它是真的,但同一份文档紧接着列了一张表,写明了三处差异。这三处每一处都会让照着 WebSocket 示例写好的客户端在 WebRTC 下行为不对——而且其中一处不是「效果差一点」,是事件直接被服务端拒掉。
这篇就把这三处摊开讲:每一处为什么会这样、你的客户端代码要改哪里、以及在什么处境下你该选 WebRTC 而不是 WebSocket。
先说前提:WebRTC 不在默认安装里
WebRTC 传输需要额外的 extra:
pip install "speech-to-speech[webrtc]"
握手是一次普通的 HTTP POST:
POST /v1/realtime/calls Content-Type: application/sdp
客户端 POST 一个 SDP offer,服务端返回 SDP answer,状态码是 201,并带一个 Location: /v1/realtime/calls/{call_id} 头。之后音频走 RTP 媒体轨,编码是 Opus,48 kHz,服务端用有状态重采样器和流水线内部的 16 kHz 速率互转。
对照一下 WebSocket 那条路你就知道差异从哪来了:WebSocket 模式下,客户端发 input_audio_buffer.append,内容是 base64 PCM,RealtimeService 解码、重采样到 16 kHz、切成 512 采样点的块,放进 recv_audio_chunks_queue 交给 VAD。**音频和事件走的是同一条连接。**WebRTC 把这两件事拆开了:音频走媒体轨,事件走数据通道。三处差异全部由这一个拆分派生出来。
差异一:input_audio_buffer.append 会被拒绝
这是三处里最硬的一处。文档原文:在 WebRTC 传输下,input_audio_buffer.append 被拒绝,返回 invalid_event_for_transport,因为音频走的是媒体轨。
判断依据很直接:如果你手里的客户端是从 WebSocket 示例改过来的,它一定在某个地方循环采集麦克风、编码 base64、发 input_audio_buffer.append。这段代码在 WebRTC 下不是「多余」,是会持续换回 invalid_event_for_transport 错误。上行音频要靠你在 SDP 协商里挂好媒体轨来送,不是靠发事件。
顺带说一个容易被忽略的连带影响:客户端 → 服务端方向一共就 5 个事件(input_audio_buffer.append、session.update、conversation.item.create、response.create、response.cancel)。被拒的只是第一个,另外四个照旧。也就是说会话配置、上下文注入、触发生成、主动取消这四条路径是完全通用的,你不需要为 WebRTC 写第二套控制逻辑——只需要把音频发送这一条抽出去。这也是我建议的客户端分层方式:控制面(事件)与媒体面(音频)在代码里就该是两个模块,切传输时只换一个。
差异二:output_audio_buffer.clear 只有 WebRTC 有
文档写的是:output_audio_buffer.clear 仅 WebRTC 支持——未播放的音频在服务端缓冲,所以抢话与取消需要在服务端把它冲掉;服务端在 response.cancel 与 VAD 打断时也会自动冲。
要理解这一条为什么只有 WebRTC 需要,得看出站路径。TTS 把 PCM 块写进 send_audio_chunks_queue,路由器的异步 _send_loop 同时抽干音频与文本两个队列,把 PCM 编码成 response.output_audio.delta 事件发出去。在 WebRTC 下,每单元的 send loop 仍然是流水线输出队列的唯一消费者,但它把 PCM 交给传输层之后,由传输层按 20 ms RTP 帧的节奏发出(空闲时发静音)。节奏一被传输层接管,服务端手里就必然攒着一段还没发出去的音频。这段音频在打断发生的那一刻已经离开了流水线队列,靠 cancel_scope 那套世代计数是拦不住的——它需要一条独立的「把传输层缓冲也冲掉」的指令。
给你的判断依据:
- 如果你的抢话完全依赖服务端 VAD(也就是
turn_detection那套),文档说服务端会自动冲,你可以不发这个事件; - 如果你做的是客户端侧的按钮打断、或者要在
response.cancel之外做更激进的静音,那output_audio_buffer.clear就是 WebRTC 下你唯一能碰到那段缓冲的手柄; - 如果你在写一份跨两种传输的客户端,这个事件必须写成传输相关的分支——在 WebSocket 下它不在支持列表里。
差异三:session.created 的发送时机变了
服务端 → 客户端一共 15 个事件,其中 session.created 在 WebSocket 下是连接时发出、携带当前会话配置;在 WebRTC 下,它在数据通道打开时发送。
这一条听起来最像细节,实际上最容易写出「偶发卡在初始化」的客户端。常见写法是:连接建立 → 等 session.created → 拿到会话配置 → 发第一条 session.update。在 WebRTC 下,「连接建立」(SDP 交换完成、拿到 201)和「数据通道打开」是两个时刻。你的等待条件如果挂在前一个时刻上,就会在数据通道还没就绪时开始发事件。
处置办法也很清楚:把初始化的触发点挂到数据通道的 open 上,而不是挂到 HTTP 响应回来的那一刻。
顺带提醒另一个和它同类、但两种传输都存在的时机陷阱:response.created 不是在请求发起时发的,是在第一个出站音频块出来时才发(响应进入 in_progress)。如果你拿它来点亮界面上的「正在思考」,那个状态会一直亮不起来,直到声音都要出来了。真正该驱动「正在思考」的是你自己发出 response.create 的那一刻,或者是 input_audio_buffer.speech_stopped。
没变的部分同样重要
除了上面三处,事件语义是通用的。这意味着这些机制你只用理解一遍:
| 你关心的事 | 走哪个事件 | 两种传输是否一致 |
|---|---|---|
| 改 instructions / tools / voice / turn detection | session.update → session.updated | 一致 |
| 注入上下文但不触发生成 | conversation.item.create → conversation.item.created | 一致 |
| 触发一次生成 | response.create | 一致 |
| 主动取消 | response.cancel | 一致 |
| 助手转写实时渲染 | response.output_audio_transcript.delta | 一致 |
| 助手转写定稿 | response.output_audio_transcript.done | 一致 |
其中转写那两行值得单独提一句,因为它是一个会咬人的兼容性变更:文档明确要求,以前消费每个块级 done 事件的客户端,必须把实时渲染改到 delta 上,把 done 只当作定稿。一个产生了转写文本的响应恰好发一次转写 done,位置在 response.output_audio.done 之后、response.done 之前,包括取消关闭了未完成助手项的情况。把 delta 拼接起来能复现终态的 done.transcript。这一条与传输方式无关,你换到 WebRTC 也躲不掉。
资源侧也有一处需要留心。会话是从池里认领队列驱动的 PipelineUnit,其中包含该会话的 RealtimeService、轮次追踪器与取消状态;文档说 WebRTC 会话从同一个池里认领。池大小由 --num_pipelines 控制,默认值是 1。它的 help 文本描述的是「最大并发 websocket 会话数等于 num_pipelines,更多的连接会被拒绝」——措辞只提了 websocket,但既然两种传输共用一个池,规划并发时就该把 WebRTC 会话一起算进去,别按「WebRTC 另有一套配额」去做容量假设。
什么时候选 WebRTC:一条决策路径
- 客户端和服务端在同一台机器或同一内网,你只是在做原型——用 WebSocket。少装一个 extra,少一次 SDP 协商,示例代码可以直接跑。
- 客户端是浏览器,且要跨公网——WebRTC 这条路是为此存在的:媒体走 RTP、按 20 ms 帧节奏发、Opus 编码,这些都是浏览器原生就有的能力,你不需要自己在应用层做音频分块与节奏控制。
- 你已经有一套成熟的 WebSocket 客户端,且当前没有明显问题——先别迁。三处差异里有两处(append 被拒、
session.created时机)会直接让现有代码出错,迁移是有成本的。 - 你的部署是「客户端无法直连服务端」的那一类——文档明确指出:对称 NAT、没暴露 UDP 的容器这类部署需要 TURN 服务器。这不是可选优化,是前置条件。ICE 服务器通过环境变量配,值是一个 JSON 列表:
export SPEECH_TO_SPEECH_ICE_SERVERS='[{"urls": "stun:stun.example.com:3478"}, {"urls": "turn:turn.example.com", "username": "u", "credential": "c"}]'
不设这个变量则用 aiortc 的默认(host candidates 加 Google STUN)。如果你的部署属于上面那一类,只靠默认 STUN 是不够的——这是文档自己写明的,不是我们的推断。
顺手要提的两件事:暴露与代理
WebRTC 场景天然意味着「客户端不在本机」,于是很多人下一步就会去改绑定地址、开 LLM 代理。这两件事文档都有明确声明,必须原样带出来。
--host 的默认值是 127.0.0.1,它的 help 文本原话是:显式传 0.0.0.0 才会把未认证的 API 暴露到网络上。注意这里的措辞——文档自己把这个 API 叫作 unauthenticated。
--enable_llm_proxy 开启后,实时服务会把它配置的远端 LLM 也作为普通 OpenAI 兼容端点暴露出来(--llm_backend chat-completions 时是 POST /v1/chat/completions,--llm_backend responses-api 时是 POST /v1/responses),供客户端跑摘要、起标题一类的侧边任务,与语音对话完全并发且不会被新的说话打断。行为上它是无状态的,代理时用服务端持有的 key(该 key 永不到达客户端),model 字段总是被覆写成服务端配置的 --model_name,客户端传来的 API key 被忽略;代理默认关闭,且要求远端后端,否则返回 501 并附原因。
而文档关于它的安全声明是这样三句,一个字都不能弱化:**服务端自己不做任何认证,也不做任何限流。只在受信网络上启用该代理,或者把服务部署在一个自己掌管访问控制的网关后面。**文档举的例子是 s2s-endpoint 的计算副本充当这样的网关——它只对「用 HF token 创建了会话的客户端」开放这些路径,按该 token 校验 API key,并按用户限流。
所以本文不会给你一句「这样配置就安全了」。能说的只有:把认证、限流、访问控制放在这个服务前面的网关上,是这份文档自己给出的方向;具体怎么落到你的环境,得你自己评估。
收个尾
这三处差异的共同来源只有一句话:WebRTC 把音频从事件通道里拿走了。拿走之后,上行的 append 没有存在意义(差异一),下行多出一段传输层缓冲需要单独冲(差异二),连接就绪的判定点从连接挪到数据通道(差异三)。你只要记住这个因果,遇到本文没覆盖的边角情况也能自己推一遍。
延伸阅读
本文依据 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 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。