포스트

실시간 음성 에이전트, 2026년 6월 기준 “STT+LLM+TTS”를 넘어선 설계 체크리스트

실시간 음성 에이전트, 2026년 6월 기준 “STT+LLM+TTS”를 넘어선 설계 체크리스트

들어가며

실시간 음성 대화(voice-to-voice) 프로젝트에서 진짜 문제는 “정확도”가 아니라 지연(latency)·턴테이킹(turn-taking)·중단/수정(barge-in)·네트워크 품질·비용 폭주입니다. 2026년 6월 기준으로 시장이 빠르게 바뀐 지점은, 더 이상 음성 UX를 “STT → LLM → TTS” 샌드위치로만 보지 않고, (1) end-to-end speech-to-speech 모델(Realtime 계열)(2) streaming 파이프라인(각 레이어 best-of-breed) 을 상황에 맞게 섞는 방향으로 정리되고 있다는 점입니다. OpenAI는 Realtime 계열 음성 모델을 “대화 중에도 tool을 쓰고 맥락을 유지하는 production voice agent” 관점에서 설명하고 있고, GPT‑Realtime‑2 같은 모델을 공개했습니다. (openai.com)

언제 쓰면 좋나?

  • 콜센터/예약/상담/세일즈 보조처럼 “대화가 곧 업무 흐름”인 경우: tool calling, 상태머신, 관측가능성(observability)이 중요
  • 브라우저/모바일 실시간 대화: WebRTC까지 포함한 미디어 전송 최적화가 필요(특히 이동통신) (aws.amazon.com)
  • 번역/통역형 경험: speech-to-speech 번역은 STT 결과를 읽는 것보다 UX가 훨씬 자연스러움(단, 비용/통제 이슈) (reddit.com)

언제 쓰면 안 되나?

  • PHI/규제 데이터가 섞이는 의료/금융에서 BAA/HIPAA/리전 같은 요건을 만족 못하는 경우: 오히려 고전적인 체인(STT/TTS를 규정 준수 가능한 벤더로 분리)이 낫습니다. (forasoft.com)
  • 초저비용이 절대 목표인 단순 IVR: end-to-end 음성 모델은 “멋진 데모”는 쉬운데, 분당 과금/스트리밍 토큰 과금 때문에 TCO가 빠르게 커집니다(특히 침묵/잡담 포함)

🔧 핵심 개념

1) 2026년의 아키텍처 2갈래: End-to-End vs Cascaded Streaming

A. End-to-End speech-to-speech(Reatime)

  • 클라이언트가 오디오를 스트리밍하면, 서버 모델이 “듣기→추론→말하기”를 한 모델/세션 안에서 처리
  • 장점: 자연스러운 prosody, interruption 처리, “대화 중 tool 사용” 같은 고수준 정책을 한곳에 모으기 쉬움 (openai.com)
  • 단점: 컴플라이언스/디버깅/벤더 락인. “왜 저 답이 나왔지?”를 STT 텍스트로 쪼개어 추적하기 어렵고, 특정 언어/도메인 튜닝도 제한적

B. Cascaded streaming(STT ↔ LLM ↔ TTS)

  • STT는 streaming partial result, LLM은 streaming generation(토큰), TTS는 streaming audio chunk로 이어 붙임
  • 장점: 레이어별 교체가 가능(예: 도메인 STT만 교체), 비용 최적화가 쉬움, 감사/로그가 명확
  • 단점: “순차 실행”하면 지연이 커짐. 그래서 2026년 논문/구현들은 incremental(부분 전사 중 추론 시작) 또는 semantic trigger(의미 단위로만 추론)로 줄이는 방향을 강조합니다. (arxiv.org)

2) 지연을 쪼개는 관점: P50 time-to-first-audio가 KPI

실시간 대화의 체감은 “정답률”보다 첫 음성 응답이 언제 나오냐가 좌우합니다. 2026년 튜토리얼/실험에서도 P50 time-to-first-audio(첫 오디오까지 걸린 시간)를 핵심 지표로 삼고, 1초 미만을 목표로 합니다. (arxiv.org)

지연을 구성요소로 분해하면:

  • uplink 오디오 캡처/전송(브라우저면 WebRTC가 유리)
  • STT partial 결과가 의미 있게 나오기까지
  • LLM이 “결정 가능한 최소 정보”를 얻는 시점(semantic trigger)
  • TTS가 첫 chunk를 생성하는 시간 + 다운링크 전송

