Како OpenAI испорачува Voice AI со ниска латентност во голем обем
Од Yi Zhang и William McDonald, членови на техничкиот персонал
Voice AI делува природно само ако разговорот се движи со брзината на говорот. Кога мрежата пречи, луѓето тоа веднаш го слушаат како непријатни паузи, отсечени прекини или одложено вклучување во разговор. Тоа е важно за ChatGPT voice, за програмерите што градат со Realtime API, за агентите што работат во интерактивни работни текови и за моделите што треба да обработуваат аудио додека корисникот сè уште зборува.
Во обемот на OpenAI, тоа се претвора во три конкретни барања:
- Глобален досег за повеќе од 900 милиони неделно активни корисници
- Брзо воспоставување врска за корисникот да може да почне да зборува веднаш штом ќе започне сесијата
- Ниско и стабилно двонасочно време на пренос за медиумите, со мало треперење и загуба на пакети, за смената во разговорот да делува прецизно
Тимот во OpenAI одговорен за интеракции со ВИ во реално време неодамна ја редизајнираше нашата WebRTC платформа за да одговори на три ограничувања што почнаа да се судираат при голем обем: завршувањето на медиумите со еден порт по сесија не се вклопува добро во инфраструктурата на OpenAI, stateful ICE (Interactive Connectivity Establishment) и DTLS (Datagram Transport Layer Security) сесиите бараат стабилна сопственост, а глобалното рутирање мора да ја одржи ниска латентноста на првиот скок. Во овој текст ја објаснуваме поделената архитектура релеј плус трансивер што ја изградивме за да го зачуваме стандардното WebRTC однесување за клиентите, додека го менуваме начинот на кој пакетите се рутираат во рамки на инфраструктурата на OpenAI.
WebRTC е отворен стандард за испраќање аудио, видео и податоци со ниска латентност меѓу прелистувачи, мобилни апликации и сервери. Често се поврзува со peer-to-peer повици, но е и практична основа за системи во реално време клиент-до-сервер бидејќи ги стандардизира тешките делови на интерактивните медиуми: ICE за воспоставување поврзување и NAT (Network Address Translation) traversal, DTLS и SRTP (Secure Real-time Transport Protocol) за шифриран пренос, договарање кодеци за компресирање и декодирање аудио, RTCP (Real-time Transport Control Protocol) за контрола на квалитетот и функции на клиентската страна како поништување ехо и jitter buffering.
Таа стандардизација е важна за производите со ВИ. Без WebRTC, секој клиент би барал различно решение за тоа како да воспостави поврзување преку NAT, да шифрира медиуми, да договори кодеци (кодери-декодери избрани за пренос и декомпресија) и да се приспособи на променливи мрежни услови. Со WebRTC, можеме да градиме врз протоколен стек што веќе е имплементиран во прелистувачите и мобилните платформи, фокусирајќи ја нашата работа на инфраструктурата што ги поврзува медиумите во реално време со моделите.
Се потпираме и на самиот WebRTC екосистем, вклучително и зрели open-source имплементации и стандардната работа што ги одржува интероперабилни прелистувачите, мобилните апликации и серверите. Основната работа на Justin Uberti (еден од оригиналните архитекти на WebRTC) и Sean DuBois (создавач и одржувач на Pion) им овозможи на тимови како нашиот да градат врз медиумска инфраструктура тестирана во пракса, наместо повторно да осмислуваат нисконивоен пренос, шифрирање и однесување за контрола на застој. Имаме среќа што и Justin и Sean сега се наши колеги тука во OpenAI, помагајќи да се насочи начинот на кој ги приближуваме WebRTC и ВИ во реално време.
За ВИ, најважното својство е тоа што аудиото пристигнува како континуиран тек. Говорен агент може да почне да транскрибира, да расудува, да повикува алатки или да генерира говор додека корисникот сè уште зборува, наместо да чека целосно прикачување. Тоа е разликата меѓу систем што делува разговорно и систем што делува како push-to-talk.
Откако го избравме WebRTC, следното прашање беше каде да го завршиме (каде ќе ја прифатиме и преземеме WebRTC врската — на пример, на edge) и како тие сесии да ги поврземе со inference backend. Завршувањето е важно затоа што одредува како ќе се справуваме со состојбата на сесијата во реално време, преносот на медиуми, рутирањето, латентноста и изолацијата на дефекти.
SFU, односно selective forwarding unit, е медиумски сервер што прима еден WebRTC тек од секој учесник и селективно ги препраќа тековите до другите. Во овој модел, SFU завршува посебна WebRTC врска за секој учесник, а ВИ се приклучува како уште еден учесник во сесијата. Тоа може да биде добро решение за производи што по природа се повеќестрани, како групни повици, училници или заеднички состаноци. Ги задржува аудио кодеците, RTCP пораките, data channels, снимањето и политиката по тек на едно место.1
Дури и кај производи клиент-до-ВИ, SFU често е почетниот избор затоа што им овозможува на тимовите повторно да користат еден докажан систем за сигнализација, рутирање медиуми, снимање, observability и идни проширувања како предавање на човек или додавање повеќе учесници.
Нашето оптоварување е поинакво. Повеќето сесии се 1:1 — еден корисник што разговара со еден модел или една апликација што разговара со еден агент во реално време — со чувствителност на латентност во секоја смена. За таков облик на сообраќај, избравме модел на transceiver: edge WebRTC услуга ја завршува клиентската врска, а потоа ги претвора медиумите и настаните во поедноставни внатрешни протоколи за model inference, транскрипција, генерирање говор, употреба на алатки и оркестрација.
Во овој дизајн, трансиверот е единствената услуга што ја поседува состојбата на WebRTC сесијата, вклучувајќи ICE проверки на поврзување, DTLS handshake, клучеви за SRTP шифрирање и животен циклус на сесијата. „Завршување“ овде значи дека трансиверот е крајната точка што ги завршува тие ракувања и ги шифрира или дешифрира медиумите. Задржувањето на таа состојба на едно место ја направи сопственоста на сесијата полесна за разбирање, а им овозможи на backend услугите да се скалираат како обични услуги, наместо самите да се однесуваат како WebRTC peers.
Откако го избравме моделот на трансивер, нашата прва имплементација беше единечна Go услуга изградена врз Pion што се справуваше и со сигнализација и со завршување на медиуми. Таа го придвижува ChatGPT voice, WebRTC крајната точка на Realtime API и голем број истражувачки проекти.
Оперативно, услугата за трансивер има две задачи:
- Сигнализација: SDP договарање, избор на кодек, ICE акредитиви и поставување сесија
- Медиуми: завршување на downstream WebRTC врски и одржување upstream врски кон backend услугите за inference и оркестрација
Сакавме услугата да работи како и остатокот од нашата инфраструктура: на Kubernetes, каде што оптоварувањата можат да се скалираат нагоре и надолу и да се преместуваат меѓу хостови како што се менува побарувачката. Но конвенционалниот WebRTC модел со еден порт по сесија слабо се вклопува во таа околина, бидејќи зависи од големи јавни UDP опсези на порти кои тешко се изложуваат, обезбедуваат и зачувуваат додека pod-овите се додаваат, отстрануваат или презакажуваат.2
Првиот проблем беше самиот модел со еден порт по сесија. При висока конкурентност, тоа значи изложување и управување со многу големи UDP опсези на порти.
- Cloud load balancers и Kubernetes services не се дизајнирани за десетици илјади јавни UDP порти по услуга. Секој дополнителен опсег додава оперативна сложеност во конфигурацијата на load balancer, проверките на здравје, firewall политиките и безбедноста при пуштање.3
- Големите UDP опсези на порти тешко се обезбедуваат бидејќи ја прошируваат надворешно достапната површина и ја отежнуваат ревизијата на мрежните политики.
- Исто така се слабо вклопуваат во автоматско скалирање. Pod-овите постојано се додаваат, отстрануваат и презакажуваат во Kubernetes. Барањето секој pod да резервира и објавува голем стабилен опсег на порти ја прави таа еластичност кршлива.4
Затоа многу WebRTC системи се движат кон еден UDP порт по сервер, со демултиплексирање на ниво на апликација зад тој порт.5
Дизајните со еден порт по сервер го решаваат бројот на порти, но воведуваат втор проблем: зачувување на сопственоста на секоја сесија низ целата флота.
ICE и DTLS се stateful протоколи. Процесот што создал сесија треба да продолжи да ги прима пакетите од таа сесија за да може да ги валидира проверките на поврзување, да го заврши DTLS handshake, да го дешифрира SRTP и да обработи подоцнежни промени во сесијата како ICE рестартирања. Ако пакетите за истата сесија пристигнат во друг процес, поставувањето може да не успее или медиумите да се нарушат.
Тоа ни даде конкретна цел: да изложиме мала, фиксна UDP површина кон јавниот интернет, а сепак секој пакет да го рутираме до трансиверот што ја поседува соодветната WebRTC сесија.
Оценивме неколку начини да стигнеме до таму, вклучувајќи TURN (Traversal Using Relays around NAT), каде edge relay завршува клиентски allocations и препраќа сообраќај во нивно име.2
Пристап | Предности | Недостатоци |
Единствена IP:port по сесија (познато и како природен директен UDP) | Директна медиумска патека клиент-до-сервер Нема слој за препраќање на патеката на податоците | Бара еден јавен UDP порт по сесија Големите опсези на порти тешко се изложуваат и обезбедуваат Слабо се вклопува во Kubernetes и cloud load balancers |
Единствена IP:port по сервер | Многу помала јавна UDP површина отколку изложување по сесија Еден споделен socket по сервер може да демултиплексира многу сесии | Работи чисто на еден хост, но не и самостојно низ споделена load-balanced флота Демултиплексирањето на сесиите на еден хост помага само откако пакетот ќе стигне до тој хост; низ load-balanced флота, првиот пакет и понатаму може да заврши на погрешна инстанца, па сè уште ви треба детерминистички начин секоја сесија да се насочи кон процесот што ја поседува |
TURN relay (со завршување на протокол) | Клиентите треба да стигнат само до адресата и портот на TURN relay Може да ја централизира политиката на edge | TURN allocations додаваат двонасочни циклуси при поставувањето Преместувањето или обновувањето allocations низ TURN сервери и понатаму е тешко |
Stateless forwarder + stateful terminator (relay + transceiver на OpenAI) | Мала јавна UDP површина Трансиверот и понатаму ја поседува целата WebRTC сесија | Додава едно препраќање пред медиумите да стигнат до трансиверот-сопственик Бара приспособена координација меѓу relay и transceiver |
Архитектурата што ја испорачавме го дели рутирањето на пакети од завршувањето на протоколот. Сигнализацијата и понатаму стигнува до трансиверот за поставување на сесијата, додека медиумите прво влегуваат преку relay. Relay е лесен слој за UDP препраќање со мала јавна површина, а трансиверот е stateful WebRTC крајна точка зад него.
Relay не ги дешифрира медиумите, не извршува ICE state machines и не учествува во договарањето кодеци. Тој чита доволно метаподатоци од пакетот за да избере дестинација, а потоа го препраќа пакетот до трансиверот што ја поседува сесијата. Трансиверот и понатаму гледа нормален WebRTC тек и сè уште ја поседува целата состојба на протоколот. Од перспектива на клиентот, ништо не се менува во WebRTC сесијата.
Рутирањето на првиот пакет е клучниот чекор во ова поставување. Relay мора да го рутира првиот пакет од клиент пред во самата патека на пакетот да постои каква било сесија, наместо да запира на надворешна lookup услуга.
Секоја WebRTC сесија веќе носи природна кука за рутирање во самиот протокол: ICE username fragment, или ufrag, краток идентификатор разменет при поставување на сесијата и повторен во STUN проверките на поврзување. Ние го генерираме server-side ufrag така што содржи токму доволно метаподатоци за рутирање за relay да може да го заклучи одредишниот кластер и трансиверот-сопственик.
За време на сигнализацијата, трансиверот ја алоцира состојбата на сесијата и враќа споделен relay VIP и UDP порт во SDP одговорот. VIP е виртуелна IP адреса пред relay флотата; во комбинација со портот, му дава на клиентот единечна стабилна дестинација, како `203.0.113.10:3478`, иако зад неа има многу relay инстанци. Првиот пакет на медиумската патека од клиентот обично е STUN (Session Traversal Utilities for NAT) binding request, што ICE го користи за да провери дека пакетите можат да стигнат до објавената адреса.
Relay анализира само доволно од тој прв STUN пакет за да го прочита server ufrag, да го декодира навестувањето за рутирање и да го препрати пакетот до трансиверот-сопственик. Секој трансивер слуша на споделен UDP socket, што значи една крајна точка на оперативниот систем врзана за внатрешна IP:port, а не еден socket по сесија. Откако relay ќе создаде сесија од IP:port изворот на клиентот до таа дестинација на трансиверот, последователните DTLS, RTP и RTCP пакети течат во рамки на сесијата без повторно декодирање на ufrag.
Сесијата на relay е намерно минимална и се состои само од сесија во меморија што го информира препраќањето пакети, заедно со неопходни бројачи за мониторинг и тајмери за истекување и чистење на сесијата. Овој дизајн го задржува рутирањето пакети директно на патеката на пакетите. Ако relay се рестартира и ја изгуби сесијата, следниот STUN пакет повторно ја гради сесијата од навестувањето за рутирање во ufrag. За да биде уште посигурно, се користи Redis кеш за да го чува мапирањето на <IP на клиент + Порт, IP на трансивер + Порт> штом рутата ќе се воспостави, за да може да се обнови многу порано, пред да пристигне следниот STUN пакет.
Откако ја сведовме јавната UDP површина на мал број стабилни адреси и порти, можевме глобално да го распоредиме истиот relay образец. Global Relay е нашата флота од географски распределени relay влезни точки што сите го спроведуваат истото однесување за препраќање пакети.
Широкиот географски влез го скратува првиот скок од клиентот до OpenAI затоа што пакетот може да влезе во нашата мрежа преку relay близок до корисникот, и географски и според мрежната топологија, наместо прво да го минува јавниот интернет до далечен регион. Во практична смисла, тоа значи пониска латентност, помал jitter и помалку загуби што можеле да се избегнат пред сообраќајот да стигне до нашата backbone мрежа.6
Користиме Cloudflare geo и proximity steering за сигнализација за почетното HTTP или WebSocket барање да стигне до блискиот трансиверски кластер. Контекстот на барањето ја диктира локацијата на сесијата и која влезна точка на Global Relay ќе му биде објавена на клиентот. SDP одговорот ја обезбедува адресата на Global Relay, додека ufrag содржи доволно информации Global Relay да ги рутира медиумите до назначениот кластер, а relay да ги рутира до одредишниот трансивер.
Заедно, географски насочената сигнализација и Global Relay ги поставуваат и поставувањето и медиумите на блиска влезна патека, додека сесијата останува закотвена за еден трансивер. Тоа го намалува двонасочното време за сигнализацијата и за првата ICE проверка на поврзување, што директно го скратува времето што корисникот го чека пред да почне говорот.
Услугата relay ја напишавме во Go и намерно ја задржавме имплементацијата тесно насочена. На Linux, мрежниот стек на кернелот прима UDP пакети од мрежниот интерфејс на машината и ги доставува до socket, крајната точка на оперативниот систем што процесот ја чита откако ќе врзе IP:Port. Relay работи во userspace, па обичен Go процес ги чита заглавјата на пакетите од тој socket, ажурира мала количина состојба на текот и ги препраќа пакетите без да завршува WebRTC. Не ни беше потребна ниедна рамка за kernel bypass, која би му овозможила на userspace процес директно да ги анкетира мрежните редици за повисоки стапки на пакети, но и би додала оперативна сложеност.
Клучни дизајнерски избори:
- Без завршување на протокол: Relay анализира само STUN headers/ufrag; за следните DTLS, RTP и RTCP користи кеширана состојба, задржувајќи ги пакетите непрозирни.
- Привремена состојба: Одржува мала in-memory мапа со краток timeout од адресата на клиентот до дестинацијата на трансиверот за состојба на текот и можност за набљудување.
- Хоризонтална скалабилност: Повеќе relay инстанци работат паралелно зад load balancer. Состојбата не е тврда WebRTC состојба, па рестартите предизвикуваат минимални падови на сообраќајот и брзо обновување на тековите.
Мерки за ефикасност:
SO_REUSEPORTе Linux socket опција што им дозволува на повеќе relay работници на истата машина да врзат ист UDP порт. Потоа кернелот ги распределува влезните пакети меѓу тие работници, со што се избегнува тесно грло во една read-loop.runtime.LockOSThreadја прикачува секоја UDP-reading goroutine на конкретна OS нишка. Во комбинација соSO_REUSEPORT, тоа обично ги задржува пакетите од истиот тек (изворна и одредишна IP:Port плус протокол) на истото CPU јадро, подобрувајќи ја локалноста на кешот и намалувајќи го префрлањето контекст.- Претходно алоцирани бафери и минимално копирање ги задржуваат ниски трошоците за анализа и алокација за да се избегне garbage collection во Go.
Оваа имплементација се справи со нашиот глобален сообраќај на медиуми во реално време со релативно мал relay отпечаток, па го задржавме поедноставниот дизајн наместо да преземеме kernel bypass пристап.
Оваа архитектура ни овозможува да работиме со WebRTC медиуми во Kubernetes без да изложуваме илјадници UDP порти. Тоа е важно затоа што помала и фиксна UDP површина полесно се обезбедува и балансира, и ѝ овозможува на инфраструктурата да се скалира без резервирање големи јавни опсези на порти. Со подобра infra поддршка од Kubernetes и поголема безбедност поради помалата површина, овој дизајн исто така го зачувува стандардното WebRTC однесување за клиентите и потврдува дека дизајн без SFU бил вистинскиот стандарден избор за нашето оптоварување. Повеќето наши сесии се point-to-point, чувствителни на латентност и полесни за скалирање кога inference услугите не треба да се однесуваат како WebRTC peers.
Пошироката поука е дека најдоброто место за додавање сложеност е во тенок слој за рутирање, а не во секоја backend услуга и не во приспособено клиентско однесување. Кодирањето метаподатоци за рутирање во поле што веќе е природно за протоколот ни даде детерминистичко рутирање на првиот пакет, мала јавна UDP површина и доволна флексибилност за да поставиме влезни точки блиску до корисниците низ светот.
Неколку избори беа особено важни:
- Зачувајте ја семантиката на протоколот на edge. Клиентите и понатаму користат стандарден WebRTC, што ја зачувува интероперабилноста меѓу прелистувачите и мобилните уреди.
- Чувајте ја тешката состојба на сесијата на едно место. Трансиверот ги поседува ICE, DTLS, SRTP и животниот циклус на сесијата; relay само ги препраќа пакетите.
- Рутирајте според информации што веќе постојат при поставувањето. ICE ufrag ни даде кука за рутирање на првиот пакет без да додадеме зависност од lookup во жешката патека.
- Оптимизирајте за најчестиот случај пред да посегнете по kernel bypass. Тесно насочена Go имплементација со внимателна употреба на
SO_REUSEPORT, прикачување на нишки и анализа со малку алокации беше доволна за нашето оптоварување.
Voice AI во реално време функционира само кога инфраструктурата прави латентноста да делува невидливо. За нас, тоа значеше да ја смениме формата на нашето WebRTC распоредување без да го смениме она што клиентите го очекуваат од самиот WebRTC.
Автор
Референци
2. GitHub - l7mp/stunner: Kubernetes медиумски gateway за WebRTC(се отвора во нов прозорец)
3. WebRTC порти накратко [Примери] - BlogGeek.me(се отвора во нов прозорец)
4. Распоредување на Kubernetes - LiveKit docs(се отвора во нов прозорец)
6. Cloudflare Calls: милиони каскадни дрва сè до дното(се отвора во нов прозорец)


