포스트

실시간 음성 에이전트: STT→LLM→TTS “스트리밍 파이프라인”으로 1초 체감지연 깨는 구현 가이드

실시간 음성 대화(voice agent)가 어려운 이유는 모델 성능보다 지연(latency)과 인터럽트(barge-in) 때문입니다. “말을 끝까지 듣고 → 전사하고 → 답 만들고 → 읽어주는” 순차 처리로는, 사용자는 전화 IVR 같은 답답함을 느낍니다.

실시간 음성 에이전트: STT→LLM→TTS “스트리밍 파이프라인”으로 1초 체감지연 깨는 구현 가이드

들어가며

실시간 음성 대화(voice agent)가 어려운 이유는 모델 성능보다 지연(latency)과 인터럽트(barge-in) 때문입니다. “말을 끝까지 듣고 → 전사하고 → 답 만들고 → 읽어주는” 순차 처리로는, 사용자는 전화 IVR 같은 답답함을 느낍니다. 2026년 기준 업계에서 가장 현실적인 해법은 STT→LLM→TTS를 모두 스트리밍으로 연결하고, 파이프라이닝(pipelining)으로 겹쳐 실행하는 방식입니다.1

언제 쓰면 좋나

  • 상담/예약/업무 보조처럼 대화 턴이 짧고, 즉답성이 UX의 핵심인 제품
  • WebRTC/telephony(예: LiveKit/Twilio)로 실시간 양방향 오디오가 필요한 서비스
  • STT/TTS/LLM을 교체하며 비용·품질·리전 지연을 튜닝해야 하는 팀(벤더 락인 회피)

언제 쓰면 안 되나

  • “정확한 문서 작성/회의록”처럼 완성된 문장 품질이 최우선인 경우(배치 STT + 후처리가 유리)
  • 감정/톤/운율 같은 패러링귀스틱(paralinguistic) 정보가 핵심인 도메인(현재 실시간 시스템들이 종종 “오디오를 결국 텍스트처럼” 다루는 한계가 보고됨)2
  • 규제/보안상 오디오 저장·전송이 민감한데도, 아직 정책/아키텍처가 준비되지 않은 팀

🔧 핵심 개념

1) “진짜 실시간”은 모델이 아니라 스트리밍/파이프라이닝에서 나온다

많은 팀이 “더 빠른 end-to-end speech-to-speech 모델”을 찾지만, 실무적으로는 cascaded streaming(STT→LLM→TTS) + 파이프라이닝이 여전히 정답인 경우가 많습니다. 한 튜토리얼 연구에서는 native S2S 모델이 TTFA(time-to-first-audio)가 너무 느려 실시간에 부적합할 수 있고, 핵심은 “각 단계가 스트리밍으로 다음 단계에 바로 흘려보내는 것”이라고 정리합니다.1

핵심 지표를 쪼개서 보세요.

  • TTFT(STT): 첫 interim transcript가 나오는 시간
  • TTFT(LLM): 첫 token이 나오는 시간
  • TTFA(TTS): 첫 오디오 chunk가 나오는 시간(사용자 체감에 가장 큼)
  • E2E p50/p95: 중앙값보다 p95가 UX를 망칩니다(모바일 네트워크, GC, 백프레셔)

2) 실시간 음성 에이전트의 내부 흐름(권장 구조)

아키텍처를 “오디오 루프(duplex loop)”로 생각하면 구현이 쉬워집니다.

Inbound(사용자→에이전트)

  1. Browser/Client에서 Opus(보통 48kHz)로 캡처
  2. Transport(WebRTC 권장, 또는 WebSocket)로 서버 전송
  3. 서버에서 VAD/turn detection으로 “말 시작/끝” 추정
  4. STT 스트리밍으로 interim/final transcript 생성

Reasoning

  1. LLM에 transcript를 증분(incremental)으로 전달(최소한 final-turn 단위라도 스트리밍 응답을 받기)
  2. Tool calling / RAG가 필요하면 여기서 실행(단, 오디오 UX에서는 tool latency가 바로 티 남)

Outbound(에이전트→사용자)

  1. LLM token 스트림을 sentence/phrase 단위로 끊어 TTS에 조기 투입(“문장 경계 TTS overlap”)
  2. TTS 오디오 chunk를 즉시 client로 스트리밍
  3. barge-in: 사용자가 다시 말하면, 진행 중인 LLM/TTS 스트림을 즉시 cancel하고 새 턴으로 전환

