Как OpenAI обеспечивает голосовой ИИ с низкой задержкой в масштабном режиме

Авторы: И Чжан и Уильям Макдональд, сотрудники технического персонала

Голосовой ИИ кажется естественным только тогда, когда беседа движется со скоростью обычной речи. Если сеть создает помехи, люди сразу замечают это по неловким паузам, обрывающимся фразам или задержкам при попытке перебить собеседника. Это критически важно для голосового ChatGPT, для разработчиков, использующих Realtime API, для агентов в интерактивных рабочих процессах, а также для моделей, которым необходимо обрабатывать аудио втот момент, когда пользователь еще продолжает говорить.

В масштабах OpenAI это трансформируется в три конкретных требования:

  • Глобальный охват для более чем 900 миллионов активных пользователей еженедельно
  • Быстрое установление соединения, чтобы пользователь мог начать говорить сразу после запуска сеанса
  • Низкое и стабильное время кругового рейса медиаданных с минимальным джиттером и потерей пакетов, чтобы смена реплик ощущалась четкой

Команда OpenAI, отвечающая за взаимодействие с ИИ в реальном времени, недавно переработала наш стек WebRTC, чтобы решить три ограничения, которые начали сталкиваться друг с другом при масштабировании: завершение медиапотоков по схеме «один порт на сеанс» плохо вписывается в инфраструктуру OpenAI, ссостояния сеансов ICE (Interactive Connectivity Establishment) и DTLS (Datagram Transport Layer Security) требуют стабильного владения, а глобальная маршрутизация должна обеспечивать низкую задержку на первом хопе. В этой статье мы подробно описываем гибридную архитектуру релей плюс трансивер, которую мы создали для сохранения стандартного поведения WebRTC для клиентов при одновременном изменении маршрутизации пакетов внутри инфраструктуры OpenAI.

WebRTC позволяет нам создавать ИИ-продукты реального времени

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, включая зрелые реализации с открытым исходнымходом и стандартную работу, обеспечивающую совместимость браузеров, мобильных приложений и серверов. Фундаментальный вклад Джастина Уберти (одного из первоначальных архитекторов WebRTC) и Шона Дюбуа (создателя и мейнтейнера Pion) позволил таким командам, как наша, использовать проверенную в боях медиаинфраструктуру, вместо того чтобы заново изобретать низкоуровневый транспорт, шифрование и поведение механизмов контроля перегрузки. Нам повезло, что и Джастин, и Шон теперь работают вместе с нами в OpenAI, помогая определять то, как мы сближаем WebRTC и ИИ реального времени.

Для ИИ важнейшим свойством является то, что аудио поступает непрерывным потоком. Голосовой агент может начать транскрипцию, рассуждения, вызов инструментов или генерацию речи в тот момент, когда пользователь еще продолжает говорить, вместо того чтобы ждать полной загрузки. В этом разница между системой, которая воспринимается как собеседник, и системой, похожей на рацию (push-to-talk).

Выбор медиаархитектуры

После выбора WebRTC следующим вопросом стало то, где именно его завершать (где принимать соединение WebRTC и управлять им — например, на периметре) и как подключать эти сеансы к бэкенду инференса. Завершение соединения имеет решающее значение, поскольку оно определяет, как мы обрабатываем состояние сеанса в реальном времени, передачу медиаданных, маршрутизацию, задержки и изоляцию сбоев.

Option 1: The SFU approach includes AI as a WebRTC participant

SFU (selective forwarding unit — селективный блок пересылки) представляет собой медиасервер, который принимает один поток WebRTC от каждого участника и избирательно пересылает потоки остальным. В этой модели SFU завершает отдельное соединение WebRTC для каждого участника, а ИИ присоединяется к сеансу в качестве еще одного участника. Это может отлично подходить для продуктов, изначально рассчитанных на множество участников, таких как групповые звонки, виртуальные классы или совместные совещания. Такая архитектура удерживает аудиокодеки, сообщения RTCP, каналы данных, запись и политики для каждого потока в едином месте.1

Даже в продуктах формата «клиент — ИИ» SFU часто служит отправной точкой по умолчанию, поскольку позволяет командам повторно использовать единую проверенную систему для сигнализации, маршрутизации медиаданных, записи, наблюдаемости и будущих расширений, таких как переключение на живого оператора или добавление новых участников.

