이 글은 한국어 전화 Voice Agent 현장 노트 7부작 중 1부다. 내가 리드했던 프로젝트 — 공공 서비스 전화 상담용 실시간 한국어 voice agent(STT → LLM → TTS) — 를 만들면서 겪은 일을 정리한다. Whisper fine-tuning 시리즈가 모델을 어떻게 학습했는지를 다뤘다면, 이번 시리즈는 그 모델이 실제 통화를 만났을 때 벌어진 일에서 시작한다.


작년 말, 내 Whisper fine-tuning 곡선은 더 바랄 게 없을 만큼 예뻤다. 공개 한국어 8 kHz 전화망 corpus(AI Hub의 저음질 전화망 음성인식 데이터)의 held-out split에서 라운드마다 기록이 갱신됐다: CER 9.0% → 6.3% → 5.5% → fine-tuned large-v3-turbo의 3.8%. 손대지 않은 large-v3-turbo는 6.5%였다. 스스로 잡은 목표가 4% 미만이었으니 3.8%면 통과다. (그때는 꽤 뿌듯했다.)

그리고 1월, 이 회선으로 실제로 들어온 통화에서 발화 400개를 뽑아 test set을 만들고 모든 모델을 돌렸다:

모델 공개 8 kHz test 실제 통화 (400 발화)
기본 large-v3-turbo 6.5% 13.1%
Medium v1 (LoRA, ~0.9M 발화, 단일 도메인) 9.0% 10.2%
Medium v2 (full FT, 1.7M 발화, 전 도메인, 정제) 6.3% 10.8%
Fine-tuned large-v3-turbo (2.0M 발화) 3.8% 1.3%

네 모델 중 셋은 나빠졌고 — 기본 Whisper는 두 배로 — 순위도 뒤섞였다. 나머지 하나는 오히려 좋아졌다. 튜닝 대상이던 깨끗한 benchmark보다 지저분한 실제 통화에서 에러가 거의 3분의 1로 줄었다. 나는 이 1.3%를 단서와 함께 보고했고(“데이터 부족, 2,000개 이상으로 재검증 필요”), 파이프라인의 LLM 쪽으로 넘어갔다. 이 글을 쓰려고 데이터를 다시 뜯어보니, 그 단서는 너무 순했다. 이 표는 네 가지 방식으로 사람을 속인다. 그리고 그중 어느 것도 CER 계산 코드의 버그가 아니다.

Evaluation 글에서는 metric을 계산하는 방법을 다뤘다. 이번 글은 eval set에 관한 이야기다.

실제로 서비스할 채널에서 측정하라

먼저 초반에 제대로 한 것 하나. 전화 audio는 8 kHz로 들어오는데, Whisper가 학습한 데이터는 대부분 그렇지 않다. 첫 benchmark에서는 기본 Whisper를 크기별로 16 kHz 한국어 test set과 8 kHz 전화망 test set에 돌렸다:

모델 16 kHz test set 8 kHz 전화망 test set
tiny 44.0% 95.3%
base 29.2% 53.1%
small 11.3% 29.3%
medium — 29.2%
large-v2 — 25.1%
large-v3-turbo — 6.5%

두 열은 서로 다른 test set에서 나온 숫자라, 통제된 ablation이 아니라 거친 비교다. 그래도 tiny가 44%에서 95%로 무너진 걸 채널 말고 다른 데 탓하기는 어렵다. 아무것도 학습하기 전에 전화 대역폭이 model zoo 대부분을 탈락시켰고, 계획도 여기서 정해졌다: 전화 대역 데이터로 fine-tuning하고, latency budget이 허락하는 한 가장 큰 모델을 쓴다.

Serving 쪽도 같은 규칙을 따른다. Whisper 서비스로 들어가는 모든 입력 경로 — 통화 audio, WAV 업로드, 브라우저 마이크 녹음 — 는 먼저 ffmpeg로 8 kHz mono PCM16이 되고, 그다음에야 Whisper가 기대하는 16 kHz로 resampling된다. 48 kHz로 녹음한 데모도 모델 귀에는 전화 통화처럼 들린다. 덕분에 offline 숫자와 live 숫자를 나란히 놓고 비교할 수 있다. 모든 경로를 똑같이 열화(degrade)시켜라. 안 그러면 데모와 eval이 실제로 내보내지도 않을 제품을 측정하게 된다.

