Come OpenAI offre Voice AI a bassa latenza su larga scala
Di Yi Zhang e William McDonald, Membri dello Staff Tecnico
Voice AI risulta naturale solo se la conversazione procede alla velocità del parlato. Quando la rete si mette di mezzo, le persone lo percepiscono subito sotto forma di pause innaturali, interruzioni troncate o inserimenti vocali ritardati. Questo è importante per ChatGPT Voice, per gli sviluppatori che usano la Realtime API, per gli agenti che operano in flussi di lavoro interattivi e per i modelli che devono elaborare l’audio mentre l’utente sta ancora parlando.
Alla scala di OpenAI, questo si traduce in tre requisiti concreti:
- Copertura globale per oltre 900 milioni di utenti attivi settimanali
- Impostazione rapida della connessione, così l’utente può iniziare a parlare non appena inizia una sessione
- Latenza audio bassa e stabile, con poco jitter e perdite di pacchetti ridotte, così la conversazione resta fluida e naturale
Il team di OpenAI che si occupa delle interazioni IA in tempo reale ha recentemente riprogettato la propria architettura WebRTC per risolvere tre vincoli che, su larga scala, avevano iniziato a entrare in conflitto tra loro: il modello con una porta per sessione per la terminazione delle connessioni WebRTC si adattava poco all’infrastruttura OpenAI, le sessioni ICE (Interactive Connectivity Establishment) e DTLS (Datagram Transport Layer Security) con stato richiedevano una gestione stabile, e il routing globale doveva mantenere bassa la latenza del primo hop. In questo post raccontiamo l’architettura separata relay + transceiver che abbiamo progettato per mantenere il comportamento WebRTC standard lato client, cambiando al tempo stesso il modo in cui i pacchetti vengono instradati all’interno dell’infrastruttura OpenAI.
WebRTC è uno standard aperto per inviare audio, video e dati a bassa latenza tra browser, app mobili e server. È spesso associato alle chiamate peer-to-peer, ma è anche una base pratica per i sistemi in tempo reale client-server perché standardizza gli aspetti più difficili degli scambi multimediali in tempo reale: ICE per l’instaurazione della connettività e l’attraversamento del NAT (Network Address Translation), DTLS e SRTP (Secure Real-time Transport Protocol) per il trasporto cifrato, la negoziazione dei codec per comprimere e decodificare l’audio, RTCP (Real-time Transport Control Protocol) per il controllo della qualità, e funzionalità lato client come la cancellazione dell’eco e il buffering del jitter.
Questa standardizzazione è fondamentale per i prodotti IA. Senza WebRTC, ogni client dovrebbe gestire in modo diverso aspetti come la connettività attraverso i NAT, la trasmissione sicura dei flussi audio e video, la negoziazione dei codec (gli algoritmi usati per comprimere e decomprimere l’audio) e l’adattamento alle condizioni di rete variabili. Con WebRTC possiamo invece basarci su uno stack di protocolli già supportato da browser e piattaforme mobili, concentrando il nostro lavoro sull’infrastruttura che collega i flussi audio/video in tempo reale ai modelli
Ci basiamo anche sull’ecosistema WebRTC, comprese implementazioni open source mature e il lavoro di standardizzazione che garantisce l’interoperabilità tra browser, app mobili e server. Il contributo fondamentale di Justin Uberti (uno degli architetti originali di WebRTC) e Sean DuBois (creatore e maintainer di Pion) ha permesso a team come il nostro di partire da un’infrastruttura multimediale già collaudata, senza dover reinventare componenti complessi come trasporto, crittografia e controllo della congestione. Siamo fortunati che oggi Justin e Sean facciano parte di OpenAI e contribuiscano ad avvicinare sempre di più WebRTC e l’IA in tempo reale.
Per l’IA, la proprietà più importante è che l’audio arrivi come flusso continuo. Un agente vocale può iniziare a trascrivere, ragionare, chiamare strumenti o generare voce mentre l’utente sta ancora parlando, invece di aspettare un caricamento completo. È questa la differenza tra un sistema che sembra conversazionale e uno che sembra push-to-talk.
Una volta scelto WebRTC, la domanda successiva è stata dove terminarlo (dove avremmo accettato e gestito la connessione WebRTC, per esempio nei punti di ingresso della rete) e come collegare queste sessioni al backend di inferenza. La terminazione è importante perché determina come gestiamo lo stato delle sessioni in tempo reale, il trasporto dei flussi audio/video, il routing, la latenza e l’isolamento dei guasti.
Un SFU, o Selective Forwarding Unit, è un server che riceve un flusso WebRTC da ciascun partecipante e inoltra selettivamente i flussi agli altri. In questo modello, l’SFU termina una connessione WebRTC separata per ogni partecipante e l’IA si unisce alla sessione come un partecipante aggiuntivo. Può essere una buona soluzione per prodotti intrinsecamente multiparte, come chiamate di gruppo, classi virtuali o riunioni collaborative. Inoltre, mantiene in un unico punto codec audio, messaggi RTCP, canali dati, registrazione e policy per singolo flusso.1
Anche nei prodotti client-to-AI, un SFU è spesso il punto di partenza predefinito perché consente ai team di riutilizzare un unico sistema collaudato per signaling, routing dei flussi audio/video, registrazione, osservabilità ed estensioni future come il passaggio a un operatore umano o l’aggiunta di altri partecipanti.
Il nostro carico di lavoro è diverso. La maggior parte delle sessioni è 1:1: un utente che parla con un modello, oppure un’applicazione che dialoga con un agente in tempo reale, con sensibilità alla latenza a ogni turno. Per questa forma di traffico, abbiamo scelto un modello transceiver: un servizio WebRTC nei punti di ingresso della rete termina la connessione del client e poi converte flussi audio/video ed eventi in protocolli interni più semplici per inferenza del modello, trascrizione, generazione vocale, uso di strumenti e orchestrazione.
In questo design, il transceiver è l’unico servizio che possiede lo stato della sessione WebRTC, inclusi i controlli di connettività ICE, l’handshake DTLS, le chiavi di cifratura SRTP e il ciclo di vita della sessione. Qui “terminazione” significa che il transceiver è l’endpoint che completa questi handshake e cifra o decifra i flussi audio/video. Mantenere questo stato in un unico posto ha reso più semplice ragionare sulla proprietà della sessione e ha permesso ai servizi backend di scalare come normali servizi invece di comportarsi essi stessi come peer WebRTC.
Dopo aver scelto il modello transceiver, la nostra prima implementazione consisteva in un singolo servizio Go basato su Pion che gestiva sia il signaling sia la terminazione delle connessioni WebRTC. Questo servizio è alla base di ChatGPT Voice, dell’endpoint WebRTC della Realtime API e di diversi progetti di ricerca.
Dal punto di vista operativo, il servizio transceiver svolge due compiti:
- Signaling: negoziazione SDP, selezione dei codec, credenziali ICE e impostazione della sessione
- Flussi audio/video: terminazione delle connessioni WebRTC downstream e mantenimento delle connessioni upstream ai servizi backend per inferenza e orchestrazione
Volevamo che il servizio funzionasse come il resto della nostra infrastruttura: su Kubernetes, dove i carichi di lavoro possono aumentare o diminuire di scala e spostarsi tra host al variare della domanda. Ma il modello WebRTC convenzionale con una porta per sessione si adatta male a quell’ambiente, perché dipende da ampi intervalli di porte UDP pubbliche difficili da esporre, proteggere e preservare man mano che i pod vengono aggiunti, rimossi o riprogrammati.2
Il primo problema era proprio il modello con una porta per sessione. Con un’elevata concorrenza, questo significa esporre e gestire intervalli molto ampi di porte UDP.
- I load balancer cloud e i servizi Kubernetes non sono progettati attorno a decine di migliaia di porte UDP pubbliche per servizio. Ogni intervallo aggiuntivo aumenta la complessità operativa nella configurazione del load balancer, nei controlli di integrità, nelle policy firewall e nella sicurezza dei rollout.3
- Gli ampi intervalli di porte UDP sono difficili da proteggere perché espandono la superficie raggiungibile dall’esterno e rendono più difficile verificare le policy di rete.
- Si adattano anche male all’autoscaling. In Kubernetes i pod vengono continuamente aggiunti, rimossi e riprogrammati. Richiedere a ogni pod di riservare e pubblicizzare un ampio intervallo stabile di porte rende questa elasticità fragile.4
Ecco perché molti sistemi WebRTC si stanno orientando verso una singola porta UDP per server, con gestione di più sessioni a livello applicativo dietro quella porta.5
I design con una singola porta per server risolvono il problema del numero di porte, ma ne introducono un secondo: garantire che ogni sessione continui a essere gestita dalla stessa istanza all’interno della flotta.
Il processo che crea una sessione deve continuare a riceverne i pacchetti per poter convalidare i controlli di connettività, completare l’handshake DTLS, decifrare SRTP e gestire modifiche successive della sessione, come i riavvii ICE. Se i pacchetti della stessa sessione vengono instradati verso un processo diverso, l’impostazione della connessione può fallire oppure il flusso audio/video può interrompersi.
Questo ci ha dato un obiettivo preciso: esporre su internet una superficie UDP pubblica ridotta e stabile, continuando però a instradare ogni pacchetto verso il transceiver responsabile della relativa sessione WebRTC.
Abbiamo valutato diversi modi per arrivarci, incluso TURN (Traversal Using Relays around NAT), in cui un relay edge termina le allocazioni client e inoltra il traffico come intermediario.2
Approccio | Vantaggi | Svantaggi |
IP:porta univoco per sessione (noto anche come UDP diretto nativo) | Percorso media diretto client-server Nessun livello di inoltro nel percorso dati | Richiede una porta UDP pubblica per sessione Gli ampi intervalli di porte sono difficili da esporre e proteggere Si adatta male a Kubernetes e ai load balancer cloud |
IP univoco per server | Impronta UDP pubblica molto più ridotta rispetto all’esposizione per sessione Un socket condiviso per server può gestire molte sessioni | Funziona bene su un singolo host, ma da solo non su una flotta condivisa con bilanciamento del carico La gestione di più sessioni su un singolo host aiuta solo dopo che un pacchetto ha raggiunto quell’host; in una flotta con bilanciamento del carico, il primo pacchetto può comunque arrivare all’istanza sbagliata, quindi serve ancora un modo deterministico per instradare ogni sessione verso il processo che la gestisce |
Relay TURN (con terminazione del protocollo) | I client devono raggiungere solo l’indirizzo e la porta del relay TURN Può centralizzare le policy nei punti di ingresso della rete | Le allocazioni TURN aggiungono round trip di configurazione Spostare o recuperare allocazioni tra server TURN resta comunque difficile |
Inoltratore senza stato + terminatore con stato (relay + transceiver di OpenAI) | Impronta UDP pubblica ridotta Il transceiver continua a gestire l’intera sessione WebRTC | Aggiunge un hop di inoltro prima che i media raggiungano il transceiver responsabile Richiede coordinamento personalizzato tra relay e transceiver |
L’architettura che abbiamo distribuito separa il routing dei pacchetti dalla terminazione del protocollo. Il signaling continua a raggiungere il transceiver per l’impostazione della sessione, mentre i flussi audio/video passano prima attraverso il relay. Il relay è un livello leggero di inoltro UDP con un’impronta pubblica ridotta, e il transceiver è l’endpoint WebRTC con stato dietro di esso.
Il relay non decifra i flussi audio/video, non gestisce le macchine a stati ICE e non partecipa alla negoziazione dei codec. Legge solo la quantità minima di metadati necessaria per determinare la destinazione del pacchetto, quindi lo inoltra al transceiver responsabile della sessione. Il transceiver continua a gestire un normale flusso WebRTC e mantiene tutto lo stato del protocollo. Dal punto di vista del client, la sessione WebRTC continua a comportarsi nello stesso modo.
L’instradamento del primo pacchetto è il passaggio chiave di questa configurazione. Il relay deve essere in grado di instradare il primo pacchetto proveniente dal client prima ancora che esista una sessione sul percorso di rete, evitando di dipendere da un servizio di lookup esterno.
Ogni sessione WebRTC porta già con sé un aggancio di routing nativo del protocollo: il frammento del nome utente ICE, o ufrag, un identificatore breve scambiato durante l’impostazione della sessione e ripetuto nei controlli di connettività STUN. Generiamo l’ufrag lato server in modo che contenga appena abbastanza metadati di routing da permettere al relay di dedurre il cluster di destinazione e il transceiver responsabile.
Durante il signaling, il transceiver alloca lo stato della sessione e restituisce nell’SDP answer un VIP relay condiviso e una porta UDP. Un VIP è un indirizzo IP virtuale davanti alla flotta di relay; combinato con la porta, fornisce al client una singola destinazione stabile, come `203.0.113.10:3478`, anche se dietro ci sono molte istanze relay. Il primo pacchetto sul percorso dei flussi audio/video del client è di solito una richiesta di binding STUN (Session Traversal Utilities for NAT), che ICE usa per verificare che i pacchetti possano raggiungere l’indirizzo pubblicizzato.
Il relay analizza solo la parte necessaria del primo pacchetto STUN per leggere l’ufrag del server, decodificare le informazioni di routing e inoltrare il pacchetto al transceiver responsabile della sessione. Ogni transceiver ascolta su un socket UDP condiviso, cioè un endpoint del sistema operativo associato a un IP interno, non un socket per ogni sessione. Dopo che il relay crea una sessione dall’IP sorgente del client alla destinazione del transceiver, i pacchetti DTLS, RTP e RTCP successivi vengono instradati all’interno della sessione senza dover decodificare nuovamente l’ufrag.
La sessione del relay è volutamente minimale e consiste solo in una sessione in memoria per guidare l’inoltro dei pacchetti, insieme ai contatori necessari per il monitoraggio e ai timer per scadenza e pulizia della sessione. Questa scelta di design mantiene il routing direttamente sul percorso di rete. Se un relay si riavvia e perde la sessione, il pacchetto STUN successivo ricostruisce la sessione dall’indizio di routing dell’ufrag. Per renderlo ancora più affidabile, viene usata una cache Redis per conservare la mappatura di <IP client + porta, IP transceiver + porta> una volta stabilita la rotta, in modo che possa essere recuperata molto prima, prima che arrivi il successivo pacchetto STUN.
Una volta ridotta la superficie UDP pubblica a un piccolo numero di indirizzi e porte stabili, abbiamo potuto distribuire lo stesso schema relay a livello globale. Global Relay è la nostra flotta di punti di ingresso relay distribuiti geograficamente che implementano tutti lo stesso comportamento di inoltro dei pacchetti.
Una presenza geografica distribuita dei punti di ingresso riduce il primo hop tra il client e OpenAI, perché i pacchetti possono entrare nella nostra rete tramite un relay vicino all’utente, sia dal punto di vista geografico sia della topologia di rete, invece di attraversare prima internet verso una regione distante. In pratica, questo si traduce in minore latenza, meno jitter e meno perdite di pacchetti prima che il traffico raggiunga la nostra backbone.6
Usiamo la geolocalizzazione e il proximity steering di Cloudflare per il signaling, così la richiesta HTTP o WebSocket iniziale raggiunge un cluster transceiver vicino. Il contesto della richiesta determina la posizione della sessione e quale punto di ingresso Global Relay viene pubblicizzato al client. L’SDP answer fornisce l’indirizzo Global Relay, mentre l’ufrag contiene informazioni sufficienti perché Global Relay instradi i flussi audio/video al cluster designato e il relay li instradi al transceiver di destinazione.
Insieme, signaling geolocalizzato e Global Relay fanno sì che sia l’impostazione della sessione sia i flussi audio/video seguano un percorso di ingresso vicino all’utente, mantenendo al contempo la sessione ancorata a un singolo transceiver. Questo riduce il tempo di andata e ritorno per il signaling e per il primo controllo di connettività ICE, accorciando direttamente il tempo di attesa prima che l’utente possa iniziare a parlare.
Abbiamo scritto il servizio relay in Go e mantenuto intenzionalmente essenziale l’implementazione. Su Linux, lo stack di rete del kernel riceve i pacchetti UDP dall’interfaccia di rete della macchina e li consegna a un socket, cioè l’endpoint del sistema operativo associato a un IP:porta che un processo usa per leggere i dati. Relay gira in userspace, quindi un normale processo Go legge gli header dei pacchetti da quel socket, aggiorna una quantità minima di stato del flusso e inoltra i pacchetti senza terminare WebRTC. Non abbiamo avuto bisogno di framework di kernel bypass, che permettono a un processo in userspace di interrogare direttamente le code di rete per gestire volumi di pacchetti più elevati, ma al costo di una maggiore complessità operativa.
Scelte progettuali chiave:
- Nessuna terminazione del protocollo: relay analizza solo header STUN/ufrag; per i successivi pacchetti DTLS, RTP e RTCP usa lo stato in cache senza interpretarne il contenuto.
- Stato effimero: mantiene una piccola mappa in memoria, con timeout breve, dall’indirizzo client alla destinazione del transceiver per lo stato del flusso e l’osservabilità.
- Scalabilità orizzontale: più istanze relay vengono eseguite in parallelo dietro un load balancer. Lo stato non corrisponde allo stato completo di una sessione WebRTC, quindi i riavvii causano perdite di traffico minime e un rapido recupero dei flussi.
Misure di efficienza:
SO_REUSEPORTè un’opzione socket Linux che consente a più worker relay sulla stessa macchina di associare la stessa porta UDP. Il kernel distribuisce quindi i pacchetti in ingresso tra questi worker, evitando un collo di bottiglia dovuto a un unico ciclo di lettura.runtime.LockOSThreadvincola ogni goroutine che legge UDP a uno specifico thread del sistema operativo. In combinazione conSO_REUSEPORT, questo tende a mantenere i pacchetti dello stesso flusso (IP sorgente e destinazione più protocollo) sullo stesso core della CPU, migliorando la località della cache e riducendo i cambi di contesto.- Buffer preallocati e copia minima mantengono basso l’overhead di parsing e allocazione, per evitare la garbage collection in Go.
Questa implementazione ha gestito il nostro traffico globale di flussi audio/video in tempo reale con un’infrastruttura relay relativamente ridotta, quindi abbiamo mantenuto il design più semplice invece di adottare una soluzione basata su kernel bypass.
Questa architettura ci permette di gestire flussi WebRTC in Kubernetes senza esporre migliaia di porte UDP. È importante perché una superficie UDP più piccola e stabile è più facile da proteggere e bilanciare, e consente all’infrastruttura di scalare senza dover riservare ampi intervalli di porte pubbliche. Grazie a un’integrazione più efficace con Kubernetes e a una maggiore sicurezza dovuta alla superficie ridotta, questo design preserva anche il comportamento WebRTC standard lato client e conferma che un’architettura senza SFU era la scelta più adatta per il nostro carico di lavoro. La maggior parte delle nostre sessioni è point-to-point, sensibile alla latenza e più facile da scalare quando i servizi di inferenza non devono comportarsi come peer WebRTC.
L’insegnamento più ampio è che il posto migliore in cui aggiungere complessità è un sottile livello di routing, non ogni servizio backend e nemmeno un comportamento client personalizzato. Codificare metadati di routing in un campo nativo del protocollo ci ha dato routing deterministico del primo pacchetto, una ridotta impronta UDP pubblica e sufficiente flessibilità per posizionare l’ingresso vicino agli utenti in tutto il mondo.
Alcune scelte sono state particolarmente importanti:
- Preservare la semantica del protocollo nei punti di ingresso della rete. I client continuano a parlare WebRTC standard, mantenendo intatta l’interoperabilità con browser e dispositivi mobili.
- Mantenere gli stati di sessione critici in un unico punto. Il transceiver gestisce ICE, DTLS, SRTP e il ciclo di vita della sessione, mentre il relay si limita a inoltrare i pacchetti.
- Instradare in base a informazioni già presenti nell’impostazione. L’ufrag ICE ci ha fornito un aggancio di routing del primo pacchetto senza aggiungere una dipendenza di lookup sul percorso critico.
- Ottimizzare per il caso più comune prima di ricorrere al kernel bypass. Per il nostro carico di lavoro è stata sufficiente un’implementazione Go essenziale con uso attento di
SO_REUSEPORT, vincolo dei thread e parsing a bassa allocazione.
Voice AI in tempo reale funziona solo quando l’infrastruttura rende la latenza impercettibile. Per noi, questo ha significato cambiare la forma del deployment WebRTC senza cambiare ciò che i client si aspettano da WebRTC.
Autore
Riferimenti
2. GitHub - l7mp/stunner: Un gateway multimediale Kubernetes per WebRTC(si apre in una nuova finestra)
3. Porte WebRTC in breve [Esempi] - BlogGeek.me(si apre in una nuova finestra)
4. Distribuzione su Kubernetes - documentazione LiveKit(si apre in una nuova finestra)
6. Cloudflare Calls: milioni di alberi a cascata fino in fondo(si apre in una nuova finestra)