Option 2: The transceiver approach terminates WebRTC at the edge and converts to a backend protocol

Наша рабочая нагрузка устроена иначе. Большинство сеансов имеют формат 1:1 — один пользователь общается с одной моделью или одно приложение взаимодействует с одним агентом реального времени, — причем требования к задержке критически важны для каждого реплики. Для такой конфигурации трафика мы выбрали модель трансивера: граничная служба WebRTC завершает клиентское соединение, а затем преобразует медиаданные и события в более простые внутренние протоколы для инференса модели, транскрипции, генерации речи, использования инструментов и оркестрации.

В этой конструкции трансивер является единственной службой, которая владеет состоянием сеанса WebRTC, включая проверки подключения ICE, рукопожатие DTLS, ключи шифрования SRTP и жизненный цикл сеанса. «Завершение» здесь означает, что трансивер выступает в качестве конечной точки, которая завершает эти рукопожатия и шифрует или расшифровывает медиаданные. Хранение этого состояния в одном месте упрощает логику работы с сеансами и позволяет бэкенд-службам масштабироваться как обычным сервисам, а не действовать как самостоятельные узлы WebRTC.

Главная проблема развертывания: WebRTC встречает Kubernetes

После выбора модели трансивера нашей первой реализацией стала единая служба на Go, построенная на базе Pion, которая обрабатывала как сигнализацию, так и завершение медиапотоков. Она обеспечивает работу голосового режима ChatGPT, эндпоинта WebRTC для Realtime API и ряда исследовательских проектов.

С операционной точки зрения служба трансивера выполняет две задачи:

  • Сигнализация: SDP-согласование, выбор кодеков, учетные данные ICE и настройка сеанса
  • Медиа: Завершение нисходящих соединений WebRTC и поддержание восходящих соединений с бэкенд-службами для инференса и оркестрации

Мы хотели, чтобы служба работала так же, как и остальная часть нашей инфраструктуры: на Kubernetes, где рабочие нагрузки могут масштабироваться вверх и вниз, а также перемещаться между хостами по мере изменения спроса. Однако традиционная модель WebRTC «один порт на сеанс» плохо подходит для этой среды, поскольку она зависит от очень больших диапазонов общедоступных UDP-портов, которые трудно выставлять наружу, защищать и сохранять при добавлении, удалении или перепланировании подов.2

Исчерпание портов

Первой проблемой стала сама модель «один порт на сеанс». При высокой степени параллелизма это означает необходимость выделения и администрирования огромных диапазонов UDP-портов.

  • Облачные балансировщики нагрузки и службы Kubernetes не рассчитаны на десятки тысяч публичных UDP-портов на одну службу. Каждый дополнительный диапазон увеличивает операционную сложность в конфигурации балансировщика нагрузки, проверке работоспособности, правилах брандмауэра и безопасности развертывания.3
  • Большие диапазоны UDP-портов сложно защитить, так как они расширяют внешнюю поверхность атаки и затрудняют аудирование сетевых политик.
  • Кроме того, они плохо подходят для автоскейлинга. В Kubernetes поды постоянно добавляются, удаляются и перепланируются. Требование к каждому поду резервировать и анонсировать большой стабильный диапазон портов делает такую эластичность хрупкой.4

Именно поэтому многие системы WebRTC переходят на использование единого UDP-порта на сервер с демультиплексированием на уровне приложения за этим портом.5

Привязка к состоянию (stickiness)

Архитектуры с одним портом на сервер решают проблему количества портов, но порождают вторую: сохранение владения каждым сеансом в рамках всего пуска (флота) серверов.

ICE и DTLS — это протоколы с сохранением состояния (stateful). Процесс, который создал сеанс, должен продолжать получать пакеты этого сеанса, чтобы подтверждать проверки подключения, завершать рукопожатие DTLS, расшифровывать SRTP и обрабатывать последующие изменения сеанса, такие как перезапуски ICE. Если пакеты одного и того же сеанса попадают в другой процесс, инициализация может завершиться сбоем или прерваться передача медиаданных.

Это сформировало для нас конкретную цель: предоставить публичному интернету небольшой фиксированный пул UDP-портов и при этом маршрутизировать каждый пакет на тот трансивер, которому принадлежит соответствующий сеанс WebRTC.

Сравнение медиаархитектур WebRTC

