Published on

OpenAI Realtime API로 음성 에이전트 지연 200ms 줄이기

Authors

음성 에이전트의 “빠르다”는 감각은 단순한 평균 응답시간이 아니라, 첫 소리(First Audio)까지 걸리는 시간대화 턴 전환(turn-taking)의 자연스러움에서 결정됩니다. OpenAI Realtime API를 쓰면 ASR(음성 인식)→LLM→TTS를 한 세션에서 스트리밍으로 엮을 수 있어 기본 지연이 낮지만, 구현 방식에 따라 체감 지연이 200ms 이상 쉽게 벌어집니다.

이 글은 Realtime API 기반 음성 에이전트에서 지연을 구성 요소로 쪼개고, “200ms 줄이기”를 재현 가능한 체크리스트로 만드는 데 초점을 둡니다.

지연을 숫자로 쪼개야 줄일 수 있다

음성 에이전트 지연은 보통 아래로 분해됩니다.

  • T_capture: 마이크 캡처 버퍼(프레임 크기)로 인한 지연
  • T_uplink: 클라이언트→서버(Realtime) 업링크 전송 지연
  • T_vad: 발화 종료 판단(VAD) 대기 시간
  • T_infer: 모델 추론(첫 토큰/첫 오디오 생성까지)
  • T_downlink: 서버→클라이언트 다운로드 지연
  • T_playout: 재생 버퍼링(오디오 큐) 지연

여기서 200ms를 줄이기 쉬운 구간은 대개 T_capture, T_vad, T_playout, 그리고 “불필요한 왕복”으로 생기는 T_uplink + T_downlink입니다. 모델 자체 추론(T_infer)은 바꾸기 어렵지만, 첫 오디오를 더 빨리 받게 만드는 설계(스트리밍, 프롬프트, 응답 포맷)로 체감 지연을 줄일 수 있습니다.

측정 포인트(필수)

성공적으로 줄이려면 이벤트 타임스탬프를 남겨야 합니다.

  • 마이크 프레임 생성 시각
  • 프레임 전송 시각
  • 서버 이벤트 수신 시각(클라이언트 측)
  • 첫 오디오 청크 수신 시각
  • 첫 오디오 재생 시작 시각

이렇게 찍으면 “200ms가 어디서 생겼는지”가 보입니다.

핵심 전략 1: 오디오 프레임 크기 줄이기(캡처/업링크)

가장 흔한 실수는 너무 큰 오디오 청크(예: 100ms~200ms 단위)를 묶어서 보내는 것입니다. 그러면 캡처 단계에서 이미 100ms 이상을 먹고 시작합니다.

권장 방향:

  • 입력 오디오는 20ms 프레임(또는 10ms) 수준으로 쪼개 전송
  • PCM16 mono 16kHz가 일반적(대역폭과 품질 균형)

Node.js(WebSocket) 예시: 20ms 프레임 전송

아래 예시는 클라이언트에서 PCM16 프레임을 만들어 input_audio_buffer.append로 스트리밍하는 형태입니다. (브라우저라면 AudioWorklet로 유사하게 구현)

import WebSocket from "ws";

const ws = new WebSocket("wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview", {
  headers: {
    Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
    "OpenAI-Beta": "realtime=v1"
  }
});

function sendEvent(type, payload) {
  ws.send(JSON.stringify({ type, ...payload }));
}

ws.on("open", () => {
  // 세션 설정: VAD, 출력 음성 포맷 등
  sendEvent("session.update", {
    session: {
      modalities: ["audio", "text"],
      input_audio_format: "pcm16",
      output_audio_format: "pcm16",
      turn_detection: {
        type: "server_vad",
        // 아래 파라미터는 예시이며, 실제 지원 필드는 모델/버전에 따라 다를 수 있습니다.
        // 핵심은 "발화 종료 판단 대기"를 과도하게 키우지 않는 것.
        silence_duration_ms: 180
      }
    }
  });
});

// pcm16Frame: Int16Array (20ms 분량)
function appendPcm16Frame(pcm16Frame) {
  const buf = Buffer.from(pcm16Frame.buffer);
  const b64 = buf.toString("base64");
  sendEvent("input_audio_buffer.append", { audio: b64 });
}

효과: 프레임을 20ms로 줄이면 T_capture에서 80~180ms가 바로 절약되는 케이스가 많습니다.

핵심 전략 2: VAD(발화 종료) 대기 시간을 줄이되, 끊김은 방지

음성 에이전트에서 “느리다”는 느낌의 절반은 사용자가 말을 끝냈는데도 모델이 기다리는 시간입니다. 이건 T_vad입니다.

  • server_vad를 쓰면 서버가 음성 스트림에서 발화 종료를 판단
  • 그러나 silence_duration_ms 같은 값이 크면 턴 전환이 늦어짐

실무 튜닝 가이드

  • 조용한 환경/헤드셋: 120ms~200ms로 낮춰도 안정적인 경우가 많음
  • 스피커폰/카페: 너무 낮추면 말 끝을 자르거나 잦은 턴 분리가 생김

따라서 운영에서는 환경별 프로파일을 두는 게 좋습니다.

  • “헤드셋 모드”: VAD 빠르게
  • “스피커 모드”: VAD 보수적으로

그리고 클라이언트에서 **사용자 UI(말하는 중 표시)**를 정확히 해주면, VAD를 공격적으로 낮춰도 불만이 줄어듭니다.

핵심 전략 3: 첫 오디오(First Audio) 빨리 받기 위한 응답 설계

