OpenAI 如何大规模交付低延迟语音 AI
How OpenAI delivers low-latency voice AI at scale
OpenAI 为 ChatGPT voice 和 Realtime API 重新设计 WebRTC stack,采用 relay plus transceiver 架构:relay 负责 UDP forwarding,transceiver 持有 ICE、DTLS、SRTP 状态。系统用 ICE ufrag 做 first-packet routing,结合 Global Relay、Cloudflare steering、Go、Kubernetes、SO_REUSEPORT 等支持全球低延迟语音会话。
只有当对话以语音的速度推进时,Voice AI 才会显得自然。当网络造成阻碍时,人们会立刻感知到:尴尬的停顿、被截断的打断,或延迟的 barge-in。这对 ChatGPT voice、使用 Realtime API 构建的开发者、在交互式 workflow 中工作的 agent,以及需要在用户仍在说话时处理 audio 的模型都很重要。
在 OpenAI 的规模下,这转化为三个具体要求:
- 面向超过 900 million 周活跃用户的全球覆盖
- 快速的连接建立,让用户在 session 开始后即可说话
- 低且稳定的 media round-trip time,并具备低 jitter 和低 packet loss,让轮替发言感觉干脆
OpenAI 负责 real-time AI 交互的团队最近重新设计了我们的 WebRTC stack,以解决三个在规模化时开始相互冲突的约束:one-port-per-session 的 media termination 不太适合 OpenAI infrastructure;有状态的 ICE(Interactive Connectivity Establishment)和 DTLS(Datagram Transport Layer Security)session 需要稳定的 ownership;global routing 必须保持较低的 first-hop latency。在本文中,我们将介绍所构建的分离式 relay plus transceiver architecture:它在为 client 保持标准 WebRTC 行为的同时,改变 packet 在 OpenAI infrastructure 内部的 routing 方式。
WebRTC 是一个开放标准,用于在 browser、mobile app 和 server 之间发送低延迟 audio、video 和 data。它通常与 peer-to-peer calling 相关,但对于 client-to-server real-time system 来说,它也是一个实用基础,因为它标准化了 interactive media 中最困难的部分:用于连接建立和 NAT(Network Address Translation)穿透的 ICE;用于加密传输的 DTLS 和 SRTP(Secure Real-time Transport Protocol);用于压缩和解码 audio 的 codec negotiation;用于质量控制的 RTCP(Real-time Transport Control Protocol);以及 echo cancellation 和 jitter buffering 等 client-side 功能。
这种标准化对 AI product 很重要。如果没有 WebRTC,每个 client 都需要用不同方式解决如何跨 NAT 建立连接、加密 media、协商 codec(为传输和解压缩所选择的 coder-decoder),以及适应不断变化的网络状况。有了 WebRTC,我们可以基于一个已经在 browser 和 mobile platform 上实现的 protocol stack,把自己的工作聚焦在将 real-time media 连接到 model 的 infrastructure 上。
我们也构建在 WebRTC ecosystem 本身之上,包括成熟的 open-source implementation,以及使 browser、mobile app 和 server 保持 interoperability 的标准工作。Justin Uberti(WebRTC 最初的架构师之一)和 Sean DuBois(Pion 的创建者和维护者)的基础性工作,使我们这样的团队能够基于经过验证的 media infrastructure 构建,而不必重新发明低层 transport、encryption 和 congestion-control 行为。我们很幸运,Justin 和 Sean 现在都是 OpenAI 的同事,正在帮助指导我们如何让 WebRTC 与 real-time AI 更紧密地结合。
对 AI 来说,最重要的属性是 audio 以连续 stream 的形式到达。spoken agent 可以在用户仍在说话时开始 transcribing、reasoning、calling tools 或 generating speech,而不是等待完整上传。这正是一个系统感觉像对话,还是感觉像 push-to-talk 的区别。
一旦选择了 WebRTC,下一个问题就是在哪里 terminate 它(也就是在哪里接受并拥有 WebRTC connection,例如在 edge),以及如何将这些 session 连接到 inference backend。Termination 很重要,因为它决定了我们如何处理 real-time session state、media transport、routing、latency 和 failure isolation。
SFU,即 selective forwarding unit,是一种 media server:它从每个 participant 接收一个 WebRTC stream,并选择性地将 stream 转发给其他 participant。在这种模型中,SFU 会为每个 participant terminate 一个单独的 WebRTC connection,而 AI 则作为 session 中的另一个 participant 加入。对于本质上是 multiparty 的产品,例如 group call、classroom 或 collaborative meeting,这可能很适合。它把 audio codec、RTCP message、data channel、recording 和 per-stream policy 放在同一个地方。1
即使在 client-to-AI 产品中,SFU 也通常是默认起点,因为它让团队可以复用一个经过验证的系统来处理 signaling、media routing、recording、observability,以及 human handoff 或添加更多 participant 等未来扩展。
我们的 workload 不同。大多数 session 是 1:1——一个用户与一个 model 对话,或一个 application 与一个 real-time agent 对话——并且每一轮都对 latency 敏感。针对这种 traffic 形态,我们选择了 transceiver model:一个 WebRTC edge service terminate client connection,然后将 media 和 event 转换为更简单的 internal protocol,用于 model inference、transcription、speech generation、tool use 和 orchestration。
在这种设计中,transceiver 是唯一拥有 WebRTC session state 的 service,包括 ICE connectivity check、DTLS handshake、SRTP encryption key 和 session lifecycle。这里的 “termination” 指 transceiver 是完成这些 handshake 并加密或解密 media 的 endpoint。将这些 state 保持在一个地方,让 session ownership 更容易推理,也让 backend service 能像普通 service 一样扩展,而不必自己充当 WebRTC peer。
选择 transceiver model 之后,我们的第一个实现是一个基于 Pion 构建的单一 Go service,同时处理 signaling 和 media termination。它支撑了 ChatGPT voice、Realtime API 的 WebRTC endpoint,以及多个 research project。
从运维角度看,transceiver service 做两件事:
- Signaling:SDP negotiation、codec selection、ICE credential 和 session setup
- Media:terminating downstream WebRTC connection,并维护到 backend service 的 upstream connection,用于 inference 和 orchestration
我们希望这个 service 像其他 infrastructure 一样运行:在 Kubernetes 上,workload 可以随着需求变化而 scale up、scale down,并在 host 之间迁移。但传统的 one-port-per-session WebRTC model 并不适合这种环境,因为它依赖大型 public UDP port range,而这些范围很难在 pod 增加、移除或 reschedule 时暴露、保护和保持稳定。2
第一个问题是 one-port-per-session model 本身。在高并发下,这意味着要暴露并管理非常大的 UDP port range。
- Cloud load balancer 和 Kubernetes service 并不是围绕每个 service 数万个 public UDP port 来设计的。每增加一个 range,都会在 load balancer config、health checking、firewall policy 和 rollout safety 上增加运维复杂度。3
- 大型 UDP port range 难以保护,因为它们扩大了外部可达的 surface area,并使 network policy 更难审计。
- 它们也不适合 autoscaling。Pod 在 Kubernetes 中不断被添加、移除和 rescheduled。要求每个 pod 保留并通告一个大型稳定 port range,会让这种弹性变得脆弱。4
这就是为什么许多 WebRTC system 会转向每个 server 使用单个 UDP port,并在该 port 后面进行 application-level demultiplexing。5
Single-port-per-server 设计解决了 port 数量问题,但引入了第二个问题:如何在 fleet 中保持每个 session 的 ownership。
ICE 和 DTLS 是有状态的 protocol。创建 session 的 process 需要持续接收该 session 的 packet,才能验证 connectivity check、完成 DTLS handshake、解密 SRTP,并处理后续的 session change,例如 ICE restart。如果同一 session 的 packet 落到另一个 process 上,setup 可能失败,media 也可能中断。
因此,我们得到了一个明确目标:向 public internet 暴露一个小而固定的 UDP surface,同时仍然将每个 packet 路由到拥有相应 WebRTC session 的 transceiver。
我们评估了多种实现方式,包括 TURN(Traversal Using Relays around NAT),其中 edge relay terminate client allocation,并代表 client 转发 traffic。2
方案优点缺点 每个 session 一个唯一 IP:port(也称为 native direct UDP)Direct client-to-server media path
Data path 中没有 forwarding layer 需要每个 session 一个 public UDP port
大型 port range 难以暴露和保护
不适合 Kubernetes 和 cloud load balancer 每个 server 一个唯一 IP:port 相比 per-session 暴露,public UDP footprint 小得多
每个 server 一个 shared socket 可以 demultiplex 许多 session 在单台 host 上运行清晰,但单独用于共享的 load-balanced fleet 时并不够
单台 host 上的 session demultiplexing 只有在 packet 到达该 host 后才有帮助;在 load-balanced fleet 中,第一个 packet 仍可能落到错误 instance 上,因此仍需要一种确定性的方式,将每个 session 导向拥有它的 process TURN relay(protocol-terminating)Client 只需要访问 TURN relay address 和 port
可以在 edge 集中 policy TURN allocation 会增加 setup round trip
在 TURN server 之间移动或恢复 allocation 仍然困难 Stateless forwarder + stateful terminator(OpenAI 的 relay + transceiver)较小的 public UDP footprint
Transceiver 仍拥有完整的 WebRTC session 在 media 到达拥有它的 transceiver 之前增加一个 forwarding hop
需要 relay 和 transceiver 之间的自定义协调
我们上线的 architecture 将 packet routing 与 protocol termination 分离。Signaling 仍到达 transceiver 进行 session setup,而 media 则首先通过 relay 进入。Relay 是一个轻量级 UDP forwarding layer,具有较小的 public footprint;transceiver 是位于其后的有状态 WebRTC endpoint。
Relay 不解密 media,不运行 ICE state machine,也不参与 codec negotiation。它读取足够的 packet metadata 来选择 destination,然后将 packet 转发给拥有该 session 的 transceiver。Transceiver 仍然看到正常的 WebRTC flow,并仍拥有所有 protocol state。从 client 的角度看,WebRTC session 没有任何变化。
First-packet routing 是这套设置中的关键步骤。Relay 必须在 packet path 本身还不存在任何 session 之前,就能路由来自 client 的第一个 packet,而不是暂停去依赖外部 lookup service。
每个 WebRTC session 本身已经携带了一个 protocol-native routing hook:ICE username fragment,即 ufrag,这是在 session setup 期间交换并在 STUN connectivity check 中回显的短标识符。我们生成 server-side ufrag,使其包含刚好足够的 routing metadata,让 relay 能推断 destination cluster 和 owning transceiver。
在 signaling 期间,transceiver 分配 session state,并在 SDP answer 中返回一个 shared relay VIP 和 UDP port。VIP 是位于 relay fleet 前方的 virtual IP address;与 port 结合后,它为 client 提供一个稳定的单一 destination,例如 203.0.113.10:3478,尽管其后有许多 relay instance。Client 的第一个 media-path packet 通常是 STUN(Session Traversal Utilities for NAT)binding request,ICE 用它来验证 packet 能否到达所通告的 address。
Relay 只解析第一个 STUN packet 中足够的信息来读取 server ufrag、解码 routing hint,并将 packet 转发给 owning transceiver。每个 transceiver 监听一个 shared UDP socket,也就是绑定到 internal IP:port 的一个 operating system endpoint,而不是每个 session 一个 socket。在 relay 根据 client 的 source IP:port 到该 transceiver destination 创建 session 后,后续 DTLS、RTP 和 RTCP packet 会在 session 内流动,不需要再次解码 ufrag。
Relay 的 session 被有意设计得很小,只包含一个用于指导 packet forwarding 的 in-memory session,以及必要的 monitoring counter 和用于 session expiration 与 cleanup 的 timer。这一设计选择让 packet routing 保持在 packet path 上。如果 relay restart 并丢失 session,下一个 STUN packet 会根据 ufrag routing hint 重建 session。为了进一步提高可靠性,我们使用 Redis cache 在 route 建立后保存 <client IP + Port, transceiver IP + Port> 的 mapping,从而可以更早恢复,而不必等到下一个 STUN packet 到达。
一旦我们将 public UDP surface 缩减到少量稳定的 address 和 port,就可以在全球范围内部署同样的 relay pattern。Global Relay 是我们由地理分布式 relay ingress point 组成的 fleet,它们都实现相同的 packet-forwarding 行为。
广泛分布的 geographic ingress 缩短了 client 到 OpenAI 的第一跳,因为 packet 可以从地理位置和网络拓扑上都接近用户的 relay 进入我们的网络,而不是先穿过 public internet 到达远端 region。实际效果是,在 traffic 到达我们的 backbone 之前,latency 更低、jitter 更少,并减少可避免的 loss burst。6
我们使用 Cloudflare geo 和 proximity steering 处理 signaling,使初始 HTTP 或 WebSocket request 到达附近的 transceiver cluster。Request context 决定 session 的 location,以及向 client 通告哪个 Global Relay ingress point。SDP answer 提供 Global Relay address,而 ufrag 包含足够信息,让 Global Relay 能将 media 路由到指定 cluster,并让 relay 路由到 destination transceiver。
结合起来,geo-steered signaling 和 Global Relay 让 setup 和 media 都进入附近的 entry path,同时将 session anchoring 到一个 transceiver 上。这降低了 signaling 和第一次 ICE connectivity check 的 round-trip time,从而直接缩短用户在开始语音前的等待时间。
我们用 Go 编写 relay service,并有意保持实现范围很窄。在 Linux 上,kernel 的 networking stack 从机器的 network interface 接收 UDP packet,并将其交付给 socket,也就是 process 在绑定 IP:Port 后读取的 operating system endpoint。Relay 运行在 userspace,因此一个普通 Go process 会从该 socket 读取 packet header,更新少量 flow state,并在不 terminate WebRTC 的情况下转发 packet。我们不需要任何 kernel-bypass framework;这种框架可以让 userspace process 直接轮询 network queue,以获得更高 packet rate,但也会增加运维复杂度。
关键设计选择:
- 不做 protocol termination: Relay 只解析 STUN header/ufrag;对后续 DTLS、RTP 和 RTCP 使用 cached state,使 packet 保持 opaque。
- Ephemeral state: 它维护一个小型、短 timeout 的 in-memory map,将 client address 映射到 transceiver destination,用于 flow state 和 observability。
- Horizontal scalability: 多个 relay instance 在 load balancer 后并行运行。State 不是硬 WebRTC state,因此 restart 只会造成最小 traffic drop,并能快速恢复 flow。
效率措施:
SO_REUSEPORT是一个 Linux socket option,允许同一机器上的多个 relay worker 绑定同一个 UDP port。随后 kernel 会将 incoming packet 分发给这些 worker,避免单个 read-loop 成为 bottleneck。runtime.LockOSThread将每个读取 UDP 的 goroutine 绑定到特定 OS thread。结合SO_REUSEPORT,这通常会让同一 flow(source 和 destination IP:Port 加 protocol)的 packet 保持在同一 CPU core 上,从而改善 cache locality 并减少 context switching。- 预分配 buffer 和最小化 copying,使 parsing 和 allocation overhead 保持较低,以避免 Go 中的 garbage collection。
这个实现用相对较小的 relay footprint 承载了我们的 global real-time media traffic,因此我们保留了更简单的设计,而没有采用 kernel bypass 路线。
这种 architecture 让我们可以在 Kubernetes 中运行 WebRTC media,而不必暴露数千个 UDP port。这很重要,因为更小且固定的 UDP surface 更容易保护和 load balance,也让 infrastructure 可以扩展而不必保留大型 public port range。凭借 Kubernetes 更好的 infra support,以及更小 surface area 带来的更高安全性,这种设计也为 client 保留了标准 WebRTC 行为,并证实了无 SFU 设计是适合我们 workload 的默认选择。我们的大多数 session 都是 point-to-point、latency-sensitive,并且当 inference service 不需要表现得像 WebRTC peer 时更容易扩展。
更广泛的经验是,增加复杂性的最佳位置是薄 routing layer,而不是每个 backend service,也不是自定义 client behavior。将 routing metadata 编码到 protocol-native field 中,为我们带来了确定性的 first-packet routing、较小的 public UDP footprint,以及足够的灵活性,可以将 ingress 放置在全球用户附近。
有几个选择尤其重要:
- 在 edge 保留 protocol semantics。Client 仍使用标准 WebRTC,这保持了 browser 和 mobile interoperability。
- 将 hard session state 保持在一个地方。Transceiver 拥有 ICE、DTLS、SRTP 和 session lifecycle;relay 只转发 packet。
- 基于 setup 中已经存在的信息进行 routing。ICE ufrag 为我们提供了 first-packet routing hook,而无需增加 hot-path lookup dependency。
- 在转向 kernel bypass 之前,先针对 common case 优化。一个范围很窄的 Go implementation,加上对
SO_REUSEPORT、thread pinning 和低 allocation parsing 的谨慎使用,已经足以应对我们的 workload。
Real-time voice AI 只有在 infrastructure 让 latency 近乎不可感知时才成立。对我们来说,这意味着改变 WebRTC deployment 的形态,同时不改变 client 对 WebRTC 本身的预期。