의도적으로 보이스 에이전트의 속도를 늦춘 이유
요약
보이스 AI 에이전트 개발 시 지나치게 빠른 응답 속도가 오히려 대화 흐름을 방해하고 사용자의 말을 끊는 문제를 해결한 사례를 다룹니다. 엔드포인팅(Endpointing) 설정과 지연 시간 사이의 상관관계를 분석하고, 자연스러운 대화를 위한 의도적인 지연의 중요성을 설명합니다.
핵심 포인트
- 지나치게 낮은 지연 시간은 조기 발화 종료 오류를 증폭시킴
- 엔드포인팅 설정과 파이프라인 속도는 상호 의존적임
- 자연스러운 대화를 위해 의도적인 '경청의 인내심'이 필요함
- 속도 최적화보다 대화의 맥락과 호흡 유지가 더 중요할 수 있음
제 파이프라인은 사람의 말을 끊을 정도로 충분히 빨랐습니다. 제가 무엇을 변경했는지, 그 대가는 무엇이었는지, 그리고 아무도 눈치채지 못한 채 통화를 먹통으로 만든 두 번째 버그에 대해 설명하겠습니다.
저는 보이스 AI 영업 교육 플랫폼을 운영하고 있습니다. 사용자들은 WebRTC를 통해 실시간으로 AI 구매자를 상대로 콜드 콜 (Cold Call) 연습을 합니다. 기술 스택은 오케스트레이션 (Orchestration)을 위한 Pipecat, 전송을 위한 LiveKit, STT를 위한 Deepgram Flux, TTS를 위한 Cartesia Sonic, 그리고 대화를 위한 OpenRouter 기반의 작고 빠른 모델을 사용합니다.
지난 7월, 저는 자랑스러워할 만한 파이프라인을 가지고 있었습니다. 발화 종료가 확인된 시점부터 첫 오디오 바이트가 돌아오기까지의 p95가 약 880ms였습니다. 하지만 실제 통화를 들어보니, AI 구매자가 사람의 말을 계속 끊고 들어오는 것이었습니다.
자연스러운 발화 경계에서 끊는 것이 아니었습니다. 문장 중간에 끊었습니다. 영업 사원이 _"저희가 하는 방식은..."_이라고 말하고 생각하기 위해 0.5초 정도 멈추면, 구매자가 미처 끝나지 않은 생각을 가로채 대답해 버리는 식이었습니다.
파이프라인은 설계된 대로 정확하게 작동하고 있었습니다. 그것이 문제였습니다.
속도가 상황을 개선하는 것이 아니라 악화시켰다
제 엔드포인팅 (Endpointing) 설정은 구성 범위 중 매우 공격적인 쪽에 고정되어 있었습니다:
| 설정 | 값 |
|---|---|
VOICE_EOT_THRESHOLD | 0.5 |
| ... |
1초의 생각하는 휴지기만으로도 발화 종료를 확정하기에 충분했습니다. 그 후 880ms의 파이프라인이 발화자가 다시 말을 시작하기도 전에 전체 답변을 전달해 버렸습니다.
제가 미처 깨닫지 못했던 부분이 바로 이것입니다. 만약 제 파이프라인이 더 느렸다면, 동일한 조기 엔드포인팅 문제가 덜 눈에 띄었을 것입니다. 답변이 발화자가 대화를 재개한 후에 도착했을 것이고, 바지인 (Barge-in) 기능이 이를 포착하여 발화 흐름이 회복되었을 것입니다. 속도가 빨랐기 때문에 탐지 오류가 대화 중단으로 이어진 것입니다.
지연 시간 (Latency)과 엔드포인팅은 독립적이지 않습니다. 파이프라인에서 줄이는 매 밀리초(ms)는 조기 발화 종료 결정의 비용을 높입니다. 속도는 공짜가 아닙니다. 속도는 엔드포인팅 오류를 증폭시킵니다.
제가 읽은 거의 모든 보이스 지연 시간 관련 게시물은 파이프라인을 최적화해야 할 대상으로, 엔드포인팅을 단순한 설정 세부 사항으로 취급합니다. 제 경험상 그 반대입니다. 파이프라인은 쉬운 부분이었습니다.
제가 변경한 것
저는 7월 15일에 스테이징(staging)과 프로덕션(production) 환경에서 라이브 A/B 테스트를 진행했습니다:
| 설정 (Setting) | 이전 (Before) | 이후 (After) |
|---|---|---|
| Eager end-of-turn (조기 발화 종료) | on (0.35) | off |
| ... |
끼어들기(cut-ins) 현상이 사라졌습니다. 이제 응답은 이전보다 약 0.5초 늦게 전달되지만, 이는 지연(delay)이라기보다 자연스러운 호흡(natural beat)으로 느껴집니다. 에이전트가 느리다는 불만은 아무도 제기하지 않았습니다. 반면, 에이전트가 말을 끊는다는 불만은 수많은 사람들이 제기했었습니다.
그다음 저는 제 문서의 제약 사항(constraint)을 다시 작성했는데, 이것이 실제 핵심 요점(takeaway)입니다:
프로세싱(Processing) p95는 확정된 발화 종료(confirmed end of turn) 시점부터 첫 오디오가 나올 때까지 1초 미만으로 유지되어야 합니다. 경청의 인내심(Listening patience)은 의도적인 것이며, 해당 예산(budget) 외부에 존재합니다.
두 개의 서로 다른 수치에는 두 개의 서로 다른 실패 모드(failure modes)가 있습니다. 이 둘을 혼동하면 이미 괜찮은 부분을 최적화하는 실수를 범하게 됩니다.
백분위수(percentiles)를 포함한 수치들
| 단계 (Stage) | 기여도 (Contribution) |
|---|---|
| Deepgram Flux STT | ~350 ms |
| ... |
이 수치가 실제로 어느 정도 위치에 있는지 솔직하게 말씀드리겠습니다. 2026년의 전형적인 프로덕션 보이스 배포(production voice deployments)는 p50 기준 약 680ms, p95 기준 약 1,180ms 정도입니다. 잘 최적화된 스택(stacks)은 p50 기준 250ms 미만을 달성합니다. 따라서 880ms p95는 견고한 수치이지만 놀라운 수준은 아니며, 저는 중앙값(median)이 아닌 꼬리 부분(tail)을 인용하고 있습니다.
이 점을 언급하는 이유는 많은 지연 시간(latency) 관련 게시물들이 p50을 인용하며 "1초 미만"이라고 부르기 때문입니다. 스택을 비교하고 있다면 반드시 백분위수(percentile)를 요구하십시오. 사용자가 실제로 느끼는 것은 바로 p95입니다. 왜냐하면 꼬리 부분(tail)이야말로 대화가 무너지는 지점이기 때문입니다. 중앙값은 훌륭해 보이지만 p95가 좋지 않다면, 그 에이전트는 데모에서는 괜찮아 보이지만 프로덕션에서는 짜증을 유발하는 에이전트가 됩니다.
진정으로 도움이 되었던 작은 팁 하나는 첫 번째 TTS 요청을 청킹(chunking)하는 것입니다. 저는 전체 문장을 버퍼링(buffering)하는 대신, 4~8개의 토큰이 확보되는 즉시(최대 250ms 대기) 첫 번째 발화 가능한 조각(fragment)을 Cartesia로 보냅니다. 이는 비용이 들지 않으면서도 매 턴마다 체감되는 응답성(perceived responsiveness)을 몇 백 밀리초 정도 확보해 줍니다.
두 번째 버그: 귀가 먹은 채로 계속 말하는 에이전트
Deepgram Flux 웹소켓 (websocket)은 연결 유지 시간이 길며, 통화 도중 갑작스럽게 끊기는 경우가 가끔 발생합니다. 제 로그에 남은 특징적인 오류는 no close frame received or sent였습니다. 드문 일이었지만, Sentry에는 일회성 오류가 아닌 하나의 실패 유형(class of failure)으로 나타났습니다.
제 코드에는 _startup_error 래치 (latch)가 있었습니다. 원래 목적은 합리적이었습니다. 세션 시작 시 연결 프로브 (connect probe)가 실패하면 nova-3로 폴백 (fallback)하는 것이었습니다. 하지만 run_stt는 이 래치를 영구적인 것으로 취급했습니다. 따라서 통화 도중 연결이 끊기고 단 한 번의 재연결 시도마저 실패하면 래치가 설정되었고, 에이전트는 문맥 (context)을 바탕으로 대화를 계속 이어가면서도 남은 통화 시간 동안 귀가 먹은 상태가 되었습니다.
두 가지 잠재적 위험 요소가 상황을 훨씬 더 악화시켰습니다:
-
와치독 (watchdog)이 실패 자체로 인해 고정됨. 만약 사용자의 발화 도중에 연결이 끊기면,
_flux_user_speaking은 계속true상태로 머물게 됩니다. 제 비활성 와치독 (inactivity watchdog)은 어느 한 쪽이라도 말하고 있는 동안에는 체크를 건너뜁니다. 따라서 귀가 먹은 통화는 절대 타임아웃 (timeout)되지 않았습니다. 사용자는 몇 분 전부터 듣기를 멈춘 대상에게 계속 말을 걸고 있고, 시스템은 크레딧과 벤더 비용을 소모하며 영원히 실행될 뿐이었습니다. -
상태가 정상이라고 보고하는 끊긴 연결. 수신 루프 (receive loop) 내의 비연결 오류 (non-connection error)로 인해 전사 (transcription) 기능은 중단되었지만, 소켓 객체 (socket object)는 여전히
open상태를 유지했습니다._is_connected()는true를 반환했습니다. 그 어디에서도 이를 감지하지 못했습니다.
해결책
- 이제 래치(latch)는 첫 번째 성공적인 연결이 이루어지기 전에만 작동합니다. 세션 시작 프로브(session-start probe)와 nova-3 폴백(fallback)은 이전과 동일하게 동작합니다.
- 그 이후에는, 수신 루프(receive-loop)의 중단이나 송신 실패가 발생하면 연결이 끊긴 것으로 표시되며, 소켓 객체가 그렇지 않다고 주장하더라도
_is_connected()는false를 반환합니다. - 청크(chunk)당 실행되는
run_stt경로는 전체 호출에 대해 제한된 백오프(backoff, 0.25s, 0.5s, 1s, 2s, 이후 5s 상한)를 적용하여 재시도하며, 각 시도는 5초로 제한됩니다. 백오프 시간 내에 도착하는 오디오 청크는 폐기되므로, 파이프라인이 이미 끊긴 것으로 알려진 연결 때문에 차단(block)되는 일은 없습니다. - 연결이 끊긴 것으로 표시되면
UserStoppedSpeakingFrame을 강제로 방출하여 와치독(watchdog)의 고정(unpin)을 해제합니다. - 이제
start()는 일시적인 연결 실패를 경고와 함께 삼켜서(swallow)StartFrame이 여전히 전파되도록 합니다. 이전에는 거기서 예외를 발생시켜 모든 다운스트림 프로세서가 시작되지 않은 상태로 남게 되었습니다.
무한 재시도는 의도적인 결정입니다. 어떤 길이의 중단이든 Deepgram에 다시 접속할 수 있는 순간 복구됩니다. 복구되지 않는 중단은 래치가 아니라 비활성 와치독(inactivity watchdog)에 의해 종료됩니다. 래치를 사용하면 통화가 영구적으로 들리지 않는 상태가 될 수 있습니다.
로깅 정책은 재시도 로직만큼 중요합니다: 매 시도마다 경고(warning)를 남기고, 중단당 정확히 한 번의 Sentry 에러를 발생시키며(3회 연속 실패 시 발생), 복구 시에는 총 중단 시간을 포함한 정보(info) 라인을 남깁니다. 이를 통해 일시적인 튀는 현상(blips)에 휩쓸리지 않고 지속적인 중단에 대한 실제 신호를 얻을 수 있습니다.
정상적인 경로에서는 비용이 전혀 발생하지 않는데, 이 모든 과정은 _is_connected()가 이미 false일 때만 실행되기 때문입니다. 11개의 새로운 테스트가 추가되었으며, 690개의 테스트 스위트가 통과되었습니다.
제가 여러분에게 말해주고 싶은 세 가지
- 파이프라인 지연 시간 (pipeline latency)과 별도로 엔드포인팅 (endpointing)을 측정하세요. 이 둘은 서로 다른 수치이며 실패 모드 (failure modes) 또한 다릅니다. 파이프라인을 더 빠르게 만든다고 해서 엔드포인팅 오류가 줄어드는 것이 아니라, 오히려 오류가 더 눈에 잘 띄게 됩니다.
- 스택 내의 모든 장기 유지 웹소켓 (long-lived websocket)이 통화 도중 끊길 것이라고 가정하세요. "죽은 (dead)" 상태를 한 번 빠지면 벗어날 수 없는 상태가 아니라, 벗어날 수 있는 상태로 만드세요. 또한, 와치독 (watchdogs)이 정작 잡아내야 할 바로 그 실패로 인해 비활성화되지 않는지 확인하세요.
- 백분위수 (percentile)를 명시하세요. 매번 말이죠.
이 세 가지에 담긴 직관에 반하는 교훈은 다음과 같습니다. 제 사용자들은 결코 더 빠른 에이전트를 요구하지 않았습니다. 그들은 문장을 끝까지 마칠 수 있게 해주는 에이전트를 요구했습니다.
저는 실시간 음성 시스템인 Pipecat, LiveKit, Deepgram, Cartesia를 구축합니다. 위의 제품은 ConvoSparr입니다. 저는 현재 음성 AI 계약 업무를 맡고 있으므로, 프로덕션 환경의 음성 에이전트에서 지연 시간 (latency), 발화 순서 교대 (turn-taking), 또는 신뢰성 문제로 어려움을 겪고 있다면 연락해 주세요: karthik@convosparr.com
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기