출시 10일 전, 우리의 음성 에이전트가 계속 사용자의 말을 끊었습니다: 발화 종료 감지(end-of-turn detection) 전쟁 이야기
요약
음성 에이전트 개발 중 발생하는 발화 종료 감지(end-of-turn detection) 문제를 다룹니다. 단순 침묵 타임아웃 방식의 한계를 지적하며, 침묵 지속 시간, 문법적 완성도, 백채널 여부를 결합한 다각적 신호 분석의 필요성을 설명합니다.
핵심 포인트
- 단일 침묵 타임아웃 방식은 사용자의 말을 끊거나 반응을 늦추는 트레이드오프 발생
- 발화 종료 감지를 위해 침묵 시간, 문법적 완성도, 백채널 신호의 가중치 결합 필요
- 실제 통화 환경의 불규칙한 휴지기와 백채널(uh-huh 등) 처리가 핵심 과제
요약(TL;DR). 우리의 전화 음성 에이전트가 계속 사람들의 말을 가로챘습니다. 우리는 발화 종료 감지(end-of-turn detection)를 단일 침묵 타임아웃(silence timeout)으로 구현하여 배포했습니다. 즉, 통화자가 700밀리초(milliseconds) 동안 조용해지면, 에이전트는 사용자가 말을 마쳤다고 판단하고 말을 시작했습니다. 이로 인해 문장 중간에 사람들의 말을 끊게 되었습니다. 중단을 막기 위해 타임아웃을 늘렸더니, 이번에는 에이전트가 아무 말 없이 침묵 속에 멈춰버리는 현상이 발생했습니다. 이를 해결한 재구조화(reframe) 방식은 단일한 고정 침묵 타임아웃에만 의존하는 것을 멈추고, 세 가지 신호를 함께 가중치를 두어 고려하기 시작한 것이었습니다. 세 가지 신호가 동시에 필요합니다. 침묵이 얼마나 지속되었는지, 전사(transcript)가 문법적으로 완성된 것처럼 보이는지, 그리고 마지막 소리가 실제 발화(turn)였는지 아니면 단순히 "어허(uh-huh)"와 같은 백채널(backchannel)이었는지 여부입니다. 여기 그 이야기와, 버그를 보여준 전사 내용, 그리고 우리가 배포했던 엔드포인팅(endpointing) 루프가 있습니다.
Day 0: 성공적이었던 데모
우리 음성 에이전트의 첫 번째 데모는 깔끔했습니다. 제가 번호로 전화를 걸어 주문 확인을 요청하자, 에이전트는 사람처럼 대답했습니다. 공동 창업자가 주차장에서 전화를 걸었을 때도 그의 억양을 잘 처리했습니다. 우리는 40초짜리 클립을 녹화하여 투자자 업데이트에 포함했고, 저는 어려운 고비는 다 넘겼다고 생각하며 집으로 돌아갔습니다.
어려운 고비는 지나가지 않았습니다. 진짜 어려운 점은, 데모 상황이란 조용한 방에서 한 명의 주의 깊은 사람이 완전한 문장으로 말하는 상황이라는 것입니다. 실제 통화자들은 생각하는 중간에 멈춥니다. "그러니까, 문제는..."라고 말한 뒤 주문 번호를 기억해 내는 동안 잠시 침묵합니다. 카드 번호를 읽을 때는 숫자 그룹 사이에 간격을 둡니다. 또한 당신이 말하고 있을 때 "어허"라고 말하기도 하는데, 이는 말을 끊으려는 것이 아니라 인간이 여전히 듣고 있다는 신호를 보내는 방식이기 때문입니다.
우리의 에이전트는 이러한 모든 일시 정지를 발화의 종료(end of a turn)로 취급했습니다.
Day 3: 설정, 그리고 모든 것을 결정짓던 단 하나의 숫자
우리가 보유했던 시스템은 다음과 같았습니다. 오디오는 전화망을 통해 WebRTC 브리지(bridge)를 거쳐 스트리밍 음성 인식(speech-to-text) 서비스로 들어왔으며, 이 서비스는 수백 밀리초(ms)마다 부분 전사(partial transcripts)를 내보냈습니다. 오디오 위에서 우리는 Silero VAD(github.com/snakers4/silero-vad)라는 오픈 소스 음성 활동 감지(voice activity detector, VAD)를 실행했습니다. 이는 짧은 오디오 프레임당 음성 확률을 제공합니다. 음성 확률이 임계값(threshold) 아래로 떨어지고 그 상태가 충분히 오래 유지되면, 우리는 이를 사용자의 발화 종료(end of user's turn)로 간주하고, 최종 전사 내용을 언어 모델(language model)로 보내 답변을 말하기 시작했습니다.
"충분히 오래"라는 기준은 설정 파일(config file)에 있는 하나의 상수였습니다. 700밀리초(ms)였습니다. 저는 누구나 처음 할 때처럼, 즉 그냥 임의로 정하는 방식으로 이 값을 선택했습니다. 데모에서는 적당해 보였습니다. 이 단 하나의 숫자가 모든 통화의 모든 발화마다, 우리가 발화자를 기다릴지 아니면 그들의 말을 끊고 말할지를 결정했습니다. 당시의 저는 그 사실을 제대로 인지하지 못했습니다.
엔드포인팅(Endpointing)은 이 문제에 붙여진 멋없지만 정확한 명칭입니다. 즉, 사람이 말을 마친 정확한 순간과 당신이 응답할 차례가 된 순간을 결정하는 것입니다. 이 값을 너무 짧게 잡으면 말을 끊게 됩니다. 너무 길게 잡으면 반응이 느리게 느껴지거나, 더 나쁘면 아예 응답하지 않게 됩니다. 고정된 타임아웃(timeout) 값 중 정답인 값은 존재하지 않으며, 저는 왜 그런지를 이해하는 데 꽤나 부끄러울 정도로 오랜 시간이 걸렸습니다.
1주 차: "자꾸 제 말을 끊어요"
우리는 에이전트를 약 30명 정도의 소규모 베타 그룹(대부분 우호적인 분들)에게 공개했습니다. 피드백은 빠르고 일관되게 돌아왔습니다. "제 말을 가로채요." "말을 끊어버려요." "주문 번호를 세 번이나 말해야 했어요." 매우 인내심이 강했던 한 테스터는 에이전트가 경청하는 대신 그저 자신의 차례가 오기만을 기다리는 사람처럼 느껴진다고 말했습니다.
마지막 피드백은 제 머릿속을 떠나지 않았습니다. 왜냐하면 그것은 말 그대로 사실이었기 때문입니다. 에이전트는 공백을, 어떤 공백이든 기다리고 있다가 그것을 낚아채고 있었습니다.
저는 여러분이 하는 방식을 그대로 따랐습니다. 아직 무엇도 낮추거나 높이지 않은 상태였습니다. 저는 데이터를 가져왔습니다. 스테이징(staging) 환경에 통화 녹음 파일과 정렬된 전사(transcripts) 데이터가 있었기에, 312개의 통화를 추출하여 발화 경계(turn boundaries)를 읽기 시작했습니다. 전체 통화를 듣지는 않았습니다. 그렇게 하면 일주일은 걸렸을 테니까요. 대신 에이전트가 말을 하기로 결정한 모든 지점 주변의 전사 내용을 읽으며, 발화자(caller)가 실제로 말을 마쳤는지 여부를 태깅(tagging)했습니다.
버그를 보여준 전사 데이터
패턴은 지저분하면서도 일관적이었습니다. 여기 약간의 익명화 처리를 거친 실제 사례가 있습니다. 타임스탬프는 발화자의 발화 시작 후 초 단위로 표시되었습니다:
0.00 user (partial): "yeah i want to return the"
0.61 <silence 610 ms>
0.70 ENDPOINT FIRED
...
발화자는 "the" 다음에 숨을 들이마셨습니다. 610밀리초(ms) 후, 우리의 임계값(threshold)이 작동했고, 에이전트가 끼어들었습니다. 그 결과 발화자의 실제 목적어("the blue one")는 에이전트의 답변 위에 덮여버려 유실되었습니다. 에이전트가 이미 다른 이야기를 하고 있는 동안, 음성-텍스트 변환(speech-to-text) 엔진은 "the blue one not the black one"이라는 내용을 허공에 계속 전사하고 있었습니다.
저는 이것들을 집계했습니다. 312개의 통화 전체를 통틀어, 사용자 발화의 약 18%가 이와 같은 절단(truncation) 현상을 보였습니다. 즉, 마지막 전사 내용이 잘려 나가서 발화자가 말을 반복하거나, 에이전트가 문장의 잘못된 절반에 대해 답변하는 상황이었습니다. 18%였습니다. 거의 다섯 번의 발화 중 한 번은 단 하나의 설정 상수(config constant) 때문에 손상되고 있었습니다.
그리고 이것은 무작위가 아니었습니다. 특정 구간에 집중되었습니다. 문장 중간에 생각하느라 멈추는 발화자들은 끊임없이 피해를 입었습니다. 숫자를 소리 내어 읽는 사람들은 숫자 그룹 사이의 모든 간격에서 끊김 현상을 겪었습니다. 영어를 제2외국어로 사용하여 절(clause) 사이의 휴지(pause)가 조금 더 길었던 한 테스터는 거의 모든 발화에서 방해를 받았습니다. 이는 그 자체로 용납될 수 없는 문제인데, 지연 시간 정책(latency policy)이 사람들의 말하기 방식 때문에 그들에게 불이익을 주어서는 안 되기 때문입니다.
내가 배포했다가 다음 날 아침 바로 롤백한 과잉 수정
이 부분은 제가 자랑스럽게 말할 수 없는 대목입니다.
해결책은 명확해 보였습니다. 타임아웃 (timeout)이 너무 짧으니, 더 길게 만들면 된다는 것이었죠. 목요일 오후, 저는 침묵 임계값 (silence threshold)을 700밀리초 (milliseconds)에서 1500밀리초로 높였고, 몇 번의 테스트 통화가 매끄럽게 진행되는 것을 확인한 뒤 베타 버전에 배포했습니다. 발화 잘림 (truncations) 현상은 즉시 줄어들었습니다. 저는 팀원들에게 말을 끊는 버그를 해결했다고 말했습니다. 하지만 저는 양쪽 방향 모두에서 틀렸습니다.
첫째, 에이전트가 이제 생동감을 잃었습니다. 모든 답변이 사용자가 말을 멈춘 뒤 한 박자 반 뒤에 나왔는데, 이는 실제로 전화 통화를 해보기 전까지는 별것 아닌 것처럼 느껴질 수 있습니다. 대화에는 리듬이 있으며, 매 발화 (turn) 이후에 발생하는 평이한 1.5초의 간격은 "이 기계는 느리다" 또는 "내 말을 들었나?"라는 인상을 줍니다. 대기 시간에 모델 (model)과 음성 합성 (speech synthesis) 과정을 추가하고 나니, 에이전트 측의 첫 응답 시간 중앙값 (median time-to-first-response)은 약 1.8초까지 늘어났습니다. 테스터들은 에이전트가 자신의 말을 들었다는 확신을 갖지 못하게 되었고, 그 결과 간극 사이에 말을 반복하기 시작했습니다. 이는 발화 중첩 (overlapping speech)을 야기했고, 전사 (transcript)를 혼란스럽게 만들었습니다. 저는 말을 끊는 문제(interruptions)를 다른 종류의 실패와 맞바꾼 셈이었습니다.
둘째, 그리고 실제로 저를 겁먹게 했던 문제는 에이전트가 멈춰버리기 (hanging) 시작했다는 점입니다. 그것도 아주 조용하게 말이죠. 어떤 통화에서는 에이전트가 아예 응답하지 않기도 했습니다. 발화자가 말을 마치고, 기다리고, "여보세요?"라고 말하고, 다시 기다리다가 전화를 끊어버리는 소리가 들렸습니다. 전화 통화에서 발생하는 정적 (dead air)은 말을 끊는 것보다 더 나쁩니다. 말을 끊는 것은 적어도 사용자에게 이 시스템이 살아있다는 것을 알려주기 때문입니다.
저는 금요일 아침에 해당 설정을 롤백 (rollback)했고, 왜 타임아웃을 높였는데 에이전트가 응답을 완전히 멈추게 되는지 원인을 찾아 나섰습니다.
침묵의 멈춤 현상, 그 원인
멈춤 현상이 더 흥미로운 버그였기에, 이 부분에 대해 계속 이야기해 보겠습니다.
고정된 침묵 타임아웃 (fixed silence timeout)은 실제로 그만큼의 연속적인 침묵이 쌓였을 때만 작동합니다. 조용한 방에서 깨끗한 헤드셋을 사용한다면 그렇게 됩니다. 하지만 전화 회선에서는 항상 그렇지 않습니다. 전화 오디오에는 배경 소음이 포함되며, Silero VAD는 다른 음성 활동 감지기 (voice activity detector)와 마찬가지로 기침 소리, 문 닫는 소리, 약간의 회선 잡음, 옆방의 TV 소리 등에 의해 가끔씩 음성 확률 (speech probability)이 임계값 위로 깜빡거립니다 (flicker). 이러한 깜빡임 하나하나가 저의 침묵 카운터 (silence counter)를 다시 0으로 리셋시켰던 것입니다.
700밀리초(millisecond)의 여유 시간으로는 가끔씩 발생하는 깜빡임은 크게 중요하지 않았습니다. 곧 700밀리초의 침묵을 모을 수 있었기 때문입니다. 하지만 1500밀리초의 여유 시간을 주자, 그 간격은 두 배 이상 길어졌고, 잡음이 심한 통화에서는 카운터가 1500에 도달하기도 전에 계속 리셋되었습니다. 발화는 끝을 보지 못했습니다. 에이전트는 소음 때문에 끊임없이 방해받는 침묵을 영원히 기다렸습니다. 제가 '더 안전하다'고 생각했던 긴 타임아웃(timeout) 설정은, 이미 가장 어려웠던 통화에서 종료 지점 조건(endpoint condition) 자체가 도달 불가능하게 만들었습니다.
이 교훈은 뼈저리게 다가왔습니다. 단일한 침묵 타임아웃으로는 해결할 수 없는 문제였습니다. 제가 어떤 값을 선택하든, 그것은 두 가지 다른 질문에 답하려는 하나의 숫자에 불과했습니다. 즉, 생각하는 화자(thinking caller)를 위해 언제 기다려야 하는지, 그리고 끝난 문장(finished sentence)에서 언제 개입해야 하는지를 결정하는 것인데, 하나의 숫자가 둘 다 할 수는 없었습니다. 저는 종료 지점의 판단이 단순히 시계(clock) 이상의 것에 의존하도록 만들어야 했습니다.
새벽 3시의 깨달음: 녹취록이 말해주고 있던 것들
정확한 시간은 생략하겠지만, 이 문제를 해결하게 된 아이디어는 제가 직접 작성한 잘린 녹취록(truncation transcripts)을 다시 읽다가, 그동안 지나쳐 왔던 무언가를 발견하면서 얻었습니다.
모든 잘못된 초기 종료 지점(early endpoint)에는 텍스트 상에 단서가 있었습니다.
- 전사(transcript)가 문법적으로 완성된 것처럼 보인다면, 빠르게 종료(endpoint)합니다. 문장이 끝났으므로 짧은 일시 정지만으로도 충분합니다.
- 전사가 미완성된 상태, 즉 매달린 단어(dangling word)로 끝난다면 훨씬 더 오래 기다립니다. 발화자가 생각 중이기 때문입니다.
- 마지막으로 들은 내용이 "네" 또는 "어허"와 같은 백채널(backchannel)이라면, 전혀 종료하지 말고 에이전트가 이를 방해하게 해서도 안 됩니다. 그것은 하나의 발화 차례(turn)가 아닙니다.
- 그리고 항상 엄격한 최대 제한 시간(hard maximum)을 유지하여, 소음이 심한 회선이 영원히 대기 상태로 남지 않도록 합니다.
이는 오픈 소스 발화 감지(turn-detection) 연구들이 수렴하고 있는 것과 동일한 통찰입니다. LiveKit은 전사를 기반으로 사용자가 실제로 말을 마쳤는지 예측하기 위해 작은 학습된 모델(learned model)을 사용하는 발화 감지 플러그인을 제공하며, Pipecat는 오디오로부터 동일한 작업을 수행하는 공개된 "Smart Turn" 모델을 가지고 있습니다. 저는 이 문제를 해결하는 동안 이 두 가지를 모두 읽었습니다. 하지만 대부분의 이점을 얻기 위해 항상 학습된 모델이 필요한 것은 아닙니다. 놀랍게도 성과의 상당 부분은 단순히 매달린 단어에서 종료하는 것을 거부하는 것만으로도 얻을 수 있습니다.
코드에서의 해결책
다음은 종료 루프(endpointing loop)를 중심으로 간략하게 구현한 우리가 출시한 코드의 형태입니다. 이 코드는 한 번에 하나의 VAD 프레임을 실행하고, 침묵을 추적하며, 결정적으로 현재의 부분 전사(partial transcript)가 완성된 것처럼 보이는지에 따라 침묵 예산(silence budget)을 선택합니다. 백채널을 보호하고, 소음이 심한 회선이 영원히 걸려 있지 않도록 엄격한 상한선(hard cap)을 강제합니다.
import torch
# Silero VAD: github.com/snakers4/silero-vad
...
그 안에는 명확히 드러나지 않지만 핵심적인 역할을 하는 몇 가지 요소가 있습니다.
두 가지 예산을 설정하는 것이 핵심입니다. 문장이 끝났을 때는 550밀리초, 미완성 상태일 때는 1,300밀리초를 적용합니다. 완성된 케이스는 빠릿빠릿하게 느껴지는데, 실제로 빠릿하기 때문입니다. 미완성 케이스는 생각 중인 발화자에게 여유를 줍니다. 이 두 숫자 사이의 차이가 단일 수치로는 할 수 없는 역할을 수행합니다.
DANGLING 설정은 조잡한 휴리스틱 (heuristic)이며, 솔직히 말해서 매우 조잡하다는 점을 인정하고 싶습니다. 이것은 단지 단어 목록일 뿐입니다. 문법을 이해하지 못합니다. 하지만 제가 태깅했던 절단 (truncation) 사례의 압도적인 대다수를 잡아냅니다. 왜냐하면 영어 문장은 실제로 몇십 개의 기능어 (function words)에서 멈추는 경향이 있기 때문입니다. 더 나은 성능을 원한다면, 바로 이 지점이 LiveKit이나 Pipecat과 같은 학습된 발화 모델 (learned turn model)로 교체하는 경계선이 됩니다. 이 휴리스틱은 오늘 오후에 바로 출시할 수 있는 80% 수준의 버전입니다.
HARD_CAP_MS는 무음 중단 (silent hang) 현상에서 얻은 흉터와 같습니다. 이는 소음 여부와 상관없이 발화가 항상 종료되도록 보장합니다. 우아한 방식은 아니지만, 발화가 항상 종료됨을 보장하며, 저는 다시는 이것 없이 엔드포인터 (endpointer)를 출시하지 않을 것입니다.
백채널 (Backchannels)과 바지인 (barge-in): 버그의 나머지 절반
사람의 말을 끊는 것은 두 가지 실패 중 하나일 뿐이었습니다. 그 반대되는 개념은 바지인 (barge-in)입니다. 이는 에이전트가 여전히 말하고 있는 동안 호출자가 말을 시작할 때, 에이전트를 멈추고 그 말을 들어야 하는 상황을 의미합니다. 바지인 기능은 반드시 필요합니다. 호출자는 언제든 말을 가로챌 것이며, 사용자의 말을 가로막고 계속 밀어붙이는 에이전트는 매우 화가 나기 때문입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기