음성봇이 매 턴마다 지연시간을 재는 이유
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
시리즈 · 음성봇 직접 만들기 — 실시간 음성봇의 뼈대를 바닥부터 만든 8편 기록입니다.
- 1. 끼어들기(barge-in) — 재생 중인 TTS를 실시간으로 끊는 법
- 2. "말이 끝났다"는 판단 — VAD 엔드포인터
- 3. 엔진을 코드 수정 없이 바꾸는 인터페이스 설계
- 4. 로컬 LLM 대화 두뇌와 도구 호출 루프
- 5. 매 턴 지연시간을 재는 이유 — 지금 보는 글
- 6. 브라우저 마이크에서 서버까지 — 오디오 프로토콜
- 7. 로컬 Whisper STT 연결하기
- 8. 스텁부터 만든 이유 — 테스트 우선 설계 (완결)
음성봇을 직접 만들면서 제일 먼저 넣은 코드는 대화 로직이 아니라 시계였습니다. 텍스트를 알아듣고, 답을 만들고, 소리로 내보내는 기능은 아직 하나도 제대로 없는 단계에서 각 구간이 몇 밀리초 걸리는지부터 재기로 했습니다. "일단 기능부터 만들고 느리면 그때 재보자"로 미뤄 두면, 막상 반응이 굼떠졌을 때 어느 구간이 범인인지 알아낼 방법이 없기 때문입니다. 이 글은 한 턴을 처리하는 함수 안에 계측을 어떻게 박아 넣었는지에 대한 기록입니다.
한 턴 안에 시계 네 개
턴 하나를 처리하는 함수는 STT(음성 인식) → 두뇌 응답 → TTS(음성 합성) 순서로 흘러갑니다. 이 각 구간의 경계마다 time.perf_counter()로 시작·끝 시각을 찍어 구간별 시간을 따로 계산합니다.
turn_start = time.perf_counter()
stt_start = time.perf_counter()
# ... STT 인식 ...
stt_ms = (time.perf_counter() - stt_start) * 1000.0
brain_start = time.perf_counter()
reply = await self._brain.respond(text, self.session)
brain_ms = (time.perf_counter() - brain_start) * 1000.0
tts_first_chunk_ms = await self._start_playback(reply.text)
total_ms = (time.perf_counter() - turn_start) * 1000.0
stt_ms, brain_ms, tts_first_chunk_ms, total_ms 네 값이 나옵니다. 하나로 뭉뚱그린 "이번 턴 몇 초 걸림"이 아니라 네 구간으로 쪼갠 이유는 단순합니다. 전체가 느려졌을 때 원인이 인식이 느린 건지, 답을 만드는 게 느린 건지, 음성으로 내보내는 게 느린 건지를 로그 한 줄로 바로 구분하고 싶었습니다.
왜 "첫 소리"까지만 따로 재는가
네 값 중 가장 특이한 건 tts_first_chunk_ms입니다. 답변 전체를 음성으로 다 합성하는 데 걸리는 시간이 아니라, 합성 스트림에서 첫 오디오 청크가 나오기까지 걸리는 시간만 잽니다.
이렇게 나눈 이유는 사람이 실제로 체감하는 반응성이 "답변이 다 끝나는 시점"이 아니라 "첫 소리가 나오는 시점"에 달려 있기 때문입니다. 답이 길면 전체 합성은 당연히 오래 걸리지만, 사람은 봇이 말을 시작하는 순간 "반응했다"고 느낍니다. 전체 합성이 끝나길 기다렸다가 재생을 시작하면, 답이 짧아도 체감 지연은 커지고 답이 길면 더 심해집니다. 그래서 "첫 소리까지"를 독립된 지표로 떼어냈습니다.
첫 소리 이후는 백그라운드가 이어받는다
첫 청크만 재고 끝내는 게 아니라, 재는 방식 자체가 구조에 영향을 줬습니다. 첫 청크를 직접 기다려 정확한 지연시간을 잰 다음, 나머지 청크는 별도로 띄운 백그라운드 태스크가 넘겨받아 계속 전송합니다.
if send is not None:
await send(first_chunk)
# 나머지 청크는 백그라운드 태스크가 넘겨받는다
self._playback_task = asyncio.create_task(self._forward_playback(gen))
return first_chunk_ms
이렇게 해야 턴을 처리하는 함수가 답변 전체 재생이 끝나기 전에 빨리 리턴합니다. 함수가 빨리 리턴한다는 건 곧, 사람이 다시 말을 걸었을 때 그 즉시 끼어들기(barge-in)를 받을 수 있다는 뜻입니다. 지난 편에서 다룬 재생 태스크 분리·취소 구조가 바로 이 지점에서 지연시간 계측과 맞물립니다. 계측을 "첫 청크까지"로 끊었기 때문에 구조도 자연스럽게 "첫 청크까지만 기다리고 나머지는 넘긴다"로 갈라진 셈입니다.
할 말이 없는 턴은 재지 않는다
STT 결과가 아예 없는 턴, 즉 사람이 아무 말도 하지 않은 채로 시간이 지나 NO_INPUT_TIMEOUT이 발생한 경우는 이 계측 자체를 건너뜁니다.
if text is None:
logger.info("turn: no transcript, skipping (stt=%.1fms)", stt_ms)
return
여기서 text is None으로 체크하는 게 포인트입니다. 인식 결과가 빈 문자열("")로 나온 경우는 "일단 뭔가 인식은 됐다"는 뜻이라 정상적으로 두뇌 응답까지 진행시켜야 하고, 아예 결과가 없는 경우("텍스트가 없다" = None)만 응답할 대상 자체가 없으니 처리를 멈춥니다. 이 구분을 is None으로 명시해 둔 덕분에, 나중에 빈 응답을 의도적으로 돌려주는 로직이 생겨도 이 스킵 조건과 충돌하지 않습니다.
이렇게 재고 남긴 stt_ms, brain_ms, tts_first_chunk_ms, total_ms는 로그에 한 줄로 남기는 동시에 turn_complete 이벤트에 실어 클라이언트로도 내보냅니다. 콘솔에서만 보고 끝나는 숫자가 아니라 테스트 코드나 화면에서도 그대로 관측할 수 있게 만든 것이 이번에 계측을 넣으며 지킨 원칙이었습니다. 다음 편에서는 이 구조가 아직 풀지 못한 지점, 즉 턴을 처리하는 동안에는 왜 마이크 입력이 밀리는지를 다룰 예정입니다.
음성봇 직접 만들기 시리즈 (전체 6편)
- 1편 — 음성봇 "끼어들기"는 어떻게 처리할까 — 재생 중인 TTS를 실시간으로 끊는 법
- 2편 — 음성봇에게 "말이 끝났다"를 어떻게 알려줄까 — VAD 엔드포인터 설계
- 3편 — STT·TTS 엔진을 코드 한 줄 안 고치고 바꾸는 구조 — 음성봇 인터페이스 설계
- 4편 — 로컬 LLM으로 음성봇 대화 두뇌 만들기 — 도구 호출 루프
- 5편 — 음성봇이 매 턴마다 지연시간을 재는 이유 (현재 글)
- 6편 — 브라우저 마이크에서 서버까지 — 음성봇 오디오 스트리밍 프로토콜 설계
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
댓글
댓글 쓰기