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

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

Прослушать статью

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

На масштабе OpenAI это означает три конкретных требования:

  • Глобальное покрытие для более чем 900 миллионов еженедельных активных пользователей
  • Быстрая установка соединения, чтобы пользователь мог начать говорить сразу после начала сессии
  • Низкое и стабильное время media round-trip, с низкими jitter и packet loss, чтобы turn-taking ощущался четким

Команда OpenAI, отвечающая за real-time AI interactions, недавно переархитектурировала наш стек WebRTC, чтобы решить три ограничения, которые начали конфликтовать на масштабе: модель termination с одним портом на сессию плохо подходит инфраструктуре OpenAI, stateful-сессиям ICE (Interactive Connectivity Establishment) и DTLS (Datagram Transport Layer Security) нужна стабильная ownership, а глобальная маршрутизация должна удерживать низкую задержку первого хопа. В этой статье мы разбираем архитектуру split relay plus transceiver, которую мы построили, чтобы сохранить стандартное поведение WebRTC для клиентов, но изменить то, как пакеты маршрутизируются внутри инфраструктуры OpenAI.

WebRTC — это открытый стандарт для передачи аудио, видео и данных с низкой задержкой между браузерами, мобильными приложениями и серверами. Его часто связывают с peer-to-peer-звонками, но он также является практичной основой для real-time систем client-to-server, потому что стандартизирует сложные части интерактивных медиа: ICE для установления соединения и обхода NAT (Network Address Translation), DTLS и SRTP (Secure Real-time Transport Protocol) для шифрованной передачи, negotiation кодеков для сжатия и декодирования аудио, RTCP (Real-time Transport Control Protocol) для контроля качества, а также клиентские функции вроде echo cancellation и jitter buffering.

Эта стандартизация важна для AI-продуктов. Без WebRTC каждому клиенту пришлось бы по-своему решать, как устанавливать соединение через NAT, шифровать медиа, согласовывать кодеки (coder-decoders, выбранные для передачи и декомпрессии) и адаптироваться к меняющимся сетевым условиям. С WebRTC мы можем опираться на протокольный стек, который уже реализован в браузерах и на мобильных платформах, сосредоточив собственную работу на инфраструктуре, связывающей real-time медиа с моделями.

Мы также опираемся на саму экосистему WebRTC, включая зрелые open-source-реализации и стандартизированную работу, которая обеспечивает совместимость браузеров, мобильных приложений и серверов. Базовая работа Justin Uberti, одного из оригинальных архитекторов WebRTC, и Sean DuBois, создателя и сопровождающего Pion, сделала возможным для таких команд, как наша, строить на battle-tested медиа-инфраструктуре вместо того, чтобы заново изобретать низкоуровневую передачу, шифрование и поведение congestion control. Нам повезло, что и Justin, и Sean теперь наши коллеги в OpenAI и помогают нам сближать WebRTC и real-time AI.

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

Когда мы выбрали WebRTC, следующим вопросом стало, где его terminate — то есть где мы будем принимать и владеть WebRTC-соединением, например на edge — и как связать эти сессии с inference backend. Termination важна, потому что она определяет, как мы обращаемся с real-time session state, media transport, routing, latency и изоляцией отказов.

SFU, или selective forwarding unit, — это media server, который получает по одному WebRTC-потоку от каждого участника и выборочно пересылает потоки другим. В этой модели SFU завершает отдельное WebRTC-соединение для каждого участника, а AI присоединяется к сессии как еще один участник. Это хорошо подходит для продуктов, которые изначально многопользовательские, например групповых звонков, классов или совместных встреч. Такой подход держит audio codecs, сообщения RTCP, data channels, запись и политику на уровне каждого потока в одном месте.1

Даже в client-to-AI продуктах SFU часто оказывается стандартной отправной точкой, потому что позволяет командам переиспользовать одну проверенную систему для signaling, media routing, записи, observability и будущих расширений вроде human handoff или добавления новых участников.

Наша нагрузка другая. Большинство сессий — это 1:1: один пользователь разговаривает с одной моделью или одно приложение обращается к одному real-time agent, при этом каждая реплика чувствительна к задержке. Для такого типа трафика мы выбрали модель transceiver: WebRTC edge service завершает клиентское соединение, а затем преобразует медиа и события в более простые внутренние протоколы для model inference, транскрибации, генерации речи, работы с инструментами и orchestration.

В этой схеме transceiver — единственный сервис, который владеет WebRTC session state, включая ICE connectivity checks, DTLS-handshake, SRTP encryption keys и lifecycle сессии. Под termination здесь понимается, что transceiver — это endpoint, который завершает эти handshakes и шифрует или расшифровывает медиа. Сосредоточение этого состояния в одном месте сделало ownership сессии проще для понимания и позволило backend-сервисам масштабироваться как обычным сервисам, а не вести себя как WebRTC peers.

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

С точки зрения эксплуатации transceiver-сервис делает две задачи:

  • Signaling: SDP negotiation, выбор кодека, ICE credentials и настройка сессии
  • Media: завершение downstream WebRTC-соединений и поддержка upstream-соединений к backend-сервисам для inference и orchestration

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