Мы оценили несколько способов достижения этой цели, включая TURN (Traversal Using Relays around NAT), где граничный релей завершает клиентские аллокации и пересылает трафик от их имени.2

Подход

Плюсы

Минусы

Уникальный IP: порт на сеанс (также известный как нативный прямой UDP)

Прямой путь медиатрафика между клиентом и сервером

Отсутствие уровня пересылки на пути данных

Требуется один публичный UDP-порт на сеанс

Большие диапазоны портов сложно открывать и защищать

Плохо подходит для Kubernetes и облачных балансировщиков нагрузки

Уникальный IP: порт на сервер

Гораздо меньший объем используемых публичных UDP-портов по сравнению с выделением портов на каждый сеанс

Один общий сокет на сервер может демультиплексировать множество сеансов

Отлично работает на одном хосте, но сам по себе не масштабируется на группу хостов с балансировкой нагрузки

Демультиплексирование сеансов на одном хосте помогает только после того, как пакет дойдет до этого хоста; в пуле с балансировкой нагрузки первый пакет все равно может попасть на неправильный экземпляр, поэтому по-прежнему нужен детерминированный способ перенаправления каждого сеанса на тот процесс, которому он принадлежит


TURN-ретранслятор (с завершением протокола)

Клиентам нужно устанавливать соединение только с адресом и портом TURN-ретранслятора

Позволяет централизовать политики на периферии

Выделение TURN-ресурсов добавляет лишние сетевые круговые пути (round trips)

Перемещение или восстановление аллокаций между TURN-серверами по-прежнему затруднено

Безсостояний ретранслятор + трансивер с сохранением состояния (ретранслятор OpenAI + трансивер)

Небольшое количество используемых публичных UDP-портов

Трансивер по-прежнему полностью управляет WebRTC-сеансом

Добавляет один пересылающий узел (хоп) до того, как медиатрафик достигнет трансивера-владельца

Требует специальной координации между ретранслятором и трансивером

Обзор архитектуры: ретранслятор + трансивер

Разработанная нами архитектура разделяет маршрутизацию пакетов и завершение протокола. Сигнализация по-прежнему поступает на трансивер для настройки сеанса, в то время как медиатрафик сначала попадает в ретранслятор. Ретранслятор представляет собой легковесный слой пересылки UDP с небольшой публичной инфраструктурой, а трансивер — это конечная WebRTC-точка с сохранением состояния, находящаяся за ним.

Relay statelessly forwards packets to transceiver

Ретранслятор не расшифровывает медиаданные, не запускает конечные автоматы ICE и не участвует в согласованиях кодеков. Он считывает лишь достаточный объем метаданных пакета для выбора пункта назначения, после чего пересылает пакет на трансивер, которому принадлежит данный сеанс. Трансивер по-прежнему видит обычный поток WebRTC и полностью контролирует состояние протокола. С точки зрения клиента в WebRTC-сеансе ничего не меняется.

Маршрутизация по учетным данным ICE

Маршрутизация первого пакета — ключевой этап в этой конфигурации. Ретранслятор должен направить первый пакет от клиента до того, как сам сеанс будет создан на пути пакета, а не за счет обращения к внешней службе поиска.

Каждый WebRTC-сеанс уже содержит встроенный в протокол механизм маршрутизации: фрагмент имени пользователя ICE, или ufrag — короткий идентификатор, обмениваемый во время настройки сеанса и дублируемый в проверках связности STUN. Мы генерируем серверный ufrag таким образом, чтобы он содержал ровно столько метаданных маршрутизации, сколько необходимо ретранслятору для определения целевого кластера и трансивера-владельца.

The sequence diagram shows how the connection is established

Во время сигнализации трансивер выделяет состояние сеанса и возвращает общий VIP-адрес ретранслятора и UDP-порт в ответе SDP. VIP-адрес — это виртуальный IP-адрес, передний край для пула ретрансляторов; в сочетании с портом он предоставляет клиенту единый стабильный адрес назначения, например `203.0.113.10:3478`, даже несмотря на то, что за ним стоят множество экземпляров ретрансляторов. Первый пакет клиента на пути медиатрафика обычно представляет собой запрос привязки STUN (Session Traversal Utilities for NAT), который ICE использует для проверки возможности достижения объявленного адреса.

