- Published on
Realtime API+LangChain 음성 에이전트 끊김·지연 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
음성 에이전트를 Realtime로 붙이면 “말은 했는데 반응이 늦다”, “중간에 뚝 끊긴다”, “내 목소리가 다시 들린다(에코)” 같은 이슈가 생각보다 자주 발생합니다. 특히 OpenAI Realtime API와 LangChain을 함께 쓰면, 모델 스트리밍 자체는 빠른데 클라이언트 오디오 캡처/전송, 서버 브리지(중계) 레이어, 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가 너무 짧아 잠깐의 쉼에도 발화 종료 처리- 사용자 환경(키보드 소리, 팬 소리)에서 노이즈 플로어가 높음
해결 순서:
- 입력 레벨(게인)과 노이즈 억제(브라우저
echoCancellation/noiseSuppression)를 먼저 안정화 - VAD의 무음 지속 시간을 늘려 “생각하는 틈”을 발화로 인정
- 발화 종료 후에도 일정 시간(
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로 터지는 유형과 원인이 유사하니, 이벤트 스키마를 점검할 때 아래 글의 접근법(요청 페이로드 최소화, 필수 필드부터 검증)을 같이 참고하면 좋습니다.
실전 체크리스트: 끊김·지연을 줄이는 우선순위
- 클라이언트 오디오 청크를
20ms~40ms로 고정하고, 전송 큐가 밀리지 않는지 확인 - TTS 재생은 타임라인 스케줄링으로 버퍼 언더런 방지(선버퍼
100ms~250ms) - VAD 무음 지속 시간을 늘려 문장 중간 끊김 방지
- 브리지 서버의 이벤트 핸들러를 블로킹하지 않기: 툴 호출은 큐/워커로 분리
- LangChain 툴/RAG 지연을 계측: 툴별 p95를 찍고 느린 도구부터 최적화
- 로그/JSON 파싱 과다를 제거: 프레임 단위 로깅 금지, 샘플링 로깅으로 전환
- 스키마 에러를 먼저 제거: 잘못된 이벤트로 인한 재시도/버스트를 차단
마무리: “모델이 느린 게 아니라 파이프라인이 막힌다”
Realtime 음성 에이전트의 체감 품질은 모델 성능보다 스트리밍 파이프라인의 안정성이 좌우합니다. 오디오 청크 크기, 재생 버퍼, VAD, 그리고 LangChain 툴 호출을 분리하는 것만으로도 끊김과 지연이 눈에 띄게 줄어듭니다.
다음 단계로는 p50/p95 지연을 대시보드화하고, 세션 수가 늘어날 때(동시 접속)도 동일한 품질이 나오는지 부하 테스트를 붙이면 운영 난이도가 크게 내려갑니다.