이 글은 한국어 전화 Voice Agent 현장 노트 7부작 중 7부이자 마지막 편이다. 내가 리드했던 프로젝트 — 공공 서비스 전화 상담용 실시간 한국어 voice agent(STT → LLM → TTS) — 를 만들면서 겪은 일을 정리해 왔다. STT 평가 → Rule vs 모델 → Latency → 조용한 실패 → Guardrail → 평가 지표 → 단순화. 5부에서는 dialog layer의 규칙이 어떻게 쌓였는지를, 6부에서는 평가가 왜 계속 그 규칙들에 점수를 몰아줬는지를 다뤘다. 이번 글은 그 규칙들을 걷어낸 이야기다.


그해 봄 model sweep에는 한쪽 끝에 4B 모델이, 반대쪽 끝에 397B mixture-of-experts가 있었다. 팀의 긴 production prompt 뒤에서는 점수가 대체로 모델 크기를 따라 올라갔다. Scaling curve가 원래 그래야 하듯이. 그런데 내 orchestration 뒤에서는 거의 꿈쩍도 하지 않았다. Dense 27B, 122B, 397B(뒤의 둘은 FP8과 Int4 각각)가 전부 같은 점수를 받았다. 비슷한 수준이 아니라 소수점까지 똑같았다. Orchestration 두 번째 버전은 전반적으로 점수가 더 낮았고, 역시 평평했다.

Parameter를 백 배 늘렸는데 아무것도 안 바뀐다면, 답을 만들고 있는 건 parameter가 아니다. 이 글은 실제로 답을 만들던 그 레이어를 지운 이야기이자, 지우고 나서 다시 넣어야 했던 것들에 대한 이야기다.

Flat Line

이 글을 쓰면서 6부의 4월 benchmark(user turn 407개, open model 네 개, 두 아키텍처) raw output을 다시 뒤져 봤는데, judge 없이도 같은 패턴이 보였다. 서로 다른 모델이 글자 하나 안 틀리고 같은 말을 한 횟수만 세면 된다.

4월 benchmark, 407 turn Orchestration 긴 prompt
Open model 네 개의 답이 전부 byte 단위로 동일 253 (62%) 78 (19%)
Qwen3-4B와 Qwen3.5-35B-A3B의 답이 byte 단위로 동일 293 (72%) 89 (22%)
Reference-free 점수, 4B → 35B (1–5점) 3.70 → 3.86 3.14 → 4.10

Orchestration 아래에서는 4B와 35B MoE가 네 turn 중 세 turn꼴로 byte까지 똑같은 답을 내놨다. 두 모델의 의견이 일치한 게 아니다. Classifier가 둘을 같은 branch로 보냈고, 말은 template가 했다. 정답지를 보지 않는 judge 기준으로, 4B에서 35B로 올라가면서 긴 prompt는 거의 1점을 얻었고 내 orchestration은 그 6분의 1을 얻었다.

그게 실제로 어떻게 들리는지는 같은 날 드러났다. 6부의 반응형 시뮬레이션 caller로 돌린 run이었고, orchestrator 뒤에는 397B가 있었다.

  • 문의를 담당 부서에 넘기겠다는 안내를 들은 통화자가, 그냥 직접 전화해도 되냐고 물었다. Bot은 작별 인사를 하고 통화를 끊었다.
  • 119로 안내받은 통화자가 따로 연락해 볼 번호가 또 있냐고 물었다. 또 작별 인사.
  • 신고 접수를 확인해 준 bot이 아까 하던 신고를 이어서 하겠냐고 물었고, 통화자가 그러겠다고 하자 주소를 처음부터 다시 물었다.

그중 어느 결정도 397B가 내린 게 아니다. Graph가 질문을 작별 인사로 분류했고, 위치 check가 주소를 너무 모호하다고 판정했다. 내가 가진 가장 큰 모델이 내 state machine이 써 준 대사를 읽고 있었던 거다. (써 놓고 보니 꽤 아픈 문장이다.)

Scaffolding은 bias 노브다. Bias–variance 관점에서 보면, 하드코딩한 규칙 하나하나가 시스템이 할 수 있는 말의 폭을 좁힌다. 약한 모델에게는 그 덕에 줄어드는 error가 새로 생기는 error보다 많다. 강한 모델에게는 대부분 그냥 bias다. 그리고 모델 밖에 있는 bias는 모델을 키운다고 줄지 않는다.

모든 Guardrail에는 모델의 약점이 새겨져 있다

3월 18일, retrieval된 문서 안의 pipe table을 bullet list로 펴 주는 helper를 하나 추가했다. 이유는 docstring에 적혀 있다. 4B는 대개 표의 한 행만 읽고 공통 header는 무시한다. 3월 24일, 35B MoE로 옮기는 commit에서 그걸 지웠다. 근거는 한 줄이었다. 새 모델은 표를 알아서 잘 다룬다.

