콘텐츠로 이동

실시간 WebSocket 프록시

라우터는 GET /v1/realtime?model=<id> 엔드포인트로 OpenAI Realtime 호환 음성 백엔드를 서빙할 수 있습니다. 짧은 경로를 쓰는 SDK(OpenAI SDK, Pipecat)를 위해 GET /realtime 호환 별칭도 제공합니다. WebSocket 업그레이드 이후 라우터는 텍스트와 바이너리 프레임을 양방향으로 바이트 동일하게 중계합니다. 프로토콜 변환이 없으므로 OpenAI Realtime 이벤트 형식(session.update, input_audio_buffer.append, response.created, response.done, 함수 호출 이벤트)을 사용하는 백엔드라면 수정 없이 동작합니다.

이 엔드포인트는 기본 기능 집합에 포함된 realtime Cargo 기능으로 컴파일되며 realtime 설정 섹션이 enabled: true로 존재할 때만 활성화됩니다. 섹션이 없거나 비활성화되었거나 기능 없이 빌드된 경우 404를 반환합니다.

백엔드 URL이 wss로 변환되면 라우터는 운영체제 신뢰 저장소로 백엔드 인증서를 검증합니다. 비공개 백엔드 CA를 라우터가 실행되는 호스트 또는 컨테이너의 신뢰 저장소에 설치하세요. 신뢰하지 않는 인증서와 호스트 이름 불일치는 거부됩니다.

설정

realtime:
  enabled: true
  max_sessions: 256
  handshake_timeout: "10s"
  idle_timeout: "60s"
  max_session_duration: "0"
  backend_path: "/v1/realtime"
필드 기본값 설명
enabled false /v1/realtime와 /realtime 라우트의 마스터 스위치입니다.
max_sessions 256 동시 세션 상한입니다. 각 세션은 수명 동안 퍼밋 하나를 점유하며 상한을 넘는 새 업그레이드 요청은 HTTP 503으로 거부됩니다. 최소 1이어야 합니다.
handshake_timeout "10s" 백엔드 WebSocket 연결(TCP 연결, TLS, 핸드셰이크)의 기한입니다. 만료되면 클라이언트 쪽 연결이 종료 코드 1011로 닫히고 서킷 브레이커에 연결 실패가 기록됩니다. 0이 아닌 duration이어야 합니다.
idle_timeout "60s" 이 시간 동안 어느 방향으로도 프레임이 오가지 않으면 양쪽 연결을 종료 코드 1000과 사유 idle timeout으로 닫습니다. "0"은 유휴 타이머를 비활성화합니다.
max_session_duration "0" 세션의 절대 상한 시간입니다. 만료되면 양쪽 연결을 종료 코드 1000으로 닫습니다. 기본값 "0"은 무제한을 의미합니다.
backend_path "/v1/realtime" 백엔드의 실시간 WebSocket 엔드포인트 경로입니다. 스킴 매핑(http는 ws로, https는 wss로) 후 백엔드 기본 URL에 결합됩니다. /로 시작해야 합니다.

max_session_duration이 기본값 "0"인 상태에서 idle_timeout: "0"까지 설정하면 세션에 기한이 전혀 없어집니다. 이 경우 TCP FIN 없이 비정상 종료된 클라이언트(half-open 연결)가 max_sessions 퍼밋 하나와 연결된 백엔드 세션을 운영체제의 TCP keepalive 만료 시점(보통 2시간이거나 비활성화됨)까지 점유합니다. 라우터는 이 조합에 대해 시작 시 경고를 남기며, 이러한 클라이언트가 max_sessions개 누적되면 엔드포인트가 더 이상 새 세션을 받지 못합니다.

리로드 클래스: restart. 이 섹션은 라우트 구성 시점에 스냅샷되므로, max_sessions를 포함해 어떤 값을 수정하더라도 프로세스 재시작이 필요합니다.

HTTP timeouts 섹션은 실시간 세션에 적용되지 않습니다. 전이중 음성 세션에는 첫 바이트나 전체 응답 기한 개념이 없으므로, SSE 스트림에 적용되는 request.streaming.first_byte나 request.standard.total, timeouts.limits 모두 이 경로에는 적용되지 않습니다. 위 표의 타이머들이 유일한 세션 기한입니다.

