Як OpenAI забезпечує голосовий ШІ з низькою затримкою в глобальному масштабі
Ї Чжан і Вільям Макдональд, технічні співробітники
Голосовий ШІ здається природним лише тоді, коли розмова рухається зі швидкістю мовлення. Коли мережа заважає, люди відразу чують це як незручні паузи, обірвані перебивання або затримку при перехопленні ініціативи в розмові. Це важливо для голосового ChatGPT, для розробників, які створюють рішення на основі Realtime API, для агентів, що працюють в інтерактивних робочих процесах, і для моделей, яким потрібно обробляти аудіо, поки користувач ще говорить.
У масштабах OpenAI це означає три конкретні вимоги:
- Глобальне охоплення для понад 900 мільйонів щотижневих активних користувачів
- Швидке встановлення з’єднання, щоб користувач міг почати говорити щойно почнеться сеанс
- Низький і стабільний час проходження медіатрафіку в обидва боки, з низькими джитером і втратою пакетів, щоб чергування реплік відчувалося чітким
Команда OpenAI, відповідальна за взаємодію з ШІ в реальному часі, нещодавно перебудувала наш стек WebRTC, щоб розв’язати три обмеження, які почали конфліктувати в масштабі: завершення медіасеансу за схемою один порт на сеанс погано узгоджується з інфраструктурою OpenAI, сеансам ICE (Interactive Connectivity Establishment) і DTLS (Datagram Transport Layer Security) зі станом потрібен стабільний власник, а глобальна маршрутизація має зберігати низьку затримку на першому переході. У цій публікації ми розповімо про розділену архітектуру relay + 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) для контролю якості, а також клієнтські функції, як-от ехопридушення та буферизація джитера.
Така стандартизація важлива для ШІ-продуктів. Без WebRTC кожному клієнту потрібне було б окреме рішення для встановлення з’єднання через NAT, шифрування медіа, узгодження кодеків (кодерів-декодерів, вибраних для передавання та розпакування) й адаптації до змін мережевих умов. Завдяки WebRTC ми можемо спиратися на стек протоколів, уже реалізований у браузерах і мобільних платформах, і зосереджувати власну роботу на інфраструктурі, що з’єднує медіа реального часу з моделями.
Ми також спираємося на саму екосистему WebRTC, зокрема на зрілі open-source реалізації та стандартизаційну роботу, що забезпечує сумісність браузерів, мобільних застосунків і серверів. Фундаментальна робота Джастіна Уберті (одного з початкових архітекторів WebRTC) і Шона Дюбуа (творця й супровідника Pion) дала командам на кшталт нашої змогу будувати на перевіреній медіаінфраструктурі, а не винаходити заново низькорівневу поведінку транспорту, шифрування й контролю перевантаження. Нам пощастило, що і Джастін, і Шон тепер працюють разом із нами в OpenAI та допомагають визначати, як зближувати WebRTC і ШІ реального часу.
Для ШІ найважливіша властивість полягає в тому, що аудіо надходить як безперервний потік. Голосовий агент може почати транскрибування, міркування, виклик інструментів або генерацію мовлення, поки користувач іще говорить, а не чекати на повне завантаження. Саме це відрізняє систему, яка сприймається як розмовна, від системи, що більше нагадує push-to-talk.
Щойно ми обрали WebRTC, наступним питанням стало, де саме його завершувати (де ми прийматимемо й утримуватимемо WebRTC-з’єднання — наприклад, на edge-рівні) і як з’єднати ці сеанси з бекендом інференсу. Точка завершення важлива, бо саме вона визначає, як ми працюємо зі станом сеансів реального часу, передаванням медіа, маршрутизацією, затримкою та ізоляцією збоїв.
SFU, або selective forwarding unit, — це медіасервер, який отримує по одному потоку WebRTC від кожного учасника та вибірково пересилає потоки іншим. У цій моделі SFU завершує окреме WebRTC-з’єднання для кожного учасника, а ШІ приєднується як ще один учасник сеансу. Це добре підходить для продуктів, які за своєю природою є багатосторонніми, наприклад групових дзвінків, занять чи спільних нарад. Такий підхід зосереджує аудіокодеки, повідомлення RTCP, канали даних, запис і політики для окремих потоків в одному місці.1
Навіть у продуктах формату клієнт-ШІ SFU часто є стандартною відправною точкою, бо дає змогу командам повторно використовувати одну перевірену систему для сигналізації, маршрутизації медіа, запису, спостережуваності та майбутніх розширень, як-от передавання людині або додавання нових учасників.
Наше навантаження інше. Більшість сеансів — це 1:1: один користувач говорить з однією моделлю або один застосунок взаємодіє з одним агентом реального часу, і затримка критична на кожному обміні репліками. Для такого типу трафіку ми обрали модель transceiver: edge-сервіс WebRTC завершує клієнтське з’єднання, а потім перетворює медіа й події на простіші внутрішні протоколи для інференсу моделей, транскрибування, генерації мовлення, використання інструментів і оркестрації.
У цьому дизайні трансивер — єдиний сервіс, який володіє станом сеансу WebRTC, зокрема перевірками зв’язності ICE, рукостисканням DTLS, ключами шифрування SRTP і життєвим циклом сеансу. «Завершення» тут означає, що саме трансивер є кінцевою точкою, яка виконує ці рукостискання й шифрує або дешифрує медіа. Збереження цього стану в одному місці спростило розуміння того, хто володіє сеансом, а також дало змогу бекенд-сервісам масштабуватися як звичайним сервісам, а не діяти як WebRTC-піри.
Після вибору моделі трансивера наша перша реалізація була єдиним Go-сервісом на базі Pion, який обробляв і сигналізацію, і завершення медіа. Саме він забезпечує ChatGPT Voice, WebRTC-кінцеву точку Realtime API та низку дослідницьких проєктів.
З операційного погляду сервіс трансивера виконує дві задачі:
- Сигналізація: узгодження 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-поверхню, але при цьому все одно маршрутизувати кожен пакет до трансивера, який володіє відповідним сеансом WebRTC.
Ми оцінили кілька способів цього досягти, зокрема TURN (Traversal Using Relays around NAT), де edge-реле завершує клієнтські алокації та пересилає трафік від їхнього імені.2
Підхід | Переваги | Недоліки |
Унікальна IP-адреса:порт для кожного сеансу (також відомо як нативний прямий UDP) | Прямий медіашлях від клієнта до сервера Відсутній шар пересилання на шляху даних | Потрібен один публічний UDP-порт на кожен сеанс Великі діапазони портів складно відкривати й захищати Погано підходить для Kubernetes і хмарних балансувальників навантаження |
Унікальна IP-адреса:порт для кожного сервера | Значно менша публічна UDP-поверхня, ніж при виділенні порту на кожен сеанс Один спільний сокет на сервер може демультиплексувати багато сеансів | Добре працює на одному хості, але саме по собі не працює коректно в межах спільного флоту за балансувальником навантаження Демультиплексування сеансів на одному хості допомагає лише після того, як пакет досяг цього хоста; у флоті за балансувальником перший пакет усе ще може потрапити не на той екземпляр, тож усе одно потрібен детермінований спосіб спрямувати кожен сеанс до процесу, який ним володіє |
TURN relay (із завершенням протоколу) | Клієнтам потрібно досягати лише адреси й порту TURN relay Дає змогу централізувати політики на edge-рівні | TURN-алокації додають додаткові проходи під час налаштування Переміщати або відновлювати алокації між TURN-серверами все ще складно |
Forwarder без стану + terminator зі станом (relay + transceiver від OpenAI) | Невелика публічна UDP-поверхня Трансивер і далі володіє повним сеансом WebRTC | Додає один крок пересилання, перш ніж медіа досягне трансивера-власника Потребує спеціальної координації між relay і transceiver |
Архітектура, яку ми розгорнули, відокремлює маршрутизацію пакетів від завершення протоколу. Сигналізація, як і раніше, надходить до трансивера для налаштування сеансу, тоді як медіатрафік спочатку заходить через relay. Relay — це легкий шар пересилання UDP із невеликою публічною поверхнею, а трансивер — це кінцева точка WebRTC зі станом за ним.
Relay не дешифрує медіа, не запускає автомати стану ICE і не бере участі в узгодженні кодеків. Він зчитує достатньо метаданих пакета, щоб вибрати призначення, а потім пересилає пакет до трансивера, який володіє сеансом. Трансивер і далі бачить звичайний потік WebRTC та як і раніше володіє всім станом протоколу. З точки зору клієнта в сеансі WebRTC нічого не змінюється.
Маршрутизація першого пакета — ключовий крок у цій схемі. Relay має маршрутизувати перший пакет від клієнта до того, як на самому шляху пакета з’явиться будь-який сеанс, а не зупинятися для зовнішнього пошуку.
Кожен сеанс WebRTC уже має власний, вбудований у протокол механізм маршрутизації: фрагмент імені користувача ICE, або ufrag, короткий ідентифікатор, яким обмінюються під час налаштування сеансу й який повторюється в перевірках зв’язності STUN. Ми генеруємо серверний ufrag так, щоб він містив лише достатньо метаданих маршрутизації, аби relay міг визначити цільовий кластер і трансивер-власник.
Під час сигналізації трансивер виділяє стан сеансу й повертає в SDP-відповіді спільний relay VIP та UDP-порт. VIP — це віртуальна IP-адреса перед флотом relay; разом із портом вона дає клієнту єдине стабільне призначення, наприклад `203.0.113.10:3478`, навіть якщо за ним стоїть багато екземплярів relay. Перший пакет клієнта на шляху медіа зазвичай є запитом прив’язки STUN (Session Traversal Utilities for NAT), який ICE використовує, щоб перевірити, чи можуть пакети досягти оголошеної адреси.
Relay розбирає лише стільки першого пакета STUN, скільки потрібно, щоб прочитати серверний ufrag, декодувати підказку маршрутизації й переслати пакет до трансивера-власника. Кожен трансивер слухає спільний UDP-сокет, тобто одну кінцеву точку операційної системи, прив’язану до внутрішньої IP-адреси й порту, а не окремий сокет на кожен сеанс. Після того як relay створює сеанс від вихідної IP-адреси й порту клієнта до призначення цього трансивера, подальші пакети DTLS, RTP і RTCP проходять у межах сеансу без повторного декодування ufrag.
Сеанс у relay навмисно мінімалістичний: це лише сеанс у пам’яті для спрямування пакетів, а також потрібні лічильники для моніторингу та таймери для завершення й очищення сеансів. Такий вибір дизайну підтримує маршрутизацію пакетів безпосередньо на шляху пакета. Якщо relay перезапуститься й втратить сеанс, наступний пакет STUN відновить його з підказки маршрутизації в ufrag. Щоб зробити систему ще надійнішою, використовується кеш Redis, який зберігає відповідність <IP + порт клієнта, IP + порт трансивера> після встановлення маршруту, щоб її можна було відновити значно раніше — ще до надходження наступного пакета STUN.
Щойно ми зменшили публічну UDP-поверхню до невеликої кількості стабільних адрес і портів, ми змогли глобально розгорнути той самий шаблон relay. Global Relay — це наш флот географічно розподілених точок входу relay, які реалізують однакову поведінку пересилання пакетів.
Широко розподілений географічний ingress скорочує перший перехід від клієнта до OpenAI, бо пакет може потрапити в нашу мережу через relay, що розташований близько до користувача — як географічно, так і з погляду мережевої топології, — замість того щоб спочатку перетинати публічний інтернет до далекого регіону. На практиці це означає нижчу затримку, менший джитер і менше втрат, яких можна було б уникнути, до того як трафік потрапить у нашу магістраль.6
Для сигналізації ми використовуємо гео- та proximity steering від Cloudflare, щоб початковий HTTP- або WebSocket-запит потрапляв до найближчого кластера трансиверів. Контекст запиту визначає розташування сеансу й те, яку саме точку входу Global Relay буде оголошено клієнту. Відповідь SDP надає адресу Global Relay, тоді як ufrag містить достатньо інформації, щоб Global Relay маршрутизував медіа до призначеного кластера, а relay — до цільового трансивера.
Разом сигналізація з геоскеруванням і Global Relay спрямовують і налаштування, і медіа через близький шлях входу, водночас утримуючи сеанс прив’язаним до одного трансивера. Це зменшує час проходження в обидва боки для сигналізації та для першої перевірки зв’язності ICE, а отже безпосередньо скорочує час очікування користувача до початку мовлення.
Ми написали сервіс relay на Go і навмисно зробили реалізацію вузькою. У Linux мережевий стек ядра отримує UDP-пакети від мережевого інтерфейсу машини й передає їх сокету — кінцевій точці операційної системи, з якої процес читає дані після прив’язки до IP:Port. Relay працює в просторі користувача, тому звичайний процес Go читає заголовки пакетів із цього сокета, оновлює невеликий обсяг стану потоку й пересилає пакети, не завершуючи WebRTC. Нам не знадобився жоден фреймворк обходу ядра, який дозволив би процесу в просторі користувача опитувати мережеві черги безпосередньо для вищих швидкостей пакетів, але водночас додав би операційну складність.
Ключові дизайнерські рішення:
- Без завершення протоколу: Relay аналізує лише заголовки STUN/ufrag; для подальших DTLS, RTP і RTCP він використовує кешований стан, залишаючи пакети непрозорими.
- Ефемерний стан: Він підтримує невелику карту в пам’яті з коротким тайм-аутом, що відображає адресу клієнта на призначення трансивера для стану потоку й спостережуваності.
- Горизонтальна масштабованість: Кілька екземплярів relay працюють паралельно за балансувальником навантаження. Цей стан не є жорстким станом WebRTC, тому перезапуски спричиняють мінімальні втрати трафіку й швидке відновлення потоків.
Заходи ефективності:
SO_REUSEPORT— це параметр Linux-сокета, який дозволяє кільком працівникам relay на одній машині прив’язувати той самий UDP-порт. Потім ядро розподіляє вхідні пакети між цими працівниками, що усуває вузьке місце у вигляді одного циклу читання.runtime.LockOSThreadзакріплює кожну goroutine, що читає UDP, за конкретним потоком ОС. У поєднанні зSO_REUSEPORTце зазвичай утримує пакети одного потоку (IP:Port джерела й призначення плюс протокол) на одному ядрі CPU, покращуючи локальність кешу та зменшуючи перемикання контексту.- Попередньо виділені буфери й мінімальне копіювання утримують низькими витрати на аналіз і виділення пам’яті, щоб уникати збирання сміття в Go.
Ця реалізація впоралася з нашим глобальним медіатрафіком реального часу, маючи відносно невеликий relay-футпринт, тому ми залишили простіший дизайн замість того, щоб брати на себе шлях з обходом ядра.
Ця архітектура дає нам змогу запускати WebRTC-медіа в Kubernetes, не відкриваючи тисячі UDP-портів. Це важливо, бо менша й фіксована UDP-поверхня простіша для захисту та балансування навантаження, а також дозволяє інфраструктурі масштабуватися без резервування великих діапазонів публічних портів. Завдяки кращій підтримці інфраструктури з боку Kubernetes і вищій безпеці через меншу площу атаки цей дизайн також зберігає стандартну поведінку WebRTC для клієнтів і підтверджує, що дизайн без SFU був правильним стандартним вибором для нашого навантаження. Більшість наших сеансів — це з’єднання point-to-point, чутливі до затримки, які легше масштабувати, коли сервіси інференсу не повинні поводитися як WebRTC-піри.
Ширший висновок полягає в тому, що найкраще місце для додавання складності — це тонкий шар маршрутизації, а не кожен бекенд-сервіс і не нестандартна поведінка клієнта. Кодування метаданих маршрутизації в поле, яке вже є частиною протоколу, дало нам детерміновану маршрутизацію першого пакета, невелику публічну UDP-поверхню та достатню гнучкість, щоб розміщувати точки входу близько до користувачів у всьому світі.
Особливо важливими були кілька рішень:
- Зберігати семантику протоколу на edge-рівні. Клієнти, як і раніше, використовують стандартний WebRTC, що зберігає сумісність браузерів і мобільних платформ.
- Тримати складний стан сеансу в одному місці. Трансивер володіє ICE, DTLS, SRTP і життєвим циклом сеансу; relay лише пересилає пакети.
- Маршрутизувати на основі інформації, яка вже є під час налаштування. ICE ufrag дав нам механізм маршрутизації першого пакета без додавання залежності від пошуку на гарячому шляху.
- Оптимізувати типові випадки, перш ніж переходити до обходу ядра. Вузької реалізації на Go з ретельним використанням
SO_REUSEPORT, закріплення потоків і аналізу з низьким рівнем алокацій виявилося достатньо для нашого навантаження.
Голосовий ШІ реального часу працює лише тоді, коли інфраструктура робить затримку майже непомітною. Для нас це означало змінити форму розгортання WebRTC, не змінюючи того, чого клієнти очікують від самого WebRTC.
Автор
Посилання
2. GitHub - l7mp/stunner: Медіашлюз Kubernetes для WebRTC(відкривається у новому вікні)
3. Порти WebRTC коротко [Приклади] - BlogGeek.me(відкривається у новому вікні)
4. Розгортання в Kubernetes - документація LiveKit(відкривається у новому вікні)
6. Cloudflare Calls: мільйони каскадних дерев до самого низу(відкривається у новому вікні)


