Published on

Realtime API+LangChain 음성 에이전트 끊김·지연 해결

Authors

음성 에이전트를 Realtime로 붙이면 “말은 했는데 반응이 늦다”, “중간에 뚝 끊긴다”, “내 목소리가 다시 들린다(에코)” 같은 이슈가 생각보다 자주 발생합니다. 특히 OpenAI Realtime APILangChain을 함께 쓰면, 모델 스트리밍 자체는 빠른데 클라이언트 오디오 캡처/전송, 서버 브리지(중계) 레이어, LangChain 툴 호출, TTS 재생 버퍼 중 하나가 병목이 되어 체감 지연이 크게 늘어납니다.

이 글은 “원인 후보를 줄이고, 측정하고, 한 번에 고친다”는 목표로 끊김·지연을 재현 가능한 지표로 만들고 파이프라인을 튜닝하는 방법을 정리합니다.

증상부터 분류하기: 끊김과 지연은 종류가 다르다

문제 해결은 먼저 증상을 어느 구간의 문제인지 분류하는 것부터 시작합니다.

1) 입력(마이크) 구간

  • 사용자가 말하는 도중, 서버로 오디오가 일정하게 안 올라감
  • 무음 구간이 갑자기 길어져 VAD가 발화 종료로 오판
  • 브라우저 탭 전환/저전력 모드에서 오디오 캡처가 끊김

2) 업링크/네트워크 구간

  • WebSocket 프레임이 지연되거나 버스트로 도착
  • 모바일 네트워크에서 RTT가 요동치며 지연이 튀는 문제

3) 서버 브리지(중계) 구간

  • Node/Python 중계 서버에서 이벤트 루프가 막혀 오디오 프레임 처리 지연
  • 툴 호출(LangChain) 중에 스트리밍이 멈추는 구조

4) 출력(TTS/재생) 구간

  • TTS 오디오가 너무 큰 청크로 내려와 재생 시작이 늦음
  • 오디오 플레이어가 버퍼 언더런으로 “뚝뚝” 끊김

이 분류가 되면, “딜레이가 모델 때문인지” 같은 막연한 추측을 줄이고, 각 구간에 맞는 해결책을 적용할 수 있습니다.

핵심 지표: E2E 지연을 4개 타임스탬프로 쪼개라

끊김·지연을 해결하려면, 체감이 아니라 측정 가능한 지표가 있어야 합니다. 최소 아래 4개 타임스탬프를 찍어 보세요.

  • t0: 마이크에서 오디오 프레임 생성 시각
  • t1: 프레임이 WebSocket으로 전송된 시각
  • t2: Realtime 서버에서 해당 프레임을 처리하기 시작한 시각(이벤트 수신 기준)
  • t3: TTS 오디오 첫 바이트(또는 첫 샘플)가 클라이언트에 도착한 시각

이때 t1 - t0가 크면 클라이언트 캡처/인코딩 문제, t2 - t1이 크면 네트워크/브리지 문제, t3 - t2가 크면 모델/툴/출력 파이프라인 문제일 확률이 큽니다.

아키텍처: LangChain을 “스트리밍을 막지 않게” 붙이기

가장 흔한 실수는 LangChain 툴 호출을 동기적으로 기다리면서 Realtime 이벤트 처리를 멈추는 것입니다. 그러면 모델이 오디오/텍스트를 스트리밍으로 내보낼 수 있어도, 중계 서버가 이벤트를 소비하지 못해 끊김이 발생합니다.

권장 패턴은 다음과 같습니다.

  • Realtime WebSocket 이벤트 처리 루프는 절대 블로킹하지 않는다
  • LangChain 툴 호출은 별도 태스크로 보내고, 결과만 이벤트로 다시 합친다
  • 오디오 입력/출력은 고정 크기 청크로 흐르게 하고, 툴 결과는 “텍스트 이벤트”로 합류

아래는 Node.js에서 “이벤트 루프를 막지 않는” 최소 예시입니다.

// server/realtime-bridge.ts
import WebSocket from "ws";

type ToolJob = { id: string; input: string };

const toolQueue: ToolJob[] = [];

async function runTool(job: ToolJob) {
  // LangChain 호출은 여기서 수행 (예: agent.invoke)
  // 중요한 점: 이벤트 핸들러 내부에서 await로 길게 잡지 말 것
  const resultText = `tool-result-for:${job.input}`;
  return resultText;
}

