이 글은 한국어 전화 Voice Agent 현장 노트 7부작 중 4부다. 내가 리드했던 프로젝트 — 공공 서비스 전화 상담용 실시간 한국어 voice agent(STT → LLM → TTS) — 를 만들면서 겪은 일을 정리한다. 1부는 STT 평가가 나를 어떻게 속였는지, 2부는 rule이 모델을 이기는 곳, 3부는 latency를 다뤘다. 이번에는 exception 한 번 던지지 않고 조용히 실패한 것들 이야기다.


웹 앱이 망가지면 누군가는 500 페이지라도 본다. Voice agent가 망가지면 전화를 건 사람은 아무것도 듣지 못한다. 침묵에 대고 “여보세요?“를 두어 번 하다가, 몇 초 기다리고, 끊는다. Stack trace는 caller에게 닿지 않고, 많은 경우 나한테도 닿지 않는다.

이 글에 나오는 실패는 바깥에서 보면 거의 다 이런 모양이었다. 아무것도 죽지 않았고, 원인을 따라가 보면 하나같이 그 자체로는 멀쩡해 보이는 선택이었다. 그것들이 남긴 규칙이 네 개다:

  • “실패했다"와 “결정했다"가 같은 code path를 타면 안 된다.
  • 자동 fallback에는 log 한 줄이 아니라 assertion이 필요하다.
  • Cache된 output을 바꾸는 입력은 전부 cache key에 들어가야 한다.
  • Evaluation 인프라는 빈 output을 “나쁜 답"이 아니라 error로 취급해야 한다.

조용한 실패의 분류

아래 표의 첫 다섯 행은 이 프로젝트에서 실제로 일어난 일이다. 마지막 행만 사고가 아니라 코드를 읽다가 찾았다.

실패 유형 겉으로 보인 모습 가장 먼저 넣을 guard
실패가 결정으로 둔갑 LLM error를 “통화 끝"으로 읽음 → 말없이 끊김 별도 error 값 + 음성 안내 fallback
Default가 누락을 가림 JSON key 이름이 바뀜 → location_type이 늘 "none" Schema-constrained decoding, contract test
Truncation 부서 목록이 context 초과, Whisper prompt가 budget 초과 호출 전에 token 세기, overflow는 시끄럽게
성능을 깎는 fallback TensorRT → CPU, ~100 ms → 2.7 s Provider assert, 배포 후 latency 체크
Eval 인프라가 error를 채점 GPT-5 답변 407개 중 399개가 빈 문자열 빈 답 = error, 모델별 실패 개수 세기
낡은 cache 목소리 튜닝이 cache 안 된 문장에만 적용 Output을 바꾸는 모든 입력을 key에

모든 행에서 시스템은 그럴듯한 output을 계속 내놓았다. 말없이 끊긴 통화는 caller가 인사하고 끊은 통화처럼 보이고, default "none"은 caller가 장소를 아예 말하지 않은 것처럼 보인다.

“실패"가 곧 “끊기"일 때

질문에 답하고 나면 agent는 더 궁금한 게 있는지 묻는다. Caller는 “아, 네…“처럼 짧고 애매하게 답하는 경우가 많다. 작별 인사일 수도 있고, 두 번째 질문을 꺼내기 전의 뜸일 수도 있다. 이걸 작은 LLM 호출 하나가 판정했다. 다음에 처리할 내용을 돌려주거나, “caller가 할 말을 다 했다"는 뜻으로 None을 돌려주거나.

문제는 LLM 호출 자체가 exception을 던졌을 때도 이 판정기가 None을 돌려줬다는 거다. Executor는 None을 종료(termination) node로 보냈고, 이 node는 아무 말 없이 통화를 끝낸다. 결국 timeout 하나, 깨진 response 하나가 곧 침묵, 그리고 뚜— 소리였다. Log에는 warning 한 줄과 평범한 작별처럼 보이는 종료 기록만 남았다(caller 입장에서는 상담원이 대답 도중 말없이 수화기를 내려놓은 셈이다). 수정(4월 9일)은 작았다. “판정기가 실패했다"를 뜻하는 sentinel을 따로 두고, 끊기 전에 “잠시 후 다시 시도해 주세요” 같은 짧은 안내를 말하게 하고, log level을 traceback과 함께 ERROR로 올렸다.

