이 글은 한국어 전화 Voice Agent 현장 노트 7부작 중 2부다. 내가 리드했던 프로젝트 — 공공 서비스 전화 상담용 실시간 한국어 voice agent(STT → LLM → TTS) — 를 만들면서 겪은 일을 정리한다. 1부에서는 STT 평가가 나를 어떻게 속였는지를 다뤘다. 이번에는 LLM을 둘러싼 텍스트 이야기다: 들어오고 나가는 숫자, STT가 거의 맞게 듣는 이름, 그리고 LLM 호출이 latency를 감수할 만한 자리.
3월의 어느 금요일 새벽 2시 17분, TTS normalizer 버그 하나를 고쳤다. 27,000원 같은 금액을 “이, 칠, 공, 공, 공 원"처럼 한 자리씩 읽어 버리는 버그였다. 그리고 30분쯤 뒤, 한국어 숫자 읽는 rule을 손으로 짜는 건 그만두고 그 일을 LLM에게 넘겼다. 어차피 한국어는 내 regex보다 LLM이 더 잘하니까. (새벽 세 시를 앞둔 사람의 논리였다.)
그다음 34분 동안의 commit log는 이렇다:
- 02:49: TTS normalization이 짧은 prompt와 함께 LLM을 거치게 된다.
- 02:57, 03:00: Prompt에 표가 자라기 시작한다. 고유어로 읽는 시(時) 열두 개 전부, 불규칙한 월, 전화번호 띄어 읽는 법, 주소 하이픈은 ‘다시’.
- 03:12: Hybrid. 이미 알던 패턴은 regex가 맡고, 괄호와 문장 흐름과 남은 숫자만 LLM이 맡는다.
- 03:17: Regex가 이미 변환해 둔 부분은 한 글자도 바꾸지 말라는 문장이 prompt에 추가된다.
- 03:23: “Ditch llm for tts norm.” 괄호 처리는 regex 한 줄이 됐다.
Commit 여섯 개를 거쳐 제자리로 돌아왔다. 이 글은 그 30분, 그리고 rule과 모델 중 하나를 골라야 했던 — 가끔은 두 번씩 — 다른 자리들에 관한 이야기다.
Rule vs LLM을 가르는 테스트
결론부터 말하면 질문 세 개다. 입력에 대해 정답 출력이 딱 하나인가? Unit test나 집합 조회(set lookup)로 싸게 검증할 수 있는가? Hot path 위에 있는가? 앞의 두 질문에 ‘예’라면 그건 코드다. LLM이 들어갈 자리는 언어가 진짜로 모호한 곳뿐이다. 그리고 거기서도 gate 뒤에 세우고, 사실(fact)은 손에 쥐여 주고, 고정된 fallback을 둔다.
| 작업 | 출력 공간 | 검증 가능? | Hot path? | 선택 |
|---|---|---|---|---|
| TTS용으로 숫자 읽기 | 맞는 읽기 하나 | 예, unit test | 매 턴 | Rule |
| 말로 한 수사 → 숫자 | 정답 하나, 동형이의어만 예외 | 대체로 | 매 턴 | Rule + gate 뒤의 LLM |
| 이 도로명/연락처 이름이 실제로 있나? | 닫힌 목록 | 예, 포함 여부 | 접수 턴 | 사전, LLM은 제안만 |
| “감사합니다” 뒤 통화 종료 | 닫힌 집합 | 예 | 마무리 턴 | Rule |
| “아 그럼 제가 그쪽에 직접 전화해 볼게요” | 열린 집합 | 아니오 | 드묾 | LLM, regex gate 뒤에 |
| 빠진 정보 다시 묻기 | 사실은 고정, 표현은 열림 | 사실만 | 재시도 | 고정 문구 먼저, 재시도 때 LLM paraphrase |
이 글은 rule 편을 드는 글이다. 나중에 천장이 된 rule들(5부, 7부)은 대화 제어(dialog control) 쪽에 있었다. 이 표가 그 경계선이다.
출력 쪽: LLM Text Normalization의 34분
TTS는 받은 문자열을 그대로 읽는다. 그러니 ‘08:30’이나 전화번호는 소리 나는 한국어로 바뀌어야 한다. 이게 은근히 까다롭다. 시는 고유어인데 분은 한자어고(한 시 삼십 분), 월은 불규칙하고(유월, 시월), 금액은 만·억 단위로 끊어 읽고, 전화번호는 한 자리씩 읽는다.
민망한 건 rule이 이미 있었다는 점이다. 바로 전날 아침에 rule 기반 normalizer를 손보고 335줄짜리 test 파일까지 붙여 뒀다. 그걸 새벽 2시 49분에 fallback으로 강등시켰고, 3시에는 prompt 안에서 그 rule을 다시 짜고 있었다. 02:57과 03:00의 두 commit은 내 rule set을 산문으로 옮겨 sampler에 돌린 것에 불과하다. 03:17에 붙인 ‘한 글자도 바꾸지 말라’는 애원은, 모델이 맞는 읽기를 고쳐 쓰고 있지 않았다면 나올 이유가 없는 문장이다.
실패한 이유는 모델의 지능과 아무 상관이 없었다:
- 출력 공간이 닫혀 있다. ‘10시 30분’의 맞는 읽기는 하나뿐이고, 변주는 전부 에러다.
- 검증할 방법이 rule뿐이다. LLM의 출력을 검증하는 게 rule이라면, rule이 곧 normalizer다.
- 매 턴 돈다. TTS 호출마다 generation이 하나 더 붙으면 그만큼 통화에 정적(dead air)이 생긴다. 그 대가로 얻는 건 전화번호를 망칠 기회뿐이다.
Prompt에 lookup table이 들어 있다면, 그건 코드다. “1 → 한, 2 → 두” 같은 표가 쌓여 가는 prompt는 test도 없고, determinism도 없고, 호출마다 latency가 붙는 언어로 짠 함수다. 그냥 함수를 써라.
괄호는 내가 유일하게 ‘이해’가 필요하다고 생각한 부분이었다. 결국 \(([^)]+)\) → , \1, 한 줄이 됐다. 괄호 안 내용을 앞뒤로 잠깐 쉬면서 덧붙이는 말처럼 읽게 하는 것이다.
Rule 버그는 진짜다, 하지만 재현된다
Rule로 돌아갔다고 버그가 사라지지는 않았다. 내 normalizer는 구체적인 패턴을 먼저 돌리고, 마지막에 남은 숫자를 한 자리씩 읽는 catch-all을 돌렸다. 전화번호에는 맞고 그 밖의 거의 모든 경우에는 틀리는 rule이다. 연도 rule은 네 자리 연도만 잡았기 때문에 ‘10년’(십 년)은 catch-all까지 흘러가 ‘일공년’으로 나왔다. 새벽 2시 17분의 금액 버그도 원인이 같았다.
첫 번째 수정은 내가 제일 자랑스럽지 않은 부분이다. QA에서 ‘일공년’이 나왔을 때, 나는 knowledge-base 문서를 숫자를 한글로 풀어 쓴 구어체로 고쳐 써서 normalizer가 아예 ‘10년’을 보지 못하게 만들었다. 코드 버그를 숨기려고 데이터를 고친 것이다. 한 달 뒤 QA가 같은 걸 다시 잡았고, 이번엔 쪽수(‘N면’)까지 딸려 나왔다. 다른 고객처의 문서에는 여전히 숫자가 있었고, LLM이 스스로 숫자를 쓰는 것도 막을 방법이 없었다. 진짜 해결은 catch-all 앞에 구체적인 rule 두 개를 넣는 것이었다. 그러고도 나는 기존 normalization test 75개가 통과하는지만 확인했고, ‘10년’에 대한 test는 끝내 추가하지 않았다. (적으면서도 부끄럽다.)
같은 계열의 금액 버그는 더 나빴다. 팀원이 만든 60-case normalization matrix가 나중에, 내 코드가 120,000,000원을 ‘억 이천만 원’으로 읽는다는 걸 찾아냈다. 만 앞에서 ‘일’을 생략하는 한국어 습관을 억에도 그대로 적용해 버린 것이다. 게다가 내 unit test는 100,000,000이 ‘억’으로 읽힌다고 assert하고 있었다. Test는 통과했고, 틀렸다.
이 버그들은 전부 재현 가능했다. 같은 문자열은 매번 같은 오답을 내고, 한 번 고치고 test를 걸어 두면 고쳐진 상태로 남는다. 그날 밤으로부터 사흘 뒤, LLM이 아닌 코드에 unit test 261개가 붙었다. Prompt에는 절대 붙일 수 없는 것이다. 다만 test는 내가 떠올린 경우만 덮는다. 내가 놓친 걸 찾아낸 건 결국 남의 matrix였다.
한 턴에 텍스트 두 개
1부에서 말한 첫 요구사항 시트에 이미 그 줄이 있었다: 화면에는 ‘119’, 음성으로는 ‘일일구’. 전화 agent에는 둘 다 필요하다 — 화면 표시·로직·LLM용 정규(canonical) 형태와 TTS용 발화(spoken) 형태. 그래서 /chat 응답은 response와 normalized_for_tts를 둘 다 담는다.
QA에도 같은 분리가 필요했다. 검수자는 LLM이 쓴 텍스트를 읽는데, ‘10년’은 글로 쓴 한국어로는 멀쩡하다. 이런 버그는 발신자가 듣는 쪽에만 있다. LLM normalizer를 버린 그날 오후, QA harness가 모든 답변 옆에 tts_normalized 컬럼을 같이 쓰게 바꿨다. LLM이 타이핑한 것만 읽는 QA는 엉뚱한 산출물을 검수하는 셈이다.
입력 쪽: 진짜 모호함에만, Gate 뒤의 LLM
반대 방향은 더 지저분하다. 내 fine-tuned Whisper(Whisper 시리즈 참고)는 학습 transcript를 따라 숫자를 한글로 풀어 쓴다. 1부에서 CER을 비틀었던 바로 그 표기 관례다. 그래서 전화번호는 ‘공일공 일이삼사’로 들어오는데, LLM과 slot validator에게는 숫자가 필요하다.
Rule이 먼저 돈다:
- 숫자 단어 연속 → 숫자 (‘공일공’ → ‘010’).
- 십/백/천/만/억이 들어간 한자어 수사 (‘이백 구십 오’ → 295). 숫자와 한글이 섞여 나온 출력도 병합한다 (‘300 이십 칠’ → 327).
- **‘다시’와 ‘에’**는 하이픈으로 바꾸고, 숫자처럼 생긴 단어는 blacklist로 거른다: 만일, 천사, 오만.
Rule이 막히는 곳은 동형이의어(homograph)다. ‘공’은 숫자 0일 수도, 차는 공일 수도 있다. ‘일’은 1일 수도 ‘일(work)‘일 수도 있고, ‘이’는 2일 수도 주격 조사일 수도 있다. “끝자리가 공이에요"와 “공이 날아왔어요"는 음절도 같고 조사까지 같다.
그래서 LLM에게는 좁은 일만 준다:
- Gate. Rule을 다 돌리고도 뭔가 남았을 때만 heuristic이 LLM을 부른다. 조사 앞에 붙은 숫자 음절, 아라비아 숫자에 붙어 나온 숫자 단어, “X이 아니라” 같은 정정 표현이 그 신호다.
- Context. 대화 이력을 같이 준다. “끝자리가 뭐예요?” 바로 다음에 나온 “공이요"는 0이기 때문이다.
- Bounds. 출력이 입력 길이의 두 배를 넘거나 호출이 실패하면 rule 결과를 그대로 쓴다. 모델은 언제나 제안만 한다.
판정은 사전이 한다
STT가 거의 맞게 듣는 또 하나가 이름이다. 자음 하나가 바뀌거나 모음 하나가 뭉개지면 실제로 있는 도로명이나 기관명이 존재하지 않는 이름이 된다. LLM에게 고쳐 달라고 하고 싶어지지만, 정답은 이미 내가 가진 닫힌 목록 안에 있다. 그래서 LLM은 제안만 하고, 결정은 사전이 한다:
- 도로명과 동네 이름. LLM normalizer를 지우고 56분 뒤에 자모 단위 fuzzy matching을 commit했다. 음절을 자모로 쪼개 Levenshtein을 돌리고, 해당 사이트에 등록된 이름 중 거리 2 이내에서 가장 가까운 것을 고른다.
- 연락처. LLM이 내놓은 기관 이름은 자모 매칭으로 닫힌 연락처 목록의 항목에 snap하고, 목록에 없는 이름은 읽어 주지 않고 버린다.
한국어는 음절이 아니라 자모로 매칭하라. 한글 한 음절에는 자모가 두세 개 들어 있어서, 음절 단위 edit distance로는 말실수와 아예 다른 단어를 구분하지 못한다.
| STT가 들은 것 | 실제 이름 (가상) | 음절 edit | 자모 edit | 매칭해야 하나? |
|---|---|---|---|---|
| 세봄로 | 새봄로 | 1 | 1 | 예, ㅐ/ㅔ 혼동 |
| 세본로 | 새봄로 | 2 | 2 | 예, 작은 실수 두 개 |
| 국봄로 | 새봄로 | 1 | 3 | 아니오, 다른 음절 |
음절 기준 threshold를 1로 잡으면 둘째 행은 버리고 셋째 행은 받아들인다. 정확히 거꾸로다. 자모 기준 threshold 2는 둘 다 맞힌다.
반례는 3월 말에 나왔다. Query-rewrite LLM에게 STT 에러도 발음을 근거로 고치라고 시켰더니, 맞게 들어온 고유명사를 ‘교정’하고 아무도 말하지 않은 날짜를 채워 넣었다. 3주 반 뒤 나는 둘 다 명시적으로 금지해야 했다. 정답 목록 없이 에러를 고치라고 하면, 모델은 고장 나지 않은 것까지 고친다.
진자 운동: Regex → LLM → Hybrid
3월 18일, 깨지기 쉬운 regex 여러 개를 작은 단일 목적 LLM 호출로 바꿨다. Retrieval용 topic 추출, 접수 flow 두 곳에서 “언제 처리되나요?“를 알아보는 판별, 발신자 요청에서 항목 하나를 더 뽑아내는 일. 하나하나가 자기가 관여하는 턴마다 LLM 호출을 하나씩 더 얹었다. 일주일 뒤 latency 압박에 밀려 ‘언제’ 판별 중 하나는 regex로 돌아갔고, LLM coherence check는 classifier 자체의 confidence로 대체됐다.
어느 오후에는 발신자가 통화를 끝내려는 건지 판단하는 acknowledgement 처리가 10분 사이에 두 번 뒤집혔다:
- 14:25: Regex로 가르던 걸 LLM judge 하나로 바꾼다.
- 14:35: Hybrid. 명확한 종결 표현(“알겠습니다”, “감사합니다”)은 deterministic하게 통화를 끝낸다. “아 그럼 제가 그쪽에 직접 전화해 볼게요” 같은 되새김형 발화만 LLM으로 보내고, LLM은 바로 끝낼지 따뜻하게 마무리 멘트를 붙일지만 정한다.
최종 설계는 맞다. 닫힌 집합은 rule이, 열린 집합은 모델이 맡고, 그 사이를 regex가 가른다. 그런데 그 rule에도 context가 필요했다. 한 달 뒤, 잠깐 다른 얘기로 샜다가 나온 평범한 ‘알겠습니다’ 하나가 접수가 아직 끝나지 않은 QA 통화를 끊어 버렸다. 중단된 flow가 남아 있는지부터 확인하도록 순서를 바꿔야 했다. (그 judge의 실패 경로에는 또 다른 함정이 숨어 있었다. 4부에서 다룬다.)
다시 한다면 바꿀 건 이거다. 이 commit들 중 어느 것도 latency나 정확도 숫자를 남기지 않았다. 진자가 한 번 흔들릴 때마다 고정된 set에서 두 가지를 쟀어야 했다: 턴당 늘어나는 ms, 그리고 100턴당 에러 수. 이게 없으면 진자는 가장 최근에 본 실패 쪽으로 흔들린다.
Grounded Paraphrase: 첫 질문은 고정 문구, 재시도에만 LLM
발신자에게서 무엇을, 어디서, 언제를 받아 내는 접수(intake) flow에는 정반대의 문제가 있었다. 고정 문구로 된 재질문은 정확했지만, 쓸 만한 위치를 대지 못하는 발신자는 재시도할 때마다 똑같은 문장을 들었다. 고장 난 기계처럼.
4월 6일 13시 27분, 요청 카테고리와 빠진 항목과 발신자의 마지막 발화를 넣어 모든 재질문을 LLM이 생성하게 바꿨다. 13시 41분에 revert했다. 질문은 부드러워졌지만, 주소나 근처 건물로 말해 달라는 고정 문구 속 형식 힌트가 사라졌다. 모델에게 어떻게 말할지만이 아니라 무엇을 말할지까지 맡겨 버린 것이다. 결국 자리를 잡은 버전은 13시 43분에 나왔다. 첫 질문은 고정 문구 그대로 두고, 재시도 때만 LLM이 그 고정 문장 자체를 받아서 paraphrase한다.
바로 다음 commit도 같은 교훈을 줬다. 마지막 확인 멘트를 다듬는 ‘polish’ 호출이 통화 도중에 “안녕하세요"로 말을 열었다. Prompt에 인사하지 말라고 이미 적혀 있었는데도. 그 호출이 본 건 고정 문장 하나뿐이었으니, 모델이 아는 한 통화는 아직 시작도 안 한 거였다. 고친 방법은 이미 통화 중이라고 알려 주고 ‘안녕하세요’를 콕 집어 금지하는 것이었다.
4월 22일 무렵엔 이게 작은 generator가 돼 있었다. 위치, 조치 내용, 접수 건이 어디로 넘어가는지 같은 사실은 모델이 표현만 하는 입력이다. 재질문은 재시도 횟수에 따라 단계를 올린다: 중립적인 질문 → 형식 힌트 → “근처 아무 건물이나 괜찮습니다”. 어미는 합쇼체로 강제하고, 모든 함수는 고정 문구로 fallback한다. 재질문이 들어 있는 QA 대화의 턴 127개(Qwen3.6-35B-A3B-FP8)에서 116개가 QA 시트를 통과했고(91.3%), 고정 문구를 그대로 반복한 재질문은 22개에서 8개로 줄었다. 그 통과율이 실제로 얼마나 믿을 만한 숫자인지는 6부에서 다룬다. 여기서 요점은 역할 분담이다: LLM은 표현하고, 코드가 결정한다.
요약
- 정답이 딱 하나면 코드다. 숫자 읽기, 말로 한 수사 파싱, 이름 조회, 명시적 종결 표현 처리는 deterministic하고 test된 코드의 몫이다. Prompt에 lookup table이 자라기 시작하면 그 표를 함수로 옮겨라. Rule 버그는 재현되고, test가 있으면 고친 상태로 남는다 — test를 썼다면.
- 언어가 진짜 모호한 곳에서는 LLM을 gate 뒤에 두고 출력을 묶어라. 싼 heuristic이 언제 LLM을 부를지 정하고, 출력은 길이로 제한하거나 닫힌 목록에 snap하고, rule 결과를 fallback으로 둔다. 한국어 이름의 거리는 자모로 재라. 그리고 작업을 rule에서 모델로, 또는 그 반대로 옮길 때마다 고정된 set에서 latency와 에러를 재라.
- 모델에게 표현은 맡기되, 결정은 맡기지 마라. 사실과 고정 문구를 쥐여 주고, 첫 질문과 fallback에는 정확한 버전을 남겨 두고, 발신자가 듣는 것을 QA하라.
다음 글 예고
이 글에 나온 LLM 호출 하나하나가 통화 속 정적이었다. 3부는 latency 이야기다: 무엇이든 줄이기 전에 단계별로 profiling하기, 턴당 LLM 호출 수 세기, 그리고 35B 모델이 4B보다 빨리 답하게 된 과정.