VP 모드 AI 응답 지연
— 엔드포인팅 타이머 리셋 원인 분석 (박지완 12회기 · 이유담 11회기) 분석 완료 · 수정 미적용 · STT 이벤트 지연 원인 미확정
마지막 업데이트 2026-09-12
결론
AI 응답이 늦게 도착한 원인은 두 겹이다. ① 아동이 말을 멈춘 뒤 앱이 1.9초를 무조건 기다렸다가(endpointing 2500ms − VAD 600ms) 턴을 확정하고, 그 뒤에야 STT final → LLM → TTS가 순서대로 붙어 모든 턴에 약 4초의 기저 지연이 생긴다. ② 그 1.9초 대기 중 아동이 다시 소리를 내면 타이머가 처음부터 다시 시작된다. 박지완 세션은 46턴 중 32턴, 이유담 세션은 83턴 중 30턴에서 발생했고, 최대 9회 반복된 턴은 첫 멈춤부터 응답까지 25~36초가 걸렸다.
"STT final 미응답"으로 보였던 현상은 두 세션에서 성격이 다르다. 박지완 세션은 STT 이벤트(interim·final)가 턴 확정(commit) 이후에만 기록됐고(59/59턴), 이 이유는 서버 로그 없이 확정하지 못했다. 이유담 세션은 final이 대기 중에도 정상 도착했고, 대신 stt_no_final_fallback("판독 불가") 8건이 실제로 발생했다. 두 세션 모두 TTS·LLM은 주된 장기 지연 요인이 아니었다.
주장 등급: A세션로그·코드로 직접 확인 / B코드·수치에 근거한 추론 / C가설. 수치는 모두 아동(guest) 세션로그의 데이터채널 수신 시각 기준이다. 문서 끝에 Codex 리뷰(right pane)에서 수정된 표현을 정리했다.
시간순 인과 다이어그램 A
아동 말 멈춤
→ Silero VAD min_silence 600ms → user_state speaking>listening
→ user_turn_pending_started → 1.9초 대기 (min_delay 2500 − VAD 600, agent.py:_endpointing_terminal_delay_seconds)
├─ 대기 중 VAD 재진입(listening>speaking) → user_turn_endpointing_resumed → 타이머 취소, 다시 멈추면 1.9초 처음부터
│ (박지완 32/46턴 · 이유담 30/83턴, 최대 9회 → 첫 멈춤→응답 25~36초)
└─ 무사히 경과 → user_turn_committed (앱 상태 이벤트, SDK commit·STT flush 호출 없음)
→ [관측] user_stt_segment(final) → on_user_turn_completed → agent_response_started (+0.5s, LLM)
→ tts_node_first_audio_frame (+0.7s) → 아동 AI_SPEAKING_START (+0.01s)
= 마지막 멈춤 기준 ~4초 고정, 그 위에 아동이 턴을 못 끝내는 시간이 얹힌다
한 턴의 지연 해부 — 리셋이 없어도 4초 A
박지완 세션 47턴의 구간별 중앙값이다(마지막 멈춤 → AI 음성 재생 = 3.97초, p90 4.46초, 최대 5.36초). 막대 폭은 시간에 비례한다.
0.60s
(2500−600)
0.77s
0.57s
0.70s
| 구간 | 박지완 12회기 (n=47) | 이유담 11회기 (n=77) | 근거 이벤트 |
|---|---|---|---|
| 멈춤 → commit | 1.90 고정 | 1.90 고정 | user_turn_pending_started → user_turn_committed |
| commit → STT final | 0.77 (0.5~1.3) | 대기 중 이미 도착 | user_stt_segment |
| final → 응답 시작 | 0.57 | — | agent_response_started |
| 응답 → TTS 첫 오디오 | 0.70 | 0.4~0.5 | tts_node_first_audio_frame |
| 마지막 멈춤 → 재생 | 3.97 / p90 4.46 | 2.47 / p90 4.15 | AI_SPEAKING_START |
이유담 세션이 1.5초 빠른 이유는 STT final이 대기 중에 이미 와 있어 commit 직후 TTS만 남기 때문이다. 박지완 세션은 commit 뒤에 STT부터 시작한다(아래 "미확정" 절).
타이머 리셋 시각화 — 박지완 20:10:22 턴 (첫 멈춤→응답 29초) A
가로축은 아동이 말을 시작한 20:10:22.08부터 30초. 파란 구간이 VAD가 "말하는 중"으로 본 시간, 빨간 구간이 1.9초 대기 타이머, 초록이 commit이다. 빨간 막대가 1.9초를 채우지 못하고 끊긴 곳이 전부 리셋이다(6회).
리셋 간격(멈춤→재진입): 0.97 · 1.59 · 1.15 · 0.20 · 0.19 · 0.24초. 23초 넘게 "말하는 중"이었는데 최종 전사는 28자(1.2자/초)였다. 리셋 턴 전체의 전사 밀도는 2.7자/초로 무리셋 턴(4.1자/초)보다 낮다. 녹음 mp3의 같은 구간에 −17~−30dB 음향 에너지는 있으나 아동 음성인지는 미확인이다(C).
타이머 리셋 시각화 — 이유담 20:56:00 턴 (첫 멈춤→응답 26.8초) A
이 세션은 STT final이 대기 중에도 도착하고(노란 표식 3개), LLM은 +17초에 이미 응답을 만들었다. 그런데도 TTS·재생은 commit(+26.3초) 뒤에야 시작됐다.
두 세션 비교 A
| 항목 | 박지완 12회기 (20:01~20:26) | 이유담 11회기 (20:31~20:59) |
|---|---|---|
| commit된 턴 / 리셋 있는 턴 | 60 / 32 (리셋 84회) | 83 / 30 (리셋 44회) |
| 첫 멈춤→재생 중앙값 / p90 | 7.9s / 29.3s | 3.8s / — |
| 25초 이상 걸린 턴 | 7턴 (최대 35.6s, 리셋 9회) | 1턴 (26.3s) |
| 대기 중 STT final 도착 | 0/59턴 — commit 뒤에만 | 26/30 리셋 턴에서 도착 |
| 대기 중 LLM 응답 시작 | 없음 | 16/30턴 |
| 대기 중 TTS 합성·재생 시작 | 0턴 | 0턴 |
| STT no-final timeout / fallback | 0 / 0 | 13 / 8 (+ user_turn_dropped 1, unbound_final_dropped 7) |
| TTS 스트림 시작 중앙값 | 566ms (max 1649) | 정상 범위 |
두 세션 모두 TTS와 아동 재생은 commit 전에 한 번도 시작되지 않았다. 리셋이 늘어나면 응답이 그만큼 뒤로 밀리는 구조는 동일하다.
리셋의 성격 — 특정 아동의 문제인가? A
"이 아동만 리셋이 많은가"를 보기 위해 두 세션의 리셋(AI 발화 중 발생분 제외)을 같은 규칙으로 해부했다. 멈춤→재진입 간격은 VAD가 speaking>listening을 통지한 시각부터 다시 listening>speaking이 찍힌 시각까지이므로, 실제 아동의 쉼은 여기에 VAD 무음 0.6초를 더한 값이다.
| 이유담 11회기 | 박지완 12회기 | |
|---|---|---|
| 리셋 횟수 | 52 | 82 |
| 멈춤→재진입 간격 중앙값 | 0.57s (실제 쉼 ≈ 1.2s) | 0.55s (실제 쉼 ≈ 1.15s) |
| 간격 분포 <0.5 / 0.5~1.0 / 1.0~1.9s | 23 / 19 / 10 | 40 / 25 / 17 |
| 재진입 후 이어 말한 길이 중앙값 | 2.27s | 2.40s |
| 재진입이 0.7초 미만 짧은 소리 | 1회 | 2회 |
| 재진입 후 2초 이상 이어 말함 | 30 / 52 | 50 / 82 |
한계: 비교 기준이 같은 날 두 세션뿐이라 이 두 아동이 평균보다 더 끊어 말하는지는 판단할 수 없다. 재진입 소리의 화자는 여전히 미확인이지만, 재진입 뒤 전사가 붙는 발화가 이어진다는 점은 아동 음성일 가능성을 높인다(B).
개선 방향 — 문턱을 줄이는 것과 판정 방식을 바꾸는 것 B
고정 문턱을 줄이면 (이유담 로그 시뮬레이션)
리셋 52회의 "멈춤→재진입" 간격을 그대로 두고 확정 문턱만 바꿨을 때, 리셋이 "확정"으로 뒤바뀌는 수다. 재진입 뒤 30/52회는 2초 이상 실제 발화가 이어졌으므로 이 쉼은 말 끝이 아니라 구절 사이였다.
| 확정 문턱(마지막 소리 기준) | 52회 중 "확정"으로 바뀌는 리셋 | 의미 |
|---|---|---|
| 2.5초 (현재) | 0 | 응답이 늦음 |
| 2.0초 | 10 | 10회는 아동이 이어 말하는 도중 AI가 답함 |
| 1.5초 | 42 | 대부분의 구절 사이 쉼에서 AI가 끼어듦 |
turn_detection_model: "none"(apps/web/lib/voice-agent/livekit-token.ts:226, 문장 종결 판정 EOU 모델 비활성), endpointing.mode: "fixed"(agent.py:277).LiveKit dynamic endpointing은 무엇을 기준으로 판단하나
SDK 기능이다(vendored livekit-agents 1.7.1, voice/turn.py:113 EndpointingOptions.mode: "fixed" | "dynamic", 구현 voice/endpointing.py DynamicEndpointing). 우리 에이전트도 값을 받아 넘긴다(agent.py:298 _resolve_endpointing_mode). 판단 기준은 "이 사용자가 말 중간에 쉬는 시간" 하나이며 문장 내용·의미는 보지 않는다.
| 항목 | 내용 |
|---|---|
| 학습 재료 1 | [발화] [쉼] [발화] — 그 사이 AI가 말하지 않았을 때, 앞 발화 끝(VAD)→다음 발화 시작(VAD) 간격 |
| 학습 재료 2 | [발화] [쉼] [AI 답 시작] → 사용자가 바로 다시 말함 — 끼어들기가 현재 min_delay 안에 일어났을 때만, 그 쉼을 "너무 일찍 답한 근거"로 기록 |
| 계산 | 지수이동평균(alpha 0.9 = 이전 값 90% + 새 값 10%). 초기값·하한 = 설정 min_delay, 상한 = max_delay. 실제 대기 = min(학습값, max_delay) |
| 제외 | AI가 말한 뒤 사용자가 답하기까지의 턴 사이 간격, AI 발화 중 겹친 짧은 소리(맞장구 추정) |
min_delay가 그보다 크면(현재 2.5초) 하한 때문에 내려가지 않아 변화가 없고, 0.5초처럼 낮게 잡으면 처음 몇 턴은 짧게 끊다가 점차 1.2초로 늘어난다. "아동이 말을 끝냈는지" 판단하는 기능은 아니다. 응답 속도를 줄이는 수단은 문장 종결 판정 모델(turn_detection_model: "multilingual") 쪽이다._endpointing_terminal_delay_seconds)는 min_delay_ms 하나만 보고 고정 계산한다. SDK를 dynamic으로 바꿔도 앱 타이머는 그대로라 SDK 턴 판정 시각과 user_turn_committed 시각이 따로 움직인다. dynamic을 쓰려면 앱 타이머도 함께 손봐야 한다.이 절은 제안이며 코드는 변경하지 않았다. EOU 모델은 live STT 전사가 필요하므로 박지완 세션처럼 STT 이벤트가 commit 뒤에만 오는 상황(미확정 1)이 먼저 해소돼야 효과가 있다.
이유담 11회기 — 리셋으로 commit이 보류된 구간 위치 A
첫 멈춤→commit 5초 이상 구간. 30턴 전체 보류 합계 229초.
| 첫 멈춤 | commit | 보류 | 리셋 | 보류 중 final / LLM 선시작 | 활동 / 스텝 |
|---|---|---|---|---|---|
| 20:56:00 | 20:56:27 | 26.3s | 3 | final 3건 · LLM +17.0s | a10 두식어필 |
| 20:32:04 | 20:32:22 | 18.6s | 4 | final 2건 · LLM +8.5s | a1 우성입풀기 |
| 20:33:22 | 20:33:39 | 16.8s | 1 | final 2건 | a1 우성입풀기 |
| 20:32:35 | 20:32:48 | 12.9s | 2 | final 3건 | a1 우성입풀기 |
| 20:40:32 | 20:40:44 | 12.1s | 2 | final 1건 · LLM +10.0s | a3 두식대화2 / 자유대화 |
| 20:34:32 | 20:34:44 | 11.8s | 2 | final 6건 · LLM +1.4s | a1 우성입풀기 |
| 20:51:00 | 20:51:09 | 9.6s | 2 | final 2건 · LLM +1.7s | a7 카드음식대화 |
| 20:40:00 | 20:40:09 | 9.5s | 2 | final 2건 | a3 자유대화 |
| 20:42:30 | 20:42:38 | 7.4s | 2 | final 1건 · LLM +7.3s | a4 카드동물대화 |
| 20:39:43 | 20:39:50 | 6.9s | 1 | final 1건 · LLM +4.9s | a3 자유대화 |
3.2~6.7초 구간 20턴: 20:33:52 · 20:37:49 · 20:39:24 · 20:40:18 · 20:41:11 · 20:43:32 · 20:43:55 · 20:46:38 · 20:49:07 · 20:49:21 · 20:50:01 · 20:50:17 · 20:50:50 · 20:53:05 · 20:53:42 · 20:53:54 · 20:54:16 · 20:57:05 · 20:57:25 · 20:58:32.
STT no-final 이벤트 위치
user_stt_no_final_fallback("판독 불가"로 LLM에 전달) 8건: 20:33:11 · 20:38:17 · 20:39:00 · 20:40:59 · 20:43:05 · 20:43:23 · 20:47:03 · 20:50:39. user_turn_dropped 1건(20:42:56), user_stt_unbound_final_dropped 7건(20:43:53~20:44:25, a4 카드동물대화). a3 자유대화·a4 카드동물대화에 집중됐다. 이 세션에서 "STT final 미응답"은 실제로 있었지만, 응답 지연의 주된 시간은 리셋 보류 구간이 차지한다.
코드 경로 A
모두 apps/livekit-agent/. 흐름 순서다.
| # | 위치 | 역할 |
|---|---|---|
| 1 | agent.py:274-280, vad_config.py:5 | 기본값 endpointing.min_delay_ms=2500, Silero VAD min_silence_duration_ms=600. 주석: "UI 총 응답속도 3초 = silence offset 0.5초 + endpointing 2.5초" |
| 2 | agent.py:1459-1477 _endpointing_terminal_delay_seconds | max(2500 − 600, 0) / 1000 = 1.9 |
| 3 | agent.py:3235-3240 → 3101-3110 _schedule_terminalization | speaking→listening에서 _run_terminalization_after_delay(turn_id, 1.9) 예약 → 경과 시 user_turn_committed |
| 4 | agent.py:2739-2759 _resume_endpointing_turn | listening→speaking 재진입 시 terminalization_tasks.pop(turn_id).cancel() → 다시 멈추면 3번부터 새로 시작. user_turn_endpointing_resumed 발행 |
def _resume_endpointing_turn(turn_id, *, event_at):
"""Continue the same local turn when VAD re-enters before endpointing commits."""
task = terminalization_tasks.pop(turn_id, None)
if task is not None and not task.done():
task.cancel() # 1.9초 카운트가 버려지는 지점
pending_turn_ids.discard(turn_id)
...
_publish({"type": "user_turn_endpointing_resumed", ...})
user_turn_committed는 앱 상태 이벤트다. terminalize_user_turn(agent.py:2103)은 SDK commit_user_turn·STT flush·Soniox 전송을 호출하지 않는다. 따라서 "commit 뒤에 STT가 왔다"는 관측이지 "commit이 STT를 시작시켰다"는 인과가 아니다(Codex 리뷰 P1 반영).
미확정 · 기각 · 리뷰 반영
agent_activity.push_audio의 STT 무음 치환(AI 발화 중 speech handle 활성일 때만이며, commit 뒤 final에 아동 발화 내용이 있으므로 오디오는 STT에 전달됨), audio_recognition transcript gate(adaptive interruption overlap 전용), 앱 _publish 배칭 없음, HalfDuplexInputGuardAgent.stt_node pass-through, DeferredInputGate는 bookkeeping. Soniox 플러그인은 endpoint 전에도 INTERIM/PREFLIGHT를 발행하므로 "endpoint 미발생"만으로는 interim 부재를 설명하지 못한다. 남은 축은 Soniox 수신 → 플러그인 → STT pump/consumer → 세션 콜백 사이 어느 경계이며, 에이전트 서버 로그(agent_event_logger timestamp, InstrumentedSTT observer)로만 가를 수 있다. 같은 날 이유담 세션은 정상 스트리밍이었다는 점도 기록해 둔다.agent_session_error(0건), 네트워크 오류, 20:16:42 "No response from OpenAI within 5 seconds" 경고(AI가 이미 발화 중일 때 진행자 텍스트 전송 후 발생한 클라이언트 오경보, 3초 뒤 다음 응답 재생). 박지완 20:10:17 final 없는 commit 1건은 AI 발화 중 입력 게이트 OFF 턴으로 watchdog 부적격(agent.py:2809) 설계 동작.Codex 리뷰(right pane) 반영
- P1 수용 — "commit→final 0.77초 = STT 대기 비용" 표현 철회. commit은 앱 이벤트이며 인과가 아닌 순서 관측.
- P1 데이터로 반박 — STT 무음 치환 가능성: commit 뒤 final에 아동 발화 내용(1~99자)이 있어 그 구간 오디오는 STT에 전달됐다.
- P2 수용 — 첫 멈춤→응답 25~36초는 아동 재발화 시간 포함(리셋×1.9초 누적 아님) / RMS는 화자 미확정 / "TTS·LLM 무관"은 "주된 장기 지연 요인 아님"으로 완화 / final은
<end>외<fin>·finished에서도 발생.
재사용 판별법
- 아동 세션로그에서
LiveKit data received의eventType만 뽑아 턴 단위 분해표를 만든다:user_turn_pending_started→user_turn_committed→user_stt_segment→agent_response_started→tts_node_first_audio_frame→[Guest] Emitting AI_SPEAKING_START. - 멈춤→commit이 1.90초로 고정이면 기저 지연 구조, 그 앞에
user_turn_endpointing_resumed가 붙어 있으면 리셋 누적. 리셋 횟수와 첫 멈춤→commit(보류) 시간을 턴별로 집계한다. - 보류 구간 안에
user_stt_segment가 있으면 STT는 정상 스트리밍(이유담형), 없고 commit 뒤에user_stt_event_received가 폭주하면 박지완형 — 이때는 서버 로그 없이 원인을 확정하지 말 것. - 보류 중
agent_response_started가 있으면 commit 뒤 TTS의agentResponseId와 대조해 선생성 응답이 그대로 나갔는지 확인한다. user_stt_no_final_*·user_turn_dropped·user_stt_unbound_final_dropped는 별도 집계한다. 없다는 것은 "그 오류의 미관측"이지 지연 부재의 증거가 아니다.
관련 문서 · 코드
- LiveKit 파이프라인 모드 — half-cascade vs voice pipeline — VP 모드의 STT→LLM→TTS 구성과 endpointing 위치.
- AI 미발화 — 빈 전사 캐시 키로 앞 턴 판정 재생 — 같은 input_gate 경로에서 턴이 소멸하는 다른 기전.
- LiveKit 도입 voice-agent 추상화 전체 플로우 — 데이터채널 agent-event-log 이벤트가 어디서 발행되는지.
- apps/livekit-agent/agent.py (274-280 · 1459-1477 · 2739-2759 · 3054-3124 · 3235-3260), apps/livekit-agent/vad_config.py, apps/livekit-agent/vendor/livekit-agents/livekit/agents/voice/audio_recognition.py (1651
_run_eou_detection, 1290_on_stt_event), apps/web/lib/voice-agent/livekit-token.ts:19-20 (VP STT = soniox stt-rt-v5)