모양을 한 번 알아보고 나니 같은 패턴이 세 군데 더 보였다. 몇 주 전에 패턴인 줄도 모르고 패치해 둔 곳이 하나, 그날 오후에 고친 곳이 하나, 일주일 뒤에 고친 곳이 하나. 매번 불확실성이 그 자리에서 고를 수 있는 가장 파괴적인 행동에 묶여 있었다:

불확실한 상황 기존 행동 바꾼 행동
Classifier가 요청을 분류하지 못함 (그냥 transcript가 뭉개졌을 수도) “도와드릴 수 없는 내용입니다” 한 번 되묻고 다시 분류
허용된 재질문을 다 쓰고도 위치가 없음 “처리할 수 없습니다"로 종료, 모은 정보는 버림 모은 만큼이라도 담당 부서로 전달
다른 얘기로 샜다가 caller가 이전 질문에 답함 (“편의점 옆이요”) 새 질의로 처리, 짧은 “네"만 이전 flow를 재개 Caller가 거절하지 않는 한 재개

QA harness만은 처음부터 이걸 제대로 하고 있었다. Semantic judge는 자기 자신의 요청이 실패하면 “NO"로 처리해서 그 행을 실패로 만든다. 실패는 늘 시스템에 불리하게 집계되고, 유리하게 집계되는 일은 없다.

불확실할 땐 가장 덜 파괴적인 행동을 골라라. 한 번 되묻는 데는 몇 초가 들고, 잘못 끊으면 통화 하나를 통째로 잃는다. 코드가 “사용자가 결정했다"와 “뭔가 망가졌다"를 구분하지 못하면, 언젠가는 싼 이유로 비싼 행동을 하게 된다.

모델 교체는 조용한 계약을 깬다

3월 24일, LLM을 Qwen3-4B에서 Qwen3.5-35B-A3B MoE로 바꿨다. 그날 오후의 시끄러운 부분은 거의 코미디였다. 31분 동안 Hugging Face model ID를 세 번 틀렸다. 존재하지 않는 크기, 이전 세대의 실존 모델, 그리고 이번 release에서는 쓰지도 않는 -Instruct 접미사. 속도를 내던 중이었고, diff 대부분은 AI coding assistant가 쓰고 있었고, 나도 assistant도 hub를 먼저 확인하지 않았다(검색 한 번이면 끝날 일이었다). 그다음엔 vLLM이 load 중에 torch.compile error를 냈다(3부에서 다룬 그 에러다). Workaround를 commit했다가 4분 만에 revert하고, 대신 서버에서 vLLM을 손으로 고쳤다. Version control 밖에서, 그것도 torch가 바뀔 때마다 vLLM을 새로 설치하는 deploy 위에서. 도화선 달린 수정이다. 언제 터질지는 다음 torch 업데이트가 정한다.

그래도 여기까지는 착한 실패였다. 모델이 안 뜨니까 안 뜬다는 걸 알았다. 조용한 쪽은 그날 밤에 왔다. 바로 전에 LLM 호출 여러 개를 classifier prompt 하나로 합쳤는데, 이 prompt는 location_type과 새로 넣은 flow_status field를 담은 JSON을 돌려주게 되어 있었다. JSON은 prompt로만 요청했고, schema-constrained decoding은 쓰지 않았다. 모델은 locationtype, flowstatus를 돌려주기 시작했고, parser는 최악의 방식으로 관대했다: result.get("location_type", "none"). 그래서 location_type은 늘 "none"이었고, flow_status는 한 번도 감지되지 않았고, 어디서도 error가 나지 않았다. 겉으로는 classifier가 갑자기 장소와 대화 종료를 못 알아듣게 된 것처럼만 보였다.