채널은 맞았다. 문제는 측정에 쓴 데이터에 있었다.

거짓말 #1: Domain Shift

Benchmark는 v2 — full fine-tune, 거의 두 배의 데이터, 정제한 transcript — 가 v1보다 2.7%p 낫다고 했다. 실제 통화에서는 v1이 10.2% vs 10.8%로 앞섰다. Benchmark에서 v2와 사실상 동률이던 기본 Whisper는 두 fine-tune보다 2~3%p 뒤처졌다. 비교 가능한 세 모델의 순위가 전부 바뀌었다.

공개 corpus도 전화 음성이긴 하지만 이 통화는 아니다. 말하면서 생각을 정리하는 사람, 끝맺지 않는 문장, corpus에서는 거의 들어본 적 없는 지역 지명. Bias–variance 글에서 말한 distribution shift 함정 그대로다: training 분포에서 떼어낸 held-out split은 그 분포에 얼마나 잘 맞췄는지를 알려줄 뿐이다. 학습 신호이지, production 추정치가 아니다.

그때 내가 묻지 않은 질문이 하나 있었다. 실제 통화 숫자로 이 모델들의 순위를 매길 수 있긴 한가? 이 글을 위해 발화별 CER을 다시 계산하고(공백과 문장부호를 제거해서 원래 숫자와 조금 다르다), 400개 발화를 2,000번 bootstrap했다:

비교 점 추정 95% CI
v2 − v1 +0.1%p [−1.3, +1.6]
기본 − v2 +3.0%p [+1.1, +5.0]

Set 전체의 reference를 다 합쳐도 약 6,000자뿐이다. Fine-tune들이 실제 통화에서 기본 모델보다 나았던 건 진짜다. 하지만 v1 vs v2는 동전 던지기다 — resample의 55%에서 v2가 더 나빴다. 그러니 v1/v2 역전은 노이즈였다.

400개 발화는 regression은 잡지만, 비슷한 모델의 순위는 못 매긴다. Paired difference의 CI가 ±1.5%p 안팎이면 그보다 작은 차이는 전부 묻힌다. CI 폭은 1/√n으로밖에 줄지 않으니, 내가 잡았던 2,000개 재검증 목표로도 약 0.65%p 정도밖에 구분하지 못한다 — 그것도 발화들이 서로 독립이라는 가정 하에서다. 한 통화에서 나온 발화들은 화자와 회선을 공유하니, 통화 단위로 resample하라. 에러가 서로 상관돼 있으면 CI가 더 넓어질 수 있다.

거짓말 #2: Reference가 내 모델의 출력이었다

이제 1.3% 얘기다. 400개 reference는 빠른 방법으로 만들었다: 최신 fine-tune의 출력으로 초안을 만들고 사람이 손으로 고쳤다. 그 모델이 바로 채점 대상 네 개 중 하나였다.

지름길이라는 건 알았다. 얼마나 큰 지름길인지는 몰랐다. 이 글을 위해 재점검하면서, 공백과 문장부호를 제거한 뒤 각 reference를 모델별 출력과 비교했다:

모델 Reference와 출력이 완전히 일치
최신 fine-tune (label 초안 작성) 349 / 400 (87%)
기본 large-v3-turbo 162 / 400 (41%)
Medium v1 175 / 400 (44%)
Medium v2 184 / 400 (46%)

