이 글은 한국어 전화 Voice Agent 현장 노트 7부작 중 6부다. 내가 리드했던 프로젝트 — 공공 서비스 전화 상담용 실시간 한국어 voice agent(STT → LLM → TTS) — 를 만들면서 겪은 일을 정리한다. STT 평가 → Rule vs 모델 → Latency → 조용한 실패 → Guardrail → 평가 지표 → 단순화. 5부에서는 실패한 QA row 하나마다 dialog layer에 규칙이 하나씩 쌓인 과정을 다뤘다. 이번 글은 그 규칙들에 계속 점수를 몰아준 평가 이야기다.
4월 말, 나는 꽤 마음에 드는 leaderboard를 하나 갖고 있었다. 같은 user turn 407개, 모델 다섯 개, 아키텍처 두 개. 한쪽은 팀의 긴 production prompt, 다른 한쪽은 내가 몇 달에 걸쳐 만든 graph orchestrator — intent classifier, slot filling, 고정 template — 였다. Orchestration 구성은 하나도 빠짐없이 모든 prompt 구성을 이겼고, 격차는 5점 척도에서 최대 1점 남짓이었다. 테스트에서 가장 작은 Qwen3-4B도 내 template를 앞세우자 4.41을 받았다. 긴 prompt를 단 GPT-5는 3.38 — 꼴찌였다.
이 표는 그 아키텍처를 밀면서 내가 가져 본 가장 강력한 근거였다. 그런데 알고 보니 이 숫자가 잰 건 대부분, 각 시스템이 정답지 문구를 얼마나 똑같이 재현하느냐였다. 이 글은 그걸 어떻게 알게 됐는지, 그리고 평가 지표가 다시는 몰래 아키텍처를 고르지 못하게 하려면 eval stack을 어떻게 쌓을지에 대한 이야기다.
루프는 이렇게 시작됐다
첫 harness는 단순했고, 다시 해도 여기서 시작할 거다. 팀의 비즈니스 쪽에서 scripted 대화를 모아 둔 공유 시트를 관리했는데, bot turn마다 정답(gold answer)이 달려 있었다. Script 하나가 각 대화를 live chat API로 replay하고, bot의 답과 통과 표시를 시트에 다시 써 넣었다. Exact string match를 먼저 돌리고, 불일치일 때만 LLM judge를 불렀다. Judge 응답이 비어 있거나 호출이 실패하면 미통과로 쳤다.
덕분에 당일 돌아가는 regression 루프가 생겼고, 그 루프의 주인은 좋은 답이 어떻게 들려야 하는지 제일 잘 아는 사람들이었다. 다만 이 루프는 아무도 적어 둔 적 없는 가정 세 개 위에 서 있었다.
- 정답지의 답이 맞는 답이고, 사실상 유일한 답이다.
- 발신자의 다음 말은 bot이 방금 한 말과 상관없다.
- Judge는 고정된 측정 도구다.
셋 다 깨졌다. 제일 먼저 깨진 건 세 번째였고, 깬 사람은 나였다.
나와 같이 움직인 Judge
5부의 3월 말 QA push — 사흘 동안 fix commit만 40개가 넘었다 — 도중에 나는 26분 사이에 judge를 두 번 느슨하게 풀었다. 00:56에는 정보가 더 붙어 있거나 띄어쓰기·표현이 달라도 통과시키게 했다. 01:22에는 정답보다 항목을 더 많이 나열한 답, 정답이 아라비아 숫자로 쓴 걸 한글로 풀어 쓴 답(또는 그 반대), 연락처를 덧붙인 답까지 통과로 쳤다. 규칙 하나하나는 변호할 수 있었고, 지금 다시 봐도 대부분은 받아들일 거다.
문제는 규칙이 아니라 규칙이 들어온 경로였다. 같은 사람이, 같은 밤에, 시스템을 고치는 바로 그 commit들 안에서 자를 바꿨다. 통과율은 올랐는데, 그중 얼마가 시스템 덕이고 얼마가 측정 덕인지 아무도 말할 수 없었다. (새벽 1시의 나는 물론 전부 시스템 덕이라고 믿고 싶었다.)
그나마 신호 일부를 살린 건 며칠 전에 내린 결정 하나였다. Exact 통과(O)와 judge가 올려 준 통과(△)를 따로 집계하기 시작한 것이다. 이 구분은 한 달 뒤에 값을 했다. Bot의 되묻기 turn(빠진 정보를 발신자에게 다시 묻는 turn)을 고정 문자열에서 LLM paraphrase로 바꿨을 때다(2부). 되묻기가 들어 있는 대화 45개, 총 127 turn을 돌렸더니 116개가 통과했다: O = 51, △ = 65, X = 11. 통과의 절반 이상이 judge의 의견이었고, 그 judge는 자기가 만든 paraphrase를 채점하는 serving model 자신이었다. 정답이 되묻기인 row에서 예전 고정 문구와 똑같은 답은 22개에서 8개로 줄었다. 시스템은 더 자연스러워졌는데, 정답지 눈에는 그게 보이지 않았다.
Harness에는 움직이는 부품이 하나 더 있었다. 시계다. Intake flow 몇 개는 요일이나 시간대에 따라 다르게 동작했는데, QA script는 시간을 고정한 적이 없었다. 같은 QA set이 언제 돌리느냐에 따라 다른 점수를 낼 수 있었다는 얘기다. 나중의 benchmark는 시간을 특정 날짜·시각 하나로 고정했지만, 이 글을 쓰면서 코드를 다시 읽어 보니 그 고정은 prompt 경로에만 닿아 있었다. Orchestrator의 시간 의존 flow는 여전히 실제 시계를 읽고 있었다.
시스템을 튜닝하기 전에 judge를 얼려라. 아니면 다른 사람에게 넘겨라. Judge prompt와 모델을 코드처럼 versioning하고, run과 run 사이에만 바꾸고, exact 통과와 judge 보조 통과는 별개의 숫자로 리포트하라.
같은 Turn, 두 Judge, 다른 승자
4월 benchmark는 같은 user turn 407개를 open model 네 개에 두 아키텍처로 각각 replay하고, GPT-5는 긴 prompt로만 돌렸다. 그리고 모든 답을 두 번 채점했다.
정답지 기반 scorer(gold-reference scorer)는 각 답을 정답과 비교했다. 이 글을 위해 출력을 다시 읽어 보니, 정답지에 대고 한 문자열 산수였다. 정답과의 overlap이 0.85 이상이면 무조건 5점이고, 그 아래에서는 similarity 값을 1–4점에 매핑한 뒤 키워드 overlap, 전화번호 일치, “둘 다 위치를 묻는가” 같은 보조 규칙을 얹는다. 3,663개 점수 전부에 그런 식의 짧고 정형화된 사유가 달려 있었고, judge가 쓴 문장은 하나도 없었다.
Reference-free judge(gpt-4.1-mini, temperature 0)는 직전 몇 turn과 답은 보지만 정답은 보지 않았고, 사실 정확성은 무시하라는 지시를 받았다. 기준은 하나 — 좋은 상담원이라면 이렇게 말하겠는가.
| 모델 | 정답지 채점: Orchestration | 정답지 채점: 긴 prompt | Reference-free: Orchestration | Reference-free: 긴 prompt |
|---|---|---|---|---|
| Qwen3-4B | 4.41 | 3.47 | 3.70 | 3.14 |
| Qwen3.5-27B | 4.69 | 3.68 | 4.09 | 4.09 |
| Qwen3.5-35B-A3B | 4.53 | 3.78 | 3.86 | 4.10 |
| Gemma-4-26B-A4B | 4.65 | 3.57 | 4.07 | 3.89 |
| GPT-5 | — | 3.38 | — | 3.50 |
1–5점 척도 평균, 셀당 407 turn.
정답지를 보여 주면 orchestration은 모든 모델에서 0.75–1.09점 앞섰다. 정답지를 가리자 확실히 앞선 건 4B뿐이었다(+0.56). 나머지 셋에서는 1점 안팎이던 격차가 0.25점 이내로 줄었다. 27B는 동점, Gemma는 근소한 우위 유지(+0.18), 그리고 35B MoE — 한 달 전쯤 우리가 갈아탄 모델이다 — 는 prompt 쪽으로 뒤집혔다(−0.24). 이 글을 위해 대화 단위 bootstrap을 돌려 보니 각 격차에 대략 ±0.1–0.15의 폭(95% 구간)이 붙는다. 35B와 Gemma의 차이는 진짜지만 작다.
이유는 숨어 있지도 않았다. Orchestration 답은 52–59%가 정답과 글자 하나 다르지 않았다. Prompt 쪽 모델은 **20–24%**였다. 정답지는 정해진 script 말투로 쓰여 있었고, 몇 달간의 QA push가 내 template에게 그 말투를 그대로 재현하도록 가르쳤다 — 인사말을 “QA 호환성을 위해” 되돌린 commit까지 있었다. 자기 문구를 기준으로 채점되는 template 시스템의 점수가 재는 건 품질이 아니라 순응도다.
한 시스템의 말투로 쓴 gold set은 그 시스템을 1등으로 뽑는다. 정답지와 template가 문구를 공유한다면, 높은 reference 점수가 알려 주는 건 대부분 ‘둘이 문구를 공유한다’는 사실뿐이다. 정답지를 한 번도 보지 않는 judge를 최소 하나는 돌려라.
GPT-5의 꼴찌를 GPT-5에 대한 판결로 읽지는 않는다. 다른 API 경로에서 다른 기본값으로 돌았고, 첫 run은 거의 전부 빈 응답으로 돌아왔다(4부). 그 순위는 모델보다 harness에 대해 더 많은 걸 말해 준다.
정답지가 틀렸을 때
발신자가 누군가 자기를 따라오고 있다고 말한다. 정답지와 내 template는 둘 다 119 — 소방·구급 — 에 전화하라고 안내했다. Prompt로 돌린 모델들은 전부 112, 경찰이라고 했다. 그런 상황의 사람에게 내가 걸라고 하고 싶은 번호도 이쪽이다. 정답지 scorer는 prompt 모델들에게 similarity 0.44–0.45로 2/5를 줬고, orchestrator에게는 틀린 정답을 글자 그대로 따라 했다는 이유로 5/5를 줬다.
Reference-free judge도 놓쳤다. 설계상 사실 확인을 안 하니 두 버전 모두에 4–5점을 줬다. 두 지표 모두 틀린 레이블을 찾지 못했다. 찾아낸 건 지표끼리 갈린 케이스를 직접 읽는 일이었다.
35B의 두 구성을 놓고 보면 orchestration 5점, prompt 2점 이하인 turn이 59개 있었다. 그중 49개는 문자열 similarity가 낮아서였다. 이 글을 쓰면서 하나씩 읽어 보니, 같은 내용을 다른 말로 한 답은 일부였고 실제로 다른 답이 더 많았다. 정답은 담당 번호를 안내하는데 prompt 쪽은 접수 질문을 던진 경우, 사실이 아닌 내용을 자신 있게 말한 경우 같은 것들이다. 4개는 마무리 멘트였다. 6개는 전화번호 불일치였다 — 바로 reference로 확인해야 할 hard fact이고, 그 scorer에서 내가 남길 유일한 부분이다. Reference-free judge는 그 59개 prompt 답 중 42개에 4점 이상을 줬고, 전화번호가 어긋난 6개 중 5개도 거기 들어 있었다. 두 점수는 서로 다른 곳에서 눈이 멀어 있었다. 문자열 scorer는 paraphrase와 오답을 구분하지 못했고, reference-free judge는 틀린 번호를 그냥 통과시켰다.
더 민망한 건, 이 원칙을 내가 이미 글로 적어 뒀다는 사실이다. LLM reference judge용으로 직접 쓴 rubric에는, 요지만 말하면, 의도한 답과 다르더라도 적절한 답이면 높은 점수를 줘야 한다고 되어 있었다. 문자열 scorer는 그 문장을 지킬 수가 없다. 교훈은 두 가지다. 지표끼리 의견이 갈리는 케이스는 review queue로 보내라. 정답이 틀렸으면 시스템이 아니라 정답지를 고쳐라.
Script Replay는 Off-Policy다
407개 turn은 대화 184개에서 나왔다 — 대화당 user turn 약 2.2개, 많아야 6개. 그리고 user 발화는 bot이 뭐라고 하든 고정이었다. Script는 위치를 묻는 질문을 기대하는데 bot이 시간을 물으면, 다음 scripted 발화는 아무도 묻지 않은 질문에 답하고, bot은 그 엉뚱한 말에 대한 응답으로 채점받았다.
강화학습 용어로 이건 off-policy evaluation이다. 다른 policy가 만든 trajectory 위에서 한 policy를 채점하는 것. 여기서 script는 내가 몇 달 동안 orchestrator를 맞춰 튜닝해 온 바로 그 대화 흐름을 전제하고 있었다. 대화를 다른 데로 끌고 가는 시스템은 전부 그 대가를 치렀다. 내 첫 multi-turn capture script는 더 심했다. Scripted 두 번째 turn 하나가 이전 run에서 나온 특정 오답에 반박하는 내용이었다(“방금 X라고 하셨는데요…”). Bot이 같은 실수를 반복해야만 말이 되는 대화였던 거다.
두 번째 버전은 script를 작은 caller 함수 13개로 바꿨다. 각 함수는 bot의 실제 답을 읽고 키워드로 분기한다. 온라인 방법을 언급하면 그걸 묻고, 얼버무리면 전화할 번호를 묻고, 마무리 멘트가 나오면 끊는다. 조잡하지만 on-policy다. 시뮬레이션 caller는 나중에 LLM이 됐고, 5월 중순에는 benchmark가 반복하지 않기(non-repetition)도 채점하고 있었다. 여기서 우리 open model은 평균 4.0/10, GPT-5는 9.38/10이었다. 다음 단계 작업의 방향을 정한 실패였고, turn 단위 정답 비교에는 이걸 담을 칸 자체가 없다.
Judge는 누가 채점하나
QA judge는 serving model 위에서 그대로 돌았다. Tooling이 GPU memory를 두고 서버와 싸우지 않도록 내가 붙인 raw generate endpoint를 통해서였다(그 사연은 3부에 있다). “어제 이후로 뭐가 regression됐나?“를 보는 데는 괜찮다. “어느 시스템이 더 나은가?“를 가리는 데는 틀렸다. 모델이 자기 출력을 채점하고, 모델을 바꾸면 judge도 같이 바뀐다. 그래서 4월 비교에서는 LLM judge를 외부 모델로 옮기고 test data를 파일로 얼렸다. Run 도중에 누가 시트를 고쳐도 set은 더는 바뀌지 않았다.
그래도 모든 자동 지표 밑에 깔린 질문은 남는다. 이게 사람의 판단과 일치하나? 마지막 조각은 blind A/B survey, 즉 지표를 위한 지표였다. QA harness가 나란히 기록해 둔 base 모델과 fine-tuned 모델의 답을 짝지었다.
- 순서 무작위화: 각 쌍의 A/B 위치를 동전 던지기로 정해서, position bias가 승자를 고르지 못하게 했다.
- 거의 같은 쌍 제외: 90% 넘게 비슷한 쌍은 건너뛰어서, 평가자가 진짜 차이만 판단하게 했다.
- “둘 다 별로"를 포함한 선택지 4개: 둘 다 틀린 답 사이에서 억지로 하나를 고르게 하면, 가장 봐야 할 결과가 가려진다.
- 전체 맥락: 각 문항에 마지막 turn만이 아니라 그때까지의 대화를 보여 줬다.
폼 문항은 26개였고, 연속된 두 sprint에 걸쳐 나갔다. 두 번째는 시뮬레이션 caller를 다시 만든 뒤였다. 명시된 목적은 자동 점수를 믿어도 되는지 판단하는 것이었다. 여기서는 결과가 아니라 설계만 다룬다. 요점은 위치다 — 지표에 대한 사람의 검증은 그 지표가 아키텍처를 고르기 전에 와야 한다.
다시 만든다면 이렇게 쌓을 Eval Stack
처음부터 다시 한다면 이 stack으로, 대략 이 순서대로 추가할 거다.
| 레이어 | 하는 일 | 가르쳐 준 사건 |
|---|---|---|
| Reference로 hard fact 확인 | 번호, 부서, 운영 시간을 추출해 정확히 비교 | 낮은 점수 59개 중 전화번호 불일치 6개 |
| Reference 없는 품질 평가 | 맥락만 보고 판단, 정답은 절대 안 보여 줌 | 정답지를 가리자 뒤집힌 판정 |
| On-policy caller | 반응형, 그다음 LLM 기반. 대화 전체를 채점 | 정답 비교에는 담을 칸이 없던 반복 |
| 얼린 외부 judge | Versioning, 시스템을 튜닝하지 않는 사람이 소유 | 26분 사이 두 번의 완화 |
| 따로 세기 | Exact 통과 vs judge 통과, 모델별 에러와 빈 응답 | O = 51 vs △ = 65, 거의 비어 있던 GPT-5 run |
| 조건 고정 | 시계, seed, 얼린 test data | 한쪽 경로로 새어 들어온 실제 시계 |
| Held-out row | 절대 보고 patch하지 않는 케이스 | 자기 test set에 맞춰 튜닝된 QA push |
| 지표에 대한 사람의 검증 | “둘 다 별로"가 있는 blind A/B | 이 모든 게 품질을 따라가는지 몰랐다는 것 |
대부분은 고전 ML 위생 수칙이 옷만 갈아입은 거다. QA 시트에 대고 row 하나씩 시스템을 patch하는 건 test set으로 튜닝하는 것이고, 시스템 문구를 빨아들인 gold set은 조용한 형태의 leakage다. 둘 다 bias–variance 글의 함정 섹션에 나온다. 머리로는 아는 내용인데, 실무에서는 그대로 밟았다. 음성 쪽에서도 같은 실수를 했다. Reference transcript의 초안을 내 모델의 출력으로 잡았던 것이다(1부). 그리고 실패는 “미통과” 쪽으로 처리하라. Judge 에러나 빈 생성은 시스템에 불리하게 세야지, 유리하게 세면 안 된다.
요약
- Reference 점수는 품질이 아니라 정답지와의 유사도를 잰다. 정답이 template와 문구를 공유하면 template 시스템은 구조적으로 이긴다. 여기서는 정답지를 가리자 큰 open model 세 개에서 1점 안팎이던 격차가 0.25점 이내로 줄었다.
- Judge와 harness도 테스트 대상 시스템의 일부다. 시스템을 튜닝하는 사람이 같이 손본 judge는 시스템과 함께 표류하고, scripted replay는 그 script가 상정한 bot에게 점수를 주고, 고정 안 된 시계는 요일을 측정한다. 얼리고, 외부로 빼고, 고정하라.
- 각 부분이 실제로 확인할 수 있는 것에 맞춰 eval을 나눠라. Hard fact는 reference로, 품질은 reference 없이, 대화는 on-policy로, 그리고 전체를 blind human preference로 검증한다. 그다음 지표끼리 갈린 케이스를 읽어라. 틀린 정답 레이블은 거기 있다.
다음 글 예고
정답지가 대신 골라 주는 일이 멈추자 진짜 질문이 남았다. 이 scaffolding이 35B 모델에게 실제로 보태 주는 게 뭐였고, 훨씬 큰 모델은 왜 그걸 넘지 못했나. 7부에서는 orchestration 13,528줄을 지우고, 영수증이 붙은 guard 세 개만 남기고, 지운 레이어가 공짜로 해 주던 일이 뭐였는지 들여다본다.