내 수정(3월 25일)은 모든 key에서 underscore를 지우는 거였다. 패치지 guard가 아니다. 더 민망한 건, 이 글을 쓰려고 코드를 다시 보다가 같은 codebase의 다른 prompt가 그 field를 애초에 locationtype으로 적고 있었다는 걸 발견한 거다. 그러니 drift의 원인이 새 모델인지, 합친 prompt인지, 들쭉날쭉한 철자인지 지금도 모른다. 열 시간 남짓한 간격을 두고 두 가지가 바뀌었고, 세 번째는 처음부터 틀려 있었고, 무엇이 계약을 깼는지 알려줄 장치는 하나도 없었다.

모델을 바꿨으면 structured-output contract test부터 돌려라. 더 좋은 건 schema-constrained decoding이다. vLLM은 generation을 JSON schema에 맞게 제약할 수 있다. 필수 key가 빠졌으면 조용히 받아들이는 default가 아니라 개수를 세는 parse error여야 한다. Prompt로만 JSON을 요청한 것, 다시 한다면 제일 먼저 바꿀 부분이다.

보이지 않는 Truncation

부서 목록. 문서로 답할 수 없는 질문이면 agent는 LLM에게 고객처의 부서 목록에서 담당 부서를 고르게 했다. 가장 큰 고객처는 부서 이름만 수백 개였고, 이걸 prompt 하나에 넣으니 4,096-token 한도를 넘었다. Lookup 함수는 error를 잡아서 None을 돌려줬는데, 하필 이 None은 “맞는 부서 없음"이라는 뜻이기도 했다. 그래서 caller는 “죄송하지만 찾으시는 내용을 확인하지 못했습니다” 같은 지극히 무난한 문장을 들었다. 듣는 사람 누구도 이상하다고 느끼지 않을 문장이다. 결국 이걸 잡아낸 건 log 깊숙이 묻힌 warning이 아니라 QA 실패였다.

수정(3월 25일)은 keyword overlap으로 부서를 미리 걸러 상위 60개 후보만 넣는 거였고, 같은 날 80개로 늘렸다. 다시 읽어보니 이 수정 자체가 또 하나의 조용한 truncation이었다. 겹치는 keyword가 하나도 없으면 모든 후보가 0점이 되고, “상위” 후보는 그냥 가나다순으로 앞에 오는 이름들이 된다. Cap이 뭔가를 잘라낼 때마다 log를 남기고, “확신할 만한 후보 없음"이라는 경로를 따로 뒀어야 했다.

Whisper prompt. 3월 31일, 이전 시리즈에서 fine-tuning한 Whisper에 사이트별 keyword prompt를 붙였다. faster-whisper의 initial_prompt로 넣었고, 공통 용어를 앞에, 사이트 용어를 뒤에 뒀다. 가장 긴 사이트 목록은 124단어, 앞에 붙는 공통 용어까지 합치면 137단어였다. Whisper decoder의 context는 448 token인데 max_new_tokens를 225로 고정해 뒀으니 prompt에 남는 자리는 223 token뿐이었다. 나중에 코드에 박아 넣은 경험칙(한국어 단어당 약 1.8 token)으로 계산하면 가장 긴 prompt는 약 250 token이다. faster-whisper는 그런 요청을 거부한다.

열흘 뒤(4월 10일) 이 산수를 코드로 옮겼다. Prompt token 수를 먼저 세고, output 상한을 min(225, max(50, 448 - prompt - 1))로 잡는다. 이 글을 준비하면서 남은 위험을 하나 더 찾았다. 내가 faster-whisper 코드를 읽은 바로는, prompt는 마지막 ~223 token만 남기고 나머지는 조용히 버린다. 꼭 살아남아야 하는 공통 용어가 앞에 있으니, 사이트 목록이 길어질수록 정작 모든 사이트에 필요한 용어부터 밀려난다.