이런 “파이프라인 서버”를 직접 만들기 부담이면, 2026년에는 Voxray(Go, config-driven) 같은 오픈소스가 STT→LLM→TTS 연결, WebRTC/WebSocket, barge-in, observability, telephony까지 한 번에 제공하는 쪽으로 발전했습니다.3
또는 WaaV(Rust gateway)처럼 “멀티 프로바이더를 하나의 WebSocket/REST로 추상화 + DAG 라우팅 + turn detection/noise suppression”을 제공하는 게이트웨이도 있습니다.4

3) WebRTC vs WebSocket (현업 기준)

  • WebRTC: 지연/지터/패킷로스에 강하고, 브라우저 마이크/스피커까지 “실시간 미디어”로 최적화(실시간 대화 UX에 유리)
  • WebSocket: 구현 단순. 하지만 오디오 품질/네트워크 변동 대응(에코캔슬, 지터버퍼 등)을 직접 챙길 일이 많아짐

결론: 브라우저 실시간 대화는 WebRTC가 기본값이고, WebSocket은 서버-서버/내부 파이프에서 자주 씁니다.


💻 실전 코드

아래는 “브라우저(WebRTC) ↔ 서버(Node/TypeScript) ↔ ElevenLabs Agents(WebSocket)”로 실시간 음성 대화를 붙이는 예제입니다.
현실적인 시나리오: 콜센터/앱 내 상담에서, 브라우저는 단지 오디오를 중계하고 대화 로직은 ElevenLabs agent가 담당. 중요한 포인트는 API key를 클라이언트에 절대 두지 않고, 서버에서 signed URL을 발급받아 브라우저가 안전하게 WS에 붙게 하는 것입니다.5

0) 구성

  • Client: WebRTC로 마이크 캡처, 서버로 Opus 전송(여기선 단순화해 “PCM chunk over WS”로 브릿지)
  • Server:
    1) ElevenLabs signed_url 발급
    2) 브라우저 오디오를 ElevenLabs WS로 relay
    3) ElevenLabs 오디오 응답을 브라우저로 relay
  • Provider: ElevenLabs Agents WebSocket (/v1/convai/conversation)5

주의: 실제 프로덕션은 WebRTC(클라↔서버) + 서버 내부에서 필요한 코덱 변환을 넣는 편이 안정적입니다. 예제는 “실행 가능한 형태”로 단순화합니다.

1) 서버 셋업 (Node.js + TypeScript)

1
2
3
4
5
mkdir realtime-voice-agent && cd realtime-voice-agent
npm init -y
npm i express ws node-fetch
npm i -D typescript ts-node @types/express @types/ws @types/node
npx tsc --init

.env

1
2
3
ELEVENLABS_API_KEY=...
ELEVENLABS_AGENT_ID=...
PORT=8080

server.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import express from "express";
import { WebSocketServer, WebSocket } from "ws";
import fetch from "node-fetch";

const app = express();
app.use(express.json());

const PORT = Number(process.env.PORT ?? 8080);
const XI_API_KEY = process.env.ELEVENLABS_API_KEY!;
const AGENT_ID = process.env.ELEVENLABS_AGENT_ID!;

if (!XI_API_KEY || !AGENT_ID) {
  throw new Error("Missing ELEVENLABS_API_KEY / ELEVENLABS_AGENT_ID");
}

// 1) 브라우저가 안전하게 ElevenLabs WS에 붙을 수 있도록 signed_url 발급
app.get("/eleven/signed-url", async (_req, res) => {
  const r = await fetch(
    `https://api.elevenlabs.io/v1/convai/conversation/get-signed-url?agent_id=${encodeURIComponent(
      AGENT_ID
    )}`,
    { headers: { "xi-api-key": XI_API_KEY } }
  );

  if (!r.ok) {
    const text = await r.text();
    return res.status(500).json({ error: text });
  }
  const data = (await r.json()) as { signed_url: string };
  res.json(data);
});

// 2) 오디오 릴레이용 WS (브라우저 <-> 서버)
const server = app.listen(PORT, () => {
  console.log(`HTTP on :${PORT}`);
});

const wss = new WebSocketServer({ server, path: "/relay" });