Первая проблема заключалась в самой модели one-port-per-session. При высокой concurrency это означает публикацию и управление очень большими диапазонами UDP-портов.

  • Cloud load balancers и Kubernetes services не рассчитаны на десятки тысяч публичных UDP-портов на сервис. Каждый дополнительный диапазон добавляет операционную сложность в конфигурации load balancer, health checking, firewall policy и безопасности раскатки.3
  • Большие диапазоны UDP-портов трудно защищать, потому что они расширяют внешне доступную поверхность атаки и усложняют аудит сетевых политик.
  • Они также плохо подходят для autoscaling. В Kubernetes pod’ы постоянно добавляются, удаляются и переразмещаются. Если каждому pod’у нужно резервировать и объявлять большой стабильный диапазон портов, такая эластичность становится хрупкой.4

Именно поэтому многие WebRTC-системы переходят к одной UDP-порту на сервер с application-level demultiplexing за этим портом.5

Модели с одним UDP-портом на сервер решают проблему количества портов, но создают вторую проблему: сохранение ownership каждой сессии в масштабе всего fleet.

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

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

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

Подход Плюсы Минусы
Unique IP:port per session, также известный как native direct UDP Прямой путь медиа от клиента к серверу Нет forwarding layer в data pathТребуется один публичный UDP-порт на сессиюБольшие диапазоны портов трудно публиковать и защищатьПлохо подходит для Kubernetes и cloud load balancers
Unique IP:port per server Гораздо меньший публичный UDP-footprint, чем при экспозиции на уровне каждой сессии Один общий socket на сервер может demultiplex множество сессийХорошо работает на одном хосте, но сам по себе не решает задачу для общего балансируемого fleet
Session demultiplexing на одном хосте Помогает после того, как пакет уже попал на этот хост В распределенном балансируемом fleet первый пакет все еще может попасть не в тот экземпляр, поэтому все равно нужен детерминированный способ направить каждую сессию к процессу, который ею владеет
TURN relay (protocol-terminating) Клиентам нужно достичь только адреса и порта TURN relay Можно централизовать политику на edgeTURN allocations добавляют round trips на установкуПеремещение или восстановление allocations между TURN-серверами все еще сложно
Stateless forwarder + stateful terminator (relay + transceiver OpenAI) Небольшой публичный UDP-footprintTransceiver по-прежнему владеет всей WebRTC-сессией Добавляет один forwarding hop до того, как медиа достигнет owning transceiverТребует собственной координации между relay и transceiver

Архитектура, которую мы запустили, разделяет маршрутизацию пакетов и termination протокола. Signaling по-прежнему приходит к transceiver для настройки сессии, а медиа сначала попадает через relay. Relay — это легковесный UDP forwarding layer с небольшим публичным footprint, а transceiver — stateful WebRTC endpoint за ним.

Relay не расшифровывает медиа, не запускает ICE state machine и не участвует в negotiation кодеков. Он читает достаточно packet metadata, чтобы выбрать destination, а затем пересылает пакет к transceiver, который владеет сессией. Transceiver по-прежнему видит обычный поток WebRTC и по-прежнему владеет всем protocol state. С точки зрения клиента ничего в WebRTC-сессии не меняется.

Ключевой шаг в этой схеме — first-packet routing. Relay должен маршрутизировать первый пакет от клиента до того, как на самом packet path появится какая-либо сессия, а не останавливаясь на внешнем lookup-сервисе.

У каждой WebRTC-сессии уже есть встроенный в протокол routing hook: ICE username fragment, или ufrag, короткий идентификатор, которым обмениваются при настройке сессии и который повторяется в STUN connectivity checks. Мы генерируем server-side ufrag так, чтобы он содержал ровно столько routing metadata, сколько нужно relay, чтобы понять destination cluster и owning transceiver.

Во время signaling transceiver выделяет session state и возвращает в SDP answer общий relay VIP и UDP-port. VIP — это virtual IP address, который фронтит relay fleet; вместе с портом он дает клиенту один стабильный адрес назначения, например 203.0.113.10:3478, даже если за ним стоит много экземпляров relay. Первый пакет media-path от клиента обычно является STUN (Session Traversal Utilities for NAT) binding request, который ICE использует, чтобы проверить, что пакеты могут достичь объявленного адреса.

Relay разбирает только этот первый STUN-пакет настолько, чтобы прочитать server ufrag, декодировать routing hint и переслать пакет к owning transceiver. Каждый transceiver слушает на shared UDP socket, то есть на одном endpoint операционной системы, привязанном к внутреннему IP:port, а не на отдельном socket на каждую сессию. После того как relay создает сессию от source IP:port клиента до destination transceiver, последующие DTLS, RTP и RTCP-пакеты проходят внутри этой сессии без повторной декодировки ufrag.