호출 전에 token을 세라. 모든 context window는 truncation 지점이다. 무엇을 자를지 내 코드가 정하지 않으면 라이브러리가 정하고, 라이브러리는 정했다고 말해주지 않는다.

Fallback에는 Assertion이 필요하다

3부에서 5월 15일에 TTS를 ONNX Runtime의 TensorRT execution provider로 옮겨 합성 시간을 약 100 ms까지 줄인 얘기를 했다. Provider chain은 TensorRT → CUDA → CPU였고, 한 칸 내려갈 때마다 성능이 떨어진다. 나는 이걸 알고 있었고, 내 대응은 서비스 문서에 한 줄 적는 게 전부였다. TensorRT load에 실패하면 ONNX Runtime이 경고 없이 fallback하니 startup log를 확인할 것.

Failure mode를 문서로 남겨 놓고, 막는 장치는 아무것도 안 만든 거다. 2주쯤 뒤, 평범한 배포의 dependency 설치 단계에서 CPU용 onnxruntime wheel이 GPU build 위에 다시 깔렸다. 합성 시간이 ~100 ms에서 2.7 s로, 대략 27배 느려졌는데 요청은 전부 성공했다. 원인은 팀원 한 명이 추적해 냈다. 정리 과정에도 함정이 있었다. CPU wheel과 GPU wheel은 같은 package directory를 공유해서, CPU wheel만 지워도 GPU 설치까지 반쯤 지워진다. 정리 script를 idempotent하게 고치기 전까지 약 2분간 crash loop가 돌았다.

원인 추적과 수정은 전부 팀의 작업이고 내 것이 아니다. 내 교훈은 내가 써 둔 그 문장에 관한 것이다. 문서에 적은 경고는 guard가 아니다. 첫날부터 넣었어야 할 것들:

  • Startup에서 assert하라. 각 ONNX session에 실제로 어떤 provider가 붙었는지 물어보고(get_providers()), TensorRT가 첫 번째가 아니면 시작을 거부하거나 health check를 실패시켜라.
  • 배포할 때마다 latency를 확인하라. 열 문장짜리 합성 smoke test를 저장해 둔 baseline과 비교하면 27배 regression은 몇 초 만에 잡힌다.
  • 느린 것보다 시끄러운 게 낫다. Crash loop는 알아서 존재를 알린다. 성공률 100%짜리 27배 slowdown은 그러지 않는다.

Eval Pipeline도 조용히 실패한다

4월 말에 benchmark를 돌렸다. 여러 모델이 같은 test turn 407개를 재생(replay)하고, 나중에 LLM judge가 채점하는 구조다. GPT-5의 첫 run은 407개 중 399개가 빈 문자열이었고, 이게 [ERROR] 표시도 없이 평범한 답변으로 저장됐다. Runner의 content or ""가 “API가 content를 안 돌려줬다"를 “모델이 아무 말도 안 했다"로 바꿔버린 거다. 대화를 turn 단위로 재생하니까 빈 답변은 다음 turn의 history에도 그대로 들어갔다. 그대로 채점했다면 GPT-5는 leaderboard 꼴찌처럼 보였을 거다(GPT-5 입장에서는 꽤 억울한 일이다).

재실행에서는 답변 405개와 명시적인 error 2개가 나왔다. 재실행에 쓴 runner는 reasoning 모델에 대해 temperature와 output-token 상한을 아예 빼고, judge에는 16,384-token completion budget을 준다. 내 추측은 보이는 텍스트가 나오기도 전에 reasoning token이 output budget을 다 먹었다는 건데, finish_reason을 log에 남기지 않았으니 증명할 방법이 없다.