3) Transport: WebSocket이 아니라 WebRTC가 필요한 순간

AWS 쪽에서도 “WebSocket은 TCP 풀듀플렉스, WebRTC는 저지연 미디어 최적화”로 정리하고, 브라우저/모바일 voice agent엔 WebRTC를 강조합니다. (aws.amazon.com)
즉, “서버가 빨라도” 마지막 200~400ms가 네트워크에서 흔들리면 UX가 무너집니다. TURN/ICE까지 포함해 설계해야 합니다.

4) “올인원 Voice Agent API”의 유혹과 제약

Deepgram은 STT/TTS/오케스트레이션을 합친 Voice Agent API를 전면에 두고 있고, TTS 속도(speed) 같은 파라미터도 노출합니다. (deepgram.com)
이 접근은 미들웨어를 줄여주지만, 커스텀 정책(예: 바지인 룰, 특정 도메인 슬롯필링)과 관측가능성이 벤더가 제공하는 형태에 종속될 수 있습니다. 또한 실제 운영에서는 벤더 status/incident를 설계 입력으로 봐야 합니다(스트리밍 서비스 장애가 곧 UX 장애). (status.deepgram.com)


💻 실전 코드

아래는 “브라우저/전화가 아니라 서버에서 실시간 상담 음성 에이전트”를 만든다는 가정으로, Cascaded streaming을 현실적으로 구성하는 예시입니다.

  • STT: Deepgram streaming
  • LLM: OpenAI(스트리밍 응답)
  • TTS: Deepgram streaming(또는 다른 벤더로 교체 가능)
  • 오케스트레이션: 서버가 턴테이킹/VAD/바지인을 통제 (여기가 제품 차별점)

실행 전제: 마이크 입력은 별도(예: WebRTC 게이트웨이/전화 SIP/LiveKit)에서 PCM 16kHz mono로 들어온다고 가정하고, 아래 코드는 “오디오 프레임이 서버로 들어오는 상황”에서 동작합니다.

0) 설치/환경변수

1
2
3
4
npm init -y
npm i ws @deepgram/sdk openai zod
export DEEPGRAM_API_KEY="..."
export OPENAI_API_KEY="..."

1) 실시간 파이프라인(Partial STT → Semantic trigger → LLM stream → TTS stream)

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
import WebSocket from "ws";
import { createClient } from "@deepgram/sdk";
import OpenAI from "openai";
import { z } from "zod";

const deepgram = createClient(process.env.DEEPGRAM_API_KEY!);
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });

// 도메인 tool: 상담원이 하던 “주문 상태 조회”
const ToolInput = z.object({ orderId: z.string().min(6) });
async function lookupOrderStatus(orderId: string) {
  // TODO: 실제로는 DB/ERP 호출. 여기선 예시.
  return { orderId, status: "SHIPPED", eta: "2026-06-21" };
}

type AudioFrame = Buffer; // PCM16 16kHz mono frame

class VoiceAgentSession {
  private dgSocket?: WebSocket;
  private ttsSocket?: WebSocket;

  private partialText = "";
  private finalTextQueue: string[] = [];

  private speaking = false;   // TTS가 재생 중인지
  private interrupted = false;

  async start() {
    // 1) STT streaming 연결
    const dg = await deepgram.listen.live({
      model: "nova-2",
      language: "ko",
      encoding: "linear16",
      sample_rate: 16000,
      punctuate: true,
      interim_results: true,
      endpointing: 80, // ms 단위로 “문장 끝” 감지 민감도 튜닝 포인트
    });

    this.dgSocket = dg.getWebSocket();

    dg.on("transcript", (msg: any) => {
      const alt = msg.channel?.alternatives?.[0];
      const text: string = alt?.transcript ?? "";
      const isFinal: boolean = msg.is_final ?? false;

      if (!text) return;

      if (!isFinal) {
        this.partialText = text;

        // barge-in: 사용자가 말하기 시작했는데 우리가 말하고 있으면 즉시 끊는다
        if (this.speaking) {
          this.interrupted = true;
          this.stopSpeaking();
        }

        // semantic trigger(초단순 버전):
        // - “주문/배송/환불/예약” 같은 의도가 드러나면 final을 기다리지 않고 LLM을 준비
        // 실전에서는 intent classifier나 regex+룰, 또는 작은 LLM으로 prefetch
        if (/(주문|배송|환불|예약)/.test(text)) {
          // 프리페치용: 아직 응답 생성은 하지 않되, 컨텍스트 준비 같은 작업 가능
        }
      } else {
        this.finalTextQueue.push(text);
        this.partialText = "";
        this.onUserUtterance(text).catch(console.error);
      }
    });

    dg.on("error", console.error);
  }