function startToolWorker(openaiWs: WebSocket) {
  setInterval(async () => {
    const job = toolQueue.shift();
    if (!job) return;

    const result = await runTool(job);

    // 툴 결과를 Realtime 세션에 "추가 컨텍스트"로 주입하는 형태
    // 실제 이벤트 타입은 사용하는 SDK/프로토콜에 맞춰 조정
    openaiWs.send(
      JSON.stringify({
        type: "conversation.item.create",
        item: {
          type: "message",
          role: "tool",
          content: [{ type: "text", text: result }],
        },
      })
    );

    openaiWs.send(JSON.stringify({ type: "response.create" }));
  }, 10);
}

export function bridge(clientWs: WebSocket, openaiWs: WebSocket) {
  startToolWorker(openaiWs);

  openaiWs.on("message", (raw) => {
    // 여기서는 절대 무거운 작업을 하지 말고 그대로 전달
    clientWs.send(raw.toString());

    // 예: 모델이 툴 호출을 요청하면 큐에 넣고 즉시 리턴
    const evt = JSON.parse(raw.toString());
    if (evt.type === "response.output_item.added" && evt.item?.type === "function_call") {
      toolQueue.push({ id: evt.item.id, input: evt.item.arguments });
    }
  });

  clientWs.on("message", (raw) => {
    // 클라이언트 오디오/텍스트 이벤트를 그대로 Realtime로 전달
    openaiWs.send(raw.toString());
  });
}

포인트는 “툴 호출을 기다리는 동안 오디오 프레임/모델 이벤트 처리가 멈추지 않게” 만드는 것입니다.

끊김의 1순위 원인: 오디오 청크 크기와 버퍼 전략

Realtime 음성에서 끊김은 대부분 청크 크기 불일치버퍼 언더런에서 시작합니다.

입력 청크: 너무 작아도, 너무 커도 문제

  • 너무 작은 청크(예: 5ms 단위)는 프레임 수가 폭증해 WebSocket 오버헤드가 커짐
  • 너무 큰 청크(예: 200ms 이상)는 발화 감지/전송이 늦어져 체감 지연이 증가

실무에서는 대개 20ms 또는 40ms 단위가 균형이 좋습니다.

출력(TTS) 버퍼: “조금 쌓고 시작”이 안정적

TTS를 받자마자 재생하면 초반은 빠르지만 네트워크가 흔들릴 때 끊김이 잘 납니다. 반대로 너무 많이 쌓으면 시작이 늦습니다.

권장 접근:

  • 첫 재생 시작 전 100ms~250ms 정도만 선버퍼링
  • 이후에는 일정 버퍼 목표치를 유지(버퍼가 줄면 재생 속도 미세 조정 또는 잠깐 대기)

브라우저에서 WebAudio로 간단히 구현하면 아래 형태입니다.

// client/audio-playback.js
const audioCtx = new AudioContext();
let nextTime = 0;

export async function enqueuePcm16(pcm16ArrayBuffer, sampleRate = 24000) {
  // PCM16 -> Float32 변환
  const pcm16 = new Int16Array(pcm16ArrayBuffer);
  const float32 = new Float32Array(pcm16.length);
  for (let i = 0; i < pcm16.length; i++) float32[i] = pcm16[i] / 32768;

  const buf = audioCtx.createBuffer(1, float32.length, sampleRate);
  buf.copyToChannel(float32, 0);

  const src = audioCtx.createBufferSource();
  src.buffer = buf;
  src.connect(audioCtx.destination);

  const now = audioCtx.currentTime;
  if (nextTime < now + 0.12) {
    // 선버퍼링 목표: 120ms
    nextTime = now + 0.12;
  }

  src.start(nextTime);
  nextTime += buf.duration;
}

이 방식은 “도착한 청크를 일정한 타임라인에 스케줄링”하므로, 네트워크 지터가 있어도 체감 끊김이 크게 줄어듭니다.

VAD(Voice Activity Detection) 튜닝: 발화가 자꾸 끊기는 이유

Realtime 음성에서 “문장 중간에 끊기고 답이 나온다”는 불만은 대개 VAD가 너무 공격적으로 설정된 경우입니다.

체크 포인트:

  • 무음 임계치가 너무 높아 작은 숨소리/마찰음을 무음으로 오판
  • silence_duration_ms가 너무 짧아 잠깐의 쉼에도 발화 종료 처리
  • 사용자 환경(키보드 소리, 팬 소리)에서 노이즈 플로어가 높음

해결 순서:

  1. 입력 레벨(게인)과 노이즈 억제(브라우저 echoCancellation/noiseSuppression)를 먼저 안정화
  2. VAD의 무음 지속 시간을 늘려 “생각하는 틈”을 발화로 인정
  3. 발화 종료 후에도 일정 시간(hangover) 오디오를 더 붙여 전송

브라우저 마이크 캡처는 다음처럼 시작합니다.