105개 발화에서는 reference가 최신 모델과 일치하고 나머지 셋 중 어느 것과도 일치하지 않는다. 그중 일부는 최신 모델이 정말로 더 잘 들은 경우다. 하지만 최소 두 건에서는 reference 속 지명이 최신 모델이 낸 한 음절짜리 오타를 그대로 달고 있었고, 기본 Whisper는 그걸 맞게 썼다. 유창하고 그럴듯한 초안을 받은 검수자는 눈에 띄게 틀린 것만 고치고 나머지는 그대로 둔다. 검수자가 놓친 에러 하나하나가 초안을 쓴 모델에게는 ‘정답’이 되고, 제대로 들은 다른 모든 모델에게는 오답이 된다. 내 손으로 만든 시험지에 내 모델의 답안을 정답으로 적어 둔 셈이다.

그래서 점수가 얼마나 부풀었을까? 추정밖에 할 수 없다. Benchmark에서 최신 모델은 나머지 셋보다 에러가 대략 4060% 적었다. 그 우위만큼 나머지 셋의 실제 통화 CER을 깎아 보면 48% 언저리가 나온다. 여전히 넷 중 최고일 수는 있지만, 1.3%와는 거리가 멀다. 솔직한 답은, 이 set으로는 그 모델을 측정할 수 없다는 것이다.

다시 한다면 이렇게 하겠다:

  • 채점할 모델로 label을 미리 채우지 마라. 처음부터 받아 적거나, 비교 대상이 아닌 모델로 초안을 만들어라.
  • 속도 때문에 초안이 필요하면 여러 hypothesis를 blind로 보여줘라. 순서를 섞고, 어느 모델이 쓴 건지 가리고, annotator가 고르거나 고치게 하라.
  • 냄새 테스트(smell test)를 돌려라. 모델별로 reference = hypothesis 비율을 계산하라. 한 모델만 87%이고 나머지가 41~46%라면 그건 승리가 아니라 labeling artifact다.

거짓말 #3: Formatting까지 채점하고 있었다

공개 corpus는 숫자를 소리 나는 대로 한글로 적는다. “오후 3시 40분"이 아니라 “오후 세 시 사십 분"이다. Fine-tuned 모델들은 이 표기 관례를 배웠다. 기본 Whisper는 아라비아 숫자를 선호해서 긴급 번호도 “일일이"가 아니라 “112"로 쓴다. 그런데 내 reference에는 숫자가 하나도 없다 — 다시 말하지만, 초안을 쓴 게 바로 그 corpus로 fine-tuning한 모델이다. 기본 Whisper는 400개 출력 중 34개에 숫자를 썼고, 그 전부가 character error로 채점됐다. 발신자의 말을 완벽하게 알아들은 경우에도.

같은 재계산 CER에서 표기에 좌우되는 행을 빼 보면:

포함한 행 기본 Medium v1 Medium v2
400개 전체 13.8% 10.6% 10.7%
어느 모델이든 숫자를 쓴 34행 제외 11.8% 10.0% 9.6%
Latin 문자가 있는 행도 제외 (356행 남음) 11.1% 9.6% 9.4%

숫자나 Latin 문자가 들어간 44개 행이 기본 Whisper character error의 약 30%를 차지한다. “baseline이 나쁘다"의 23%p 정도는 인식 문제가 아니라 표기 스타일이었다는 얘기다. 첫 두 행 사이에서 v1/v2 순서가 또 뒤집힌다는 점도 보라. 띄어쓰기도 조금 보탠다. 기본 Whisper와 두 medium fine-tune에서 각각 1214개 행이 reference와 오직 띄어쓰기 위치만 달랐다. Scorer가 공백을 남겨 두면 그만큼 고스란히 모델 손해다.

해법은 지루하지만 맨 먼저 해야 하는 일이다: 숫자, 띄어쓰기, 문장부호, filler, Latin 표기 용어에 대한 normalization spec을 쓰고, CER 계산 전에 reference와 hypothesis 양쪽에 적용하라. 안 그러면 leaderboard의 일부는 어느 모델이 annotator와 표기 취향이 같은지를 겨루는 순위표가 된다.

아이러니한 건, 내 첫 요구사항 시트에 이미 화면 표시용 “119"와 발화용 “일일구"를 구분하라는 줄이 있었다는 거다. 정작 내 채점에는 적용하지 않았을 뿐이다. (등잔 밑이 어둡다.)