wss.on("connection", async (clientWs) => {
  // 브라우저는 서버로부터 signed_url을 받아 직접 ElevenLabs에 붙게 할 수도 있지만,
  // 여기서는 "서버가 ElevenLabs WS에 붙고 relay"하는 형태로 예시를 구성합니다.
  // (사내 정책/감사 로깅/레이트리밋/녹취 저장을 서버에서 통제하기 쉬움)

  // ElevenLabs WS endpoint: wss://api.elevenlabs.io/v1/convai/conversation?agent_id=... (또는 signed_url)
  // 문서상 private agent는 signed URL 권장 + API key 노출 금지.[^5]
  const signedUrlResp = await fetch(
    `http://localhost:${PORT}/eleven/signed-url`
  );
  const { signed_url } = (await signedUrlResp.json()) as { signed_url: string };

  const elevenWs = new WebSocket(signed_url);

  const closeAll = (code = 1000, reason = "closing") => {
    try { clientWs.close(code, reason); } catch {}
    try { elevenWs.close(code, reason); } catch {}
  };

  clientWs.on("close", () => closeAll());
  elevenWs.on("close", () => closeAll());

  elevenWs.on("message", (data) => {
    // ElevenLabs -> Browser
    // 여기에는 agent의 audio chunks / events 등이 섞여 옵니다(바이너리/JSON 이벤트).
    clientWs.send(data);
  });

  clientWs.on("message", (data) => {
    // Browser -> ElevenLabs
    // 브라우저가 보내는 PCM/Opus chunk 또는 control event를 그대로 전달.
    // 실제로는 포맷(샘플레이트/채널/코덱)과 이벤트 스키마를 ElevenLabs 요구사항에 맞춰야 합니다.
    elevenWs.send(data);
  });

  clientWs.send(
    JSON.stringify({ type: "server_ready", note: "relay connected" })
  );
});

예상 동작

  • /eleven/signed-url에서 signed_url을 반환(서버만 API key 보유)5
  • 브라우저가 /relay에 WS로 연결해 오디오를 push하면, ElevenLabs가 실시간으로 오디오 응답을 스트리밍

2) 클라이언트(브라우저) — “현실적인” 최소 구현 포인트

브라우저에서 해야 할 일은 단순합니다.

  • 마이크 캡처
  • 오디오를 일정 프레임으로 잘라 WS로 전송
  • 서버가 보내는 audio chunk를 받아 재생
  • push-to-talk / VAD / barge-in UI(이게 UX를 좌우)

여기서는 코드 길이를 줄이기 위해 핵심만 적습니다(실제로는 AudioWorklet로 PCM 추출 권장).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// client.ts (개념 코드; 번들러(Vite/Next) 환경 가정)
const ws = new WebSocket("ws://localhost:8080/relay");
ws.binaryType = "arraybuffer";

ws.onmessage = async (evt) => {
  // JSON event 또는 audio chunk 처리
  // 실제 구현에서는 evt.data가 JSON인지 바이너리인지 구분해서,
  // 바이너리면 WebAudio로 enqueue 재생합니다.
  console.log("from server", evt.data);
};

async function startMic() {
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  const audioCtx = new AudioContext({ sampleRate: 48000 });

  const src = audioCtx.createMediaStreamSource(stream);
  const processor = audioCtx.createScriptProcessor(4096, 1, 1);

  processor.onaudioprocess = (e) => {
    const input = e.inputBuffer.getChannelData(0); // Float32Array
    // 여기서 Float32 -> Int16 PCM 변환 후 전송 (실서비스는 Opus/WebRTC가 더 효율적)
    const pcm16 = floatTo16BitPCM(input);
    if (ws.readyState === WebSocket.OPEN) ws.send(pcm16.buffer);
  };

  src.connect(processor);
  processor.connect(audioCtx.destination);
}

function floatTo16BitPCM(float32: Float32Array) {
  const out = new Int16Array(float32.length);
  for (let i = 0; i < float32.length; i++) {
    const s = Math.max(-1, Math.min(1, float32[i]));
    out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
  }
  return out;
}

startMic();

이 예제는 “동작”에 초점을 맞춰 간소화했습니다. 프로덕션에서는 (1) WebRTC 사용 (2) server-side에서 Opus decode/encode 또는 provider가 요구하는 포맷으로 변환 (3) turn detection + barge-in cancel 이벤트를 명시적으로 연결해야 지연과 비용이 잡힙니다.