Ретранслятор анализирует ровно столько данных первого STUN-пакета, чтобы прочитать ufrag сервера, декодировать подсказку маршрутизации и переслать пакет на трансивер-владелец. Каждый трансивер прослушивает общий UDP-сокет, что означает наличие одной конечной точки операционной системы, привязанной к внутреннему IP: порту, а не по одному сокету на сеанс. После того как ретранслятор создает сеанс от исходного IP: порта клиента до пункта назначения на трансивере, последующие пакеты DTLS, RTP и RTCP передаются в рамках сеанса без повторного декодирования ufrag.

Сеанс ретранслятора намеренно сделан минималистичным и состоит только из сеанса в оперативной памяти для управления пересылкой пакетов, а также необходимых счетчиков для мониторинга и таймеров для истечения срока действия и очистки сеанса. Этот архитектурный выбор сохраняет маршрутизацию пакетов непосредственно на пути передачи данных. Если ретранслятор перезапускается и теряет информацию о сеансе, следующий STUN-пакет восстанавливает сеанс из подсказки маршрутизации ufrag. Для еще большей надежности используется кэш Redis, в котором сохраняется отображение сразу после установления маршрута, чтобы его можно было восстановить гораздо раньше, до прибытия следующего STUN-пакета.

Глобальный ретранслятор и сигнализация с географической маршрутизацией

Как только мы сократили поверхность публичных UDP-портов до небольшого числа стабильных адресов и портов, мы смогли развернуть ту же модель ретранслятора по всему миру. Global Relay — это наш пул географически распределенных точек входящего трафика ретрансляторов, в которых реализовано одинаковое поведение пересылки пакетов.

Широкая географическая сеть входящего трафика сокращает первый участок пути от клиента до OpenAI, поскольку пакет может войти в нашу сеть через ретранслятор, расположенный близко к пользователю как географически, так и в рамках топологии сети, вместо того чтобы сначала пересекать публичный Интернет в удаленный регион. На практике это означает более низкую задержку, меньший джиттер и меньше предотвратимых всплесков потерь пакетов до того, как трафик достигнет нашей магистрали.6

The Global Relay layer receives packets from client and forwards to transceiver cluster

Мы используем геомаршрутизацию Cloudflare и маршрутизацию по близости для сигнализации, чтобы начальный запрос HTTP или WebSocket попадал на ближайший кластер трансиверов. Контекст запроса определяет местоположение сеанса и то, какая точка входа Global Relay рекламируется клиенту. Ответ SDP предоставляет адрес Global Relay, в то время как ufrag содержит достаточно информации для того, чтобы Global Relay перенаправил медиатрафик в указанный кластер, а ретранслятор направил его на целевой трансивер.

Географически направляемая маршрутизация сигналов и Global Relay в сочетании направляют как процесс настройки, так и медиаданные по ближайшему пути подключения, при этом сохраняя привязку сессии к одному трансиверу. Это сокращает время кругового обмена (RTT) для сигнального трафика и первой проверки подключения ICE, что напрямую уменьшает время ожидания пользователя перед началом разговора.

Реализация ретранслятора и производительность

Мы написали службу ретрансляции (relay) на Go и намеренно сделали её реализацию узкоспециализированной. В Linux сетевой стек ядра принимает UDP-пакеты с сетевого интерфейса машины и доставляет их в сокет — конечную точку операционной системы, которую процесс читает после привязки IP-адреса и порта (IP: Port). Ретранслятор работает в пространстве пользователя (userspace), поэтому стандартный процесс Go считывает заголовки пакетов из этого сокета, обновляет небольшой объем состояния потока и пересылает пакеты, не завершая сеанс WebRTC. Нам не потребовались никакие фреймворки обхода ядра (kernel-bypass), которые позволили бы процессу userspace напрямую опрашивать сетевые очереди для достижения более высокой скорости обработки пакетов, но при этом добавили бы эксплуатационной сложности.

Ключевые архитектурные решения:

  • Отсутствие терминирования протокола: ретранслятор разбирает только заголовки/ufrag STUN; он использует кэшированное состояние для последующих DTLS, RTP и RTCP, сохраняя пакеты непрозрачными.
  • Эфемерное состояние: поддерживается небольшая, хранящаяся в памяти карта с коротким временем жизни (timeout), связывающая адрес клиента с пунктом назначения трансивера для отслеживания состояния потока и наблюдаемости.
  • Горизонтальная масштабируемость: за балансировщиком нагрузки параллельно работают несколько экземпляров ретрансляторов. Состояние не является жестким состоянием WebRTC, поэтому перезапуски приводят лишь к минимальной потере трафика и быстрому восстановлению потоков.