Сессия relay намеренно минимальна: это только in-memory session для маршрутизации пакетов, а также необходимые счетчики для мониторинга и таймеры для истечения и очистки сессии. Такой выбор сохраняет маршрутизацию прямо на packet path. Если relay перезапускается и теряет сессию, следующий STUN-пакет восстанавливает ее по routing hint из ufrag. Чтобы сделать это еще надежнее, мы используем Redis cache, который хранит mapping <client IP + Port, transceiver IP + Port> после установления маршрута, чтобы его можно было восстановить значительно раньше, до прихода следующего STUN-пакета.

После того как мы сократили публичную UDP-поверхность до небольшого числа стабильных адресов и портов, мы смогли развернуть тот же relay pattern глобально. Global Relay — это наш fleet географически распределенных ingress-точек relay, которые реализуют одно и то же packet-forwarding поведение.

Широкий географический ingress сокращает первый hop от клиента до OpenAI, потому что пакет может войти в нашу сеть через relay, расположенный близко к пользователю и географически, и по network topology, а не проходить сначала через публичный интернет в далекий регион. На практике это означает меньшую задержку, меньший jitter и меньше избегаемых всплесков потерь до того, как трафик достигнет нашего backbone.6

Для signaling мы используем Cloudflare geo и proximity steering, чтобы первоначальный HTTP- или WebSocket-запрос попал в близкий transceiver cluster. Контекст запроса определяет location сессии и то, какой Global Relay ingress point будет объявлен клиенту. SDP answer передает адрес Global Relay, а ufrag содержит достаточно информации, чтобы Global Relay направил медиа в назначенный cluster, а relay — к целевому transceiver.

Вместе geo-steered signaling и Global Relay отправляют и setup, и media по близкому entry path, при этом закрепляя сессию за одним transceiver. Это уменьшает round-trip time для signaling и для первого ICE connectivity check, а значит, напрямую сокращает время ожидания пользователя до начала речи.

Мы написали relay-сервис на Go и намеренно сделали реализацию узкой. В Linux kernel networking stack получает UDP-пакеты от сетевого интерфейса машины и доставляет их в socket — endpoint операционной системы, который процесс читает после binding IP:Port. Relay работает в userspace, поэтому обычный процесс Go читает заголовки пакетов из этого socket, обновляет небольшой объем flow state и пересылает пакеты, не завершая WebRTC. Нам не понадобился kernel-bypass framework, который позволил бы userspace-процессу напрямую опрашивать network queues ради более высокой packet rate, но добавил бы операционную сложность.

Ключевые решения по дизайну:

  • Нет termination протокола: Relay разбирает только STUN headers/ufrag; для последующих DTLS, RTP и RTCP он использует cached state, сохраняя пакеты opaque.
  • Эфемерное состояние: он хранит небольшой in-memory map с коротким timeout от адреса клиента к destination transceiver для flow state и observability.
  • Горизонтальная масштабируемость: несколько экземпляров relay работают параллельно за load balancer. Это не жесткое WebRTC-состояние, поэтому перезапуски приводят к минимальным потерям трафика и быстрому восстановлению потока.

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

  • SO_REUSEPORT — это опция Linux socket, которая позволяет нескольким relay workers на одной машине привязываться к одному и тому же UDP-порту. Затем kernel распределяет входящие пакеты между этими workers, что устраняет узкое место одного read-loop.
  • runtime.LockOSThread привязывает каждую goroutine, читающую UDP, к конкретному OS thread. В сочетании с SO_REUSEPORT это обычно помогает удерживать пакеты одного flow (source и destination IP:Port плюс protocol) на одном CPU core, улучшая cache locality и уменьшая context switching.
  • Предварительно выделенные buffers и минимальное копирование держат накладные расходы на parsing и allocation низкими, чтобы избежать garbage collection в Go.

Эта реализация обслуживала наш глобальный real-time media traffic с относительно небольшим relay footprint, поэтому мы оставили более простую схему, а не пошли по пути kernel bypass.

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

Более общий вывод состоит в том, что лучшее место для добавления сложности — тонкий routing layer, а не каждый backend-сервис и не пользовательское поведение клиента. Встраивание routing metadata в протокольное поле дало нам детерминированный first-packet routing, небольшой публичный UDP-footprint и достаточную гибкость, чтобы размещать ingress ближе к пользователям по всему миру.

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

  • Сохранять семантику протокола на edge. Клиенты по-прежнему говорят на стандартном WebRTC, что сохраняет совместимость браузеров и мобильных приложений.
  • Держать жесткое session state в одном месте. Transceiver владеет ICE, DTLS, SRTP и lifecycle сессии; relay только пересылает пакеты.
  • Маршрутизировать по информации, уже присутствующей на этапе setup. ICE ufrag дал нам first-packet routing hook без добавления зависимости от hot-path lookup.
  • Оптимизировать под common case, прежде чем прибегать к kernel bypass. Узкой реализации на Go с аккуратным использованием SO_REUSEPORT, thread pinning и low-allocation parsing оказалось достаточно для нашей нагрузки.

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


Материал — перевод статьи с английского.

Оригинал: How OpenAI delivers low-latency voice AI at scale