나머지 위생 장치는 제 몫을 했다. Judge 실패는 가짜 낮은 점수로 평균에 섞지 않고 제외한다. Checkpoint와 resume이 있으니 “끝난 것만 그냥 쓰자"는 유혹이 생기지 않는다. Test data를 JSON cache로 고정해 두니 run끼리 비교가 된다. 하나 더한다면 모든 평균 옆에 제외된 개수를 같이 적겠다. 가장 어려운 행들이 채점에 실패한 모델은 실제보다 좋아 보일 수 있기 때문이다.

Leaderboard를 읽기 전에 모델별로 빈 답부터 세라. 모델마다 빈 문자열, [ERROR] 행, judge 실패 개수를 센다. 하나라도 0이 아니면 그 leaderboard는 아직 초안이다.

Cache는 예전 설정을 기억한다

이건 사고가 아니라 코드를 읽다가 찾은 것이다. TTS 서비스는 합성한 audio를 SQLite에 cache하는데, key는 텍스트의 SHA-256, 그게 전부다. 같은 인사말을 하루 종일 반복하는 agent에게는 큰 이득이다. 문제는 이 key가 audio를 바꾸는 나머지 모든 것을 무시한다는 거다. 3월 16일에 기본 voice(F1 → F2), 속도(1.05 → 1.15), 합성 step 수(5 → 10)를 바꿨고, 5월 15일에는 Supertonic 2에서 Supertonic 3으로 옮겼다. 이 변경들은 전부 아직 cache되지 않은 문장에만 적용됐다.

Eviction은 가장 덜 쓰인 항목부터 지운다. 그러니 caller가 제일 자주 듣는 문장이 정확히 영영 갱신되지 않는 문장이 되고, 튜닝한 목소리와 예전 목소리가 한 통화 안에서 번갈아 나올 수도 있었다. 게다가 WAV header는 읽는 시점에 현재 모델의 sample rate로 쓰여서, sample rate가 바뀌었다면 cache된 클립 전부가 조용히 엉뚱한 음높이로 재생됐을 거다. 둘 중 어느 쪽도 문제로 보고된 기록은 없고, 누가 손으로 cache 파일을 지웠는지도 모른다. 모른다는 것 자체가 문제다.

(목소리를 튜닝한 그날, 이 버그의 사촌뻘인 버그도 하나 고쳤다. Cache의 database 경로가 상대 경로라서, process를 다른 디렉터리에서 띄우면 조용히 다른 cache를 쓰게 됐다.)

지금 다시 한다면 model, voice, speed, steps, sample rate, normalizer 버전을 key에 넣거나, cache 자체에 버전을 붙이고 audio에 영향을 주는 변경마다 그 버전을 올리겠다. Cache key는 “이 두 output은 서로 바꿔 써도 된다"는 주장이다. 그 주장이 참인지 확인하라.

요약

  1. 실패에는 자기만의 값을 줘라. None, 빈 문자열, default 값이 결정을 겸하면 안 된다. 불확실성은 가장 덜 파괴적인 행동에 연결하고, evaluation에서는 실패가 시스템에 불리하게 집계되게 하라.
  2. 가정한 것은 assert하라. Provider chain, JSON contract, context budget은 모두 소리 없이 무너진다. Startup에서 실제 provider를 확인하고, structured output은 제약하고, 라이브러리가 대신 자르기 전에 token을 세라.
  3. 저장되는 모든 것에 버전을 붙여라. Cache key는 output을 바꾸는 모든 입력을 담아야 하고, benchmark의 행은 “답이 없음"과 “나쁜 답"을 구분해야 한다.

다음 글 예고

이 글에 나온 수정은 대부분 작은 패치였다. 여기 sentinel 하나, 저기 prefilter 하나, normalization 단계 하나 더. 5부에서는 이런 패치가 dialog system이 진화하는 주된 방식이 되면서, 실패한 QA 행 하나하나가 규칙 하나로 굳어 간 과정을 다룬다.