OpenAI が大規模低遅延音声 AI を実現する手法
OpenAI は、9 億人のユーザー規模における低遅延音声 AI の実現のため、WebRTC スタックを「リレー+トランスシーバー」アーキテクチャへ再設計し、グローバルな接続性と安定性を確保した。
キーポイント
大規模スケールでの音声品質要件
9 億人の週次アクティブユーザーを対象に、会話の速度を維持するために、高速な接続設定と低ジッター・パケットロスの安定したメディア往復時間が必須条件として挙げられている。
WebRTC アーキテクチャの再設計
従来の「1 セッションあたり 1 ポート」方式がインフラと適合しない問題を解決するため、クライアントには標準的な WebRTC 動作を維持しつつ、内部ルーティングを変更した新しいアーキテクチャを採用した。
技術的制約の克服
状態管理が必要な ICE および DTLS セッションの安定した所有権確保と、グローバルな最初のホップ遅延を低減するルーティング戦略が、この再設計の核心として説明されている。
影響分析・編集コメントを表示
影響分析
この記事は、大規模音声 AI サービスの運用において、単なるモデル性能だけでなく、ネットワーク層の最適化がユーザー体験(UX)に直結することを示す重要な事例である。特に、9 億人規模のユーザーを捌くインフラ設計における WebRTC の活用方法や、遅延削減のためのアーキテクチャ変更は、同分野の開発者にとって実装の指針となる高度な知見を提供する。
編集コメント
音声 AI の実用化において、モデルの性能だけでなくネットワークレイヤーでの最適化が不可欠であることを示す技術的深掘り記事です。大規模サービスにおける WebRTC の運用知見は、開発者にとって非常に価値が高い情報源と言えます。
会話の速度で会話が進行するときにのみ、Voice AI は自然に感じられます。ネットワークが邪魔になると、人々はすぐにぎこちない一時停止、切り捨てられた中断、または遅れた割り込みとしてそれを聴き取ります。これは ChatGPT の音声機能にとって重要であり、Realtime API を使って構築する開発者にとって重要であり、対話型ワークフローで動作するエージェントにとって重要であり、ユーザーがまだ話している間にオーディオを処理する必要があるモデルにとっても重要です。
OpenAI のスケールにおいて、これは3 つの具体的な要件に翻訳されます:
- 9 億人以上の週次アクティブユーザーに対するグローバルな到達範囲
- ユーザーがセッション開始と同時に話し始められるようにするための高速な接続セットアップ
- 低いジッターとパケットロスを持つ低く安定したメディア往復時間により、ターンテイクが鮮明に感じられること
リアルタイム AI インタラクションを担当する OpenAI のチームは最近、スケールにおいて衝突し始めた3 つの制約に対処するために WebRTC スタックを再設計しました。1 セッションあたり 1 ポートのメディア終端は OpenAI のインフラストラクチャに適合せず、ステートフルな ICE(Interactive Connectivity Establishment)および DTLS(Datagram Transport Layer Security)セッションには安定した所有権が必要であり、グローバルルーティングは最初のホップの遅延を低く保つ必要があります。この投稿では、クライアントに対して標準的な WebRTC の動作を維持しつつ、OpenAI のインフラストラクチャ内でのパケット経路変更を行うために構築した「リレーとトランスシーバの分離」アーキテクチャについて詳しく説明します。
WebRTC は、ブラウザ、モバイルアプリ、サーバー間で低遅延のオーディオ、ビデオ、データを転送するためのオープン標準です。ピアツーピア通話とよく関連付けられていますが、インタラクティブメディアの難しい部分を標準化しているため、クライアントからサーバーへのリアルタイムシステムの堅牢な基盤としても機能します。具体的には、接続確立および NAT(ネットワークアドレス変換)透過のための ICE、暗号化転送のための DTLS および SRTP(Secure Real-time Transport Protocol)、オーディオの圧縮と復号のためのコーデックネゴシエーション、品質制御のための RTCP(Real-time Transport Control Protocol)、そしてエコーキャンセレーションやジッターバッファリングなどのクライアントサイド機能です。
この標準化は AI プロダクトにとって重要です。WebRTC がなければ、NAT を越えた接続確立方法、メディアの暗号化、コーデック(送信および復元のために選択される符号化・復号器)のネゴシエーション、変化するネットワーク条件への適応などについて、各クライアントが異なる対応策を必要とすることになります。WebRTC があるおかげで、ブラウザやモバイルプラットフォームですでに実装されているプロトコルスタックの上に構築し、リアルタイムメディアをモデルに接続するインフラストラクチャの開発に注力することができます。
私たちは WebRTC エコシステムそのものにも依存しており、そこには成熟したオープンソースの実装や、ブラウザ、モバイルアプリ、サーバー間の相互運用性を維持するための標準的な取り組みが含まれています。WebRTC の元アーキテクトの一人である Justin Uberti 氏と、Pion(Pion)の作成者かつメンテナである Sean DuBois 氏による基盤となる作業のおかげで、私たちのようなチームは低レベルの転送、暗号化、輻輳制御の動作をゼロから作り直すのではなく、実戦で検証されたメディアインフラストラクチャの上に構築することが可能になりました。Justin 氏と Sean 氏が現在 OpenAI の同僚として在籍し、WebRTC とリアルタイム AI をより密接に統合する方法を導いてくれていることは、私たちにとって幸運なことです。
AI において最も重要な特性は、音声が入力されるのが連続ストリームであるという点です。話者エージェントは、ユーザーがまだ話している最中に、転写、推論、ツールの呼び出し、または音声生成を開始できます。これは、完全なアップロードを待たなければならない「プッシュ・トゥ・トーク」方式のシステムと、会話のように感じるシステムの決定的な違いです。
WebRTC を採用した後の次の課題は、それをどこで終端するか(つまり、WebRTC 接続を受け取り所有する場所、例えばエッジ上など)であり、またそのセッションを推論バックエンドにどのように接続するかという点でした。終端の位置が重要なのは、それがリアルタイムセッションの状態管理、メディア転送、ルーティング、レイテンシ、および障害分離をどのように処理するかを決定するためです。
SFU(Selective Forwarding Unit)とは、各参加者から WebRTC ストリームを 1 つ受け取り、他の参加者にストリームを選択的に転送するメディアサーバーのことです。このモデルでは、SFU は各参加者ごとに個別の WebRTC 接続を終端し、AI もセッション内の別の参加者として参加します。これはグループ通話、教室、共同会議など、本質的に複数参加型の製品には適しています。音声コーデック、RTCP メッセージ、データチャネル、録画機能、ストリームごとのポリシーをすべて 1 か所に集約できるためです。
クライアントから AI への製品であっても、SFU はデフォルトの起点となることが多いです。これは、シグナリング、メディアルーティング、録画、観測性(Observability)、および将来的な拡張機能(人間のハンドオフや参加者数の増加など)に対して、1 つの実証済みのシステムを再利用できるためです。
私たちのワークロードは異なります。ほとんどのセッションは 1:1 です—1 人のユーザーが 1 つのモデルと対話するか、1 つのアプリケーションが 1 つのリアルタイムエージェントと対話します。各ターンで遅延に敏感な対応が必要です。そのようなトラフィックの形状に対して、私たちはトランスシーバー(Transceiver)モデルを採用しました。これは WebRTC エッジサービスがクライアント接続を終端し、その後、メディアとイベントをモデル推論、文字起こし、音声生成、ツール使用、オーケストレーションのためのより単純な内部プロトコルに変換するものです。
この設計において、トランシーバーは WebRTC セッションの状態(ICE 接続チェック、DTLS ハンドシェイク、SRTP 暗号化キー、セッションライフサイクルを含む)を所有する唯一のサービスです。ここで言う「終端」とは、トランシーバーがそれらのハンドシェイクを完了し、メディアの暗号化または復号化を行うエンドポイントであることを意味します。この状態を一つの場所に保持することで、セッションの所有権について推論しやすくなり、バックエンドサービスが WebRTC ピア自体として振る舞うのではなく、通常のサービスのようにスケーリングできるようになりました。
トランシーバーモデルを選択した後、最初の導入では Pion を基盤とした単一の Go サービスを構築し、シグナリングとメディアの両方の終端処理を担当させました。このサービスは ChatGPT ボイス、Realtime API の WebRTC エンドポイント、および多数の研究プロジェクトを支えています。
運用面において、トランシーバーサービスは以下の 2 つの役割を果たします:
- シグナリング: SDP ネゴシエーション、コーデック選択、ICE クレデンシャル、セッションセットアップ
- メディア: 下流側の WebRTC 接続の終端と、推論およびオーケストレーションのためのバックエンドサービスへの上流側接続の維持
このサービスを他のインフラと同様に動作させたいと考えていました。つまり、需要の変化に応じてワークロードをスケールアップ・ダウンでき、ホスト間を移動できる Kubernetes 上で実行することです。しかし、従来の「セッションごとに 1 ポート」という WebRTC モデルは、そのような環境にはあまり適していません。なぜなら、これは公開 UDP ポートの大きな範囲に依存しており、ポッドの追加、削除、再スケジューリングの際に、これらのポートを公開し、セキュリティを確保し、維持することが困難だからです。
2
最初の課題は、セッションごとに 1 つのポートを使用するモデルそのものでした。高い同時接続数では、非常に大きな UDP ポート範囲を公開して管理することを意味します。
- クラウドロードバランサーや Kubernetes サービスは、サービスあたり数万の公開 UDP ポートを想定して設計されていません。追加される各範囲が、ロードバランサー設定、ヘルスチェック、ファイアウォールポリシー、ロールアウトの安全性において運用上の複雑さを増大させます。
- 大きな UDP ポート範囲はセキュリティが困難です。外部から到達可能な表面積が拡大し、ネットワークポリシーの監査が難しくなるためです。
- また、オートスケーリングにも不向きです。Kubernetes ではポッド(Pod)が絶えず追加・削除・再スケジュールされます。各ポッドが大規模で安定したポート範囲を予約して広告することを要求すると、その弾力性が脆くなります。
そのため、多くの WebRTC システムは、サーバーごとに 1 つの UDP ポートを使用し、その背後でアプリケーションレベルでのデマルチプレクシング(demultiplexing)を行う方向へ移行しています。
サーバーごとの単一ポート設計はポート数の問題を解決しますが、ファーム全体にわたって各セッションの所有権を維持するという第 2 の課題を導入します。
ICE と DTLS はステートフルなプロトコルです。セッションを作成したプロセスが、そのセッションのパケットを受け取り続けなければなりません。これにより接続チェックの有効化、DTLS ハンドシェイクの完了、SRTP の復号化、および ICE リスタートなどの後のセッション変更処理が可能になります。同じセッションのパケットが異なるプロセスに届いた場合、セットアップが失敗したりメディアが切断されたりします。
これにより、私たちが目指した具体的な目標は、パブリックインターネットに対して小さく固定された UDP の表面を公開しつつ、すべてのパケットが対応する WebRTC セッションを所有するトランスシーバーにルーティングされるようにすることでした。
私たちはそこへ到達するためのいくつかの方法を検討しました。その中には TURN(NAT 周囲のルーターを使用したトランザバース)も含まれており、エッジ上のルーターがクライアントのアロケーションを終端し、代わりにトラフィックを転送する方式です。
アプローチ
利点
欠点
セッションごとに一意の IP:ポート(ネイティブ直接 UDP とも呼ばれる)
クライアントからサーバーへのメディア経路が直接接続される
データパスに転送層が存在しない
セッションごとに 1 つのパブリック UDP ポートが必要となる
大規模なポート範囲を公開してセキュリティを確保するのは困難である
Kubernetes やクラウドロードバランサーには適合性が低い
サーバーごとに一意の IP:ポート
セッションごとの公開に比べ、パブリック UDP のフットプリントがはるかに小さい
サーバーごとに 1 つの共有ソケットで多数のセッションをデマルチプレックス可能
単一ホスト上ではきれいに動作するが、それ自体では共有ロードバランサー化されたファーム全体には対応できない
単一ホスト内でのセッションデマルチプレックスは、パケットがそのホストに到達した後にのみ機能する。ロードバランサー化されたファーム全体では、最初のパケットが誤ったインスタンスに到着する可能性があり、そのため依然として各セッションをそれを所有するプロセスへ誘導するための決定論的な方法が必要となる
TURN リレー(プロトコル終端型)
クライアントは TURN リレーのアドレスとポートへの到達のみでよい
エッジ側でポリシーを一元化できる
TURN アロケーションにはセットアップの往復遅延が発生する
TURN サーバー間でのアロケーションの移動や回復はまだ困難である
ステートレスフォワーダー+ステートフルターミネーター(OpenAI のリレー+トランスシーバー)
小さなパブリック UDP フットプリント
トランシーバーは WebRTC セッション全体を依然として所有している
メディアが所有するトランシーバーに到達する前に、転送ホップが 1 つ追加される
リレーとトランシーバー間のカスタム調整が必要
私たちが実装したアーキテクチャでは、パケットルーティングとプロトコル終端を分離しています。シグナリングはセッション設定のために依然としてトランシーバーに到達しますが、メディアはまずリレーを通じて入ってきます。リレーは小さなパブリックフットプリントを持つ軽量な UDP 転送層であり、その背後にある状態管理型 WebRTC エンドポイントがトランシーバーです。
リレーはメディアの復号化を行わず、ICE(Interactive Connectivity Establishment)ステートマシンを実行せず、コーデックネゴシエーションにも参加しません。必要なパケットメタデータのみを読み取り、宛先を選択してセッションを所有するトランシーバーへパケットを転送します。トランシーバーは通常の WebRTC フローを依然として見ており、すべてのプロトコル状態を依然として所有しています。クライアントの視点からは、WebRTC セッションに関する何の変化もありません。
このセットアップにおける鍵となるステップが最初のパケットルーティングです。リレーは、パス自体上にセッションが存在する前に、外部のルックアップサービスで停止することなく、クライアントからの最初のパケットをルーティングする必要があります。
すべての WebRTC セッションには、プロトコルネイティブなルーティングフックが既に備わっています。それは ICE ユーザー名フラグメント、つまり *ufrag* です。これはセッション設定時に交換される短い識別子であり、STUN 接続チェックでもエコーされます。サーバー側の ufrag を生成することで、リレーが宛先クラスターと所有トランシーバーを推論するのに十分なルーティングメタデータを含ませています。
シグナリング中、トランシーバーはセッション状態を割り当て、SDP 応答内で共有リレー VIP と UDP ポートを返します。VIP はリレーファームの前面に位置する仮想 IP アドレスであり、ポートと組み合わせることで、背後には多数のリレーインスタンスが存在していても、クライアントにとっては 203.0.113.10:3478 のような単一の安定した宛先となります。クライアントからの最初のメディア経路パケットは通常、STUN(Session Traversal Utilities for NAT)バインディング要求であり、ICE はこれを使用して、パケットが広告されたアドレスに到達できるかを確認します。
リレーはこの最初の STUN パケットを解析し、サーバーの ufrag を読み取り、ルーティングヒントを復号化して、所有するトランシーバーへ転送します。各トランシーバーは共有 UDP ソケットでリスニングしており、これはセッションごとに 1 つずつソケットを持つのではなく、内部 IP:ポートにバインドされた OS エンドポイントが 1 つだけであることを意味します。リレーがクライアントのソース IP:ポートからそのトランシーバー宛先へセッションを作成した後、DTLS、RTP、RTCP パケットはセッション内で流れるようになり、ufrag の再デコードは不要になります。
リレーのセッションは意図的に最小限に設計されており、パケット転送を通知するためのメモリー内セッションと、監視用の必要なカウンタ、およびセッションの有効期限切れとクリーンアップのためのタイマーのみで構成されています。この設計により、パケットルーティングがパケットパス上で直接維持されます。リレーが再起動してセッションを失った場合、次の STUN パケットによって ufrag ルーティングヒントからセッションが再構築されます。さらに信頼性を高めるため、ルートが確立された後は Redis キャッシュを使用して *<クライアント IP + ポート,トランスシーバ IP + ポート>* のマッピングを保持し、次の STUN パケットが届く前に早期に復元できるようにしています。
パブリック UDP サーフェスを少数の安定したアドレスとポートに削減した後、同じリレーパターンをグローバルに展開できるようになりました。Global Relay は、すべてが同じパケット転送動作を実装する地理的に分散されたリレーイングレスポイントのファームウェアです。
広範な地理的イングレスにより、最初のクライアントから OpenAI へのホップが短縮されます。パケットは、遠隔地を経由してパブリックインターネットを横断するのではなく、ユーザーに近い地理およびネットワークトポロジー上のリレーでネットワークに入力できるためです。実用的には、これはトラフィックがバックボーンに到達する前に、レイテンシの低下、ジッターの減少、回避可能な損失バーストの削減を意味します。
シグナリングには Cloudflare の地理情報および近接性ステアリング機能を使用し、初期の HTTP または WebSocket リクエストが近くのトランスシーバ・クラスターに到達するようにしています。リクエストのコンテキストがセッションの場所を決定し、クライアントに対してどのグローバル・リレーイングレスポイントが広告されるかを指定します。SDP アサーはグローバル・リレーのアドレスを提供し、ufrag にはグローバル・リレーがメディアを指定されたクラスターへルーティングし、リレーが宛先のトランスシーバへ転送するために十分な情報が含まれています。
地理情報に基づくシグナリングとグローバル・リレーを組み合わせることで、セットアップとメディアの両方を近くの入口経路に配置しつつ、セッションは1つのトランスシーバにアンカー(固定)されたまま維持されます。これにより、シグナリングおよび最初の ICE 接続チェック(ICE connectivity check)の往復時間が短縮され、ユーザーが音声開始を待つ時間が直接的に短くなります。
リレーサービスは Go で記述し、実装範囲をあえて狭く保ちました。Linux では、カーネルのネットワークスタックがマシンのネットワークインターフェースから UDP パケットを受け取り、プロセスが IP:ポートをバインドした後に読み取る OS エンドポイントであるソケットへパケットを配信します。リレーはユーザー空間で動作するため、通常の Go プロセスがそのソケットからパケットヘッダーを読み取り、少量のフロー状態を更新し、WebRTC を終了させることなくパケットを転送します。より高いパケットレートのためにユーザー空間プロセスがネットワークキューを直接ポーリングできるようにするカーネルバイパスフレームワークは不要でした。これは運用上の複雑さを増すためです。
主要な設計判断:
- プロトコル終端なし:リレーは STUN ヘッダーと ufrag のみを解析し、その後の DTLS、RTP、RTCP にはキャッシュされた状態を使用し、パケットを不透明なまま維持します。
- エフェメラル(一時的)状態:フローの状態と観測のために、クライアントアドレスからトランスシーバー宛先へのマッピングを保持する小規模で短時間タイムアウトのメモリ内マップを維持します。
- 水平スケーラビリティ:複数のリレーインスタンスがロードバランサーの背後で並列実行されます。状態は WebRTC のハードな状態ではないため、再起動によるトラフィックの減少は最小限に抑えられ、フローの回復も迅速です。
効率化対策:
- SO_REUSEPORT は、同じマシン上の複数のリレーワーカーが同じ UDP ポートにバインドできるようにする Linux ソケットオプションです。カーネルはこのオプションにより、着信パケットをこれらのワーカー間で分散し、単一の読み込みループによるボトルネックを回避します。
- runtime.LockOSThread は、各 UDP 読み取り用ゴルーチンを特定の OS スレッドに固定(ピン留め)します。SO_REUSEPORT と組み合わせることで、同じフロー(ソースと宛先の IP:ポートおよびプロトコル)からのパケットが同一の CPU コア上に保たれやすくなり、キャッシュの局所性が向上してコンテキストスイッチングが削減されます。
- 事前割り当てバッファと最小限のコピーにより、解析と割り当てのオーバーヘッドを低く抑え、Go 言語におけるガベージコレクションを回避します。
この実装により、比較的狭いリレーのフットプリントでグローバルなリアルタイムメディアトラフィックを処理できたため、カーネルバイパス経路を採用するよりもシンプルな設計を維持しました。
このアーキテクチャにより、Kubernetes 上で WebRTC メディアを実行しつつ、数千の UDP ポートを公開する必要がなくなります。これは重要です。なぜなら、小さく固定された UDP の表面領域はセキュリティと負荷分散が容易であり、大規模なパブリックポート範囲を予約することなくインフラをスケールできるからです。Kubernetes からのより良いインフラサポートと、表面領域の縮小によるセキュリティ向上により、この設計はクライアントに対して標準的な WebRTC の動作を維持し、SFU-less(SFU なし)の設計が私たちのワークロードにとって適切なデフォルトであったことを確認しています。セッションの大半はポイントツーポイントであり、レイテンシに敏感で、推論サービスが WebRTC ピアのように振る舞う必要がない場合にスケールしやすいものです。
より広範な教訓として、複雑性を追加する最適な場所は、すべてのバックエンドサービスやカスタムクライアント動作ではなく、薄いルーティング層にあります。ルーティングメタデータをプロトコルネイティブのフィールドにエンコードすることで、決定論的な最初のパケットルーティング、小さなパブリック UDP フットプリント、そして世界中のユーザーに近い場所にイングレスを配置するための十分な柔軟性が得られました。
いくつかの選択が特に重要でした:
- エッジ上でプロトコルの意味論を維持する。クライアントは標準的な WebRTC を使用し続けることで、ブラウザとモバイル間の相互運用性が保たれます。
- 厳密なセッション状態は単一の場所に保持します。Transceiver が ICE、DTLS、SRTP、およびセッションライフサイクルを管理し、リレーはパケットの転送のみを行います。
- セットアップ時に既に存在する情報に基づいてルーティングします。ICE の ufrag は、ホットパスルックアップ依存性を追加することなく、最初のパケットに対するルーティングフックを提供しました。
- カーネルバイパスに頼る前に一般的なケースを最適化します。SO_REUSEPORT の慎重な使用、スレッドピンニング、低割り当てパーシングを適切に組み合わせた狭い範囲の Go 実装で、私たちのワークロードには十分でした。
リアルタイム音声 AI は、インフラストラクチャがレイテンシを感じさせない場合にのみ機能します。私たちにとってそれは、クライアントが WebRTC から期待するものを変えずに、WebRTC デプロイメントの形状を変えることを意味しました。
原文を表示
Voice AI only feels natural if conversation moves at the speed of speech. When the network gets in the way, people hear it immediately as awkward pauses, clipped interruptions, or delayed barge-in. That matters for ChatGPT voice, for developers building with the Realtime API, for agents working in interactive workflows, and for models that need to process audio while a user is still talking.
At OpenAI’s scale, that translates into three concrete requirements:
- Global reach for more than 900 million weekly active users
- Fast connection setup so a user can start speaking as soon as a session begins
- Low and stable media round-trip time, with low jitter and packet loss, so turn-taking feels crisp
The team at OpenAI responsible for real-time AI interactions recently rearchitected our WebRTC stack to address three constraints that started to collide at scale: one-port-per-session media termination does not fit OpenAI infrastructure well, stateful ICE (Interactive Connectivity Establishment) and DTLS (Datagram Transport Layer Security) sessions need stable ownership, and global routing has to keep first-hop latency low. In this post, we walk through the split *relay plus transceiver* architecture we built to preserve standard WebRTC behavior for clients while changing how packets are routed inside OpenAI’s infrastructure.
WebRTC is an open standard for sending low-latency audio, video, and data between browsers, mobile apps, and servers. It’s often associated with peer-to-peer calling, but it’s also a practical foundation for client-to-server real-time systems because it standardizes the hard parts of interactive media: ICE for connectivity establishment and NAT (Network Address Translation) traversal, DTLS and SRTP (Secure Real-time Transport Protocol) for encrypted transport, codec negotiation for compressing and decoding audio, RTCP (Real-time Transport Control Protocol) for quality control, and client-side features such as echo cancellation and jitter buffering.
That standardization matters for AI products. Without WebRTC, every client would need a different answer for how to establish connectivity across NATs, encrypt media, negotiate codecs (the coder-decoders selected for transmission and decompression) and adapt to changing network conditions. With WebRTC, we can build on a protocol stack that’s already implemented across browsers and mobile platforms, focusing our own work on the infrastructure that connects real-time media to models.
We also build on the WebRTC ecosystem itself, including mature open-source implementations and the standard work that keeps browsers, mobile apps, and servers interoperable. Foundational work by Justin Uberti (one of WebRTC’s original architects) and Sean DuBois (creator and maintainer of Pion) made it possible for teams like ours to build on battle-tested media infrastructure rather than reinvent low-level transport, encryption, and congestion-control behavior. We’re fortunate that both Justin and Sean are now colleagues here at OpenAI, helping guide how we bring WebRTC and real-time AI closer together.
For AI, the most important property is that audio arrives as a continuous stream. A spoken agent can begin transcribing, reasoning, calling tools, or generating speech while the user is still talking, instead of waiting for a full upload. That’s the difference between a system that feels conversational and one that feels like push-to-talk.
Once we chose WebRTC, the next question was where to terminate it (where we’d accept and own the WebRTC connection—for example, at the edge) and how to connect those sessions to the inference backend. Termination matters because it determines how we handle real-time session state, media transport, routing, latency, and failure isolation.
An *SFU*, or selective forwarding unit, is a media server that receives one WebRTC stream from each participant and selectively forwards streams to the others. In this model, the SFU terminates a separate WebRTC connection for every participant, and the AI joins as another participant in the session. That can be a good fit for products that are inherently multiparty, such as group calls, classrooms, or collaborative meetings. It keeps audio codecs, RTCP messages, data channels, recording, and per-stream policy in one place.1
Even in client-to-AI products, an SFU is often the default starting point because it lets teams reuse one proven system for signaling, media routing, recording, observability, and future extensions such as human handoff or adding more participants.
Our workload is different. Most sessions are 1:1—one user talking to one model, or one application talking to one real-time agent—with latency sensitivity on every turn. For that shape of traffic, we chose a *transceiver* model: a WebRTC edge service terminates the client connection and then converts media and events into simpler internal protocols for model inference, transcription, speech generation, tool use, and orchestration.
In this design, the transceiver is the only service that owns the WebRTC session state, including ICE connectivity checks, the DTLS handshake, SRTP encryption keys, and session lifecycle. “Termination” here means the transceiver is the endpoint that completes those handshakes and encrypts or decrypts the media. Keeping that state in one place made session ownership easier to reason about, and it let backend services scale like ordinary services instead of acting as WebRTC peers themselves.
After choosing the transceiver model, our first implementation was a single Go service built on Pion that handled both signaling and media termination. It powers ChatGPT voice, the Realtime API’s WebRTC endpoint, and a number of research projects.
Operationally, the transceiver service does two jobs:
- Signaling: SDP negotiation, codec selection, ICE credentials, and session setup
- Media: Terminating downstream WebRTC connections and maintaining upstream connections to backend services for inference and orchestration
We wanted the service to run like the rest of our infrastructure: on Kubernetes, where workloads can scale up and down, and move across hosts as demand changes. But the conventional one-port-per-session WebRTC model fits that environment poorly, because it depends on large public UDP port ranges that are difficult to expose, secure, and preserve as pods are added, removed, or rescheduled.2
The first problem was the one-port-per-session model itself. At high concurrency, that means exposing and managing very large UDP port ranges.
- Cloud load balancers and Kubernetes services are not designed around tens of thousands of public UDP ports per service. Each additional range adds operational complexity in load balancer config, health checking, firewall policy, and rollout safety.3
- Large UDP port ranges are hard to secure because they expand the externally reachable surface area and make network policy harder to audit.
- They’re also a poor fit for autoscaling. Pods are constantly added, removed, and rescheduled in Kubernetes. Requiring each pod to reserve and advertise a large stable port range makes that elasticity brittle.4
This is why many WebRTC systems move toward a single UDP port per server, with application-level demultiplexing behind that port.5
Single-port-per-server designs solve port count, but they introduce a second problem: preserving ownership of each session across a fleet.
ICE and DTLS are stateful protocols. The process that created a session needs to keep receiving that session’s packets so it can validate connectivity checks, complete the DTLS handshake, decrypt SRTP, and process later session changes such as ICE restarts. If packets for the same session land on a different process, setup can fail or media can break.
That gave us a specific target: expose a small, fixed UDP surface to the public internet, while still routing every packet to the transceiver that owns the corresponding WebRTC session.
We evaluated several ways to get there, including TURN (Traversal Using Relays around NAT), where an edge relay terminates client allocations and forwards traffic on their behalf.2
Approach
Pros
Cons
Unique IP:port per session (also known as native direct UDP)
Direct client-to-server media path
No forwarding layer in the data path
Requires one public UDP port per session
Large port ranges are difficult to expose and secure
Poor fit for Kubernetes and cloud load balancers
Unique IP:port per server
Much smaller public UDP footprint than per-session exposure
One shared socket per server can demultiplex many sessions
Works cleanly on a single host, but not across a shared load-balanced fleet by itself
Session demultiplexing on a single host only helps after a packet reaches that host; across a load-balanced fleet, the first packet can still land on the wrong instance, so you still need a deterministic way to steer each session to the process that owns it
TURN relay (protocol-terminating)
Clients only need to reach the TURN relay address and port
Can centralize policy at the edge
TURN allocations add setup round trips
Moving or recovering allocations across TURN servers is still difficult
Stateless forwarder + stateful terminator (OpenAI’s relay + transceiver)
Small public UDP footprint
Transceiver still owns the full WebRTC session
Adds one forwarding hop before media reaches the owning transceiver
Requires custom coordination between relay and transceiver
The architecture we shipped splits packet routing from protocol termination. Signaling still reaches the transceiver for session setup, while media enters through the relay first. The relay is a lightweight UDP forwarding layer with a small public footprint, and the transceiver is the stateful WebRTC endpoint behind it.
The relay does not decrypt media, run ICE state machines, or participate in codec negotiation. It reads enough packet metadata to choose a destination, then forwards the packet to the transceiver that owns the session. The transceiver still sees a normal WebRTC flow and still owns all protocol state. From the client’s perspective, nothing about the WebRTC session changes.
First-packet routing is the key step in this setup. A relay has to route the first packet from a client before any session exists on the packet path itself rather than by pausing on an external lookup service.
Every WebRTC session already carries a protocol-native routing hook: the ICE username fragment, or *ufrag*, a short identifier exchanged during session setup and echoed in STUN connectivity checks. We generate the server-side ufrag so it contains just enough routing metadata for relay to infer the destination cluster and owning transceiver.
During signaling, the transceiver allocates session state and returns a shared relay VIP and UDP port in the SDP answer. A VIP is a virtual IP address fronting the relay fleet; combined with the port, it gives the client a single stable destination, such as 203.0.113.10:3478, even though many relay instances sit behind it. The client’s first media-path packet is usually a STUN (Session Traversal Utilities for NAT) binding request, which ICE uses to verify that packets can reach the advertised address.
Relay parses just enough of that first STUN packet to read the server ufrag, decode the routing hint, and forward the packet to the owning transceiver. Each transceiver listens on a shared UDP socket, meaning one operating system endpoint bound to an internal IP:port, not one socket per session. After the relay creates a session from the client’s source IP:port to that transceiver destination, subsequent DTLS, RTP, and RTCP packets flow within the session without re-decoding the ufrag.
The relay’s session is purposefully minimal, consisting only of an in-memory session to inform packet forwarding, along with necessary counters for monitoring and timers for session expiration and cleanup. This design choice maintains packet routing directly on the packet path. If a relay restarts and loses the session, the next STUN packet rebuilds the session from the ufrag routing hint. To make it even more reliable, a Redis cache is employed to hold the mapping of *<client IP + Port, transceiver IP + Port>* once the route is established so that it can be recovered much earlier, before the next STUN packet arrives.
Once we reduced the public UDP surface to a small number of stable addresses and ports, we could deploy the same relay pattern globally. Global Relay is our fleet of geographically distributed relay ingress points that all implement the same packet-forwarding behavior.
Broad geographic ingress shortens the first client-to-OpenAI hop because a packet can enter our network at a relay close to the user, in both geography and network topology, instead of crossing the public internet to a distant region first. In practical terms, that means lower latency, less jitter, and fewer avoidable loss bursts before traffic reaches our backbone.6
We use Cloudflare geo and proximity steering for signaling so the initial HTTP or WebSocket request reaches a nearby transceiver cluster. The request context dictates the session’s location and which Global Relay ingress point is advertised to the client. The SDP answer provides the Global Relay address, while the ufrag contains sufficient information for Global Relay to route media to the designated cluster and relay to route to the destination transceiver.
Together, geo-steered signaling and Global Relay put both setup and media on a nearby entry path while keeping the session anchored to one transceiver. That reduces the round-trip time for signaling and for the first ICE connectivity check, which directly shortens how long a user waits before speech can start.
We wrote the relay service in Go and kept the implementation narrow on purpose. On Linux, the kernel’s networking stack receives UDP packets from the machine’s network interface and delivers them to a socket, the operating system endpoint that a process reads after binding an IP:Port. Relay runs in userspace, so a regular Go process reads packet headers from that socket, updates a small amount of flow state, and forwards packets without terminating WebRTC. We did not need any kernel-bypass framework, which would let a userspace process poll network queues directly for higher packet rates but also add operational complexity.
Key design choices:
- No protocol termination: Relay parses only STUN headers/ufrag; it uses cached state for subsequent DTLS, RTP, and RTCP, keeping packets opaque.
- Ephemeral state: It maintains a small, short-timeout, in-memory map of client address to transceiver destination for flow state and observability.
- Horizontal scalability: Multiple relay instances run in parallel behind a load balancer. State is not hard WebRTC state, so restarts cause minimal traffic drops and quick flow recovery.
Efficiency measures:
- SO_REUSEPORT is a Linux socket option that allows multiple relay workers on the same machine to bind the same UDP port. The kernel then distributes incoming packets across those workers, which avoids a single read-loop bottleneck.
- runtime.LockOSThread pins each UDP-reading goroutine to a specific OS thread. Combined with SO_REUSEPORT, that tends to keep packets from the same flow (the source and destination IP:Port plus protocol) on the same CPU core, improving cache locality and reducing context switching.
- Pre-allocated buffers and minimal copying keep parsing and allocation overhead low to avoid garbage collection in Go.
This implementation handled our global real-time media traffic with a relatively small relay footprint, so we kept the simpler design instead of taking on a kernel bypass route.
This architecture lets us run WebRTC media in Kubernetes without exposing thousands of UDP ports. That matters because a smaller and fixed UDP surface is easier to secure and load balance, and it lets the infrastructure scale without reserving large public port ranges. With better infra support from Kubernetes and more security due to smaller surface area, this design also preserves standard WebRTC behavior for clients and confirms that an SFU-less design was the right default for our workload. Most of our sessions are point-to-point, latency-sensitive, and easier to scale when inference services don’t need to behave like WebRTC peers.
The broader lesson is that the best place to add complexity is in a thin routing layer, not in every backend service, and not in custom client behavior. Encoding routing metadata into a protocol-native field gave us deterministic first-packet routing, a small public UDP footprint, and enough flexibility to place ingress close to users around the world.
A few choices were especially important:
- Preserve protocol semantics at the edge. Clients still speak standard WebRTC, which keeps browser and mobile interoperability intact.
- Keep hard session states in one place. Transceiver owns ICE, DTLS, SRTP, and session lifecycle; relay only forwards packets.
- Route on information already present in setup. The ICE ufrag gave us a first-packet routing hook without adding a hot-path lookup dependency.
- Optimize for the common case before reaching for kernel bypass. A narrow Go implementation with careful use of SO_REUSEPORT, thread pinning, and low-allocation parsing was enough for our workload.
Real-time voice AI only works when infrastructure makes latency feel invisible. For us, that meant changing the shape of our WebRTC deployment without changing what clients expect from WebRTC itself.
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み