Как OpenAI осигурява гласов AI с ниска латентност в голям мащаб
От Yi Zhang и William McDonald, членове на техническия екип
Гласовият AI се усеща естествено само ако разговорът се движи със скоростта на речта. Когато мрежата пречи, хората веднага го усещат като неловки паузи, прекъсвания с отрязани думи или забавено включване в разговора. Това е важно за ChatGPT Voice, за разработчиците, които изграждат решения с Realtime API, за Агентите, работещи в интерактивни работни потоци, и за моделите, които трябва да обработват аудио, докато потребителят все още говори.
В мащаба на OpenAI това се свежда до три конкретни изисквания:
- Глобален обхват за над 900 милиона активни потребители седмично
- Бързо установяване на връзка, за да може потребителят да започне да говори веднага щом започне сесията
- Ниско и стабилно време за двупосочно предаване на медията, с нисък джитър и малка загуба на пакети, така че редуването в разговора да е плавно
Екипът в OpenAI, който отговаря за AI взаимодействията в реално време, наскоро преработи архитектурата на нашия WebRTC стек, за да реши три ограничения, които започнаха да се сблъскват при голям мащаб: прекратяването на медията с по един порт на сесия не се вписва добре в инфраструктурата на OpenAI, сесиите с ICE (Interactive Connectivity Establishment) и DTLS (Datagram Transport Layer Security) със състояние изискват стабилно притежание, а глобалното маршрутизиране трябва да поддържа ниска латентност на първия скок. В тази публикация разглеждаме разделената архитектура relay plus transceiver, която изградихме, за да запазим стандартното поведение на WebRTC за клиентите, като същевременно променим начина, по който пакетите се маршрутизират в инфраструктурата на OpenAI.
WebRTC е отворен стандарт за изпращане на аудио, видео и данни с ниска латентност между браузъри, мобилни приложения и сървъри. Често се свързва с peer-to-peer разговори, но е и практична основа за системи в реално време между клиент и сървър, защото стандартизира трудните части на интерактивната медия: ICE за установяване на свързаност и преминаване през NAT (Network Address Translation), DTLS и SRTP (Secure Real-time Transport Protocol) за криптиран транспорт, договаряне на кодеци за компресиране и декодиране на аудио, RTCP (Real-time Transport Control Protocol) за контрол на качеството и функции от страната на клиента като потискане на ехото и буфериране на джитъра.
Тази стандартизация е важна за AI продуктите. Без WebRTC всеки клиент би имал нужда от различен отговор на въпроса как да установи свързаност през NAT, да криптира медията, да договори кодеци (кодерите и декодерите, избрани за предаване и декомпресиране) и да се адаптира към променящите се мрежови условия. С WebRTC можем да стъпим върху протоколен стек, който вече е реализиран в браузъри и мобилни платформи, като съсредоточим собствената си работа върху инфраструктурата, която свързва медията в реално време с моделите.
Ние също така стъпваме върху самата WebRTC екосистема, включително зрели имплементации с отворен код и работата по стандартите, която поддържа оперативната съвместимост между браузъри, мобилни приложения и сървъри. Основополагащата работа на Justin Uberti (един от първоначалните архитекти на WebRTC) и Sean DuBois (създател и поддръжник на Pion) направи възможно екипи като нашия да изграждат върху изпитана медийна инфраструктура, вместо да преоткриват транспортното ниво, криптирането и управлението на натоварването от ниско ниво. За нас е късмет, че и Justin, и Sean вече са наши колеги в OpenAI и помагат да насочваме сближаването между WebRTC и AI в реално време.
За AI най-важното свойство е, че аудиото пристига като непрекъснат поток. Един говорещ Агент може да започне да транскрибира, да извършва структурирано анализиране, да извиква инструменти или да генерира реч, докато потребителят все още говори, вместо да чака пълно качване. Това е разликата между система, която се усеща разговорна, и такава, която прилича на push-to-talk.
След като избрахме WebRTC, следващият въпрос беше къде да го прекратим (къде да приемем и поемем WebRTC връзката — например в периферията) и как да свържем тези сесии с бекенд системата за инференция. Точката на прекратяване е важна, защото определя как управляваме състоянието на сесиите в реално време, медийния транспорт, маршрутизирането, латентността и изолацията при отказ.
SFU, или selective forwarding unit, е медиен сървър, който получава по един WebRTC поток от всеки участник и избирателно препраща потоците към останалите. В този модел SFU прекратява отделна WebRTC връзка за всеки участник, а AI се присъединява като още един участник в сесията. Това може да е подходящо за продукти, които по природа са с много участници, като групови разговори, класни стаи или съвместни срещи. Така аудио кодеците, RTCP съобщенията, каналите за данни, записът и политиките за всеки поток се държат на едно място.1
Дори при продукти от клиент към AI SFU често е естествена начална точка, защото позволява на екипите да използват повторно една доказана система за сигнализация, маршрутизиране на медията, запис, наблюдаемост и бъдещи разширения като предаване към човек или добавяне на още участници.
Нашето натоварване е различно. Повечето сесии са 1:1 — един потребител говори с един модел или едно приложение говори с един Агент в реално време — при чувствителност към латентността във всеки ход. За такава форма на трафик избрахме модел transceiver: edge услуга за WebRTC прекратява клиентската връзка и след това преобразува медията и събитията в по-прости вътрешни протоколи за инференция на модел, транскрибиране, генериране на реч, използване на инструменти и оркестрация.
В този дизайн transceiver е единствената услуга, която притежава състоянието на WebRTC сесията, включително проверките за ICE свързаност, DTLS ръкостискането, ключовете за SRTP криптиране и жизнения цикъл на сесията. „Прекратяване“ тук означава, че transceiver е крайната точка, която завършва тези ръкостискания и криптира или декриптира медията. Поддържането на това състояние на едно място направи притежанието на сесиите по-лесно за разбиране и позволи на бекенд услугите да се мащабират като обикновени услуги, вместо самите те да действат като WebRTC партньори.
След като избрахме модела transceiver, първата ни реализация беше една Go услуга, изградена върху Pion, която обработваше както сигнализацията, така и прекратяването на медията. Тя осигурява ChatGPT Voice, WebRTC крайната точка на Realtime API и редица изследователски проекти.
От оперативна гледна точка услугата transceiver изпълнява две задачи:
- Сигнализация: SDP договаряне, избор на кодек, ICE идентификационни данни и настройване на сесията
- Медия: прекратяване на низходящи WebRTC връзки и поддържане на възходящи връзки към бекенд услуги за инференция и оркестрация
Искахме услугата да работи като останалата част от инфраструктурата ни: върху Kubernetes, където натоварванията могат да се мащабират нагоре и надолу и да се преместват между хостове според търсенето. Но традиционният WebRTC модел с по един порт на сесия се вписва лошо в тази среда, защото зависи от големи диапазони от публични UDP портове, които трудно се излагат, защитават и запазват, когато pod-овете се добавят, премахват или пренасрочват.2
Първият проблем беше самият модел с по един порт на сесия. При висока едновременност това означава излагане и управление на много големи диапазони от UDP портове.
- Облачните балансировчици на натоварване и услугите в Kubernetes не са проектирани за десетки хиляди публични UDP портове на услуга. Всеки допълнителен диапазон добавя оперативна сложност в конфигурацията на балансировчика, проверките за изправност, правилата на защитната стена и безопасността при пускане на промени.3
- Големите диапазони от UDP портове са трудни за защита, защото разширяват повърхността, достижима отвън, и правят мрежовите политики по-трудни за одит.
- Те също така не са подходящи за автоматично мащабиране. В Kubernetes pod-овете постоянно се добавят, премахват и пренасрочват. Изискването всеки pod да резервира и обявява голям стабилен диапазон от портове прави тази еластичност крехка.4
Затова много WebRTC системи се насочват към един UDP порт на сървър с демултиплексиране на ниво приложение зад този порт.5
Дизайните с по един порт на сървър решават проблема с броя на портовете, но въвеждат втори проблем: запазване на притежанието на всяка сесия в рамките на цял флот от инстанции.
ICE и DTLS са протоколи със състояние. Процесът, който е създал дадена сесия, трябва да продължи да получава пакетите за нея, за да може да валидира проверките за свързаност, да завърши DTLS ръкостискането, да декриптира SRTP и да обработва по-късни промени в сесията като рестартиране на ICE. Ако пакетите за една и съща сесия попаднат в различен процес, настройката може да се провали или медията да прекъсне.
Това ни даде ясна цел: да изложим малка, фиксирана UDP повърхност към публичния интернет, като същевременно всеки пакет продължи да се маршрутизира към transceiver-а, който притежава съответната WebRTC сесия.
Оценихме няколко начина да постигнем това, включително TURN (Traversal Using Relays around NAT), при който edge relay прекратява клиентските алокации и препраща трафика от тяхно име.2
Подход | Предимства | Недостатъци |
Уникален IP:port за всяка сесия (известно още като native direct UDP) | Директен медиен път от клиента до сървъра Няма слой за препращане в пътя на данните | Изисква един публичен UDP порт за всяка сесия Големите диапазони от портове са трудни за излагане и защита Не е подходящо за Kubernetes и облачни балансировчици на натоварване |
Уникален IP:port за всеки сървър | Много по-малък публичен UDP отпечатък в сравнение с излагането на портове за всяка сесия Един споделен socket на сървър може да демултиплексира много сесии | Работи добре на един хост, но не и самостоятелно в споделен флот зад балансировчик на натоварване Демултиплексирането на сесии на един хост помага едва след като пакетът достигне този хост; в рамките на флот зад балансировчик първият пакет все още може да попадне на грешната инстанция, така че все още е нужен детерминиран начин всяка сесия да се насочва към процеса, който я притежава |
TURN relay (с прекратяване на протокола) | Клиентите трябва да достигат само адреса и порта на TURN relay Позволява централизиране на политиките в периферията | TURN алокациите добавят допълнителни цикли на двупосочна комуникация при настройване Преместването или възстановяването на алокации между TURN сървъри остава трудно |
Статичен препращач без състояние + терминатор със състояние (relay + transceiver на OpenAI) | Малък публичен UDP отпечатък Transceiver продължава да притежава цялата WebRTC сесия | Добавя едно препращане, преди медията да достигне transceiver-а собственик Изисква персонализирана координация между relay и transceiver |
Архитектурата, която внедрихме, разделя маршрутизирането на пакетите от прекратяването на протокола. Сигнализацията все още достига до transceiver-а за настройване на сесията, докато медията първо влиза през relay. Relay е лек слой за UDP препращане с малък публичен отпечатък, а transceiver е WebRTC крайна точка със състояние зад него.
Relay не декриптира медията, не изпълнява ICE state machine и не участва в договарянето на кодеци. Той прочита достатъчно метаданни от пакета, за да избере дестинация, след което препраща пакета към transceiver-а, който притежава сесията. Transceiver-ът продължава да вижда нормален WebRTC поток и продължава да притежава цялото състояние на протокола. От гледна точка на клиента нищо в WebRTC сесията не се променя.
Маршрутизирането на първия пакет е ключовата стъпка в тази схема. Relay трябва да маршрутизира първия пакет от клиента, преди по самия път на пакета да съществува каквато и да е сесия, вместо да спира и да разчита на външна услуга за справка.
Всяка WebRTC сесия вече носи вграден в протокола механизъм за маршрутизиране: ICE username fragment, или ufrag, кратък идентификатор, обменян по време на настройването на сесията и повтарян в STUN проверките за свързаност. Ние генерираме ufrag от страната на сървъра така, че той да съдържа точно достатъчно метаданни за маршрутизиране, за да може relay да определи целевия клъстер и transceiver-а собственик.
По време на сигнализацията transceiver-ът заделя състояние на сесията и връща споделен relay VIP и UDP порт в SDP отговора. VIP е виртуален IP адрес пред флот от relay инстанции; в комбинация с порта той дава на клиента една стабилна дестинация, например `203.0.113.10:3478`, въпреки че зад нея стоят много relay инстанции. Първият пакет по медийния път от клиента обикновено е STUN (Session Traversal Utilities for NAT) binding заявка, която ICE използва, за да провери, че пакетите могат да достигнат обявения адрес.
Relay анализира само толкова от този първи STUN пакет, колкото е нужно, за да прочете server ufrag, да декодира маршрутизиращия намек и да препрати пакета към transceiver-а собственик. Всеки transceiver слуша на споделен UDP socket, тоест една крайна точка на операционната система, свързана към вътрешен IP:port, а не по един socket на сесия. След като relay създаде сесия от IP:port на клиента към тази дестинация на transceiver, следващите DTLS, RTP и RTCP пакети се движат в рамките на сесията без повторно декодиране на ufrag.
Сесията в relay умишлено е минимална и се състои само от сесия в паметта, която информира препращането на пакетите, заедно с нужните броячи за мониторинг и таймери за изтичане и почистване на сесиите. Този избор на дизайн поддържа маршрутизирането на пакетите директно по пътя на пакета. Ако relay се рестартира и загуби сесията, следващият STUN пакет я изгражда отново от маршрутизиращия намек в ufrag. За още по-голяма надеждност се използва Redis кеш, който съхранява съпоставянето на <IP на клиента + порт, IP на transceiver + порт>, след като маршрутът бъде установен, така че то да може да бъде възстановено много по-рано, още преди да пристигне следващият STUN пакет.
След като сведохме публичната UDP повърхност до малък брой стабилни адреси и портове, можехме да внедрим същия relay модел глобално. Global Relay е нашият флот от географски разпределени входни relay точки, които всички прилагат едно и също поведение за препращане на пакети.
Широкото географско входно покритие скъсява първия скок между клиента и OpenAI, защото пакетът може да влезе в нашата мрежа през relay близо до потребителя — както географски, така и по мрежова топология — вместо първо да пресича публичния интернет към отдалечен регион. На практика това означава по-ниска латентност, по-малко джитър и по-малко предотвратими пикове на загуба, преди трафикът да достигне нашия гръбнак.6
Използваме гео и proximity steering на Cloudflare за сигнализацията, така че първоначалната HTTP или WebSocket заявка да достига близък transceiver клъстер. Контекстът на заявката определя местоположението на сесията и коя входна точка на Global Relay се обявява на клиента. SDP отговорът предоставя адреса на Global Relay, а ufrag съдържа достатъчно информация, за да може Global Relay да маршрутизира медията към определения клъстер, а relay — към целевия transceiver.
Заедно географски насочваната сигнализация и Global Relay поставят както настройването, така и медията по близък входен път, като същевременно запазват сесията закрепена към един transceiver. Това намалява времето за двупосочно предаване както за сигнализацията, така и за първата ICE проверка за свързаност, което директно съкращава времето, което потребителят чака, преди да може да започне речта.
Написахме relay услугата на Go и нарочно запазихме реализацията тясно фокусирана. В Linux мрежовият стек на ядрото получава UDP пакети от мрежовия интерфейс на машината и ги доставя до socket — крайна точка на операционната система, от която процесът чете след свързване към IP:Port. Relay работи в потребителското пространство, така че обикновен Go процес чете заглавките на пакетите от този socket, обновява малко количество състояние на потока и препраща пакетите, без да прекратява WebRTC. Не ни беше нужна рамка за заобикаляне на ядрото, която би позволила на процес в потребителското пространство да проверява директно мрежовите опашки за по-високи скорости на пакетите, но би добавила и оперативна сложност.
Ключови проектни решения:
- Без прекратяване на протокола: Relay анализира само STUN заглавки/ufrag; за следващите DTLS, RTP и RTCP използва кеширано състояние, като запазва пакетите непрозрачни.
- Ефимерно състояние: Поддържа малка in-memory карта с кратък timeout от адрес на клиента към дестинация на transceiver за състоянието на потока и наблюдаемост.
- Хоризонтална мащабируемост: Множество relay инстанции работят паралелно зад балансировчик на натоварване. Състоянието не е твърдо WebRTC състояние, така че рестартите водят до минимални загуби на трафик и бързо възстановяване на потоците.
Мерки за ефективност:
SO_REUSEPORTе Linux опция за socket, която позволява на множество relay worker-и на една и съща машина да се свържат към един и същ UDP порт. След това ядрото разпределя входящите пакети между тези worker-и, което избягва тясно място в един цикъл за четене.runtime.LockOSThreadзакрепя всяка goroutine, която чете UDP, към конкретен OS thread. В комбинация съсSO_REUSEPORTтова обикновено държи пакетите от един и същи поток (IP:Port на източника и дестинацията плюс протоколът) на едно и също CPU ядро, което подобрява локалността на кеша и намалява превключването на контекст.- Предварително заделени буфери и минимално копиране поддържат нисък разход за анализ и алокация, за да се избегне garbage collection в Go.
Тази реализация обслужва глобалния ни медиен трафик в реално време с относително малък relay отпечатък, затова запазихме по-простия дизайн, вместо да поемаме по пътя на заобикаляне на ядрото.
Тази архитектура ни позволява да изпълняваме WebRTC медия в Kubernetes, без да излагаме хиляди UDP портове. Това е важно, защото една по-малка и фиксирана UDP повърхност е по-лесна за защита и балансиране на натоварването и позволява на инфраструктурата да се мащабира, без да резервира големи диапазони от публични портове. С по-добра инфраструктурна поддръжка от Kubernetes и повече сигурност заради по-малката повърхност, този дизайн също така запазва стандартното поведение на WebRTC за клиентите и потвърждава, че дизайн без SFU е правилният избор по подразбиране за нашето натоварване. Повечето ни сесии са point-to-point, чувствителни към латентността и по-лесни за мащабиране, когато услугите за инференция не трябва да се държат като WebRTC партньори.
По-общият извод е, че най-доброто място за добавяне на сложност е в тънък слой за маршрутизиране, а не във всяка бекенд услуга и не в персонализирано поведение на клиента. Кодирането на метаданни за маршрутизиране в поле, естествено за протокола, ни даде детерминирано маршрутизиране на първия пакет, малък публичен UDP отпечатък и достатъчно гъвкавост да разполагаме входните точки близо до потребителите по света.
Няколко решения бяха особено важни:
- Запазване на семантиката на протокола в периферията. Клиентите продължават да говорят стандартен WebRTC, което запазва оперативната съвместимост между браузъри и мобилни устройства.
- Съхраняване на сложните състояния на сесията на едно място. Transceiver притежава ICE, DTLS, SRTP и жизнения цикъл на сесията; relay само препраща пакети.
- Маршрутизиране по информация, която вече присъства при настройването. ICE ufrag ни даде механизъм за маршрутизиране на първия пакет, без да добавяме зависимост от справка по горещия път.
- Оптимизация за най-честия случай, преди да посягаме към заобикаляне на ядрото. Тясно фокусирана Go реализация с внимателна употреба на
SO_REUSEPORT, закрепване на нишки и анализ с малко алокации беше достатъчна за нашето натоварване.
Гласовият AI в реално време работи само когато инфраструктурата прави латентността почти незабележима. За нас това означаваше да променим формата на нашето WebRTC внедряване, без да променяме това, което клиентите очакват от самия WebRTC.
Автор
Източници
2. GitHub - l7mp/stunner: Kubernetes медиен шлюз за WebRTC(отваря се в нов прозорец)
3. WebRTC портове накратко [Примери] - BlogGeek.me(отваря се в нов прозорец)
4. Внедряване в Kubernetes - документация на LiveKit(отваря се в нов прозорец)
6. Cloudflare Calls: милиони разклоняващи се дървета до самото дъно(отваря се в нов прозорец)


