이 글은 한국어 전화 Voice Agent 현장 노트 7부작 중 5부다. 내가 리드했던 프로젝트 — 공공 서비스 전화 상담용 실시간 한국어 voice agent(STT → LLM → TTS) — 를 만들면서 겪은 일을 정리한다. 1부는 STT 평가가 나를 속인 방식, 2부는 rule이 모델을 이기는 곳, 3부는 latency, 4부는 exception 한 번 없이 조용히 실패한 것들을 다뤘다. 이번 글은 dialog layer 이야기다. 그 안의 규칙이 실패한 QA row 하나마다 하나씩 불어난 과정을 따라간다.
3월 25일 하루에만 dialog service에 commit을 32개 했다. 첫 commit이 00:56, 마지막이 16:59였고, 그중 26개의 제목이 “Fix"로 시작한다. 11:00에는 한 요청 유형의 첫 안내 멘트를 바꿨다. 정작 물어야 할 질문 앞에 주의 사항이 세 문장이나 붙어 있던 것을 짧고 자연스러운 질문 하나로 줄였다. 그리고 11:07에 되돌렸다. Commit message에는 “original detailed greeting for QA compatibility"로 돌아간다고 적혀 있다. 전화로 들으면 짧은 쪽이 분명 나았다. 그런데도 진 이유는 하나, 정답지가 옛 멘트를 기준으로 쓰여 있었기 때문이다. (이 commit message를 다시 읽는 건 꽤 민망한 일이었다.)
4월 말에 돌아가던 시스템은 아무도 설계하지 않았다. 그냥 쌓였다. 여기 regex 하나, 저기 node 하나, prompt에 중요 표시가 붙은 줄 하나 — 하나하나가 실패한 test row로 정당화됐다. 이 글은 그 쌓임(accretion)을 해부하고, 몇 주 동안 commit log에 그대로 있었는데도 내가 읽지 못한 경고 신호를 정리한다.
12월에는 맞았던 결정
규칙이 처음부터 실수였던 건 아니다. 11월에는 LoRA fine-tuning을 시도했다. 12월 중순 그걸 보류하고, 당시 serving할 수 있던 작은 모델 주변에 dialog logic을 orchestration으로 하드코딩했다. 그 크기의 모델은 유형별 절차가 빼곡한 긴 prompt 하나를 안정적으로 따르지 못할 거라고 봤고, 그래서 결정을 prompt에서 빼 코드로 옮겼다. 12월 19일에 완성했고, happy path는 전부 통과했다.
1월 말에는 이걸 graph 구조로 refactoring했다. 결정 단계마다 node처럼 동작하는 class를 하나씩 두고, 어느 고객처의 요청 flow든 부품을 조립해 만들 수 있게 했다: regex fast path → LLM intent classifier → slot filling → 사이트별 접수(intake) node, 여기에 정보 문의를 받는 RAG node. Intake는 무슨 일이 언제 어디서 있었는지 받아 적고, 확인한 뒤 접수하는 흐름을 말한다.
작은 모델에게는 맞는 선택이었고, 같은 조건이면 또 그렇게 할 거다. 실수는 그 뒤에, 더 조용히 왔다. 규칙을 갚는 장치를 하나도 만들지 않았다. 1월의 2주짜리 sprint 계획을 다시 열어 보면 KPI가 딱 하나 적혀 있다: “QA 통과”.
Accretion의 해부
Service가 별도 repository로 떨어져 나온 게 3월 9일이고, 그때 모델은 Qwen3-4B였다. 세 시점을 비교하면 이렇다:
| 시점 | Python 코드 줄 수 (core + API) | re.compile 호출 |
Request routing 패턴 | 답변 prompt 규칙 (글자 수) | “중요” 표시 |
|---|---|---|---|---|---|
| 분리 (3/9) | 5,888 | 12 | 11 | 6 (348) | 0 |
| 첫 version tag (3/22) | 12,783 | 82 | 63 | 9 (620) | 2 |
| Peak (4/27) | 16,405 | 126 | 121 | 25 (2,572) | 10 |
7주 만에 코드는 세 배 가까이, compile된 regex는 열 배가 됐다. 문장을 어느 flow로 보낼지 정하는 keyword 패턴은 11개에서 121개로 늘었다. 3월 24일에는 밑에 깔린 모델이 Qwen3-4B에서 35B-A3B MoE(mixture-of-experts) Qwen으로 바뀌었는데, 곡선은 꺾이지 않았다. 필요 없어진 규칙도 있었을 텐데, 그걸 확인하는 절차가 내 프로세스에는 아예 없었다.
늘어난 코드의 상당 부분은 코드로 박아 넣은 사이트별 logic이었다:
- 한 사이트에는 3월 12일, 손으로 쓴 intake node class 9개가 1,136줄짜리 파일 하나에 들어갔다. 일주일 뒤 generic한 data-driven intake node를 만들었다. 방향은 맞았다. 그런데 사이트별 파일은 그 옆에서 계속 자랐다.
- 다른 사이트의 config는 3월 24일 10:46, 처음 flow를 commit했을 때 113줄이었다. 12:57에는 514줄, 다음 날 오후엔 618줄. Peak 시점에는 keyword regex 60개 가까이, intake config 수십 개, 하드코딩된 redirect 메시지 십여 개가 들어 있었다. 그 첫날의 commit 하나는 패턴을 “for QA pass rate” 다듬었다고, 아주 정직하게 적고 있다.
새 사이트가 붙을 때마다 그 사이트용 scripted conversation이 따라왔고, 실패한 대화 하나하나가 패턴이나 node나 config field가 됐다.
Accretion을 먹여 살린 Loop
QA harness는 scripted multi-turn 대화를 live service에 replay하고, 매 turn을 reference 답변과 비교했다(이 metric 이야기는 6부에서 따로 한다). 내 loop는 단순했다: 돌린다 → 실패한 row의 trace를 읽는다 → patch → 다시 돌린다. 이게 상수 하나에 무슨 짓을 하는지 보자.
3월 25일 09:12, 첫 turn에 규칙 하나를 넣었다: 통화자의 첫 문장이 15자보다 길면 그걸 상황 설명으로 보고 따로 묻지 않는다. 09:17, 짧은 첫마디가 너무 이른 접수로 이어져서 기준을 20으로 올렸다. 09:25, 다른 유형의 row가 예전 값을 필요로 해서 다시 15로 내렸다. Commit 이름에 그 유형 이름까지 박아서. 8분, commit 2개, 숫자는 제자리. 이 숫자에 담긴 건 실제 통화자에 대한 정보가 아니라 test row 두 개에 대한 정보뿐이었다. 그리고 5분 뒤, 그 규칙을 아예 지웠다.
그다음엔 loop 자체를 빠르게 만들었다. vLLM 재시작에 5분쯤 걸려서, 3월 30일에 prompt와 상수를 그 자리에서 갈아 끼우는 318줄짜리 hot-reloader를 짰다. 그리고 사흘 뒤, 상관없는 fix commit 안에서 조용히 지웠다(추도사도 없이). Reloader 자체가 문제는 아니었다. 다만 내가 최적화하던 게 loop가 무엇을 만들어 내는지가 아니라 얼마나 빨리 도는지였다는 걸 보여 준다.
도구가 loop를 한 번 더 가속했다. 3월 25일 commit 32개 중 27개에 AI coding assistant 태그가 붙어 있다. 실패한 turn과 trace를 붙여 넣으면 1분 안에 그럴듯한 patch가 나온다. 특수 케이스 하나 더 쓰는 비용은 거의 0이 됐는데, 그걸 떠안고 사는 비용은 한 푼도 줄지 않았다. 공짜 점심은 여기에도 없다.
Patch만 보지 말고 시스템의 모양을 review하라. 그 시기 diff는 하나씩 보면 다 멀쩡하다. 작고, 주석이 달려 있고, 실제 실패에 묶여 있다. 내가 한 번도 review하지 않은 건 주간 추세였다: 코드 줄 수, regex 개수, prompt 규칙 수, “중요” 표시 수. 이 네 숫자를 chart 하나에 그려 놨다면, 내가 알아채기 한 달 전에 문제가 보였을 거다.
Fix가 Fix를 되돌리기 시작할 때
가장 선명한 사례는 flow 중간의 끼어들기(interruption)다. Intake를 절반쯤 진행한 통화자가 새 요청처럼 들리는 말을 한다. Intake를 멈춰야 하나, 말아야 하나?
- 3/20. 정보성 질문이 나오면 무조건 intake를 일시 중단했다. 그랬더니 봇이 방금 던진 질문에 대답하고 있던 통화자까지 끊겼다. 그래서 앞단에 LLM relevance check를 붙였다.
- 3/26, 08:21. “오늘 오후 세 시쯤이요"처럼 시간만 말한 답이 새 intent로 분류돼 flow를 이탈시켰다. 해당 node를 interrupt 불가 목록에 넣었다.
- 3/26, 16:39. 답으로 불러 준 식별 번호 하나가 새 질의처럼 보였다. 접수가 끝날 때까지 모든 전용 intake node를 interrupt 불가로 만들었다.
- 4/23. 테스트해 보니 intake 도중 곁가지 질문을 하거나 취소하려던 통화자가 이제 소리 없이 갇혀 있었다. 일괄 guard를 걷어 내고 classifier의 continue/new/terminate 판단을 믿기로 했다. 취소 표현용 fast path 하나만 명시적으로 추가했다.
3번은 1번의 목적을 무력화했고, 4번은 4주 뒤에 3번을 되돌렸다. 각 단계는 실제 row에 대한 합리적인 대응이었다. 모아 놓고 보면 두더지 잡기였다.
같은 패턴이 다른 곳에서도 나왔다:
- 종료 vs 재개. 잠깐 곁가지로 샜다 돌아온 통화자가 그냥 “알겠습니다"라고 하면, intake가 아직 중단 상태인데도 통화가 끝나 버렸다. 2부에서 다룬 deterministic 종료 규칙이 재개 check보다 먼저 돌았기 때문이다. 규칙 하나하나는 맞았다. 버그는 그 순서에 살고 있었다.
- 숨은 logic이 된 순서. 어떤 유형의 keyword는 다른 유형보다 먼저 검사해야 했다. 안 그러면 더 넓은 패턴이 그걸 집어삼켰다. 한 사이트에서는 global 패턴을 먼저 검사하는 바람에 최소 17개 row가 엉뚱한 경로로 빠졌다. Dictionary 순서를 바꿨더니 row 17개가 고쳐진다면, 그 순서가 곧 프로그램이다. 그리고 나는 그걸 어디에도 적어 두지 않았다.
- 문장 하나, 주인 열둘. 마무리 멘트 “더 도와드릴 일 있으실까요?“를 최소 12개 파일이 제각각 덧붙였고, 가끔은 한 답변에 두 번 붙었다. 4월 9일에 한 곳으로 모으기 전까지 그랬다.
“중요” 인플레이션: 모든 규칙이 “중요"할 때
RAG 답변 prompt는 분리 시점에 규칙 6개·348자였다. Peak 때는 규칙 25개·2,572자였고, 그중 10개에 “중요"나 “매우 중요"가 붙어 있었다. 분류(classification) prompt도 861자에서 2,667자로 대략 세 배가 됐다.
모아서 읽어 보면 규칙끼리 말다툼을 하고 있었다:
- 간결함 vs 완결성. 물어본 것만 답하라는 규칙과 두세 문장 상한이, 질문이 포괄적이면 세부 항목을 빠짐없이 다루라는 규칙과 몇 줄 사이를 두고 붙어 있었다.
- 전화번호 규칙 세 개. 3월 26일: 연락처는 물어볼 때만. 4월 23일: 부서를 안내할 때 번호도 같이. 4월 24일, 바로 다음 날 아침: 이제 답이 내용 대신 전화번호로 시작해서, 세 번째 규칙이 붙었다. 내용 먼저, 번호는 그다음.
- Row 하나짜리 규칙. 몇몇 규칙은 scripted 질문 하나에 대한 답안처럼 읽혔다. 특정 날짜에만 맞는 값, 이름이 두 개인 장소, 말하면 안 되는 내부 사항 같은 것들.
4월 6일 10:19, 답이 덜 퉁명스럽게 들리도록 문장 수 상한과 군더더기 금지 규칙을 빼고 공감 표현 가이드를 넣었다. 10:56, 상한을 되살리고, 인사말로 시작하는 걸 금지하고, 생성이 끝난 뒤 인사말을 잘라 내는 regex까지 붙였다. 37분 만에 한쪽 끝에서 반대쪽 끝으로 갔고, 덤으로 post-processor가 하나 생겼다.
이 길이 어디서 끝나는지는 meta-talk가 보여 준다. 모델은 계속 “제공된 문서에는 해당 내용이 없습니다” 류의 말을 했다. 전화 건 사람에게는 아무 의미 없는 문장이다. Prompt에서 금지해도 안 먹혀서 뒤에 non-answer 탐지 regex를 세우고, 걸리면 안내 멘트로 fallback하게 했다. 몇 주 뒤 그 탐지기에는 패턴이 또 하나 필요해졌다.
모든 게 중요하면 아무것도 중요하지 않다. “중요” 개수는 prompt 건강 상태를 재는 싸고 정직한 지표다. 표시 하나하나가 앞선 규칙이 안 먹혔다는 자백이니까. Prompt 규칙을 지키게 하려고 뒤에 regex를 세워야 하는 순간, 그 prompt는 더 이상 명세(specification)가 아니라 그동안 쌓인 불만 사항 목록이다.
같은 선을 긋고, 지우고, 다시 긋기
Retrieval도 같은 loop를 돌았다. 질문은 하나였다: 대화 history를 retrieval query에 언제 넣을 것인가?
- 3/18, 11:10. History-augmented retrieval을 넣었다. 최근 turn을 query 앞에 붙여서, 애매한 후속 질문도 맞는 문서를 찾게 하는 방식이다. 11:15: revert. 4월 1일에 다시 들어왔는데, 이번엔 classifier가 같은 주제의 연속(
flow_status == "CONTINUE")이라고 판단한 turn에만 쓰도록 gate를 걸었다. - 4/24, 08:46. QA 라운드 fix 하나가 그 gate를 넓혔다. 짧은 후속 발화(30자 미만)면 새 주제로 분류돼도 최근 history를 끌어오게 했다. 한 scripted 대화에서 통화자가 신고를 접수한 뒤, 전혀 상관없는 공공시설의 운영 시간을 물었다. Query가 앞의 신고 내용까지 끌고 들어갔고, reranker는 신고 쪽 문서에 0.98, 정답 문서에 0.00을 줬다. 09:13: 다시 좁혔다. History는 한 turn만, 질문에 주어가 따로 있으면 아예 안 붙이도록.
- 4/27, 11:01. 주제끼리 섞이지 않게 하려고
TopicStatetracker를 넣었다. Commit 하나에 파일 12개, 858줄. 23분 뒤 codebase는 peak을 찍었다.
5분, 8분, 27분, 37분. Fix와 그 정정 사이의 간격들이다. 당시엔 이걸 민첩함(agility)으로 읽었다. Retrieval fix가 하나 들어갈 때마다 “history를 쓴다/안 쓴다"의 경계선이 옮겨졌고, 그 선을 지키려면 state가 더 필요해졌다. 그 선을 꼭 사람이 손으로 그어야 하는지는 한 번도 묻지 않았다.
다음 날 나는 clean-slate 실험을 시작했다. 7부가 그 이야기다.
더 일찍 읽었어야 할 경고 신호
어느 것도 사후 확신(hindsight)이 필요하지 않았다. 전부 그때 commit log에 있었다:
| 경고 신호 | 내 log에 남은 흔적 | 의미 |
|---|---|---|
| Row 하나에 맞춘 상수 | 8분 사이 15 → 20 → 15 | 통화자를 묘사하는 게 아니라 test set에 fit하고 있다 |
| 정답지와의 “호환성"을 위한 revert | 11:07의 안내 멘트 rollback | Test set이 명세가 됐다 |
| 순서에 의존하는 규칙 | Dictionary 순서를 바꿔 row 17개 해결 | 문서화되지 않은 control flow |
| 한 시간 안에 fix → 정정 | 5분, 8분, 27분, 37분 간격 | 한 번이면 평범한 하루다. 습관이 되면, 촉발한 row 너머는 생각하지 않고 있다는 뜻이다 |
| 늘어만 가는 “중요” 개수 | 7주 동안 0 → 10 | 앞선 규칙이 안 먹히고 있다 |
| Guard를 참조하는 guard | Interrupt 불가 목록, interrupt 건너뛰기 flag, 종료보다 재개를 먼저 보는 순서 | 시스템의 본체가 모델이 아니라 규칙이 됐다 |
| 코드 속 tenant logic | 한 사이트 파일에 intake class 9개 | 코드가 고객처 수 × test row 수로 자란다 |
호환성 revert는 LLM 제품판 test set으로 튜닝하기다. 점수는 오르고, 그 값은 일반화가 치른다.
다시 한다면
대부분은 장부 정리다:
- 모든 규칙에 영수증을 붙여라. 그 규칙을 낳은 QA row, 그 규칙이 보완하는 모델의 약점, 언제까지 다시 정당화해야 하는지를 꼬리표로 달아라.
- Scripted 대화를 둘로 나눠라. Patch해도 되는 set과, 점수만 매기는 holdout으로.
- 네 가지 모양 지표를 pass rate 옆에 매주 chart로 그려라.
- Fix를 쓰기 전에, 그 row가 속한 실패의 class에 이름부터 붙여라. 솔직한 답이 “이 row"라면, 쓰지 마라.
요약
- Guardrail은 아무도 설계하지 않는다. 쌓일 뿐이다. 충분히 변호할 수 있는 출발점에서 시작한 dialog layer가 7주 만에 5,888줄에서 16,405줄로, compile된 regex 12개에서 126개로, “중요” 표시 0개에서 10개로 늘었다. 실패한 row 하나씩.
- 빠른 loop는 특수 케이스를 거의 공짜로 만든다. 바로 그게 위험이다. Scripted QA, hot-reloader, AI coding assistant 덕에 patch 하나 쓰는 건 싸졌다. 그중 어느 것도 patch를 떠안고 사는 비용은 줄여 주지 않았고, fix가 fix를 되돌리기 시작했다.
- Patch가 아니라 모양을 review하라. Row 하나에 맞춘 threshold, 호환성 revert, 순서 의존 규칙, 늘어나는 “중요” 개수 — 전부 log에 있었다. Pass rate처럼 추적하라.
다음 글 예고
이 글의 patch는 하나같이 QA 숫자를 올렸다. 6부에서는 그 숫자가 왜 그렇게 쉽게 만족했는지를 묻는다. 매 turn을 시스템 자신의 말투로 쓰인 정답(gold answer)과 비교하는 metric은 정확히 이런 하드코딩에 점수를 준다. 그리고 시스템과 나란히 튜닝된 judge는 시스템과 함께 표류한다.