요청 흐름

  1. 업그레이드 요청은 표준 API 인증 미들웨어를 거치므로, api_keys.mode: blocking 환경에서는 HTTP 요청과 동일하게 Authorization: Bearer <key>로 인증합니다.
  2. Origin 헤더를 검사합니다(아래 브라우저 Origin 참고). 라우터와 동일 출처가 아니면서 server.cors 섹션에서도 허용하지 않은 브라우저 페이지는 즉시 403으로 거부됩니다. Origin을 보내지 않는 클라이언트는 영향을 받지 않습니다.
  3. model 쿼리 파라미터는 필수이며(누락 시 400), 별칭과 양자화/날짜 접미사 정규화를 포함한 공유 메타데이터 매칭 파이프라인으로 해석됩니다. 알 수 없는 모델은 404로, 메타데이터에 audio 기능이 없는 모델은 400으로 거부됩니다. 이 해석에 앞서 요청한 이름은 모든 HTTP 경로와 같은 키의 allowed_models 검사를 거칩니다. 비어 있지 않은 목록에 없는 이름은 핸드셰이크가 완료되기 전, 백엔드에 연결하기 전에 permission_error 본문과 함께 403으로 거부됩니다. 이 검사는 클라이언트가 보낸 이름을 기준으로 하므로, 키가 사용할 수 없는 모델의 별칭도 거부됩니다.
  4. 백엔드 후보는 /v1/chat/completions와 동일한 모델 조회 및 라우팅 필터로 추려지며, 세션 백엔드는 공유 선택·서킷 승인 경로를 통해 결정됩니다.
    • 사용할 수 있는 백엔드가 없으면(모두 비정상이거나 서킷이 열린 경우) 업그레이드가 503으로 거부됩니다.
    • 클라이언트가 보낸 원본 모델 문자열로는 백엔드를 직접 찾지 못했지만 해석된 정식 카탈로그 ID로는 매칭된 경우(별칭 조회), 라우터는 별칭 대신 정식 ID를 백엔드의 ?model= 쿼리 파라미터로 전달합니다. 따라서 백엔드에는 항상 실제 서빙 중인 모델 이름이 전달되며, 클라이언트에게 반환되는 404 오류에는 호출자가 보낸 원래 문자열이 그대로 표시됩니다.
  5. 인증된 키의 allowed_backends 허용 목록은 모든 HTTP 경로와 동일한 방식으로 후보군을 좁힙니다.
    • 허용 목록에 해당 모델을 서빙하는 백엔드가 하나도 없으면 permission_error 본문과 함께 403으로 거부됩니다. 이는 '이 키로는 해당 모델을 사용할 수 없음'과 '해당 모델의 모든 백엔드가 현재 장애 상태임(503)'을 구분하여 재시도 가능 여부를 명확히 하기 위함입니다.
    • 허용 목록이 비어 있거나 설정되지 않은 경우, 또는 api_keys.mode: permissive에서 익명으로 호출하는 경우에는 제한이 적용되지 않습니다.
  6. 업그레이드 요청의 x-backend 헤더는 남은 후보 중에서 선호하는 백엔드를 지정합니다.
    • 지정한 백엔드가 가시적이고 허용 목록을 통과하며 후보군 내에서 승인 가능한 상태라면 우선 선택되고, 그렇지 않으면 조용히 무시되어 핸드셰이크를 실패시키지 않습니다.
    • WebSocket 세션은 백엔드를 한 번만 선택하므로, 선호 백엔드가 채택되면 단일 시도가 아니라 세션 전체 수명 동안 해당 백엔드가 고정되며 이후 다른 백엔드로 폴백하지 않습니다. 101 업그레이드 응답에는 x-served-backend 헤더가 포함되지 않습니다.
  7. 라우터는 백엔드의 실시간 엔드포인트에 연결할 때 백엔드에 설정된 자격 증명(api_key, 또는 컨트롤 플레인 환경에서 허브가 전달한 자격 증명)으로 인증합니다. 클라이언트의 Authorization 헤더는 백엔드로 전달되지 않습니다.
  8. 프레임은 한쪽이 연결을 닫거나, 타이머가 만료되거나, 연결 구간(leg)에 오류가 발생할 때까지 양방향으로 중계됩니다. 종료 프레임은 원래의 코드와 사유 그대로 전파되며, 한쪽 연결이 비정상 종료되면 다른 쪽은 종료 코드 1011로 닫힙니다. 백엔드 연결 실패와 세션 성립은 HTTP 트래픽과 동일한 서킷 브레이커에 기록되므로, WebSocket과 HTTP 디스패치가 백엔드별 페일오버 상태를 공유합니다.

프레임 페이로드에는 사용자의 실제 음성이 담기므로 어떤 로그 레벨에서도 기록하지 않습니다. 로그와 메트릭은 이벤트 종류, 개수, 크기, 지속 시간, 결과만 기록합니다.

브라우저 Origin

