
오디오 게이트웨이 (Audio Gateway): 실시간 음성 AI를 위한 프로덕션 패턴
요약
실시간 음성 AI 시스템의 성능 저하를 방지하기 위해 미디어 핫 패스와 비즈니스 플레인을 분리하는 '오디오 게이트웨이' 아키텍처 패턴을 제안합니다. Python의 비동기 모델 한계를 극복하고 하드 실시간 요구사항과 가변적인 추론 워크로드를 효율적으로 관리하는 방법을 다룹니다.
핵심 포인트
- 실시간 오디오와 느린 추론 워크로드를 하나의 프로세스에서 처리하면 성능 충돌 발생
- 오디오 패스는 밀리초 단위의 하드 실시간성을 보장해야 함
- 오디오 게이트웨이와 비즈니스 플레인을 gRPC로 분리하는 아키텍처 권장
- Python의 비동기 및 GIL 경합이 실시간 오디오 파이프라인에 미치는 영향 분석
만약 당신의 음성 에이전트가 부하 상황에서 말을 더듬거나, 통화자를 가로막아 말하거나, 도구 호출 (tool call)이 실행되는 동안 문장 중간에 멈춘다면 — 그 해결책은 아마 더 나은 모델이 아닐 것입니다. 그것은 아키텍처의 분리 (architectural split) 문제입니다.
지난 포스트인 Python Is Lying to You에서, 저는 Python의 비동기 (async) 모델이 실시간 오디오 파이프라인을 조용히 어떻게 방해하는지에 대해 모든 방식을 살펴보았습니다: 이벤트 루프 (event loop)를 멈추게 하는 비동기처럼 보이는 가드레일, 매 프레임마다 블로킹 I/O (blocking I/O)를 수행하는 로거, 네트워크 지터 (network jitter)와 똑같이 보이는 GIL 경합 (GIL contention) 등이 그것입니다. 해결책들은 효과가 있었습니다. 하지만 그것들은 모두 _방어적 (defensive)_입니다. 이것을 핫 패스 (hot path)에서 분리하세요. 그것을 스레드 (thread)로 오프로드 (offload)하세요. 무해해 보이는 모든 호출을 감사(audit)하세요. 당신은 자신의 프로세스를 상대로 두더지 잡기 게임을 하고 있는 것입니다.
방어가 확장을 멈추는 지점이 있습니다. 진짜 해결책은 하나의 프로세스 내부에서 핫 패스를 방어하는 것이 아니라, 프로세스에서 다른 모든 것을 완전히 제거하는 것입니다.
이 포스트는 그 아키텍처에 관한 것입니다: 미디어 핫 패스 (media hot path)를 소유하는 오디오 게이트웨이 (audio gateway), 그 외의 모든 것을 소유하는 비즈니스 플레인 (business plane), 그리고 그들 사이의 작은 gRPC 계약 (contract)입니다. 이것은 우리가 현재 구축하고 있는 패턴이며, 실시간 음성 산업 전반에서 활용되어 왔습니다. 저는 API 키 없이도 클론하여 작동을 확인할 수 있는 완전하고 실행 가능한 오픈 소스 레퍼런스 구현체를 구축했습니다 - 단, OpenAI 키 사용을 강력히 권장합니다: ai-audio-gateway.
결코 하나의 프로세스를 공유해서는 안 되는 두 가지 워크로드 (workloads)
음성 AI 시스템은 서로 반대되는 요구사항을 가진 두 가지 워크로드를 포함합니다:
**오디오 패스 (audio path)**는 하드 실시간 (hard real time)입니다. 20ms마다 20ms의 프레임이 영원히 지속되어야 하며, 그렇지 않으면 통화자는 글리치 (glitch)를 듣게 됩니다. 이는 재시도할 수 없고, 배치 (batch)할 수 없으며, 스피너 (spinner) 뒤로 지연 시간 (latency)을 숨길 수도 없습니다. 이 스케줄링 요구사항은 한 자릿수 밀리초 (milliseconds) 단위로 측정됩니다.
**추론 (Reasoning)**은 그 반대입니다. 느리고, 폭발적이며, 가변적입니다. LLM 호출은 500ms가 걸릴 수도 있고 5초가 걸릴 수도 있습니다. 도구 호출 (tool call)은 데이터베이스, 내부 API, 또는 다른 서비스에 접근합니다. 다단계 에이전트 루프 (multi-step agent loops)는 예측 불가능하게 확장됩니다. 이러한 작업은 본질적으로 느립니다. 우리가 번개 같은 속도로 심층 추론 (deep reasoning)을 수행할 수 있게 될 때까지는 말이죠. 하지만 우리는 아직 그 단계에 도달하지 못했습니다.
두 가지를 하나의 프로세스에 넣으면, 느린 작업이 빠른 작업을 방해합니다. 이전 포스트에서 언급한 모든 버그는 이러한 충돌의 구체적인 사례입니다: CPU 집약적인 가드레일 (guardrail) 작업이 이벤트 루프 (event loop)를 고갈시키거나, LLM SDK가 잘못된 시점에 차단(blocking)되거나, 체크포인트 쓰기 (checkpoint write)가 프레임 중간에 발생하는 것 등이 있습니다. 프로세스 상에서 실행하며 각 사례를 개별적으로 수정할 수도 있지만, 작업 부하 특성 (workload characteristics)에 따라 이를 분할할 수도 있습니다.
프로덕션 환경에서의 해답은 시스템을 두 개의 평면 (planes)으로 나누는 것입니다:
- 미디어 평면 (media plane) — 오디오 게이트웨이 (audio gateway) — 하드 실시간 (hard real-time) 작업을 담당합니다: 전화 (telephony) 또는 WebRTC 오디오 I/O, VAD (음성 활동 감지), 재생 페이싱 (playback pacing), 바지인 (barge-in, 끼어들기), 코덱 처리 (codec handling).
- 비즈니스 평면 (business plane) — 의미 (meaning)를 담당합니다: 프롬프트 (prompts), 도구 (tools), 오케스트레이션 (orchestration), 다단계 추론 (multi-step reasoning), 데이터 액세스.
이들 사이에는 작고 타입이 지정된 계약 (typed contract)을 전달하는 양방향 gRPC 스트림 (bidirectional gRPC stream)이 존재합니다.
[
전체 설계를 관통하는 하나의 규칙이 있습니다: 오디오 타이밍은 비즈니스 작업을 절대 기다리지 않습니다. 비즈니스 평면은 도구 결과 전달을 지연시킬 수는 있습니다. 하지만 바지인 (barge-in), 응답 취소 (response cancellation), VAD, 또는 출력 오디오 프레임 (outbound audio frame)을 차단할 수는 없습니다. 이 포스트의 다른 모든 내용은 이 규칙의 결과물입니다.
제 말을 무조건 믿으실 필요는 없습니다
이러한 분리는 진지한 음성 AI 기업들이 독립적으로 도달하게 되는 결론입니다. 프로덕션 환경이 모든 이를 동일한 지점으로 몰아넣기 때문입니다.
LiveKit — OpenAI가 ChatGPT의 Advanced Voice Mode를 출시하는 데 사용했으며, 현재 연간 수십억 건의 통화를 처리하고 있는 인프라 — 가 가장 깔끔한 사례입니다. 이들의 미디어 서버는 오디오 패킷을 디코딩(decoding)하지 않고 전달하는 SFU(Selective Forwarding Unit)이며, 에이전트(agents)는 다른 참가자와 마찬가지로 룸(room)에 참여하는 완전히 별개의 프로세스로 실행됩니다. 이들의 프레임워크(framing)는 다음과 같습니다: 미디어 전송(media transport)과 AI 추론(inference)은 독립적인 레이어(layer)입니다. LiveKit은 의미(meaning)에 대해서는 의도적으로 무지한 상태를 유지합니다. 이해(understanding), 추론(reasoning), 또는 저장(storage)과 관련된 모든 것은 미디어 레이어 외부에 존재합니다.
Vapi는 스스로를 문자 그대로 "오케스트레이션 레이어 (orchestration layer)"라고 설명합니다. 그리고 이들의 데이터 흐름(data-flow) 문서는 정확히 이러한 경계를 그립니다: 엔드포인팅(endpointing), 중단 감지(interruption detection), 백채널링(backchanneling)은 오직 Vapi의 인프라에서 실행되는 반면, 사용자의 커스텀 LLM과 도구(tools)는 웹훅(webhook)을 통해 연결되는 _사용자_의 서버에서 실행됩니다. 기능적으로 Vapi는 서비스로 판매되는 미디어/오케스트레이션 평면(plane)이며, 사용자의 서버 URL이 비즈니스 평면(business plane) 역할을 합니다.
Pipecat (Daily의 오픈 소스 프레임워크) 또한 동일한 분리 원칙, 즉 한쪽에는 전송(transports)이 있고 다른 한쪽에는 프레임 프로세서(frame processors)가 있는 구조로 구축되었습니다.
그리고 이들 모두보다 앞선 가장 깊은 선례가 있습니다: 전화 기술(telephony)은 SIP가 존재한 이래로 시그널링(signaling)과 미디어를 분리해 왔습니다. 업계는 이 분리를 계속해서 재발견하고 있는데, 그 이유는 실시간 미디어와 애플리케이션 로직(application logic)이 결코 동일한 프로세스에 속할 수 없었기 때문입니다. 이 패턴은 새로운 것이 아닙니다. 바로 그 점이 핵심입니다: 이는 수렴(convergent)하고 있습니다. 이 포스트에서 추가하고자 하는 내용은 그 이면에 있는 것이 _에이전트(agent)_일 때 그 경계가 어떤 모습인지, 즉 도구, 추론, 그리고 중단 조정(interruption coordination)이 어떻게 선을 넘어 전달되는지에 대한 것입니다.
계약(The contract): 1분 안에 읽을 수 있는 두 개의 열거형(enums)
참조 구현(reference implementation)에는 .proto 파일이 없습니다. 와이어 포맷(wire format)은 identity serializer를 사용하는 gRPC stream_stream을 통해 JSON으로 직렬화된 Pydantic 엔벨로프(envelopes)입니다. 이를 통해 로그를 남기고 읽을 수 있는 페이로드(payload)와 함께 HTTP/2 멀티플렉싱(multiplexing) 및 양방향 스트리밍(bidirectional streaming)을 제공합니다. 예시 리포지토리(repo)의 경우, 이 개념을 입증하기에 완벽합니다.
두 개의 열거형(enums)이 계약(contract)입니다:
class GatewayEventType(str, enum.Enum):
"""Gateway -> Business. 미디어 평면(media plane)에서 *발생한* 일들."""
CALL_STARTED = "call.started"
...
이벤트(Events)는 미디어 평면이 현실을 소유하고 이를 보고하는 과거 시제입니다. 명령(Commands)은 비즈니스 평면이 의미를 소유하고 작은 어휘를 통해 미디어 평면을 지시하는 명령형입니다. 어휘가 이처럼 작을 때 경계가 명확하게 유지됩니다. 만약 새로운 기능이 두 열거형 중 어느 쪽에도 깔끔하게 맞지 않는다면, 그것은 대개 해당 기능이 와이어(wire)의 잘못된 쪽에 위치해 있다는 신호입니다.
껍데기뿐인 프록시 도구(Hollow proxy tools): 스키마는 넘어가지만, 동작은 넘어가지 않는다
실시간 모델에는 도구(tools)가 필요합니다. 도구에는 데이터베이스, 내부 API, 비즈니스 로직이 필요합니다. 이 중 그 어느 것도 오디오 핫 패스(audio hot path)에 속해서는 안 됩니다. 이를 조정하는 메커니즘이 제가 가장 좋아하는 아키텍처의 부분입니다.
도구는 보통 함께 존재하는 두 부분으로 구성됩니다: 스키마(schema) (이름, 설명, JSON 파라미터 등 모델이 도구를 호출할지 결정하는 데 필요한 것)와 구현(implementation) (실제 작업을 수행하는 코드)입니다. 이 아키텍처는 이 둘을 와이어를 사이에 두고 분리합니다.
통화 설정 시, 비즈니스 평면은 게이트웨이에 도구 명세(tool specs) 목록을 보냅니다. 이는 동작을 제외한 스키마만 포함합니다:
class ToolSpec(BaseModel):
name: str
description: str
...
게이트웨이는 각 명세당 하나의 **프록시(proxy)**를 생성합니다. 이는 실시간 모델이 호출할 수 있는 객체로, 그 본체 전체는 "이 호출을 와이어를 통해 전달하고 결과를 기다린다"는 내용뿐입니다.
async def _relay_tool_call(self, name: str, arguments: dict) -> Any:
tool_call_id = "tc_" + uuid.uuid4().hex[:10]
fut = asyncio.get_running_loop().create_future()
...
모델은 자신에게 로컬 도구(local tools)가 있다고 믿습니다. 모델은 별도의 예외 처리 없이 자신이 원하는 방식 그대로 도구를 호출합니다. 하지만 비즈니스 평면(business plane)을 제거하면, 프록시(proxies)는 아무것도 전달하지 않게 됩니다. 게이트웨이는 동작(behavior)이 아닌 형태(shapes)를 보유합니다.
증거: 두 개의 에이전트, 하나의 게이트웨이, 게이트웨이 변경 사항 제로
참조 리포지토리(reference repo)는 동일한 게이트웨이에 대해 두 개의 에이전트를 제공하며, 디렉토리 목록만으로도 그 근거를 충분히 설명합니다:
**단일 에이전트(single agent)**는 네 가지 카페 도구(get_menu, place_order, ...)를 직접 노출합니다. 게이트웨이는 네 개의 프록시를 구축합니다. 모든 도구 호출은 네트워크(wire)를 가로질러 전달됩니다. 평면적인 구조입니다.
**응답자-사고 에이전트(responder–thinker agent)**는 단 하나의 도구, 즉 사고 도구(thinker)를 노출합니다. 게이트웨이는 단일 프록시를 구축합니다. 모델이 이를 호출하면 하나의 엔벨로프(envelope)가 네트워크를 가로질러 전달되며, 그 후 비즈니스 평면에서 실행되는 수제 에이전트 루프(hand-rolled agent loop)인 사고 도구(thinker)가 자체적인 도구 호출들(get_menu, 검증, place_order)로 확산(fan out)됩니다. 이러한 중첩된 호출 중 어느 것도 다시 역으로 넘어오지 않습니다. 게이트웨이는 하나의 엔벨로프가 나가고 하나의 엔벨로프가 돌아오는 것만 봅니다. 그 이면에서는 게이트웨이가 인지하지 못하는 전체 트리(tree)가 실행되었습니다.
도구 실행에 대한 이러한 '맹목성(blindness)'이 바로 경계(boundary)가 위치하는 핵심 이유입니다. 미디어 평면(media plane)은 형태(shapes)를 전달할 뿐입니다. 요청을 해결하는 데 한 번의 왕복(round trip)이 걸리든 수십 번의 중첩된 호출이 걸리든, 그것은 비즈니스 평면의 영역입니다. 이것이 바로 게이트웨이 배포 없이도 에이전트 패턴을 교체해도 아키텍처가 유지될 수 있었던 이유이기도 합니다.
만약 제 Responder-Thinker 포스트를 읽어보셨다면, 이것이 바로 그 패턴이며, 이제 평면 경계(plane boundary)가 그 사이를 가로지르도록 그려진 것입니다. 즉, 응답자(responder)는 게이트웨이 뒤에서 실시간 모델과 함께 존재하며, 사고 도구(thinker)의 전체 확산(fan-out) 과정은 미디어 평면에서 보이지 않습니다.
끼어들기(Barge-in)와 턴 노후화(turn staleness): 하나의 정수로 두 프로세스 조정하기
이는 자연스러운 대화를 구현하는 데 있어 가장 어려운 부분 중 하나입니다. 발화자(caller)가 말을 가로챌 때(interrupt), 두 가지 일이 빠르게 일어나야 합니다. 어시스턴트가 말을 멈춰야 하며, 이전 턴(turn)을 위해 이미 진행 중이던 추론(reasoning)이 다시 돌아와 새로운 대화 흐름을 방해하며 말을 내뱉어서는 안 됩니다.
첫 번째 문제는 전적으로 게이트웨이(gateway) 내에서 해결됩니다. 모델은 훨씬 더 빠르게 스트리밍하지만, 게이트웨이는 어시스턴트의 오디오를 실시간 속도에 맞춰 내보냅니다. 이는 아웃바운드 큐(outbound queue)가 아직 듣지 못한 음성의 백로그(backlog)임을 의미합니다. 끼어들기(Barge-in)는 이 큐를 즉시 비웁니다. 또한, 모델이 생성 중일 때뿐만 아니라 오디오가 여전히 배출(draining)되고 있는 동안에도 작동해야 합니다. 왜냐하면 빠른 모델은 발화자가 듣기를 마치는 것보다 훨씬 이전에 생성을 완료하기 때문입니다. OpenAI 모드에서 게이트웨이는 추가적으로 응답을 취소하고, 취소된 응답에서 진행 중인 델타(deltas)를 버리며, 모델의 대화 아이템(conversation item)을 실제로 재생된 부분 정도로 잘라냅니다(truncate). 이를 통해 다음 턴이 발화자가 듣지 못한 단어들에 기반하여 생성되는 것을 방지합니다.
두 번째 문제는 네트워크 선을 가로질러 발생하며, 메커니즘은 작지만 영향력이 큽니다. 게이트웨이는 끼어들기가 발생할 때마다 증가하는 turn_id를 소유합니다. 이 정수(integer)는 모든 tool_call.requested 프레임에 실려 전달됩니다. 비즈니스 평면(business plane)은 이를 미러링하여, 느린 작업이 시작되기 전에 스냅샷을 찍고 작업 후에 이를 확인합니다:
# 사고 루프(thinker's loop) 내, 매 단계 사이:
if is_stale(snapshot_turn_id):
return {"stale": True} # 중단; 진행된 대화 흐름을 방해하지 않음
두 개의 프로세스. 공유 메모리 없음. 네트워크상의 정수 하나를 통해 실시간 상태를 조정합니다.
(만약 이 메커니즘이 익숙하게 느껴진다면, 그것은 퇴화된 형태의 Lamport clock입니다. 프로세스 간에 '발생 전(happened-before)' 관계를 확립하는 단조 증가 카운터(monotonic counter)는 분산 시스템에서 가장 오래된 도구 중 하나이며, 적재적소에 배치된 정수 하나가 영리한 프로토콜보다 나을 때가 많다는 사실을 상기시켜 줍니다.)
참조 구현(reference implementation)에서는 이것이 호출 ID(call id), 턴 ID(turn id), 실시간 is_stale() 상태를 포함하여 모든 도구 호출(tool invocation)에 전달되는 ToolContext를 통해 스레드(threaded)로 연결됩니다. 또한 Thinker는 동일한 컨텍스트를 중첩된 호출(nested calls)로 전달합니다. 팬아웃(fan-out)의 3단계 깊이에서 끼어들기(barge-in)가 감지되면 전체 트리(tree)를 중단합니다.
논점을 증명한 버그
참조 구현을 구축하는 동안 버그를 하나 발견했습니다. 이는 이전 기사 전체가 다루었던 것과 동일한 교훈이며, 한 단계 위 계층에서 반복되는 문제입니다.
저의 첫 번째 비즈니스 평면 핸들러(business-plane handler)는 유입되는 이벤트를 순차적으로 처리했습니다. 즉, 하나의 async for 루프를 통해 각 이벤트를 처리하고 다음으로 넘어가는 방식이었습니다. 도구 호출(Tool calls)은 인라인(inline)으로 대기(awaited)되었기에 겉보기에는 깔끔해 보였지만, 완전히 망가진 상태였습니다. 핸들러가 느린 Thinker 실행을 대기(await)하는 동안, barge_in 이벤트를 수신할 수 없었기 때문입니다. Staleness(데이터의 신선도 저하)를 유발하는 메시지가 정작 무효화해야 할 작업 뒤에 갇혀 있었기 때문에, Staleness 체크는 결코 실행될 수 없었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기