음성 AI 구축 완전 가이드
STT·LLM·TTS 파이프라인 설계, ElevenLabs/Deepgram 실전, 그리고 latency를 끝까지 쥐어짜는 스트리밍 아키텍처
음성 AI는 더 이상 "녹음 버튼 → 텍스트 변환" 수준의 단발성 기능이 아니다. 사용자가 말하는 동안 실시간으로 듣고(STT), 생각하고(LLM), 자연스러운 목소리로 답하며(TTS), 중간에 끼어들면 멈추는 — 사람과 구분하기 어려운 대화 에이전트가 현실적인 제품 범위에 들어왔다. 콜센터 자동화, 음성 비서, 게임 NPC, 접근성 도구, 언어 학습까지 적용처는 빠르게 늘고 있다.
하지만 직접 만들어보면 거의 모두가 같은 벽에 부딪힌다. latency다. 텍스트 챗봇은 2~3초 응답도 견디지만, 음성 대화에서 1초 침묵은 영원처럼 느껴진다. 사람 대화의 평균 turn-taking 간격은 약 200ms이고, 700ms를 넘어가면 사용자는 "끊겼나?" 하고 말을 더한다. 그래서 음성 AI 엔지니어링의 본질은 각 단계의 모델 품질이 아니라 단계를 어떻게 스트리밍으로 겹치게(pipeline) 만드느냐에 있다.
이 가이드는 음성 파이프라인의 3대 구성요소(STT, LLM, TTS)를 각각 깊이 다루고, 이를 실시간으로 엮는 방법, ElevenLabs/Deepgram/OpenAI Realtime 같은 실제 API의 사용법과 함정, 그리고 endpointing·barge-in·VAD 같은 "대화를 사람처럼" 만드는 디테일까지 코드와 함께 다룬다. 다음과 같은 핵심 질문에 답하는 것이 목표다.
- STT/TTS를 직접 호스팅할 것인가, ElevenLabs/Deepgram 같은 API를 쓸 것인가, OpenAI Realtime 같은 통합 speech-to-speech 모델로 갈 것인가?
- WebSocket 스트리밍 STT와 batch 전사의 latency 차이는 어디서 오는가?
- 사용자가 말을 끝냈는지(end-of-turn)를 어떻게 판단하는가?
- LLM의 첫 토큰을 TTS로 어떻게 즉시 흘려보내 첫 음절을 0.5초 안에 내보내는가?
- barge-in(끼어들기)을 어떻게 구현하는가?
아래는 전형적인 cascading(STT→LLM→TTS) 파이프라인의 데이터 흐름이다.
[마이크] --PCM 오디오--> [VAD] --> [STT 스트리밍]
|
partial/final transcript
v
[Endpointing 판단]
|
(말 끝남 감지)
v
[LLM 스트리밍 생성]
|
token chunk (문장 단위)
v
[TTS 스트리밍 합성]
|
audio chunk (PCM/Opus)
v
[스피커 재생] <--barge-in 시 즉시 중단
각 화살표가 "기다렸다가 넘기는" 것이 아니라 "흐르는 대로 겹쳐서" 처리되도록 만드는 것이 전체 게임이다.
1. 두 가지 아키텍처: Cascading 파이프라인 vs. Speech-to-Speech
음성 에이전트를 만드는 방법은 크게 두 갈래다. 어느 쪽을 고르느냐가 이후 모든 설계 결정을 좌우하므로 먼저 짚는다.
(A) Cascading 파이프라인 (STT → LLM → TTS)
세 개의 독립 모델을 직렬로 엮는다. 가장 보편적이고, 각 단계를 따로 교체·튜닝할 수 있어 통제력이 높다.
| 장점 | 단점 |
|---|---|
| 각 단계를 best-of-breed로 선택 가능 (예: STT=Deepgram, LLM=Claude, TTS=ElevenLabs) | 단계마다 latency 누적 |
| transcript·LLM 입출력이 텍스트라 로깅/디버깅/가드레일이 쉽다 | 비언어 정보(어조, 감정, 망설임) 손실 |
| 비용·성능을 단계별로 최적화 | 파이프라인 오케스트레이션이 복잡 |
| 도메인 LLM, function calling, RAG를 자유롭게 결합 | 끼어들기·turn-taking을 직접 구현해야 함 |
(B) Speech-to-Speech (통합 멀티모달 모델)
OpenAI Realtime API(gpt-4o-realtime 계열), Gemini Live API처럼 오디오를 직접 입력받아 오디오를 직접 출력하는 단일 모델이다. 중간 텍스트 단계가 없어 latency가 극적으로 낮고(수백 ms), 어조·웃음·말 끊김 같은 paralinguistic 정보를 보존한다.
| 장점 | 단점 |
|---|---|
| 최저 latency (단일 왕복) | 모델·목소리 선택지가 제한적 |
| 어조·감정·끼어들기를 모델이 네이티브로 처리 | transcript 기반 가드레일/로깅이 약함 |
| 구현이 단순 (한 개 WebSocket) | function calling·RAG 통합이 cascading보다 덜 유연 |
| 자연스러운 turn-taking | 특정 벤더 종속, 비용 예측이 까다로움 |
어떻게 고를까
- 고객 응대·예약·정확성이 중요한 업무 자동화 → cascading. transcript를 남기고 도메인 로직을 LLM 텍스트 레이어에서 통제할 수 있어야 한다.
- 데모·자연스러운 잡담·낮은 latency가 최우선 → speech-to-speech.
- 하이브리드도 흔하다: speech-to-speech로 대화를 끌고 가되, 정확한 transcript가 필요한 구간만 별도 STT로 병렬 전사. OpenAI Realtime도 입력 오디오의 transcription을 별도로 받을 수 있다.
베스트 프랙티스: 프로덕션 음성 에이전트라면 처음부터 둘 중 하나에 못 박지 말고, STT/LLM/TTS 어댑터 인터페이스를 추상화해두자. 벤더 가격·품질이 분기마다 바뀌므로 교체 가능성을 코드에 내장하는 것이 1년 뒤 자신을 살린다. LiveKit Agents, Pipecat 같은 오픈소스 프레임워크가 이 추상화를 이미 제공한다.
2. STT 기초 — Batch vs. Streaming, 그리고 latency의 정체
STT(Speech-to-Text, ASR)는 음성 에이전트의 입력단이다. 핵심 구분은 **batch(파일 전사)**와 **streaming(실시간 전사)**이다.
Batch 전사
전체 오디오 파일을 통째로 보내고 완성된 transcript를 받는다. 회의 녹음·팟캐스트 자막처럼 실시간성이 필요 없을 때 쓴다. 정확도는 높지만 사용자가 말을 끝낼 때까지 + 전사 시간만큼 기다려야 하므로 대화형에는 부적합하다.
# OpenAI Whisper API batch 전사 (한국어)
from openai import OpenAI
client = OpenAI()
with open("audio.mp3", "rb") as f:
result = client.audio.transcriptions.create(
model="whisper-1",
file=f,
language="ko", # 언어 힌트를 주면 정확도/속도 개선
response_format="verbose_json", # 타임스탬프 포함
)
print(result.text)
Streaming 전사
오디오 청크를 WebSocket으로 흘려보내면서 partial(중간) transcript와 final transcript를 실시간으로 받는다. 대화형 음성 에이전트는 거의 항상 streaming을 쓴다.
- partial(interim) result: 아직 확정되지 않은, 계속 갱신되는 전사. UI에 흐릿하게 보여주거나 endpointing 판단에 쓴다.
- final result: 모델이 한 발화 단위가 끝났다고 확정한 전사. 이걸 LLM에 넘긴다.
latency는 어디서 오는가
Streaming STT의 체감 지연은 주로 두 군데에서 발생한다.
- 오디오 청크 크기 / 전송 주기: 너무 큰 청크(예: 500ms)를 모아서 보내면 그만큼 시작이 늦다. 보통 20~100ms 단위로 보낸다.
- endpointing 대기 시간: "말이 끝났다"고 판단하기까지의 침묵 대기(예: 500ms~800ms). 이게 실제 체감 지연의 대부분을 차지한다. 너무 짧으면 말 중간에 끊고, 너무 길면 응답이 느려진다.
흔한 함정: "STT 모델이 빠른데 왜 응답이 느리지?"의 90%는 모델 추론 속도가 아니라 endpointing 대기 시간 설정 때문이다. 모델을 바꾸기 전에 이 파라미터부터 측정하라.
베스트 프랙티스:
- 오디오는 16kHz 16-bit mono PCM(또는 모델이 지원하는 Opus)로 보내라. 44.1kHz 스테레오는 대역폭 낭비이고 STT 정확도에 도움 안 된다.
- 한국어는 영어보다 모델별 정확도 편차가 크다. 반드시 본인 도메인 오디오(억양·전문용어·전화 음질)로 실측 WER(Word Error Rate)을 비교하고 고르라. 벤더 벤치마크 숫자는 영어 깨끗한 오디오 기준인 경우가 많다.
- 전화 통화면 8kHz μ-law(telephony codec)를 그대로 STT에 넣을 수 있는지 확인하라. 리샘플링 추가는 latency·품질 손실이다.
3. 실시간 STT 구현 — Deepgram WebSocket 스트리밍
실시간 streaming STT의 대표 격인 Deepgram으로 구체적인 구현을 보자. (AssemblyAI, Google Speech-to-Text, Azure Speech도 개념은 동일하다.)
Node.js 스트리밍 STT
import { createClient, LiveTranscriptionEvents } from "@deepgram/sdk";
const deepgram = createClient(process.env.DEEPGRAM_API_KEY);
const connection = deepgram.listen.live({
model: "nova-2", // 최신 일반 모델 (도메인별 모델 별도 존재)
language: "ko",
encoding: "linear16", // 16-bit PCM
sample_rate: 16000,
channels: 1,
interim_results: true, // partial transcript 받기
punctuate: true,
smart_format: true, // 숫자/날짜 포맷팅
endpointing: 300, // 300ms 침묵이면 발화 종료로 간주
utterance_end_ms: 1000, // 발화 경계 보조 신호
vad_events: true, // 말 시작/끝 이벤트
});
connection.on(LiveTranscriptionEvents.Open, () => {
console.log("STT 연결됨");
});
connection.on(LiveTranscriptionEvents.Transcript, (data) => {
const alt = data.channel.alternatives[0];
const transcript = alt.transcript;
if (!transcript) return;
if (data.is_final) {
// 확정된 전사 — 누적해두고 utterance end에서 LLM으로 넘긴다
console.log("[FINAL]", transcript);
} else {
// partial — UI 갱신용
console.log("[partial]", transcript);
}
});
connection.on(LiveTranscriptionEvents.UtteranceEnd, () => {
// 발화가 끝났다고 판단된 시점 — 여기서 LLM 호출 트리거
console.log(">> 발화 종료, LLM 호출");
});
// 마이크/통화에서 받은 PCM 청크를 흘려보낸다 (20~100ms 단위)
function sendAudioChunk(pcmBuffer) {
connection.send(pcmBuffer);
}
핵심 파라미터 해설
| 파라미터 | 역할 | 튜닝 팁 |
|---|---|---|
endpointing | 이 ms만큼 침묵이면 발화 끝으로 판단 | 짧게(200~300) = 빠른 응답, 말 끊김 위험 / 길게(600~800) = 안전, 느림 |
interim_results | partial transcript 활성화 | endpointing·UI에 필수, 켜두자 |
utterance_end_ms | partial 기반 발화 경계 보조 신호 | endpointing과 병행해 안정성 ↑ |
vad_events | SpeechStarted 이벤트 | barge-in 감지에 활용 |
smart_format | 전화번호·금액·날짜 포맷팅 | 한국어 숫자 처리에 유용, 도메인 따라 검증 |
흔한 함정
is_final과UtteranceEnd를 혼동:is_final=true는 "이 조각이 확정"이라는 뜻이지 "사용자가 말을 다 끝냈다"가 아니다. 한 발화 안에 여러 final 조각이 올 수 있다. LLM 호출 트리거는UtteranceEnd(또는 직접 만든 endpointing 로직)로 해야 한다. 이걸 헷갈리면 사용자가 한 문장 말하는데 LLM이 3번 호출된다.- 연결 keep-alive 누락: 침묵이 길어지면 WebSocket이 idle timeout으로 끊긴다. 주기적으로 keep-alive 메시지를 보내거나 빈 오디오를 흘려야 한다.
- 오디오 포맷 불일치: SDK에
encoding/sample_rate를 선언했는데 실제 보내는 PCM이 다르면 전사가 깨지거나 무음 처리된다. 마이크 캡처 포맷을 정확히 맞춰라.
4. Endpointing과 Turn-Taking — 대화를 사람처럼 만드는 핵심
음성 에이전트의 자연스러움을 좌우하는 가장 어려운 문제는 "사용자가 말을 끝냈는가?"를 정확히 판단하는 것이다. 이걸 endpointing 또는 end-of-turn detection이라 한다.
문제의 본질
사람은 문장 중간에도 숨을 쉬고 망설인다. "음... 그러니까 내일... (0.8초 침묵) ...오후 3시에 예약하고 싶은데요"에서 침묵만으로 판단하면 "내일"에서 끊어버린다. 반대로 너무 길게 기다리면 둔하게 느껴진다.
세 가지 접근
1) 단순 침묵 타이머 (silence-based) 가장 기본. "X ms 침묵 = 발화 종료". 구현이 쉽지만 위 예시처럼 자연스러운 멈춤에 취약하다. 보통 500~700ms로 시작한다.
2) VAD + 침묵 타이머 VAD(Voice Activity Detection)로 "지금 사람 음성이 있는가"를 프레임 단위로 판단하고, 음성이 끝난 뒤 침묵이 임계치를 넘으면 종료로 본다. Silero VAD가 사실상 표준 오픈소스다.
# Silero VAD (PyTorch) — 프레임 단위 음성 확률
import torch
model, utils = torch.hub.load(
repo_or_dir='snakers4/silero-vad',
model='silero_vad',
)
(get_speech_timestamps, _, read_audio, _, _) = utils
# audio: 16kHz mono float32 텐서, 30~96ms 프레임
speech_prob = model(audio_chunk, 16000).item()
# speech_prob > 0.5 면 음성 있음으로 판단
# 음성 종료 후 silence_ms 누적 -> 임계치 초과 시 endpoint
3) 의미 기반 end-of-turn 모델 (semantic endpointing) 최신 접근. transcript의 의미·구문이 완결됐는지를 별도 모델/LLM로 판단한다. "내일 오후 3시에"는 미완결, "예약하고 싶은데요"는 완결로 본다. LiveKit의 turn detector 모델, 일부 STT 벤더가 제공하는 "smart endpointing"이 이 방향이다. 침묵 타이머를 동적으로 조정해 — 문장이 완결돼 보이면 짧게, 미완결이면 길게 기다린다.
실용적 하이브리드 전략
1. VAD로 음성 종료 감지
2. partial transcript가 의미상 완결인가? (간단한 휴리스틱 또는 소형 분류기)
- 완결 → 짧은 대기 (예: 300ms)
- 미완결(접속사/조사로 끝남) → 긴 대기 (예: 800ms)
3. 대기 중 새 음성 시작되면 타이머 리셋
4. 임계 초과 → end-of-turn 확정 → LLM 트리거
베스트 프랙티스 / 함정
- 숫자·이메일·주소를 받을 때는 endpointing을 길게. "공일공... (생각) ...일이삼사"에서 끊으면 전화번호가 잘린다. 컨텍스트별로 동적 조정하라.
- 한국어는 조사·어미가 turn 신호로 유용. "~인데요", "~할게요" 같은 종결어미 vs "~하고", "~인데" 같은 연결어미를 휴리스틱에 넣으면 정확도가 올라간다.
- 첫 응답이 느린 것보다 끊는 게 더 짜증난다. 사용자 테스트에서 거의 항상 "말 중간에 잘림"이 더 강한 불만으로 나온다. 의심스러우면 endpointing을 약간 길게 잡고, barge-in으로 보완하라.
- speech-to-speech 모델(OpenAI Realtime 등)은 이 turn-taking을 모델이 내부적으로 처리한다 — cascading의 큰 구현 부담 하나를 덜어주는 것이 그쪽 매력 중 하나다.
5. TTS 기초 — 목소리, 포맷, 그리고 첫 음절 latency
TTS(Text-to-Speech)는 출력단이다. 음성 에이전트에서 TTS 선택의 핵심 지표는 세 가지다: 목소리 품질·자연스러움, TTFB(Time To First Byte, 첫 오디오 청크까지 시간), 스트리밍 지원 여부.
왜 TTFB가 latency 품질보다 중요한가
전체 음성을 다 만든 뒤 재생을 시작하면, 답변이 길수록 침묵이 길어진다. 대신 첫 음절을 만들자마자 흘려보내면 사용자는 모델이 답을 다 만들기 전에 이미 듣기 시작한다. 그래서 TTS는 "얼마나 빨리 다 만드냐"보다 "첫 청크가 얼마나 빨리 나오냐(TTFB)"가 체감 latency를 결정한다. 좋은 streaming TTS는 TTFB가 수백 ms 수준이다.
오디오 포맷 선택
| 포맷 | 특징 | 용도 |
|---|---|---|
| PCM (linear16) | 무압축, 즉시 재생 가능, 대역폭 큼 | 서버 내부 파이프, 낮은 latency |
| Opus / WebM | 압축률 높음, 브라우저 친화 | 웹·WebRTC 전송 |
| MP3 | 범용, 약간의 인코딩 지연 | 다운로드·비실시간 |
| μ-law 8kHz | 전화 코덱 | Twilio 등 telephony |
실시간 대화에서는 PCM 또는 Opus가 기본이다. MP3는 인코딩 프레임 정렬 때문에 스트리밍 시 청크 경계가 깔끔하지 않을 수 있다.
스트리밍 vs. 비스트리밍
- 비스트리밍: 텍스트 전체 → 완성된 오디오 파일 1개. 구현 쉽지만 긴 답변에서 latency가 텍스트 길이에 비례한다.
- 스트리밍: 텍스트(또는 텍스트 스트림) → 오디오 청크 연속. 대화형 필수. ElevenLabs, Cartesia, OpenAI TTS 등이 지원한다.
흔한 함정
- 숫자·약어·단위 발음: "3kg", "010-1234", "$50", "2026년"을 TTS가 엉뚱하게 읽는 경우가 많다. 한국어는 특히 숫자 읽기("이천이십육 년" vs "두천...")가 모델마다 다르다. 중요한 발화는 텍스트 정규화(number-to-words)를 직접 전처리하거나 SSML/모델별 발음 제어를 쓰라.
- 문장 경계에서만 자연스러운 운율: LLM 토큰을 글자 단위로 TTS에 흘리면 운율이 깨진다. 보통 문장/절 단위로 버퍼링해서 TTS에 넣는다(다음 섹션 참고).
- 샘플레이트 불일치로 재생 속도 이상: TTS가 24kHz로 주는데 플레이어를 16kHz로 설정하면 느리고 낮은 목소리가 난다. 출력 샘플레이트를 정확히 맞춰라.
6. ElevenLabs 실전 — 목소리, 모델, 스트리밍, WebSocket
ElevenLabs는 목소리 자연스러움과 다국어(한국어 포함) 품질, voice cloning으로 음성 에이전트 TTS의 사실상 표준 중 하나다. 실전 사용법을 단계별로 본다.
모델 선택 (latency vs. 품질 트레이드오프)
ElevenLabs는 용도별로 다른 모델 패밀리를 제공한다. 이름·세대는 갱신되므로 현재 콘솔의 모델 목록을 확인하되, 선택 기준은 일정하다.
- 저지연(turbo/flash 계열): 실시간 대화용. TTFB가 매우 낮다. 품질은 약간 양보.
- 고품질(multilingual 계열): 내레이션·콘텐츠용. latency보다 표현력 우선.
실시간 보이스 에이전트라면 저지연 계열을 기본으로 깔고, 품질 이슈가 있는 특정 발화만 고품질로 처리하는 식으로 분기하라.
기본 스트리밍 (HTTP chunked)
import requests
VOICE_ID = "..." # 콘솔에서 목소리 선택
url = f"https://api.elevenlabs.io/v1/text-to-speech/{VOICE_ID}/stream"
resp = requests.post(
url,
headers={"xi-api-key": API_KEY, "Content-Type": "application/json"},
json={
"text": "안녕하세요, 무엇을 도와드릴까요?",
"model_id": "eleven_turbo_v2_5", # 저지연 다국어 계열 예시
"voice_settings": {
"stability": 0.5, # 낮을수록 표현력↑ 변동↑
"similarity_boost": 0.75,
"style": 0.0,
"use_speaker_boost": True,
},
"output_format": "pcm_24000", # 24kHz PCM
},
stream=True,
)
for chunk in resp.iter_content(chunk_size=4096):
if chunk:
play_audio(chunk) # 받는 즉시 재생
WebSocket 스트리밍 (LLM 토큰을 실시간으로 흘려보낼 때)
LLM이 토큰을 생성하는 동안 그 텍스트를 곧바로 TTS로 넘기려면 WebSocket 엔드포인트를 쓴다. 텍스트를 조금씩 보내면서 오디오를 받는다. 핵심은 chunk_length_schedule(또는 flush 제어) 로 "얼마나 텍스트가 모이면 합성을 시작할지"를 조절하는 것이다.
const ws = new WebSocket(
`wss://api.elevenlabs.io/v1/text-to-speech/${VOICE_ID}/stream-input?model_id=eleven_turbo_v2_5`
);
ws.onopen = () => {
// 1) 초기 설정 + 첫 텍스트
ws.send(JSON.stringify({
text: " ",
voice_settings: { stability: 0.5, similarity_boost: 0.75 },
// 작은 값 = 더 빨리 합성 시작(낮은 latency), 큰 값 = 더 자연스러운 운율
generation_config: { chunk_length_schedule: [120, 160, 250, 290] },
xi_api_key: API_KEY,
}));
};
// 2) LLM 토큰이 올 때마다 텍스트 청크 전송
function pushText(textChunk) {
ws.send(JSON.stringify({ text: textChunk }));
}
// 3) 발화 끝나면 flush
function endUtterance() {
ws.send(JSON.stringify({ text: "" })); // 빈 문자열 = flush/종료 신호
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.audio) {
const pcm = Buffer.from(msg.audio, "base64");
playAudio(pcm);
}
if (msg.isFinal) console.log("합성 완료");
};
함정과 베스트 프랙티스
stability를 너무 낮추지 마라: 표현력은 올라가지만 같은 텍스트가 매번 다르게, 가끔 이상하게 읽힌다. 에이전트 음성은 0.4~0.6 근처에서 안정적인 일관성을 잡는 게 보통 낫다.- 글자 단위로 WebSocket에 보내지 마라: LLM 토큰을 그대로 한 글자씩 보내면 운율이 깨지고 메시지 오버헤드가 커진다. 단어/절 단위로 모아서 보내고
chunk_length_schedule로 시작 시점을 제어하라. - 목소리 일관성: voice cloning한 목소리는 라이선스·동의가 필수다. 실제 사람 목소리를 무단 복제하면 법적 문제가 된다. 프로덕션은 라이브러리 목소리 또는 적법하게 동의받은 클론만 쓰라.
- 비용은 글자 수 기반: 긴 답변·반복 재생성이 비용을 빠르게 키운다. 자주 쓰는 고정 안내 멘트(예: "잠시만 기다려 주세요")는 사전 합성해서 캐싱하라.
- 별도 제품인 ElevenLabs의 통합 음성 에이전트 플랫폼도 있다. STT/LLM/TTS·turn-taking을 묶어 제공하므로, cascading을 직접 다 엮기 부담되면 검토할 가치가 있다. 다만 통제력은 직접 조립보다 낮다.
7. 파이프라인 오케스트레이션 — STT→LLM→TTS를 스트리밍으로 겹치기
이 섹션이 가이드의 심장이다. 세 모델을 직렬로 쓰되 단계를 겹쳐서(overlap) latency를 최소화하는 법이다.
나이브한 구현이 느린 이유
STT 완료(2.0s) → LLM 완료(1.5s) → TTS 완료(1.0s) → 재생 시작
총 첫 소리까지: 4.5초 ← 사용자는 영원처럼 느낀다
각 단계가 앞 단계의 "완료"를 기다린다. 이걸 스트리밍으로 겹치면:
STT: endpoint 감지 즉시 final transcript 확보 (~0.3s 추가 대기)
LLM: 첫 토큰을 ~0.3s 만에 시작, 토큰을 스트리밍
TTS: LLM 첫 문장이 모이는 즉시 합성 시작, 첫 오디오 ~0.2s 만에
→ 첫 소리까지: 약 0.8~1.2초
핵심 패턴: 문장 단위 청킹 (sentence chunking)
LLM 토큰을 글자 단위로 TTS에 넣으면 운율이 깨진다. 그렇다고 답변 전체를 기다리면 느리다. 정답은 첫 문장(또는 절)이 완성되는 즉시 TTS로 넘기고, 나머지는 계속 스트리밍하는 것이다.
import re, asyncio
async def stream_llm_to_tts(user_text, tts_session):
buffer = ""
# 문장 종결 부호 또는 충분한 길이에서 flush
sentence_end = re.compile(r'[.!?。!?\n]|(?<=[다요죠음])\s')
async for token in llm_stream(user_text): # LLM 토큰 스트림
buffer += token
# 문장 경계가 잡히면 그 부분까지 TTS로 보낸다
match = sentence_end.search(buffer)
while match:
sentence = buffer[:match.end()].strip()
if sentence:
await tts_session.send_text(sentence) # 즉시 합성 시작
buffer = buffer[match.end():]
match = sentence_end.search(buffer)
# 남은 꼬리 flush
if buffer.strip():
await tts_session.send_text(buffer.strip())
await tts_session.flush()
한국어는 종결어미(~다, ~요, ~죠)가 문장 경계의 좋은 신호이므로 마침표만 보지 말고 어미도 함께 보는 게 유리하다. 단, 첫 문장은 운율이 가장 중요하니 너무 짧게 자르지 말고 최소 길이(예: 10~15자)를 함께 조건으로 걸어라.
전체 오케스트레이션 골격
class VoiceAgent:
def __init__(self):
self.is_speaking = False # 현재 에이전트가 말하는 중인가 (barge-in용)
self.current_tts = None
self.history = []
async def on_user_utterance_end(self, transcript):
self.history.append({"role": "user", "content": transcript})
# 새 TTS 세션 시작
tts = await open_tts_session()
self.current_tts = tts
self.is_speaking = True
try:
assistant_text = ""
async for sentence in self._llm_sentences(self.history):
if not self.is_speaking: # barge-in으로 취소됨
break
assistant_text += sentence
await tts.send_text(sentence)
await tts.flush()
self.history.append({"role": "assistant", "content": assistant_text})
finally:
self.is_speaking = False
async def on_user_speech_started(self):
# 에이전트가 말하는 중에 사용자가 끼어들면 즉시 중단
if self.is_speaking and self.current_tts:
self.is_speaking = False
await self.current_tts.cancel() # TTS 합성 취소
await stop_audio_playback() # 재생 버퍼 비우기
베스트 프랙티스
- 모든 단계를 async/streaming으로: 한 단계라도 blocking이면 전체가 막힌다.
- 단계 간 backpressure 관리: TTS가 LLM보다 느리면 텍스트가 쌓인다. 큐 길이를 모니터링하고, 사용자가 끼어들면 버퍼를 즉시 버려라.
- "생각 중" 신호: LLM이 RAG/function call로 1초 이상 걸릴 땐 "잠시만요" 같은 filler를 먼저 재생해 침묵을 메워라(사전 합성 캐시 활용).
- end-to-end latency를 단계별로 계측하라: STT endpoint→LLM 첫 토큰→TTS 첫 청크→스피커 출력까지 타임스탬프를 찍어 어디가 병목인지 항상 알 수 있게 하라. 추측으로 튜닝하면 엉뚱한 데를 만진다.
8. Barge-In(끼어들기) — 사람처럼 멈추기
에이전트가 말하는 도중 사용자가 말을 시작하면 즉시 멈춰야 한다. 이게 barge-in이고, 없으면 대화가 워키토키처럼 부자연스럽다. 사용자가 "아니 그게 아니라—" 하는데 에이전트가 5초 더 떠들면 최악이다.
구현의 세 박자
1) 사용자 음성 시작 감지 (빠르게) VAD 또는 STT의 SpeechStarted 이벤트로 "사용자가 말하기 시작했다"를 감지한다. 여기서 핵심은 에코를 사용자 음성으로 오인하지 않는 것이다. 스피커에서 나오는 에이전트 목소리가 마이크로 다시 들어오면 자기 목소리에 자기가 끼어든다.
2) 출력 즉시 중단
- 진행 중인 TTS 합성 취소 (서버 비용·대역폭 절약)
- 이미 재생 버퍼/스피커 큐에 쌓인 오디오를 비우기 ← 이걸 빼먹으면 "멈춰야 하는데 버퍼에 있던 2초가 더 나온다"
- 클라이언트 측 오디오 큐를 flush하라.
3) 상태 정리 / 컨텍스트 처리 중간에 끊긴 에이전트 답변을 history에 어떻게 기록할지 정해야 한다. 보통 "여기까지 말했다"를 부분 기록하거나, 끊긴 발화를 무시한다. 잘못 처리하면 LLM이 자기가 한 말을 헷갈린다.
에코 캔슬레이션은 필수
barge-in을 진지하게 하려면 **AEC(Acoustic Echo Cancellation)**가 거의 필수다. 스피커 출력을 reference로 마이크 입력에서 빼주는 신호처리다.
- WebRTC를 쓰면 브라우저/네이티브 스택에 AEC가 내장되어 있다 (
echoCancellation: true). 이게 헤드폰 없이 스피커폰 환경에서 음성 에이전트가 동작하는 핵심 이유다. - 헤드폰을 쓰면 에코가 물리적으로 없어 AEC 부담이 준다 — 데모/내부 테스트는 헤드폰으로 하되, 프로덕션은 스피커폰 환경을 반드시 테스트하라.
// 브라우저 마이크 캡처 시 AEC 활성화
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true, // 핵심
noiseSuppression: true,
autoGainControl: true,
sampleRate: 16000,
channelCount: 1,
},
});
함정
- half-duplex 가정: "한 번에 한 명만 말한다"고 코드를 짜면 barge-in이 불가능하다. 입력 캡처와 출력 재생을 항상 동시에(full-duplex) 돌려야 한다.
- 민감도 튜닝: VAD가 너무 민감하면 기침·배경 소음·"음~" 같은 backchannel에도 멈춰버린다. 반대로 둔하면 끼어들기가 안 먹는다. 짧은 노이즈는 무시하고 일정 시간 이상 지속된 음성에만 barge-in을 트리거하는 게 보통이다.
- 취소 후 잔여 오디오: TTS 합성을 취소해도 네트워크·디코더·OS 오디오 버퍼에 이미 들어간 샘플은 계속 나온다. 재생 파이프라인 전체에서 flush 가능한 지점을 확보해두라.
9. Speech-to-Speech 경로 — OpenAI Realtime API
cascading의 turn-taking·barge-in을 직접 구현하는 부담이 크다면, 통합 음성 모델이 대안이다. OpenAI Realtime API를 예로 본다. (Gemini Live API도 구조가 유사하다.)
구조
단일 WebSocket(또는 WebRTC)으로 오디오를 양방향 스트리밍한다. 모델이 음성 입력을 직접 받아 음성 출력을 직접 낸다. VAD·turn detection·barge-in을 모델/서버가 내부 처리한다.
import WebSocket from "ws";
const ws = new WebSocket(
"wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview",
{ headers: { Authorization: `Bearer ${OPENAI_API_KEY}` } }
);
ws.on("open", () => {
ws.send(JSON.stringify({
type: "session.update",
session: {
modalities: ["audio", "text"],
instructions: "당신은 친절한 한국어 예약 상담원입니다. 간결하게 답하세요.",
voice: "alloy",
input_audio_format: "pcm16",
output_audio_format: "pcm16",
input_audio_transcription: { model: "whisper-1" }, // 입력 transcript도 별도로 받기
turn_detection: {
type: "server_vad", // 서버 측 VAD로 turn 자동 감지
threshold: 0.5,
prefix_padding_ms: 300,
silence_duration_ms: 500,
},
},
}));
});
// 마이크 PCM을 base64로 흘려보낸다
function sendAudio(pcm16Buffer) {
ws.send(JSON.stringify({
type: "input_audio_buffer.append",
audio: pcm16Buffer.toString("base64"),
}));
}
ws.on("message", (raw) => {
const evt = JSON.parse(raw);
switch (evt.type) {
case "response.audio.delta": // 출력 오디오 청크
playAudio(Buffer.from(evt.delta, "base64"));
break;
case "input_audio_buffer.speech_started":
// 사용자가 말 시작 → 재생 중이면 barge-in 처리
stopAudioPlayback();
break;
case "response.audio_transcript.delta":
// 에이전트가 말하는 내용의 텍스트 (로깅/자막용)
break;
case "conversation.item.input_audio_transcription.completed":
// 사용자 발화의 transcript
break;
}
});
Function calling도 된다
Realtime 모델도 tool 정의를 받아 함수를 호출할 수 있다. 예약 조회·DB 검색 같은 액션을 음성 대화 중에 수행한 뒤, 결과를 다시 음성으로 답하게 만든다. 이걸로 "speech-to-speech는 도메인 통합이 약하다"는 단점이 상당히 보완된다.
cascading 대비 트레이드오프 (실전 관점)
- 장점: turn-taking·barge-in·자연스러운 어조가 거의 공짜. 코드가 훨씬 단순.
- 단점: 목소리 선택지가 제한적(브랜드 보이스 클론은 cascading의 ElevenLabs가 유리). transcript 기반 가드레일·정밀 로깅·후처리가 cascading보다 약하다. 비용 구조(오디오 토큰)가 텍스트보다 비싸고 예측이 까다롭다.
- server_vad 설정이 곧 체감 품질:
silence_duration_ms,threshold가 cascading의 endpointing과 정확히 같은 역할을 한다. 디폴트로 두지 말고 본인 시나리오로 튜닝하라.
결론: 프로토타입·자연스러운 잡담형 에이전트는 Realtime으로 빠르게 띄우고, 정밀 통제·브랜드 보이스·강한 가드레일이 필요해지면 cascading으로 이주하는 경로가 현실적이다.
10. 전화 통합 — Twilio Media Streams로 실전 콜 봇 만들기
음성 에이전트의 가장 현실적인 수익처는 전화(콜센터·예약·아웃바운드)다. Twilio Media Streams가 사실상 표준 경로다.
흐름
[전화 발신/수신]
→ Twilio (TwiML: <Connect><Stream>)
→ WebSocket으로 통화 오디오 양방향 스트리밍 (8kHz μ-law base64)
→ 내 서버: STT → LLM → TTS 파이프라인
→ 합성 오디오를 다시 Twilio WebSocket으로 전송 → 발신자에게 재생
TwiML로 스트림 연결
<!-- 수신 통화 시 Twilio가 호출하는 webhook의 응답 -->
<Response>
<Connect>
<Stream url="wss://your-server.com/media" />
</Connect>
</Response>
WebSocket 핸들러 (오디오 포맷이 핵심)
Twilio는 8kHz μ-law(G.711) base64로 오디오를 보낸다. STT/TTS가 이 포맷을 직접 지원하지 않으면 변환이 필요하다.
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (twilioWs) => {
let streamSid;
twilioWs.on("message", (raw) => {
const msg = JSON.parse(raw);
switch (msg.event) {
case "start":
streamSid = msg.start.streamSid;
break;
case "media": {
// 들어온 오디오: 8kHz μ-law base64
const muLaw = Buffer.from(msg.media.payload, "base64");
// STT가 μ-law 지원하면 그대로, 아니면 PCM 변환 후 STT로
sttSession.send(muLaw);
break;
}
case "stop":
sttSession.finish();
break;
}
});
// TTS 오디오를 다시 Twilio로 (8kHz μ-law base64로 맞춰서)
function sendToCaller(muLawChunk) {
twilioWs.send(JSON.stringify({
event: "media",
streamSid,
media: { payload: muLawChunk.toString("base64") },
}));
}
// barge-in: 사용자가 말 시작하면 Twilio 재생 버퍼 비우기
function clearPlayback() {
twilioWs.send(JSON.stringify({ event: "clear", streamSid }));
}
});
함정과 베스트 프랙티스
- 8kHz의 품질 한계: 전화 음질은 좁은 대역(8kHz)이라 STT 정확도가 떨어진다. 전화 전용/telephony 최적화 STT 모델을 쓰고, TTS도 8kHz로 다운샘플될 것을 감안해 너무 섬세한 목소리보다 명료한 목소리를 골라라.
- μ-law ↔ PCM 변환: STT/TTS가 μ-law를 네이티브 지원하면 변환 단계를 없애 latency를 줄여라. 변환이 불가피하면 G.711 코덱 라이브러리로 정확히 처리하라(잘못하면 노이즈·찢어짐).
- Twilio
clear이벤트로 barge-in: 위 코드의clear가 Twilio 재생 버퍼를 비우는 공식 방법이다. 이걸 안 쓰면 끼어들어도 쌓인 오디오가 계속 나온다. - latency 예산이 더 빡빡하다: PSTN 네트워크 지연이 이미 깔려 있어 파이프라인 여유가 적다. endpointing·TTFB를 더 공격적으로 잡아야 한다.
- 법적/규제: 통화 녹음·AI 응대 고지 의무가 지역마다 다르다. 한국은 통신비밀·개인정보 이슈가 있으니 녹음·전사 저장·동의 고지를 법무 검토하라. 아웃바운드 자동 발신은 규제가 더 빡세다.
11. 프레임워크로 가속하기 — LiveKit Agents / Pipecat
지금까지의 STT·endpointing·LLM 스트리밍·TTS·barge-in·전화 통합을 매번 손으로 짜는 건 큰 부담이다. 이걸 묶어주는 오픈소스 프레임워크 두 개가 실전에서 많이 쓰인다.
LiveKit Agents
WebRTC 인프라(LiveKit) 위에서 음성 에이전트를 만드는 Python/Node 프레임워크. STT/LLM/TTS 플러그인을 끼우면 turn detection·barge-in·오디오 전송을 알아서 처리한다.
# LiveKit Agents 개념 예시 (플러그인 조립 방식)
from livekit.agents import AgentSession, Agent
from livekit.plugins import deepgram, openai, elevenlabs, silero
session = AgentSession(
vad=silero.VAD.load(), # 음성 활동 감지
stt=deepgram.STT(model="nova-2", language="ko"),
llm=openai.LLM(model="gpt-4o"), # 또는 다른 LLM 어댑터
tts=elevenlabs.TTS(voice="...", model="eleven_turbo_v2_5"),
# turn detection·endpointing·barge-in은 프레임워크가 처리
)
agent = Agent(instructions="당신은 한국어 예약 상담원입니다. 간결하게 답하세요.")
# session.start(...) 로 룸에 연결해 실시간 대화
핵심 가치: STT/LLM/TTS를 한 줄로 교체 가능하다. "오늘은 Deepgram+ElevenLabs, 내일은 다른 조합"을 코드 한 줄로 실험할 수 있다. 섹션 1의 "어댑터 추상화"를 프레임워크가 대신 해준다.
Pipecat
파이프라인을 프로세서들의 그래프로 구성하는 Python 프레임워크. 오디오 입력 → VAD → STT → LLM → TTS → 출력을 명시적 파이프라인으로 엮고, Twilio·WebRTC·Daily 같은 transport를 붙인다. 흐름을 직접 제어하고 싶을 때 통제력이 좋다.
직접 짤까, 프레임워크를 쓸까
| 상황 | 권장 |
|---|---|
| 빠르게 동작하는 에이전트가 필요, 표준적인 cascading | 프레임워크 (LiveKit Agents / Pipecat) |
| 매우 특수한 오디오 처리·커스텀 transport·극한 latency 튜닝 | 직접 구현 (단, 이 가이드의 패턴을 다 직접 짜야 함) |
| speech-to-speech 단일 모델로 충분 | 프레임워크의 Realtime 통합 또는 직접 WebSocket |
베스트 프랙티스
- 프레임워크를 쓰더라도 내부 동작(endpointing·barge-in·청킹)을 이해하라. 프레임워크는 디폴트를 주지만, 제품 품질을 결정하는 건 결국 endpointing 임계치·VAD 민감도 같은 튜닝이고 이건 본인 도메인 지식이 필요하다.
- 벤더 lock-in 회피: 프레임워크의 플러그인 추상화 덕에 STT/TTS 교체가 쉽다. 가격·품질이 자주 바뀌는 시장이니 이 유연성을 적극 활용하라.
- 로컬 폴백 / self-host: STT(예: Whisper self-host), VAD(Silero), 일부 TTS는 온프레미스로 돌릴 수 있다. 비용·프라이버시(통화 내용 외부 전송 우려)가 민감하면 검토하라. 다만 self-host TTS는 ElevenLabs급 자연스러움을 내기 어렵다는 점을 감안하라.
12. 프로덕션 체크리스트 — 비용·관측·품질·안전
데모가 동작하는 것과 프로덕션 음성 에이전트는 완전히 다른 일이다. 출시 전 점검 항목을 정리한다.
Latency 예산 (목표 가이드)
| 구간 | 목표 |
|---|---|
| 사용자 발화 종료 → endpoint 확정 | 300~700ms (도메인별 튜닝) |
| endpoint → LLM 첫 토큰 | < 500ms |
| LLM 첫 문장 → TTS 첫 오디오 청크 | < 300ms |
| end-to-end (발화 끝 → 첫 소리) | 목표 800ms~1.2s, 1.5s 넘으면 둔하게 느껴짐 |
각 구간에 타임스탬프를 박아 매 통화마다 분포(p50/p95)를 기록하라. p95가 진짜 사용자 경험이다.
비용 관리
- STT(분 단위 과금), LLM(토큰), TTS(글자 수)가 각각 따로 청구된다. 세 축을 동시에 모니터링하라.
- 고정 멘트 캐싱: 인사·대기·안내 멘트는 사전 합성해 재사용. TTS 비용·latency 동시 절감.
- LLM 컨텍스트 다이어트: 음성 대화 history는 빠르게 길어진다. 오래된 턴을 요약 압축하라. prompt caching이 가능한 LLM이면 시스템 프롬프트·도구 정의를 캐싱해 비용·TTFT를 줄여라.
- idle 연결 정리: 끊긴 WebSocket이 살아있으면 STT 비용·소켓이 샌다. timeout·정리 로직 필수.
관측성(Observability)
- 전 구간 로깅: 입력 transcript, LLM 입출력, TTS 입력 텍스트, 단계별 타임스탬프. cascading의 장점이 바로 이 텍스트 가시성이다 — 살려라.
- 통화 녹음/전사 저장은 동의·법무 검토 후. PII(전화번호·주소·카드)는 마스킹/redaction.
- 실패 모드 알람: STT WER 급증, TTS 오류율, latency p95 스파이크를 알람으로.
품질·안전
- edge case 테스트: 침묵, 배경 소음, 동시 발화, 사투리/억양, 코드스위칭(한영 혼용), 숫자/이메일 받아쓰기, 매우 긴 발화, 욕설.
- 가드레일: LLM 출력에 프롬프트 인젝션·부적절 응답 필터. 음성은 텍스트보다 모더레이션이 어려우니 cascading의 transcript 레이어에서 거르는 게 현실적.
- graceful degradation: STT/TTS/LLM 중 하나가 실패하면? 폴백 멘트("죄송합니다, 다시 말씀해 주시겠어요?")로 대화를 유지하고, 반복 실패 시 사람 상담원·콜백으로 escalation 경로를 두라.
- AI 고지: "AI 상담원입니다" 고지를 도입부에 넣어라. 규제·신뢰 양면에서 중요하고, 일부 지역은 의무다.
출시 전 최종 체크리스트
[ ] 헤드폰 없는 스피커폰 환경에서 barge-in이 동작하는가 (AEC)
[ ] 전화 8kHz 환경에서 한국어 STT 정확도를 실측했는가
[ ] 숫자/전화번호/이메일 받아쓰기와 읽기가 정확한가
[ ] end-to-end latency p95가 목표 안에 드는가
[ ] 동시 발화·끼어들기 시 오디오 버퍼가 깨끗이 비워지는가
[ ] STT/LLM/TTS 각각 실패 시 폴백·escalation이 있는가
[ ] 통화 녹음/전사 저장에 대한 동의·법무 검토를 마쳤는가
[ ] 비용 3축(STT 분·LLM 토큰·TTS 글자) 모니터링과 예산 알람이 있는가
[ ] 벤더 한 곳이 죽어도 교체 가능한 어댑터 구조인가
음성 AI의 마지막 5%는 모델이 아니라 이 운영 디테일에서 갈린다. latency를 계측하고, 실패를 우아하게 처리하고, 사람처럼 끼어들 수 있게 만드는 것 — 그게 데모와 제품을 가른다.