거짓말 #4: 연출된 트래픽

400개 발화는 몇 달치 통화에서 나왔지만 고르게 퍼져 있지 않았다. 초기 구간(처음 두 달, 302개 발화)에서는 발신자 세 명이 발화의 57%를 차지했고, 그 통화들은 시나리오를 읽는 것 같았다. 완결된 문장, 깔끔한 시간과 장소, 몇 가지 상황을 또박또박 읊는 식이다. 내부 테스트와 데모 콜로 보인다. 후기 구간(나머지 기간, 98개 발화)은 진짜 발신자처럼 들린다 — 끊긴 말, 처음부터 다시 시작하는 문장, 가물가물한 이름.

모델 초기 (302 발화) 후기 (98 발화) 배율
기본 large-v3-turbo 12.1% 19.0% 1.6×
Medium v1 9.3% 14.8% 1.6×
Medium v2 9.1% 16.1% 1.8×
최신 fine-tune 1.1% 2.1% 1.8×

모든 모델이 후기 구간에서 1.5~1.8배 나빴다. 98개 발화뿐이라 후기 숫자의 error bar는 넓지만, 방향은 네 모델 모두 같다. 내 ‘실제 통화’ set의 4분의 3은, 이 회선이 앞으로 받을 audio 중 가장 친절한 audio가 대부분이던 시기에서 나왔다.

그러니 모든 발화에 출처와 기간 tag를 달고, 구간별로 따로 리포트하라. 초기 트래픽에는 팀이 자기 제품을 시험하는 통화가 유독 많다. 숫자를 하나만 리포트할 수 있다면, 데모처럼 보이지 않는 트래픽에서 가져와라.

모델 탓이 아닌 것: 에러는 발화의 어디에 떨어지는가

전체 CER은 에러가 어디에 있는지를 숨긴다. 같은 재점검에서 각 출력의 처음 두 글자와 마지막 두 글자를 reference와 비교했다. “어"나 “그” 같은 filler로 시작하는 발화는 건너뛰었다. 기본 Whisper와 두 medium fine-tune 모두, 처음 두 글자는 발화의 약 3분의 1에서 틀렸고 마지막 두 글자는 약 8분의 1에서 틀렸다. 앞쪽이 뒤쪽보다 대략 2.5~3배 더 자주 틀린다.

앞쪽 에러는 양방향으로 나고, 모델마다 기우는 방향이 다르다:

  • 기본 Whisper는 앞을 떨군다. 36개 발화에서 출력이 reference에서 정확히 앞부분만 빠진 형태다. “OO초등학교 앞인데요"가 “초등학교 앞인데요"로 나오는 식이다.
  • Fine-tune들은 앞에 뭔가를 붙인다. 모델마다 32~38개 발화에서 출력이 reference 앞에 무언가 더 붙은 형태다 — 이전 segment에서 흘러들어온 단어이거나, 그냥 추측이다.

Fine-tune들은 기본 Whisper에게서는 거의 볼 수 없던 버릇도 새로 들였다. 두 medium fine-tune은 각각 10~12개 발화에서 reference에 없는 실제 한국 도시 이름을 썼고, 대부분 처음 몇 글자 안이었다. 기본 Whisper에서는 사실상 한 번도 없었다. 대개는 발음이 비슷한 지역 지명을 도시 이름으로 바꿔 쓴 경우였고, 몇 번은 아무것도 없는 데서 튀어나왔다(“버스 정류장 앞인데요” → “OO시 버스 정류장 앞인데요”). 내 해석은 이렇다. 지명 텍스트가 가득한 corpus가 decoder에 자주 본 이름 쪽으로 기우는 prior를 심었고, audio 앞부분이 너덜너덜하면 익숙한 도시 이름이 꽤 그럴듯한 첫마디가 된다. 그래서 다음 학습 라운드는 production audio와 녹음된 지명을 추가하는 쪽으로 계획했다. Decoder의 prior를 실제 발신자들이 말하는 장소 쪽으로 끌어오기 위해서다.