  // 외부(게이트웨이)에서 호출: 마이크 오디오 프레임을 STT로 밀어넣음
  sendAudio(frame: AudioFrame) {
    this.dgSocket?.send(frame);
  }

  private async onUserUtterance(userText: string) {
    // 2) LLM streaming + tool calling
    //    - “실시간”의 핵심은: 답을 다 만든 뒤 TTS하지 말고,
    //      LLM 토큰이 나오자마자 TTS로 흘려보내는 것.
    const system = `
너는 한국어 고객상담 음성 에이전트다.
짧게 말하고, 필요한 경우에만 질문한다.
orderId가 있으면 lookupOrderStatus tool을 호출해 상태를 확인해라.
`;

    const stream = await openai.chat.completions.create({
      model: "gpt-4.1-mini", // 예시: 비용/지연 고려해 작은 모델 + tool로 보강
      stream: true,
      messages: [
        { role: "system", content: system },
        { role: "user", content: userText },
      ],
      tools: [
        {
          type: "function",
          function: {
            name: "lookupOrderStatus",
            description: "주문 상태를 조회한다",
            parameters: {
              type: "object",
              properties: { orderId: { type: "string" } },
              required: ["orderId"],
            },
          },
        },
      ],
    });

    // 3) TTS streaming 연결(필요 시 lazy connect)
    await this.ensureTts();

    this.interrupted = false;
    this.speaking = true;

    let buffer = "";

    for await (const chunk of stream) {
      // tool call 처리
      const toolCalls = chunk.choices?.[0]?.delta?.tool_calls;
      if (toolCalls?.length) {
        // 단순화: 첫 tool call만 처리
        const fn = toolCalls[0].function;
        if (fn?.name === "lookupOrderStatus" && fn.arguments) {
          const args = ToolInput.parse(JSON.parse(fn.arguments));
          const result = await lookupOrderStatus(args.orderId);

          // tool 결과를 다시 모델에 전달(2nd pass)
          const stream2 = await openai.chat.completions.create({
            model: "gpt-4.1-mini",
            stream: true,
            messages: [
              { role: "system", content: system },
              { role: "user", content: userText },
              { role: "tool", tool_call_id: toolCalls[0].id, content: JSON.stringify(result) },
            ],
          });

          for await (const chunk2 of stream2) {
            const delta = chunk2.choices?.[0]?.delta?.content ?? "";
            if (!delta) continue;
            if (this.interrupted) break;

            buffer += delta;
            // 문장 단위로 TTS에 밀어넣기(너무 자주 보내면 비용/지연 역효과)
            if (/[.?!]\s|(\n)/.test(buffer)) {
              this.ttsSpeak(buffer);
              buffer = "";
            }
          }
          break;
        }
      }

      // 일반 텍스트 스트리밍
      const delta = chunk.choices?.[0]?.delta?.content ?? "";
      if (!delta) continue;
      if (this.interrupted) break;

      buffer += delta;
      if (/[.?!]\s|(\n)/.test(buffer)) {
        this.ttsSpeak(buffer);
        buffer = "";
      }
    }

    if (!this.interrupted && buffer.trim()) this.ttsSpeak(buffer);

    this.speaking = false;
  }

  private async ensureTts() {
    if (this.ttsSocket) return;

    // Deepgram TTS streaming (예시)
    // 실제 엔드포인트/파라미터는 Deepgram 문서에 맞게 조정 필요.
    // 포인트: speaking rate 같은 파라미터를 “UX 튜닝 레버”로 써라. ([developers.deepgram.com](https://developers.deepgram.com/changelog/2026/3/26?utm_source=openai))
    const url = "wss://api.deepgram.com/v1/speak?model=aura-2";
    this.ttsSocket = new WebSocket(url, {
      headers: { Authorization: `Token ${process.env.DEEPGRAM_API_KEY}` },
    });

    this.ttsSocket.on("message", (data) => {
      // 여기로 오디오 chunk가 옴 -> 클라이언트로 전달(또는 RTP/WebRTC로 송출)
      // console.log("tts audio bytes", (data as Buffer).length);
    });

    await new Promise<void>((resolve, reject) => {
      this.ttsSocket!.once("open", () => resolve());
      this.ttsSocket!.once("error", reject);
    });
  }