// client/mic.js
const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true,
  },
});

에코가 심하면 echoCancellation이 도움이 되지만, 특정 장치에서는 오히려 음질을 망치거나 레벨 펌핑이 생길 수 있어 A/B 테스트가 필요합니다.

LangChain 때문에 느려지는 지점: 툴 호출과 RAG

LangChain을 붙이면 “말은 빨리 알아듣는데 답이 늦다”가 자주 발생합니다. 원인은 보통 다음 중 하나입니다.

  • 툴 호출이 네트워크 I/O(검색, DB)로 느림
  • RAG 검색이 느리거나, TopK가 과도해 임베딩/리랭킹 비용이 큼
  • 도구 결과를 기다리느라 모델 출력 스트리밍이 지연되는 설계

대응 전략:

  • 음성 UX에서는 “바로 답할 수 있는 부분은 먼저 말하고, 필요한 정보는 뒤에 보강”하는 점진적 응답이 효과적
  • RAG는 하이브리드 검색/리랭킹을 튜닝해 지연을 줄이면서 품질을 유지

RAG 튜닝 자체는 별도 주제로 더 깊게 다뤘는데, 지연과 품질을 동시에 잡는 관점에서 아래 글이 도움이 됩니다.

네트워크/서버 레이어: WebSocket 지터와 이벤트 루프 블로킹

Realtime은 WebSocket 기반이라 “연결은 살아있는데 지터가 커지는” 상황이 생깁니다.

서버에서 반드시 확인할 것

  • CPU가 순간적으로 100% 치솟는지(오디오 인코딩/디코딩, JSON 파싱)
  • 로그가 과도한지(프레임마다 로그 찍으면 지연이 커짐)
  • 단일 프로세스에서 너무 많은 세션을 처리하는지

Node에서 흔한 실수는 message 핸들러에서 큰 JSON을 파싱하고, 거기서 곧바로 무거운 작업을 수행하는 것입니다. “핸들러는 최대한 가볍게, 무거운 작업은 워커/큐로”가 기본입니다.

운영 중에 프로세스가 불안정해 재시작 루프에 빠지면 지연/끊김이 더 심해집니다. 배포 환경이 K8s라면 아래 체크리스트로 CrashLoopBackOff 원인을 빠르게 좁힐 수 있습니다.

설정 실수로 생기는 “쓸데없는 왕복”: 스키마 검증 에러

간헐적으로만 끊기는 경우, 실제로는 네트워크가 아니라 요청 이벤트가 스키마 검증에서 실패해 재시도/복구 로직이 돌면서 지연이 커지는 패턴도 있습니다. 특히 프로토콜 이벤트 타입/필드명을 잘못 보내면 서버가 에러 이벤트를 내고, 클라이언트는 그 사이 오디오를 계속 쌓아두다가 한 번에 보내 “버스트”가 생깁니다.

Responses 계열에서 422로 터지는 유형과 원인이 유사하니, 이벤트 스키마를 점검할 때 아래 글의 접근법(요청 페이로드 최소화, 필수 필드부터 검증)을 같이 참고하면 좋습니다.

실전 체크리스트: 끊김·지연을 줄이는 우선순위

  1. 클라이언트 오디오 청크를 20ms~40ms로 고정하고, 전송 큐가 밀리지 않는지 확인
  2. TTS 재생은 타임라인 스케줄링으로 버퍼 언더런 방지(선버퍼 100ms~250ms)
  3. VAD 무음 지속 시간을 늘려 문장 중간 끊김 방지
  4. 브리지 서버의 이벤트 핸들러를 블로킹하지 않기: 툴 호출은 큐/워커로 분리
  5. LangChain 툴/RAG 지연을 계측: 툴별 p95를 찍고 느린 도구부터 최적화
  6. 로그/JSON 파싱 과다를 제거: 프레임 단위 로깅 금지, 샘플링 로깅으로 전환
  7. 스키마 에러를 먼저 제거: 잘못된 이벤트로 인한 재시도/버스트를 차단

마무리: “모델이 느린 게 아니라 파이프라인이 막힌다”

Realtime 음성 에이전트의 체감 품질은 모델 성능보다 스트리밍 파이프라인의 안정성이 좌우합니다. 오디오 청크 크기, 재생 버퍼, VAD, 그리고 LangChain 툴 호출을 분리하는 것만으로도 끊김과 지연이 눈에 띄게 줄어듭니다.

다음 단계로는 p50/p95 지연을 대시보드화하고, 세션 수가 늘어날 때(동시 접속)도 동일한 품질이 나오는지 부하 테스트를 붙이면 운영 난이도가 크게 내려갑니다.