Меры по повышению эффективности:

  • SO_REUSEPORT — это опция сокета Linux, которая позволяет нескольким рабочим процессам ретранслятора на одной машине привязываться к одному и тому же UDP-порту. Затем ядро распределяет входящие пакеты между этими воркерами, что позволяет избежать узких мест в виде единственного цикла чтения.
  • runtime.LockOSThread закрепляет каждую горутину, читающую UDP, за конкретным потоком ОС. В сочетании с SO_REUSEPORT это позволяет удерживать пакеты из одного потока (IP-адрес и порт источника/назначения плюс протокол) на одном ядре ЦП, улучшая локальность кэша и сокращая количество переключений контекста.
  • Предварительно выделенные буферы и минимальное количество операций копирования поддерживают низкие накладные расходы на парсинг и выделение памяти, позволяя избегать сборки мусора в Go.

Эта реализация справилась с нашим глобальным трафиком реляционных медиаданных в реальном времени при относительно небольших системных требованиях ретранслятора, поэтому мы сохранили более простой дизайн вместо перехода на обход ядра.

Результаты и выводы

Такая архитектура позволяет нам запускать медиасервисы WebRTC в Kubernetes без необходимости открывать тысячи UDP-портов. Это важно, поскольку меньшую и фиксированную поверхность UDP-портов проще защищать и балансировать, а инфраструктура может масштабироваться без резервирования больших диапазонов публичных портов. Благодаря улучшенной поддержке инфраструктуры со стороны Kubernetes и повышенной безопасности за счет меньшей площади атаки, данный подход также сохраняет стандартное поведение WebRTC для клиентов и подтверждает, что архитектура без SFU была правильным выбором по умолчанию для нашей рабочей нагрузки. Большинство наших сессий являются точечными (point-to-point), чувствительными к задержкам и их проще масштабировать, когда службам вывода (inference) не нужно вести себя как пиры WebRTC.

Более общий вывод заключается в том, что лучшее место для добавления сложности — это тонкий уровень маршрутизации, а не каждая бэкенд-служба и не кастомное клиентское поведение. Кодирование метаданных маршрутизации в поле, являющееся нативным для протокола, обеспечило нам детерминированную маршрутизацию первого пакета, небольшой публичный UDP-профиль и достаточную гибкость для размещения точек входящего трафика (ingress) рядом с пользователями по всему миру.

Несколько решений оказались особенно важными:

  • Сохранение семантики протокола на периферии. Клиенты по-прежнему используют стандартный WebRTC, что обеспечивает совместимость с браузерами и мобильными устройствами.
  • Хранение жестких состояний сеанса в одном месте. Трансивер владеет ICE, DTLS, SRTP и жизненным циклом сессии; ретранслятор занимается исключительно пересылкой пакетов.
  • Маршрутизация на основе информации, уже присутствующей при настройке. ICE ufrag предоставил нам хук маршрутизации первого пакета без добавления зависимости поиска на критическом пути (hot-path).
  • Оптимизация типичных сценариев перед тем, как прибегать к обходу ядра. Узкоспециализированной реализации на Go с аккуратным использованием SO_REUSEPORT, привязкой к потокам и синтаксическим анализом с низким выделением памяти оказалось достаточно для нашей рабочей нагрузки.

Искусственный интеллект для голосового общения в реальном времени работает эффективно только тогда, когда инфраструктура делает задержки незаметными. Для нас это означало изменение формы развертывания WebRTC без изменения того, чего клиенты ожидают от самого WebRTC.

Автор

Yi Zhang, William McDonald

Ссылки

1. How Discord Handles Two and Half Million Concurrent Voice Users using WebRTC

2. GitHub — l7mp/stunner: A Kubernetes media gateway for WebRTC

3. WebRTC Ports in a nutshell [Examples] — BlogGeek.me

4. Deploy to Kubernetes — LiveKit docs

5. Use only a single UDP port instead a big port range for media connection — mediasoup

6. Cloudflare Calls: millions of cascading trees all the way down

Полный текст статьи читайте на OpenAI