그 migration에서 새 모델을 근거로 지운 건 그것 하나뿐이었다. 나머지 4B 시절 장치들 — regex fast path, intent classifier, slot-filling node, template — 은 12월에 내가 그 크기 모델은 긴 prompt 하나를 안정적으로 따르지 못할 거라고 판단해서 만든 것들이었다(5부). 그것들은 그 뒤로 5주를 더 살아남았고, 35B에게도 아직 그게 필요한지 나는 한 번도 다시 묻지 않았다.

그 질문에 대한 답은 6부의 reference-free 점수에 있다. Orchestration 점수에서 긴 prompt 점수를 빼면 Qwen3-4B +0.56, Gemma-4-26B-A4B +0.18, Qwen3.5-27B 0.00, Qwen3.5-35B-A3B −0.24다. Scaffolding의 가치는 그게 가려 주던 약점의 크기를 그대로 따라갔고, 우리가 이미 옮겨 간 모델 언저리에서 0을 지나 음수가 됐다. Benchmark 다음 날의 clean-slate commit은 목표를 분명히 적었다. Qwen3-4B에 맞춰 만든 small-model scaffolding을 전부 걷어낸다.

모든 규칙에 그 규칙이 메우는 약점을 꼬리표로 달아라. “4B는 pipe table의 한 행만 읽는다"는 테스트할 수 있는 주장이다. 모델이 바뀌면 테스트를 다시 돌리고, 통과하면 규칙을 지워라. 꼬리표가 없으면 모든 규칙이 건물을 떠받치는 기둥처럼 보인다.

Graph는 지우고, API는 남기고

두 번에 걸쳐 했다. Benchmark 다음 날 branch에서 돌린 clean-slate 실험에서 service를 retrieval과 LLM 호출 한 번만 남기고 다 걷어냈다. −9,566줄이었고, chat history만은 일부러 남겼다. 진짜로 자른 건 5월 12일, 이번에도 branch에서였다. Graph node 40여 개, intent classifier, slot filling, 사이트별 override까지 테스트 포함 13,528줄. Commit message는 돌려 말하지 않는다. Graph는 제약이 너무 심했고, 평가에서 plain LLM + raw RAG가 더 나았다.

대신 API는 일부러 그대로 뒀다. session_id는 여전히 받았고, DELETE /session/{id}도 여전히 200을 돌려줬고, schema도 그대로였다. intent만 null로 돌아왔을 뿐이다. Telephony 쪽도 QA harness도 전처럼 호출하면 됐고, 되돌려야 하면 migration 없이 배포 한 번이면 끝났다.

Service 코드 Peak (4/27) Graph 제거 (5/12) 최종 branch (5/13)
Python 줄 수 (core + API) 16,405 3,707 4,020
re.compile 호출 126 35 46
가장 큰 사이트별 config 629줄 12줄 12줄

Peak 대비 −75%다. 남은 regex 46개는 text normalization, 위치 check, 그리고 아래에 나올 guard 안에 있다. End-of-call gate를 빼면 대화가 어디로 갈지 정하는 regex는 하나도 없다. 2부에서 rule은 여기에 있어야 한다고 했던 바로 그 자리, 정답이 정확히 하나뿐인 곳이다.

Tenant는 data가 됐다. 한때 keyword regex, intake 정의, redirect 메시지까지 들고 있던 사이트별 config가 field 네 개(key, 이름, 문서 폴더, 마무리 멘트)로 줄었고, 사이트마다 다른 문구는 prompt 파일로 빠졌다. 팀의 긴 production prompt도, 전날 내가 덧붙인 부분까지 합쳐 430줄에서 96줄로 줄였다. 5부에서는 “중요” 표시를 셌는데, 이번에 문제가 된 단어는 “절대"였다. “절대"가 들어간 줄이 22줄에서 3줄이 됐다.

지운 레이어가 공짜로 해 주던 일

Graph 제거 commit은 새 /chat을 stateless라고 소개한다. 나는 그걸 무슨 feature인 양 적었다. 원래는 orchestrator가 session마다 대화 context를 들고 있었다. 그게 사라지자 모델이 볼 수 있는 건 지금 발화와 retrieval된 문서뿐이었고, 매 turn이 첫 turn이 됐다. Clean-slate branch는 history를 일부러 남겨 뒀는데, 진짜로 자른 쪽은 그걸 버렸다. (전화 상담 bot이 stateless라니. 그때 이상하다고 느끼지 못한 게 지금은 더 이상하다.)