  private ttsSpeak(text: string) {
    if (!this.ttsSocket) return;
    // 실전: SSML, voice, speed, pronunciation lexicon 등을 여기서 제어
    this.ttsSocket.send(JSON.stringify({ type: "text", text }));
  }

  private stopSpeaking() {
    // 실전: 현재 재생중인 오디오 버퍼 flush, 클라이언트 플레이어 stop 등과 연동
    this.speaking = false;
  }
}

// 예상 출력(서버 로그 관점)
// - STT transcript(interim/final) 이벤트
// - tool 호출이 발생하면 DB 조회 후 2nd-pass 스트림
// - TTS chunk가 지속적으로 발생(클라이언트로 송출)

왜 이 구성이 “toy”가 아니냐?

  • “주문 상태 조회”처럼 tool 호출이 필수인 상담 시나리오를 전제로 했고
  • partial STT에서 barge-in을 처리하며
  • LLM 토큰을 문장 단위로 끊어 streaming TTS로 흘려보내 “time-to-first-audio”를 줄입니다

⚡ 실전 팁 & 함정

Best Practice

1) KPI를 WER이 아니라 latency budget으로 잡기

  • P50/P95 time-to-first-audio, barge-in 성공률(끊김 체감), 대화 턴당 비용을 같이 보세요. 실시간 튜토리얼/구현에서도 1초 내 첫 오디오를 중요한 목표로 둡니다. (arxiv.org)

2) Transport를 제품 요구사항으로 명시

  • 브라우저/모바일이면 WebRTC(ICE/TURN 포함) 고려가 사실상 필수입니다. AWS도 “저지연 미디어는 WebRTC”로 정리합니다. (aws.amazon.com)

3) 벤더 장애를 “아키텍처 입력”으로 다루기

  • status 페이지를 보고 멀티리전/폴백(예: STT 벤더 2개, TTS 벤더 2개)을 설계하세요. 특히 streaming은 장애가 곧 대화 단절입니다. (status.deepgram.com)

흔한 함정/안티패턴

  • final transcript만 기다리기: 대화가 “무조건 느리다”는 인상을 줌. semantic trigger/partial 기반 선행 작업이 필요 (arxiv.org)
  • TTS를 토큰 단위로 바로바로 호출: 호출 오버헤드/과금/끊김 증가. 문장/구 단위로 chunking 전략을 가져가야 함
  • 대화 상태를 LLM 메모리에만 의존: 운영에서 재현/감사가 안 됩니다. 세션 state(슬롯, 마지막 tool 결과, 사용자 프로필)는 서버에 구조화 저장

비용/성능/안정성 트레이드오프

  • End-to-end(Reatime)는 UX가 좋아지기 쉽지만 벤더 종속/관측가능성/규정준수가 어려워질 수 있음 (openai.com)
  • Cascaded는 레이어별 최적화가 가능하지만, “incremental”을 못 하면 지연이 커짐 (arxiv.org)
  • “올인원 Voice Agent API”는 빠른 출시엔 유리하나, 커스터마이징 한계와 장애 블라스트 반경이 커질 수 있음 (deepgram.com)

🚀 마무리

2026년 6월의 실시간 음성 에이전트 구현은 “STT+LLM+TTS를 붙였다”에서 끝나지 않고, 턴테이킹(barge-in), latency budget, transport(WebRTC), tool calling, 관측가능성, 장애/폴백이 제품 품질을 결정합니다. OpenAI는 Realtime 음성 모델을 production voice agent 관점으로 밀고 있고, AWS도 WebRTC 기반 양방향 스트리밍을 공식 기능으로 강조하는 흐름이라, “실시간”은 이제 옵션이 아니라 기본 요구가 되고 있습니다. (openai.com)

도입 판단 기준(현실 체크):

  • 1초 내 첫 응답 오디오가 필요한가? → 필요하면 streaming+chunking 설계를 우선
  • 규정 준수/감사 로그가 핵심인가? → cascaded + 레이어 분리 + 저장 가능한 텍스트 로그
  • 브라우저/모바일 비중이 큰가? → WebRTC(ICE/TURN)까지 포함해 네트워크 엔지니어링 필요 (aws.amazon.com)

다음 학습 추천:

  • incremental/semantic-trigger 기반의 streaming voice agent 설계(논문) (arxiv.org)
  • 실제 측정 기반으로 latency를 쪼개는 엔드투엔드 튜토리얼/벤치마크 접근 (arxiv.org)
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.