LLM이 긴 문장을 완성한 뒤에 TTS를 시작하면 당연히 느립니다. Realtime API의 장점은 오디오를 스트리밍으로 받는 것이므로, 아래를 확인해야 합니다.

  • 응답을 반드시 스트리밍으로 소비하고 있는가
  • 클라이언트 재생 큐가 “충분히 쌓일 때까지” 기다리며 과도한 버퍼링을 하지 않는가

재생 버퍼(플레이아웃) 최소화

오디오 재생 파이프라인에서 흔히 100~300ms를 추가로 쌓습니다.

  • 네트워크 지터를 감안해 40ms~80ms 정도만 선버퍼링
  • 청크가 들어오는 즉시 재생 큐에 넣고, 큐가 비면 무음 삽입 대신 짧게 멈추는 편이 체감상 낫기도 함(서비스 성격에 따라 다름)

핵심 전략 4: 네트워크 왕복 줄이기(지역/연결 유지)

200ms 중 상당수는 모델이 아니라 네트워크입니다.

  • 클라이언트가 있는 지역과 가까운 리전을 사용
  • 모바일 환경에서는 DNS/핸드셰이크가 큰 편이므로 연결 재사용이 중요

연결 유지 팁

  • 세션을 매 턴마다 새로 만들지 말고, 대화 단위로 유지
  • 앱 백그라운드 전환 시 재연결 전략을 분리(짧은 유휴면 유지, 길면 종료)

또한 WebSocket이 불안정한 환경에서 스트리밍이 끊기면 “다시 붙는 동안” 지연이 폭발합니다. 이때는 스트리밍 재시도/차단기 설계가 필요합니다. gRPC 글이지만 원리(재시도, 서킷 브레이커, 지터 백오프)는 그대로 적용됩니다.

핵심 전략 5: Rate limit/백오프가 지연으로 보이지 않게

실서비스에서 간헐적으로 “갑자기 느려짐”이 발생하면, 원인이 단순 지연이 아니라 429로 인한 재시도일 수 있습니다. 특히 Realtime은 세션/스트림이 길어질수록 동시성이 커지고, 순간적으로 제한에 걸릴 수 있습니다.

  • 재시도는 하되, 오디오 UX에서는 “기다리는 침묵”이 치명적
  • 재시도 중에는 짧은 안내 음성(로컬 TTS) 또는 UI 피드백으로 체감 지연을 완화

백오프/재시도는 아래 글의 패턴을 참고해 적용하면 안정적입니다.

실전 체크리스트: 200ms 줄이는 우선순위

아래는 “대부분의 팀에서 바로 먹히는” 순서입니다.

  1. 오디오 프레임 20ms 전송으로 T_capture 절감
  2. VAD 침묵 대기 120~200ms로 축소(환경별 프로파일)
  3. 재생 선버퍼 40~80ms로 축소(과도한 큐잉 제거)
  4. 세션/연결 재사용으로 핸드셰이크 비용 제거
  5. 스트리밍 이벤트를 즉시 소비(첫 오디오 수신 즉시 재생)

이 5개만 제대로 해도, 기존 구현 대비 200ms 이상 줄어드는 경우가 흔합니다.

디버깅: 지연이 “갑자기” 늘어날 때 보는 순서

지연이 지속적으로 큰 게 아니라 스파이크로 나타나면, 아래를 우선 확인합니다.

  • 클라이언트 CPU 스파이크로 오디오 처리 지연(프레임 생성이 밀림)
  • 네트워크 지터/패킷 손실로 재전송 또는 버퍼 증가
  • 서버 측 재시도(429), 혹은 세션 재생성
  • 프로세스 재시작으로 인한 콜드 스타트/캐시 미스

서비스 프로세스가 재시작 루프에 빠지면 지연이 아니라 “끊김”으로 체감됩니다. 배포 후 갑자기 음성 에이전트가 불안정해졌다면 아래 글처럼 원인 추적 루틴을 갖추는 게 좋습니다.

브라우저에서의 추가 팁: AudioWorklet과 GC 피하기

브라우저 구현에서 100~200ms를 갉아먹는 주범은 GC(가비지 컬렉션)와 메인 스레드 블로킹입니다.

  • ScriptProcessorNode 대신 AudioWorklet 사용
  • 오디오 프레임 버퍼는 재사용(풀링)하고, 매 프레임마다 큰 객체 생성 금지
  • base64 인코딩 비용이 커질 수 있으니, 가능하면 인코딩 경로를 최적화(워커 사용 등)

아래는 “프레임을 복사하지 않고” 큐로 넘기는 단순 예시(개념용)입니다.

// 개념 예시: 프레임 풀링
const pool = [];
function rentFrame(size) {
  return pool.pop() ?? new Int16Array(size);
}
function returnFrame(frame) {
  pool.push(frame);
}

function onAudioFrame(rawInt16) {
  const frame = rentFrame(rawInt16.length);
  frame.set(rawInt16);
  appendPcm16Frame(frame);
  returnFrame(frame);
}

결론: “모델”보다 “파이프라인”이 200ms를 만든다

OpenAI Realtime API로 음성 에이전트를 만들 때 200ms는 대개 모델 추론이 아니라,

  • 큰 프레임 단위 전송
  • 보수적인 VAD
  • 과도한 재생 버퍼
  • 불필요한 연결 재생성

같은 파이프라인 선택에서 발생합니다.

먼저 타임라인을 계측하고(T_capture, T_vad, T_playout), 프레임 20ms, VAD 120200ms, 선버퍼 4080ms, 연결 재사용까지 적용하면 “말이 끝나자마자 바로 대답하는” 체감이 만들어집니다. 이후에는 지역 라우팅, 재시도 설계, 브라우저 오디오 처리 최적화로 스파이크를 줄여 안정적인 대화 품질을 완성하면 됩니다.