WebSocket 핸드셰이크는 동일 출처 정책과 CORS를 모두 우회하며 연결을 연 페이지는 스트림을 그대로 읽을 수 있습니다. 검사가 없다면 운영자가 방문한 아무 웹 페이지나 접근 가능한 라우터(로컬호스트, 사내망 호스트)로 실시간 세션을 열어 음성 백엔드에 오디오를 흘려보내고 모델의 응답을 읽을 수 있습니다. 그래서 라우터는 업그레이드 요청의 Origin을 검사합니다.

요청의 Origin 결과
없음 허용. 브라우저가 아닌 클라이언트(OpenAI SDK, Pipecat, CLI 도구)는 이 헤더를 보내지 않고 브라우저는 이 헤더를 없애거나 위조할 수 없습니다.
요청과 authority가 같음(Host, HTTP/2에서는 :authority) CORS 설정 없이 허용되므로 라우터가 직접 서빙하는 페이지는 그대로 동작합니다. 스킴은 비교하지 않는데 TLS 종단 장치 뒤에 있는 라우터를 그대로 동작시키기 위함입니다.
server.cors.enabled: true 상태에서 server.cors.allow_origins에 등재됨 허용. 정확한 origin, *, host:* 포트 와일드카드가 CORS 레이어와 똑같이 매칭됩니다.
그 외 전부, null 리터럴 포함 HTTP 403과 오류 코드 origin_not_allowed로 거부.

다른 origin의 브라우저 프런트엔드가 실시간 세션을 열게 하려면 CORS 섹션에 명시합니다.

server:
  cors:
    enabled: true
    allow_origins:
      - "https://console.example.com"
      - "http://localhost:*"

null origin은 * 패턴에서도 거부됩니다. 샌드박스 iframe이나 data: 문서가 제시하는 불투명 origin이라서 아무것도 식별해 주지 않습니다.

사용량 계량

중계 경로는 전달되는 프레임을 변경하지 않고 백엔드가 보고하는 사용량을 계량합니다. 백엔드에서 클라이언트로 가는 텍스트 프레임을 전달한 뒤, 라우터는 그 사본을 검사해 response.created(응답 시작 시점)와 response.done(최종 usage 및 status) 두 이벤트를 인식합니다. 각 response.done은 HTTP 완료 요청과 동일한 기록기를 통해 하나의 사용량 레코드로 기록되며, /admin/stats 수집기, 키별 llm_tokens_total 카운터, 그리고 control_plane.enabled: true일 때는 허브 사용량 레코드에 반영됩니다.

각 레코드에는 다음 값이 담깁니다.

필드 값
input_tokens response.usage.input_tokens에서 input_token_details.cached_tokens를 뺀 값
cached_input_tokens response.usage.input_token_details.cached_tokens
output_tokens response.usage.output_tokens, 오디오와 텍스트 합계
model 세션 해석·라우팅·인가에 사용된 디스패치 모델. 수신 프레임의 response.model이나 session.model은 백엔드가 작성한 미검증 값이며 클라이언트의 session.update를 그대로 반향할 수 있으므로 사용하지 않습니다
stream true
http_status 101, 백엔드 WebSocket 핸드셰이크 상태
request_id 업그레이드 요청에 라우터가 붙인 id. 세션의 모든 응답이 공유합니다
attempt_seq 세션의 첫 response.done은 1, 두 번째는 2, 이후 차례로 증가하며 parent_attempt는 없습니다
latency_ms 라우터가 중계한 response.created부터 response.done까지의 시간. response.created를 보지 못했으면 0
ttft_ms 없음(ttft_absent_reason: not_observed)
token_source engine_final. 이벤트에 사용량이 없거나 모두 0이면 none

결과(outcome)는 response.status를 따릅니다.

response.status status_details.reason outcome
completed 무엇이든 completed
incomplete max_output_tokens length
incomplete 그 밖의 값(content_filter, turn_detected 등) 또는 없음 completed
cancelled 무엇이든 client_cancelled
failed 무엇이든 upstream_error
없음 또는 알 수 없는 값 무엇이든 completed

failed나 cancelled 응답은 사용량이 없어도 토큰 0과 token_source: none으로 기록합니다. 그 응답도 백엔드에 도달했기 때문입니다.