3) “프레임워크로 빨리 가는 길”: LiveKit Agents + 플러그인 STT/TTS

직접 오디오 플러밍을 하기 싫다면, LiveKit Agents 같은 프레임워크에 STT/TTS 플러그인을 붙여 WebRTC 기반 에이전트를 빠르게 구성할 수 있습니다. Smallest AI의 LiveKit 통합 문서는 STT(WebSocket streaming, 매우 낮은 TTFT 언급) + TTS(Low-latency)를 예시로 제공합니다.6


⚡ 실전 팁 & 함정

Best Practice (바로 체감되는 것 3개)

1) Barge-in은 “기능”이 아니라 “취소 전파(cancel propagation)” 설계

  • 사용자가 말 시작 → 즉시 (a) TTS playback stop (b) TTS 요청 cancel (c) LLM stream cancel (d) STT 새 세션/턴으로 전환
  • Voxray 같은 플랫폼이 interruption 전략을 기능으로 제공하는 이유가 여기 있습니다.3

2) 문장 경계(sentence boundary)로 TTS를 조기 시작

  • LLM의 token을 그대로 TTS에 밀어 넣으면 발화가 부자연스럽거나 재생 큐가 흔들립니다.
  • “sentence aggregator”를 두고, ., ?, !, \n 또는 prosody 힌트에서 끊어 짧은 phrase 단위로 TTS하면 TTFA를 줄이면서도 자연스러움 유지

3) 관측 가능성(Observability): p95를 스테이지별로 쪼개라

  • STT p95, LLM first-token p95, TTS first-audio p95, transport jitter를 분리 측정
  • WaaV처럼 게이트웨이 레이어에서 turn detect/noise filter/connection pooling을 제공하는 이유가 “성능 문제를 한 곳에서 제어”하려는 니즈 때문입니다.4

흔한 함정 / 안티패턴

  • “배치 STT + 스트리밍 TTS” 혼합: transcript가 늦게 확정되어 전체가 늦어짐. 실시간은 STT부터 스트리밍이어야 합니다.
  • VAD를 클라이언트에서 대충 처리: 조용한 환경에선 되지만, 실제 사용 환경(차량, 카페, 스피커폰)에선 오탐/미탐으로 비용 폭발 + 대화 끊김
  • 긴 세션에서 컨텍스트 비용 증가: 실시간 API는 턴마다 대화 히스토리를 재전송/재처리하면서 비용이 증가할 수 있습니다. “요약/메모리/세션 롤링” 전략이 필요합니다(벤더별 과금/캐시 정책에 민감).

비용/성능/안정성 트레이드오프 (결정 프레임)

  • 최저 지연: managed realtime API(단, 벤더 락인/비용/리전 제약)
  • 통제/감사/교체 용이: Voxray/WaaV 같은 오케스트레이션/게이트웨이 계층 도입3
  • 데이터 주권/고정비 선호: self-hosted(STT/TTS/LLM) + WebRTC(구현 난이도↑, 운영 난이도↑)

🚀 마무리

2026년 9월 시점의 결론은 명확합니다. 실시간 음성 에이전트의 승부처는 “모델”보다 “스트리밍 파이프라인 + 취소 전파 + 문장 경계 TTS + 스테이지별 p95 측정”입니다.1
도입 판단 기준은 아래 3개로 정리합니다.

  • 우리 제품의 핵심 UX가 “대화의 리듬(끊김/끼어들기/즉답성)”인가?
  • STT/TTS/LLM을 교체/혼합해야 하는가(비용/리전/언어/보안)?
  • 팀이 오디오/네트워크/관측(지연 p95)까지 운영할 준비가 되었는가?

다음 학습 추천(실무 순서) 1) STT→LLM→TTS 스트리밍 튜토리얼/벤치마크를 기준선으로 삼기(서브 1초 TTFA 목표)1
2) 오케스트레이션 레이어(Voxray/WaaV 등)로 barge-in/observability를 표준화하기3
3) 특정 벤더(예: ElevenLabs Agents WS)의 인증/이벤트 스키마를 정확히 맞추고, API key는 절대 클라이언트에 두지 않기5

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.