이 글은 한국어 전화 Voice Agent 현장 노트 7부작 중 3부다. 내가 리드했던 프로젝트 — 공공 서비스 전화 상담용 실시간 한국어 voice agent(STT → LLM → TTS) — 를 만들면서 겪은 일을 정리한다. 1부는 STT 평가가 나를 어떻게 속였는지, 2부는 rule이 모델을 이기는 지점을 다뤘다. 이번 글은 latency 이야기다: 시간이 어디로 새고 있었는지, 그리고 실제로 효과가 있었던 처방이 왜 거의 한 번도 “더 작은 모델 쓰기"가 아니었는지.
첫 latency 작업에 들어가기 전, 응답 하나가 end-to-end로 평균 4.5초 걸렸다. 전화에서 4.5초는 “느리다"가 아니라 “고장 났다"로 들린다. 그 정도 침묵이면 발신자는 전화가 끊겼나 싶어 “여보세요?“를 하고, 하필 agent가 막 대답을 시작하는 순간에 말이 겹친다.
처음 든 생각은 “LLM을 더 작은 걸로 바꾸면 되지 않을까?“였다. 질문부터 틀렸고, 답은 정반대로 나왔다. 결국 35B 모델이 4B보다 빨리 답했다. 지금 내가 쓰는 멘탈 모델은 이렇다:
턴당 latency ≈ 턴당 LLM 호출 수 × (prompt 처리 + 내뱉기로 한 token 수 × token당 시간) + serving stack이 낭비하는 시간
모델 크기는 “token당 시간"을 정하는 요인 중 하나일 뿐이고, 제일 큰 요인인 경우도 드물다. 내가 얻은 개선은 대부분 다른 데서 나왔다: inference engine, 수치 정밀도(precision), CUDA graph, ONNX execution provider, 그리고 한 턴에서 모델을 몇 번 부르느냐.
모델을 줄이기 전에 Stage별 Profiling부터
처음 한 일 중 제일 쓸모 있었던 건 지루한 작업이었다. 응답 약 100개에 대해 stage별 시간을 따로 쟀다. 아래는 1월 초에 보고한 첫 serving 작업의 전후 수치이고, 옆에 팀이 잡은 ~820 ms streaming budget을 붙였다(전화망 구간 몫으로 ~120 ms를 따로 떼어 둔 숫자다):
| Stage | 이전 | 이후 (1월) | 변화 | 팀 budget |
|---|---|---|---|---|
| STT | 351 ms | 157 ms | −55% | 250 ms |
| LLM | 584 ms | 324 ms | −45% | 250 ms |
| TTS | 3,555 ms | 713 ms | −80% | 200 ms |
| 합계 | 4,492 ms | 1,196 ms | −73% | ~820 ms |
교훈은 ‘이전’ 열에 있다. 기다림의 **79%**가 TTS였고 LLM은 13%였다. LLM부터 줄였다면 아무리 잘해도 그 13%의 일부만 건질 수 있었다.
각 행을 줄인 건 이것들이다:
- STT: faster-whisper(CTranslate2)로 decoding. 학습 과정은 Whisper 시리즈에서 다뤘다.
- LLM: Serving을
transformers에서 vLLM으로 옮겼다. 이것 하나로 호출 한 번이 0.6–1.5초에서 0.1–0.3초가 됐다. - TTS: 문장 단위로 합성하고 streaming해서, 나머지 문장이 렌더링되는 동안 첫 문장이 먼저 재생되게 했다.
73%를 깎고도 여전히 budget을 넘었고, TTS는 자기 몫의 3.5배였다. 그리고 이 표는 평균이다. 그게 실수였다. 평균이 뭘 가리고 있었는지는 LLM 호출 수 섹션에서 드러난다.
35B가 4B를 이긴 이유
3월에 dense 4B를 Qwen3.5-35B-A3B로 바꿨다. Token당 약 3B parameter만 활성화되는 mixture-of-experts(MoE) 모델이다. 품질을 얻는 대신 latency로 값을 치를 각오를 했다. 4월에는 후보들을 같은 harness로 benchmark했다: 팀의 긴 production prompt, 모델당 407턴, BF16 weight, CUDA graph on, 완성된 답변 하나당 wall-clock 시간.
| 모델 | 전체 / 활성 params | Median 턴 | p90 | 평균 답변 길이 |
|---|---|---|---|---|
| Qwen3-4B (dense) | 4B / 4B | 279 ms | 474 ms | 86자 |
| Qwen3.5-27B (dense) | 27B / 27B | 1,225 ms | 1,769 ms | 71자 |
| Gemma-4-26B-A4B (MoE) | 26B / ~4B | 251 ms | 414 ms | 62자 |
| Qwen3.5-35B-A3B (MoE) | 35B / ~3B | 289 ms | 414 ms | 65자 |
| GPT-5 (API, 기본 설정) | — | 19,840 ms | 41,443 ms | 78자 |
눈에 띄는 게 세 가지다:
- 속도를 정하는 건 전체 parameter가 아니라 활성 parameter다. Dense 27B는 35B MoE보다 ~4배 느렸고, MoE 둘과 4B는 같은 구간에 모였다.
- 4B가 말이 더 많았다. 4B의 답변은 35B보다 3분의 1쯤 길었고, 검색된 문서가 prompt에 들어간 턴에서는 차이가 1.5배 가까이 벌어졌다(106자 vs 72자). 이 글을 쓰려고 데이터를 다시 보면서 linear fit을 해 보니, 출력 글자당 4B는 약 2.8 ms, 35B는 약 2.3 ms였다. 글자당으로는 거의 비슷하다. 4B는 낮은 고정 비용으로 번 시간을 군더더기 말에 다 써 버린 셈이다. 결과는 median에서 동률, tail은 35B 쪽이 더 짧았다.
- GPT-5는 품질과 상관없이 탈락이었다. Median ~20초는 전화 통화가 아니다.
동률을 승리로 바꾼 건 precision이었다. BF16에서 35B는 weight만 ~70 GB라서, KV cache를 올리기도 전에 80 GB 카드를 거의 다 먹는다. FP8 checkpoint는 그걸 절반으로 줄이고, 생성 token 하나당 옮겨야 하는 byte도 같이 줄인다. CUDA graph를 켜고 돌린 별도의 모델 sweep에서, FP8 35B는 BF16으로 도는 dense 4B보다 턴을 빨리 끝냈다. 비즈니스 팀에도 대략 이렇게 설명했다: 글자당 속도는 같고, 작은 모델은 말이 길고, 큰 모델은 더 가벼운 숫자 포맷을 쓴다.
전화에서는 군더더기 token이 두 번 비싸다. 한 번은 생성할 때, 또 한 번은 TTS가 그걸 합성하고 발신자가 그걸 끝까지 듣고 있어야 할 때. 답변 길이도 latency 설정이다.
CUDA Graph와 “임시” Eager Flag의 대가
모델을 바꾸기 두어 시간 전, GPU나 driver 호환성 문제에 대비한 탈출구로 VLLM_ENFORCE_EAGER 환경 변수 스위치를 넣어 뒀다. 교체 직후 새 MoE가 시작 시점에 torch.compile 안에서 FakeTensorMode 에러를 내며 죽었다. 한 줄짜리 해결책이 바로 눈앞에 있었다: flag 켜고, compile 건너뛰고, 다음으로.
그러지 않은 건 vLLM의 eager mode가 CUDA graph capture까지 꺼 버리기 때문이다. Batch size 1에서 decode step 하나는 수백 개의 작은 kernel launch이고, 그 launch overhead가 실제 연산과 맞먹을 수 있다. CUDA graph는 step을 한 번 녹화해 두고 launch 한 번으로 재생한다. Routing에 expert별 작은 matmul까지 잔뜩 있는 MoE layer가 잃을 게 제일 많다. 그래서 torch.compile만 끄고 graph capture는 살려 뒀다. 4분 뒤에는 그 우회책을 되돌리고 서버에 깔린 vLLM을 손으로 직접 patch했다. 그건 그것대로 위태로운 해결책이었지만, 어쨌든 graph는 켜진 채로 남았다.
그게 얼마짜리 결정이었는지는 나중에 돌린 모델 sweep이 보여줬다. CUDA graph를 끄면 모든 모델이 느려졌고, 내가 걸고 있던 MoE 모델들이 가장 크게 잃었다. 턴당 몇 배씩. Eager mode에서는 35B가 dense 4B에 졌고, graph를 켜면 이겼다. 3월에 그 flag를 켰다면 모델 교체는 latency regression처럼 보였을 테고, 나는 아마 엉뚱한 이유로 4B로 롤백했을 거다.
탈출구도 성능 설정이다. Graph capture, compile, fused kernel을 끄는 flag라면 무엇이든 시작할 때 크게 log를 남기고, latency 숫자 옆에 같이 적혀 있어야 한다. “임시” flag는 그걸 넣게 만든 버그보다 오래 산다.
턴당 LLM 호출 수를 세라
3월 중순까지 나는 regex 기반 추출 몇 개를 LLM 호출로 바꿔 놨다(2부). STT가 뱉은 지저분한 텍스트를 모델이 내 pattern보다 잘 다루는 것 같았기 때문이다. 변경 하나하나는 다 합리적이었다. 그런데 합쳐 놓고 보니 접수 흐름의 한 턴에서 intent 분류, 흐름 중간의 relevance check, 위치 추출, 시간 추출, coherence check, query rewrite가 순서대로 터질 수 있었고, 호출마다 자기 prompt부터 읽었다. 호출 6번에 각각 100–300 ms면 TTS가 시작하기도 전에 budget이 날아간다.
3월 24–25일에 걸쳐 대부분을 structured call 하나로 합쳤다. Intent, 요청 유형, 위치, 시간, 그리고 흐름 상태(계속 / 종료 / 새 주제)를 한 번에 돌려받는다. 뒤 단계는 다시 묻지 않고 이 slot을 읽는다. Query rewrite와 연락처 조회의 2-call chain은 각각 1-call이 됐고, yes/no 질문 하나는 regex로 돌아갔고, coherence check는 classifier의 confidence threshold로 바뀌었다. 당시 내 추산으로는 요청당 모델 호출이 약 48% 줄었다.
그때 재지 않은 건 결과의 모양이었다. 그건 4월 benchmark가 보여줬다. 같은 35B, 407턴, 하드코딩한 orchestration vs 긴 single prompt:
| 경로 (Qwen3.5-35B-A3B) | Mean | Median | p90 | p99 | 600 ms 초과 턴 |
|---|---|---|---|---|---|
| Orchestrated flow | 295 ms | 161 ms | 858 ms | 1,237 ms | 84 / 407 |
| Single prompt | 302 ms | 289 ms | 414 ms | 502 ms | 2 / 407 |
어떤 통계를 고르느냐에 따라 결론이 바뀐다. Mean으로 보면 동률이다. Median으로 보면 orchestration이 거의 두 배 빠른데, orchestrated 턴의 41%가 5 ms 안에 돌아왔기 때문이다. 모델을 아예 거치지 않은 canned template이다. p90으로 보면 두 배 느린데, 모델까지 간 턴은 호출을 줄줄이 엮는 경우가 많았기 때문이다. Dense 27B에서는 orchestrated p90이 3.75초, single prompt가 1.77초였고, 모델 sweep에서 가장 큰 모델들은 아예 orchestrated 쪽이 느렸다. 호출이 하나 늘 때마다 큰 모델 속도로 값을 치르니까. 발신자가 듣는 건 tail이다.
Latency는 분포로 보고하라. Stage별 median, p90, p99, 그리고 budget을 넘은 턴이 몇 개인지까지. 평균은 bimodal 시스템도 멀쩡해 보이게 만든다.
TTS: GPU는 최적화가 아니라 가설이다
TTS 목표는 상용 TTS API와 MOS, 그리고 ASR round-trip CER(합성 → 받아쓰기 → 비교)에서 맞먹는 것이었다. 더 낫거나, 적어도 유의미하게 나쁘지 않거나. 지나온 길은 이렇다:
| 시기 | 선택 | 결과 |
|---|---|---|
| 12월 | Qwen3-Omni (speech LLM) | 탈락: 감정과 말 속도를 prompt로만 조절 가능, vLLM으로 안정적으로 serving하기엔 너무 새로움 |
| 12–1월 | Zonos, 문장 단위 합성 + streaming | TTS stage 평균 3,555 → 713 ms |
| 3월 | Supertonic, ~305 MB ONNX 모델, CPU | 작고 CPU 친화적. ONNX thread 수 튜닝 |
| 3월 | 같은 모델, 기본 CUDA execution provider | GPU에 올라갔지만, 5월에 파고들어 보니 CPU보다 느렸다 |
| 5월 | Supertonic 3 + TensorRT EP, engine cache | 합성 1회 ~2초 → ~100 ms |
GPU에 올리는 것 자체에 13분 동안 commit 세 개가 들었다. Supertonic은 내부적으로 ONNX session 네 개를 만든다. 라이브러리의 기본 provider 상수를 덮어써도 아무것도 안 바뀌었고, 두 번째 module에서 덮어써도 마찬가지였다. 아마 내 override가 돌기 전에 그 값이 이미 읽혔기 때문일 거다. 통한 방법은 InferenceSession 네 개를 명시적인 provider로 전부 다시 만들고, 각각 get_providers()를 log로 찍는 것이었다. Session마다 실제로 어떤 provider를 받았는지 물어보라. 내가 넘긴 config로 짐작하지 마라.
그런데 “GPU에 올라갔다"는 건 위치 얘기지 속도 얘기가 아니다. 5월에 파고들어 보니 기본 CUDA EP는 device에 둘 수 없는 op 주변에 host↔device Memcpy node를 끼워 넣고 있었고, 이 모델에서는 그 때문에 CPU보다 느렸다. TensorRT execution provider는 graph를 GPU에 상주하는 engine으로 compile한다. Engine cache와 timing cache를 디스크에 두니 합성이 ~2초에서 ~100 ms가 됐고, agent 턴 218개짜리 benchmark가 12.1분에서 22초로, 약 30배 줄었다. (1월과 5월 TTS 숫자는 측정 방식이 달라서 이어 붙여 계산하면 안 된다.)
대가도 있다. 첫 engine build는 모델당 1–5분 걸린다. CPU용과 GPU용 onnxruntime wheel은 같은 module을 제공해서 서로 충돌한다. 그리고 TensorRT → CUDA → CPU fallback chain은 소리 없이 성능이 떨어진다. 마지막 건 문서에 경고로 적어 뒀다. 문서의 경고는 assertion이 아니다.
GPU 하나, 모델 셋
이 모든 게 80 GB H100 한 장을 나눠 썼다:
| 서비스 | Engine | VRAM |
|---|---|---|
| LLM | vLLM, 35B-A3B MoE FP8 (gpu_memory_utilization=0.75) |
~60 GB |
| STT | faster-whisper, fine-tuned Whisper large-v3-turbo (fp16) | ~8 GB |
| TTS | Supertonic 3, TensorRT engine | ~1.7 GB |
vLLM은 weight와 KV cache용으로 카드의 지정된 비율을 미리 잡아 둔다. 그래서 gpu_memory_utilization은 상한이 아니라 이웃과 협상해서 정하는 예산이다. 내 값은 일주일 안에 0.4 → 0.9 → 0.8 → 0.75로 움직였다. 교체 당일엔 BF16 MoE를 띄우려고 올렸고, FP8로 메모리가 풀리고 STT와 TTS가 자리를 요구하면서 다시 내렸다.
Tooling도 같은 카드를 두고 싸웠다. 3월에 QA judge를 server 옆에 두 번째 vLLM engine으로 올렸는데, context용으로 5.62 GiB가 필요했고 남은 건 5.52 GiB였다. 0.1 GiB가 모자랐다. 그 뒤 35분 동안 commit 네 개가 이어졌다: context 상한, utilization 5%에 2,048-token context, 그리고 commit 두 개에 걸친 transformers 전환. 진짜 해결책은 나흘 뒤에 나왔고, 민망할 만큼 단순했다. 이미 떠 있는 server에 /generate endpoint를 하나 열어서 tooling이 load된 모델을 재사용하게 한 것이다. 그날 오후 judge를 30B급 모델로 막 바꿔 둔 참이었으니 대략 60 GB를 아낀 셈이다.
이제 multi-model run을 돌리기 전에 꼭 쓰는 sizing 규칙이 있다: parameter당 byte × 전체 parameter 수. MoE 이름의 “A3B"는 연산량 얘기지 메모리 얘기가 아니다. Expert는 전부 load된다. 35B면 BF16 ~70 GB, FP8 ~35 GB. 397B MoE를 BF16으로 올리면 KV cache는 한 token도 없이 weight만 ~794 GB다.
다시 한다면 바꿀 것
- 처음부터 비동기로 serving하라. 4월 말 시점의 코드에서
/chat은asynchandler가 동기 orchestrator를 그대로 호출하는 구조였고, uvicorn worker는 하나, 그 아래는 vLLM의 offlineLLMclass였다. 동시에 걸려 온 전화는 batch로 묶이지 않고 줄을 섰을 것이다. 테스트에서 이게 문제가 됐다는 기록은 내 노트에 없다. 하지만 전화는 원래 동시에 걸려 온다. 다음엔 vLLM async engine이나 OpenAI-compatible server를 쓰고, 모든 latency 리포트에 concurrency 테스트를 넣겠다. - 발신자가 듣는 시간을 재라. 내 benchmark는 답변이 완성될 때까지의 시간을 쟀다. 팀 budget은 streaming을 전제로 했고, 그건 첫 오디오가 나올 때까지의 시간에 더 가깝다.
- 첫 리포트부터 percentile로. 팀 설계 시트는 p50/p95 logging을 요구했다. 내 1월 리포트는 평균을 냈다.
요약
- 모델을 건드리기 전에 stage별로, percentile로 재라. 원래 latency의 79%가 TTS, 13%가 LLM이었고, 평균은 orchestration의 tail을 통째로 가렸다.
- 속도 = 활성 parameter × 내뱉기로 한 token 수, 단 시간을 낭비하지 않는 stack 위에서. 활성 ~3B짜리 FP8 MoE에 CUDA graph를 켜고 답을 짧게 하니 dense 4B를 이겼다. Dense 27B는 ~4배 느렸다. Parameter만큼 턴당 호출 수도 꼼꼼히 세라.
- 가속기는 가정하지 말고 확인하라. ONNX session마다 어떤 provider를 받았는지 물어보고, “GPU에 올렸다"를 믿지 말고 benchmark하고, CUDA graph는 켜 두고, 공유 카드는 byte × 전체 parameter로 예산을 짜라.
다음 글 예고
이 글에 나온 speedup 하나는 나중에 평범한 배포 한 번 뒤에 사라졌다. 에러도, crash도, alert도 없이. 4부는 그런 실패들 이야기다: 소리 없는 fallback, 빠진 데이터를 대신 메운 기본값, 보이지 않는 truncation, 그리고 빈 답변에도 기꺼이 점수를 매긴 evaluation pipeline.