이 글은 한국어 전화 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를 멈춰야 하나, 말아야 하나?

  1. 3/20. 정보성 질문이 나오면 무조건 intake를 일시 중단했다. 그랬더니 봇이 방금 던진 질문에 대답하고 있던 통화자까지 끊겼다. 그래서 앞단에 LLM relevance check를 붙였다.
  2. 3/26, 08:21. “오늘 오후 세 시쯤이요"처럼 시간만 말한 답이 새 intent로 분류돼 flow를 이탈시켰다. 해당 node를 interrupt 불가 목록에 넣었다.
  3. 3/26, 16:39. 답으로 불러 준 식별 번호 하나가 새 질의처럼 보였다. 접수가 끝날 때까지 모든 전용 intake node를 interrupt 불가로 만들었다.
  4. 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. 주제끼리 섞이지 않게 하려고 TopicState tracker를 넣었다. 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으로 튜닝하기다. 점수는 오르고, 그 값은 일반화가 치른다.

다시 한다면

대부분은 장부 정리다:

  1. 모든 규칙에 영수증을 붙여라. 그 규칙을 낳은 QA row, 그 규칙이 보완하는 모델의 약점, 언제까지 다시 정당화해야 하는지를 꼬리표로 달아라.
  2. Scripted 대화를 둘로 나눠라. Patch해도 되는 set과, 점수만 매기는 holdout으로.
  3. 네 가지 모양 지표를 pass rate 옆에 매주 chart로 그려라.
  4. Fix를 쓰기 전에, 그 row가 속한 실패의 class에 이름부터 붙여라. 솔직한 답이 “이 row"라면, 쓰지 마라.

요약

  1. Guardrail은 아무도 설계하지 않는다. 쌓일 뿐이다. 충분히 변호할 수 있는 출발점에서 시작한 dialog layer가 7주 만에 5,888줄에서 16,405줄로, compile된 regex 12개에서 126개로, “중요” 표시 0개에서 10개로 늘었다. 실패한 row 하나씩.
  2. 빠른 loop는 특수 케이스를 거의 공짜로 만든다. 바로 그게 위험이다. Scripted QA, hot-reloader, AI coding assistant 덕에 patch 하나 쓰는 건 싸졌다. 그중 어느 것도 patch를 떠안고 사는 비용은 줄여 주지 않았고, fix가 fix를 되돌리기 시작했다.
  3. Patch가 아니라 모양을 review하라. Row 하나에 맞춘 threshold, 호환성 revert, 순서 의존 규칙, 늘어나는 “중요” 개수 — 전부 log에 있었다. Pass rate처럼 추적하라.

다음 글 예고

이 글의 patch는 하나같이 QA 숫자를 올렸다. 6부에서는 그 숫자가 왜 그렇게 쉽게 만족했는지를 묻는다. 매 turn을 시스템 자신의 말투로 쓰인 정답(gold answer)과 비교하는 metric은 정확히 이런 하드코딩에 점수를 준다. 그리고 시스템과 나란히 튜닝된 judge는 시스템과 함께 표류한다.