제한 사항:

  • 256 KiB를 넘는 텍스트 프레임은 그대로 중계하되 파싱하지 않으며(oversized로 집계), 읽을 수 있는 이벤트가 아닌 텍스트 프레임은 unparsed로 집계합니다. 어느 쪽도 세션에 영향을 주지 않습니다.
  • response.done 전에 세션이 종료된 응답(백엔드 연결이 끊기거나 클라이언트가 종료한 경우)은 session_closed_in_flight로 집계하며 사용량을 기록하지 않습니다. 프로토콜 특성상 response.done 이전에는 사용량이 보고되지 않으므로, 중단 시점까지의 생성량은 네트워크 프레임만으로는 관측할 수 없습니다.
  • 바이너리 프레임과 클라이언트에서 백엔드로 가는 프레임은 파싱하지 않습니다.
  • 세션마다 response.created와 response.done 사이에 있는 응답을 최대 16개까지 추적합니다. 이를 넘으면 가장 오래된 응답을 버리고, 그 응답의 response.done은 latency_ms: 0으로 기록됩니다.
  • 5초의 종료 유예 시간이 만료되어 중계가 중단될 때까지 클라이언트 싱크가 아직 프레임을 수락하지 못한 response.done은 기록 시점을 놓친 것으로 간주하며, 미완료된 다른 응답과 함께 session_closed_in_flight로 집계됩니다.
  • 프레임 본문(전사 내용, 응답 ID, 프레임 내 모델 문자열 등)은 로그에 남기지 않습니다. 레코드당 디버그 로그에는 백엔드, 결과, 시도 번호, 토큰 수, 라우터의 request_id, 그리고 고정된 값 집합인 status_reason(max_output_tokens, content_filter, turn_detected, client_cancelled, other, absent)만 기록됩니다.

예시: NVIDIA NemotronLabs VoiceChat

NemotronLabs VoiceChat 11B 컨테이너는 /v1/realtime의 OpenAI Realtime 호환 WebSocket으로만 추론을 서빙합니다. 백엔드를 컨테이너로 지정하고 모델을 나열한 뒤 섹션을 활성화합니다.

backends:
  - name: voicechat
    url: "http://10.0.0.5:8000"
    type: vllm
    models:
      - nvidia-nemotronlabs-voicechat-11b

realtime:
  enabled: true
  idle_timeout: "120s"

클라이언트는 OpenAI Realtime 호환 SDK로 접속합니다.

ws://router:8080/v1/realtime?model=nvidia-nemotronlabs-voicechat-11b

오디오는 base64로 인코딩된 PCM16(모델 방향은 24 kHz 모노 리틀엔디언이며 컨테이너가 내부에서 리샘플링) 형태로 input_audio_buffer.append 이벤트에 실려 전달되고, 서버는 도구 호출용 response.function_call_arguments.done을 포함한 표준 response.* 이벤트를 내보냅니다. 라우터는 이 모든 이벤트를 수정 없이 중계합니다.

헬스 체크는 백엔드별 HTTP 프로브(health_check.endpoint)를 그대로 사용합니다. WS 전용 추론 컨테이너도 HTTP 헬스 엔드포인트를 노출하므로 그대로 동작합니다.

메트릭

metrics 기능이 활성화되면 프록시는 다음을 내보냅니다.

메트릭 유형 레이블 설명
realtime_active_sessions gauge backend 현재 열린 세션 수.
realtime_sessions_total counter backend, outcome 종료 결과별 세션 수: completed, backend_connect_failed, idle_timeout, max_duration, client_abort, backend_abort.
realtime_frames_relayed_total counter direction 중계된 프레임 수, client_to_backend / backend_to_client.
realtime_session_duration_seconds histogram backend 성립된 세션의 지속 시간.
realtime_usage_events_total counter backend, result 사용량 관측 결과(사용량 계량 참고): recorded(response.done마다 하나), oversized, unparsed, session_closed_in_flight.

제한과 범위 밖 항목

  • 메시지와 프레임 크기는 양쪽 모두 4 MiB로 제한됩니다.
  • max_sessions은 가용 메모리에 맞춰 조정해야 합니다. 4 MiB 메시지 상한이 레그마다 적용되므로 최악의 순간 메모리 사용량은 대략 max_sessions * 2 * 4 MiB이며 기본값 max_sessions: 256에서는 약 2 GiB입니다. 이는 세션마다 레그당 초대형 메시지 하나를 버퍼링할 때 도달할 수 있는 상한일 뿐 정상 상태는 아닙니다. 일반적인 실시간 트래픽은 작은 프레임 위주이고 정상 상태의 읽기/쓰기 버퍼링은 세션당 약 256 KiB 수준입니다.
  • 브라우저식 서브프로토콜 인증(openai-insecure-api-key.<key>)은 지원하지 않습니다. 인증이 활성화된 경우 클라이언트는 표준 Authorization 헤더를 보내야 합니다.
  • 라우터는 OpenAI Realtime 호환이 아닌 음성 프로토콜을 변환하지 않으며 WebRTC/SIP를 전송하지 않습니다. 사용량은 response.done에서만 읽고(사용량 계량 참고) 다른 이벤트는 계량하지 않습니다.