앞부분이 너덜너덜한 데는 아마 upstream에 이유가 있었다. 올봄 나는 발신자 audio의 앞부분이 STT에 닿기도 전에 잘려 나가고 있다는 걸 발견해서 팀에 공유했다. 서비스는 Whisper를 vad_filter=False로 돌린다. Upstream gateway가 segmentation과 endpointing을 맡고 있으니, 모델은 그 경계가 넘겨주는 것만 본다. 애초에 전송되지 않은 음절은 어떤 fine-tuning이나 decoding 설정으로도 되살릴 수 없다. 고칠 곳은 STT 모델이 아니라 audio front end(pre-roll buffering과 endpointing)다.

발화 경계도 모델 입력의 일부다. 에러가 앞쪽에 몰려 있으면 acoustic model을 탓하기 전에 segmentation부터 의심하라. 학습을 한 번 더 돌리기 전에 경계부터 고쳐라.

처음부터 다시 한다면

점검 항목 이유 내가 본 것
두 번째 학습 run 전에 in-channel, in-domain test set을 만든다 Benchmark는 학습 신호다 비교 가능한 세 모델 모두 순위가 바뀜
채점하는 모든 모델과 독립적으로 label을 만든다 검수자는 초안에 끌려간다(anchoring) Reference 349/400이 초안 모델과 일치, 나머지는 162~184
Normalization spec을 쓰고 양쪽에 적용한다 안 그러면 표기를 채점하게 된다 숫자/Latin 행이 기본 모델 에러의 ~30%
모든 비교에 paired bootstrap CI를 붙인다 작은 set은 비슷한 모델의 순위를 못 매긴다 v2 − v1: [−1.3, +1.6]%p
출처와 기간을 tag하고 구간별로 리포트한다 초기 트래픽은 연출돼 있다 후기 트래픽이 1.5~1.8배 나쁨
발화 내 위치별로 에러를 그려 본다 경계도 입력이다 앞쪽이 뒤쪽보다 에러가 2.53배 많음
발화 수천 개를 예산에 잡고, 통화 단위로 resample한다 CI 폭은 1/√n으로만 준다 2,000개로도 ~0.65%p밖에 구분 못 함

하나하나는 싸다. 그리고 하나하나가 그 첫 표를 읽는 방식을 바꿔 놓았을 것이다.

요약

  1. 공개 benchmark는 학습 신호이지 production 추정치가 아니다. 내 8 kHz 곡선은 진짜였지만, 실제 통화에서는 세 모델이 나빠지고 순위가 뒤섞였다. In-channel, in-domain set을 일찍 만들고, 순위를 매기기 전에 confidence interval부터 붙여라.
  2. Eval set도 자체 QA가 필요한 제품으로 다뤄라. 채점 대상 모델로 초안을 쓴 label, 서로 다른 숫자 표기, 연출된 초기 트래픽 — 각각이 내가 측정하려던 차이보다 숫자를 더 크게 움직였다. 모델별 reference 일치율을 확인하고, 양쪽을 normalize하고, 출처와 기간으로 쪼개라.
  3. STT 에러가 전부 모델 탓은 아니다. 에러는 발화 앞부분에 몰렸다. 나중에 알고 보니 바로 그 자리에서 audio가 Whisper에 닿기 전에 잘려 나가고 있었고, fine-tune들이 아무도 말하지 않은 도시 이름을 쓴 곳도 주로 거기였다. 에러가 어디에 떨어지는지 보고, 학습을 한 번 더 돌리기 전에 경계부터 고쳐라.

다음 글 예고

잘 측정된 STT 모델이라도 지명은 잘못 듣고, 숫자는 다음 단계가 기대하지 않는 형태로 쓴다. 2부에서는 파이프라인 하류로 내려간다: STT 에러를 사후에 교정하는 법, 화면 표시용 텍스트와 발화용 텍스트를 분리하는 법, 그리고 내가 왜 LLM에게 숫자를 소리 내어 읽게 하는 걸 그만뒀는지.