두 시간도 안 돼서 guard commit이 bot의 최근 답 세 개를 담는 buffer를 추가했다. Repetition guard용으로 만든 건데 모델에 history로도 넘겼고, 코드 주석에도 스스로 “approximate"라고 적혀 있다. 다음 날 아침 window는 20으로 늘었다. 이 글을 쓰면서 코드를 다시 읽어 보니, 통화자가 앞서 한 말은 끝내 돌아오지 않았다. 모델은 자기가 한 말은 전부 볼 수 있었고, 통화자가 한 말은 하나도 볼 수 없었다.

아무도 적어 두지 않은 일은 기억만이 아니었다.

암묵적으로 하던 일 Graph 시절 담당 자른 뒤 담당
대화 기억 Session context Bot의 최근 답 3개, 이후 20개. 통화자 turn은 빠짐
애매한 후속 질문에 대한 retrieval context History-aware retrieval node 없음. Retrieval은 발화만 그대로 사용
개인 번호 요청 거절 Contact node 안의 privacy check 코드에서도, 두 prompt에서도 찾지 못함

제일 마음에 걸리는 건 privacy 행이다. “이웃분 번호는 알려 드릴 수 없습니다"는 data가 아니라 behavior다. 그런데 자른 뒤에 그 behavior를 맡은 곳을 나는 찾지 못했다.

레이어를 지우기 전에, 그 레이어가 interface 어디에도 이름 없이 하던 일부터 목록으로 만들어라. 일마다 담당을 정하거나, 테스트를 붙이거나, “이건 잃어도 된다"고 명시하라. 나는 자르기 전에 이 목록을 만들지 않았다. 위 표는 다 끝난 뒤에 만든 거다.

영수증이 붙은 Guard 세 개

Guard commit의 설명은 한 줄이다. State machine 없음, intent classifier 없음, benchmark data가 실제로 정당화하는 hook만. Hook 세 개에 unit test 19개다.

Guard 겨냥한 실패 Context gate 동작 테스트
통화 종료 short-circuit 작별 직전에 또 요약하기; 토씨 하나 바뀌면 안 되는 마무리 멘트를 바꿔 말하기 직전 bot turn이 “더 도와드릴 일 있으실까요?“로 끝났고, 동시에 답이 짧은 마무리 말(30자 이하, 고정 목록) LLM을 건너뛰고 고정 마무리 멘트 반환 5
Repetition guard 같은 fallback 반복(이후 benchmark 라운드에서 non-repetition 4.0/10, GPT-5는 9.38) 답 본문이 session 안의 이전 답과 0.7 이상 유사 반복 금지 지시를 붙여 한 번 reroll, 그래도 안 되면 고정된 정중한 마무리 6 + 2
서식 문자 제거 Markdown, 괄호, 슬래시, 화살표가 TTS까지 감 항상, TTS 직전 기호만 지우고 내용은 유지 6

핵심은 gate다. 예전 종료 logic은 신고가 아직 중단된 채 남아 있는데도 “알겠습니다” 한마디에 통화를 끝냈다(5부). 이번 guard는 “알겠습니다"가 “더 도와드릴 일 있으실까요?“에 대한 대답일 때만 동작하고, 그 밖에서는 가만히 있는지 확인하는 테스트도 붙어 있다. Guard마다 측정된 실패가 뒤에 있고, “통화자가 X라고 했다"보다 좁은 gate가 있고, deterministic fallback과 테스트가 있다. Routing은 어느 것도 하지 않는다. Turn의 가장자리 — 마무리, 반복, 서식 — 에서만 움직이고, 가운데는 모델에게 맡긴다.

그럼 사실은 어디로 갔나

규칙이 없으면 모든 사실은 문서에서 나와야 한다. 그것도 reranker가 잘 찾고 귀로 들어도 자연스럽게 쓴 문서에서. 그 작업은 대부분 teardown보다 먼저 해 두었고, teardown이 가능했던 것도 그 덕이다.

  • 혼자서도 답이 되는 항목 (3/18). 공통 header가 있는 표를, 항목마다 운영 시간과 서비스를 다시 적은 list로 바꿨다. 어느 chunk든 혼자서 답이 된다.
  • 문서 통째로 (3/19). 짧은 문서는 450-token window로 자르지 않고 통째로 뒀고, retrieval 후보도 3개에서 10개로 늘렸다.
  • 말하는 문체 (3/23). 상담원이 실제로 말하듯이 썼다. 다만 TTS가 잘못 읽었던 숫자를 한글로 풀어 쓴 건 예외다. 2부에서 말했듯, 그건 코드 버그를 가린 것이었다.
  • Rerank 후 상위 3개 (4/9). 문서 하나로는 두 주제에 걸친 질문을 감당하지 못했다.

사실을 넣는 방법으로 fine-tuning은 처음부터 택하지 않았다. 초기 계획서에서부터 retrieval 쪽으로 논리를 세웠다. Hallucination이 적고, QC가 쉽고, 사실이 바뀔 때마다 재학습할 필요가 없다. 그래도 style과 sub-task용으로는 시도해 봤다. Whisper 시리즈의 full fine-tune과 달리 이번에는 35B를 H100 한 장에서 4-bit QLoRA로 돌렸다. 전화번호, 부서, 주소를 placeholder로 바꾼 style 전용 set에 task에 맞춘 set 몇 개를 더했고, eval loss 0.27, token accuracy 93.6%까지 갔다. 그 주변 toolkit에는 training script가 열 개나 쌓여 있었다. Stack은 두 번째 script를 쓰기 전에 정해라.

이제 자기비판. 그 run의 training file을 보면 835개 중 487개가 orchestrator 자신의 sub-task, 즉 intent JSON과 위치 JSON이었다. 나는 모델을 graph의 더 나은 부품으로 길들이고 있었고, 그 toolkit을 commit한 건 graph를 지우고 96분 뒤였다. Token accuracy는 모델이 내 format에 얼마나 잘 맞느냐를 잴 뿐, 통화자가 더 나은 응대를 받느냐는 재지 못한다. 그걸 재려면 6부에서 다룬 blind 비교 같은 게 필요하다. 증거는 scaffolding을 걷어내라고 말하는데, 나는 모델을 scaffolding에 맞추고 있었다.

실제로 일어난 일, 그리고 다시 한다면

Teardown은 branch에서 검증한 뒤 넘겼다. 다음 sprint 계획은 “prompt 단순화"와 “orchestration 구현"을 한데 묶었고, 목표는 과도한 guardrail 없이 QA를 통과하는 것이었다. 실제로 전달된 건 타협안이었다. 훨씬 가벼운 flow와 단순화한 prompt. 나쁘지 않은 착지점이라고 본다. 반대편에도 진짜 장점이 있었으니까.

  • Template는 빠르다. 4월 benchmark에서 orchestration을 붙인 35B는 median 161 ms에 답했고, 긴 prompt 쪽은 289 ms였다.
  • Template는 예측할 수 있다. 비즈니스 팀은 bot이 무슨 말을 할지 정확히 읽을 수 있었다. Prompt가 그들에게 건네는 건 분포다.
  • 4B에게는 정말 필요했다. +0.56은 진짜였다. Scaffolding은 그걸 위해 만든 모델에게는 옳았다. 다음 모델까지 그대로 끌고 간 게 실수였다.

다시 한다면 바꿀 건 거창한 게 아니라 위생이다. 그것도 첫 규칙부터.

  1. 모든 규칙에 꼬리표를 달아라. 그 규칙이 메우는 모델 약점과, 그 규칙을 낳은 QA row를.
  2. 모델을 바꿀 때마다 scaffolded vs minimal을 다시 돌려라. Scaffolding에 점수를 주지 않는 지표로.
  3. 규칙 예산에 상한을 둬라. 새 규칙이 들어오면 옛 규칙 하나는 은퇴하거나 합쳐진다.
  4. 다시 넣는 건 좁고, gate가 걸려 있고, 테스트된 guard만. 측정된 실패가 정당화하는 것만.

정말로 바뀐 건 내 기본값이다. 예전엔 row 하나가 실패할 때마다 규칙을 더했다. 이제는 규칙이 스스로 존재 이유를 증명해야 하고, 그 기준은 밑에 깔린 모델이 좋아질 때마다 올라간다.

시리즈를 마치며

7부, pipeline 하나, 그리고 옷만 갈아입고 계속 나타난 실수 하나. 숫자나 규칙을, 그게 대표하던 것보다 더 믿은 것이다.

  1. STT 평가: 실제로 서비스할 채널에서 측정하고, eval set도 QA하라. 내 reference는 내 모델의 출력에서 출발했다.
  2. Rule vs 모델: 정답이 정확히 하나라면 코드로 짜라. 모델에게는 결정 말고 표현을 맡겨라.
  3. Latency: stage별로 profiling하라. 속도는 active parameter × 말하기로 정한 token 수다.
  4. 조용한 실패: 실패에 고유한 값을 주고, 가정하는 건 assert하라.
  5. Guardrail: 규칙은 실패한 row 하나씩 쌓인다. Patch가 아니라 모양을 review하라.
  6. 평가 지표: 한 시스템의 말투로 쓰인 gold set은 그 시스템에 왕관을 씌운다. 정답지를 보지 않는 judge를 하나는 둬라.
  7. 단순화: 약한 모델을 위해 만든 scaffolding은 강한 모델의 천장이 된다. 모델을 바꿀 때마다 규칙을 다시 정당화하고, 사실은 retrieval에 둬라.

실패는 측정하고, 규칙은 정당화하고, 나머지는 지워라.