VP 모드 AI 응답 지연
— 엔드포인팅 타이머 리셋 원인 분석 (박지완 12회기 · 이유담 11회기) 분석 완료 · 수정 미적용 · STT 이벤트 지연 원인 미확정

마지막 업데이트 2026-09-12

쉬운 설명 한 장 보기 · 사전지식 없이 읽는 요약

작성일: 2026-09-11사건일: 2026-09-08 20:01~20:26 (박지완 12회기), 20:31~20:59 (이유담 11회기)런타임: LiveKit voice_pipeline · Soniox stt-rt-v5 · livekit_vad · 끼어들기 OFF자료: 아동/모니터 세션로그, 녹음 mp3, agent.py · vendored livekit-agents 1.7.1

결론

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초). 막대 폭은 시간에 비례한다.

VAD 무음
0.60s
엔드포인팅 대기 1.90s
(2500−600)
STT final
0.77s
LLM
0.57s
TTS 첫 오디오
0.70s
아동 마지막 음성VAD 통지commitfinal응답 시작재생 4.5s
구간박지완 12회기 (n=47)이유담 11회기 (n=77)근거 이벤트
멈춤 → commit1.90 고정1.90 고정user_turn_pending_starteduser_turn_committed
commit → STT final0.77 (0.5~1.3)대기 중 이미 도착user_stt_segment
final → 응답 시작0.57agent_response_started
응답 → TTS 첫 오디오0.700.4~0.5tts_node_first_audio_frame
마지막 멈춤 → 재생3.97 / p90 4.462.47 / p90 4.15AI_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회).

아동 발화(VAD)
1.9초 대기 타이머
STT · LLM · 재생
final 28자
재생 29.2s
0s51015202530s
VAD speakingpending(1.9s 타이머)commitSTT finalLLM 응답 시작

리셋 간격(멈춤→재진입): 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초) 뒤에야 시작됐다.

아동 발화(VAD)
1.9초 대기 타이머
STT · LLM · TTS · 재생
final 96자
LLM 시작
10자
28자
TTS·재생 28.6s
0s51015202530s
사이드 이펙트(추정 B). commit 전에 생성된 응답 16건 중 10건은 같은 응답 ID가 commit 뒤 그대로 재생됐다. 위 턴은 96자 전사로 +17초에 만든 응답이, 아동이 38자를 더 말한 뒤 +26.8초에 그대로 나갔다. 뒤에 말한 내용이 응답에 반영되지 않은 것으로 추정된다. 나머지 6건은 새 응답 ID로 교체됐다.

두 세션 비교 A

항목박지완 12회기 (20:01~20:26)이유담 11회기 (20:31~20:59)
commit된 턴 / 리셋 있는 턴60 / 32 (리셋 84회)83 / 30 (리셋 44회)
첫 멈춤→재생 중앙값 / p907.9s / 29.3s3.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 / fallback0 / 013 / 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회기
리셋 횟수5282
멈춤→재진입 간격 중앙값0.57s (실제 쉼 ≈ 1.2s)0.55s (실제 쉼 ≈ 1.15s)
간격 분포 <0.5 / 0.5~1.0 / 1.0~1.9s23 / 19 / 1040 / 25 / 17
재진입 후 이어 말한 길이 중앙값2.27s2.40s
재진입이 0.7초 미만 짧은 소리1회2회
재진입 후 2초 이상 이어 말함30 / 5250 / 82
결론. 리셋은 이 아동에게만 많은 현상이 아니다(박지완이 더 많다). 재진입 뒤에는 대부분 2초 이상의 실제 발화와 STT 전사가 이어지고, 잡음·기침 같은 짧은 소리가 타이머를 깨운 경우는 두 세션 합쳐 3회다. 아동은 구절 사이를 약 1.1~1.2초 쉬고 다음 구절을 잇는데 앱은 2.5초 침묵을 "말 끝"으로 본다. 원인은 아동 개인 특성이 아니라, 끊어 말하는 아동 발화 패턴과 2.5초 고정 엔드포인팅의 불일치다.

한계: 비교 기준이 같은 날 두 세션뿐이라 이 두 아동이 평균보다 더 끊어 말하는지는 판단할 수 없다. 재진입 소리의 화자는 여전히 미확인이지만, 재진입 뒤 전사가 붙는 발화가 이어진다는 점은 아동 음성일 가능성을 높인다(B).

개선 방향 — 문턱을 줄이는 것과 판정 방식을 바꾸는 것 B

고정 문턱을 줄이면 (이유담 로그 시뮬레이션)

리셋 52회의 "멈춤→재진입" 간격을 그대로 두고 확정 문턱만 바꿨을 때, 리셋이 "확정"으로 뒤바뀌는 수다. 재진입 뒤 30/52회는 2초 이상 실제 발화가 이어졌으므로 이 쉼은 말 끝이 아니라 구절 사이였다.

확정 문턱(마지막 소리 기준)52회 중 "확정"으로 바뀌는 리셋의미
2.5초 (현재)0응답이 늦음
2.0초1010회는 아동이 이어 말하는 도중 AI가 답함
1.5초42대부분의 구절 사이 쉼에서 AI가 끼어듦
고정 문턱 조정은 지연과 끼어들기를 맞바꾸는 것이다. 침묵 길이만으로는 이 아동의 "구절 사이 쉼(약 1.2초)"과 "말 끝"을 구분할 수 없다. 현재 VP 모드는 그 구분 수단을 꺼 둔 상태다: 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 발화 중 겹친 짧은 소리(맞장구 추정)
주의 1 — 방향이 "빨라지는" 쪽이 아니다. 사용자가 구절 사이를 자주 쉬면 그 길이에 맞춰 대기를 늘려 끼어들기를 줄이는 설계다. 이유담 아동에 대입하면 학습값은 1.2초 근처로 수렴하는데, 설정 min_delay가 그보다 크면(현재 2.5초) 하한 때문에 내려가지 않아 변화가 없고, 0.5초처럼 낮게 잡으면 처음 몇 턴은 짧게 끊다가 점차 1.2초로 늘어난다. "아동이 말을 끝냈는지" 판단하는 기능은 아니다. 응답 속도를 줄이는 수단은 문장 종결 판정 모델(turn_detection_model: "multilingual") 쪽이다.
주의 2 — 앱 자체 타이머와 어긋난다. 앱의 1.9초 commit 타이머(_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:0020:56:2726.3s3final 3건 · LLM +17.0sa10 두식어필
20:32:0420:32:2218.6s4final 2건 · LLM +8.5sa1 우성입풀기
20:33:2220:33:3916.8s1final 2건a1 우성입풀기
20:32:3520:32:4812.9s2final 3건a1 우성입풀기
20:40:3220:40:4412.1s2final 1건 · LLM +10.0sa3 두식대화2 / 자유대화
20:34:3220:34:4411.8s2final 6건 · LLM +1.4sa1 우성입풀기
20:51:0020:51:099.6s2final 2건 · LLM +1.7sa7 카드음식대화
20:40:0020:40:099.5s2final 2건a3 자유대화
20:42:3020:42:387.4s2final 1건 · LLM +7.3sa4 카드동물대화
20:39:4320:39:506.9s1final 1건 · LLM +4.9sa3 자유대화

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/. 흐름 순서다.

#위치역할
1agent.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초"
2agent.py:1459-1477 _endpointing_terminal_delay_secondsmax(2500 − 600, 0) / 1000 = 1.9
3agent.py:3235-32403101-3110 _schedule_terminalizationspeaking→listening에서 _run_terminalization_after_delay(turn_id, 1.9) 예약 → 경과 시 user_turn_committed
4agent.py:2739-2759 _resume_endpointing_turnlistening→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 반영).

미확정 · 기각 · 리뷰 반영

미확정 1 — 박지완 세션에서 STT 이벤트가 commit 전에 하나도 안 온 이유. 59/59턴에서 첫 interim은 commit+0.30~0.66초, final은 commit+0.66초+발화 1초당 17ms(r=0.66)였고 아동이 말하는 동안 도착한 interim은 0건이다. 코드로 배제한 경로: 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)로만 가를 수 있다. 같은 날 이유담 세션은 정상 스트리밍이었다는 점도 기록해 둔다.
미확정 2 — 타이머를 재진입시킨 소리의 화자. 녹음에 에너지는 있으나 아동·진행자·배경 구분은 못 했다. 리셋 턴의 낮은 전사 밀도는 웅얼거림·필러 가능성을 시사하는 정도다.
기각. TTS 공급 지연(스트림 시작 중앙값 566ms), Soniox 429·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) 반영

재사용 판별법

  1. 아동 세션로그에서 LiveKit data receivedeventType만 뽑아 턴 단위 분해표를 만든다: user_turn_pending_starteduser_turn_committeduser_stt_segmentagent_response_startedtts_node_first_audio_frame[Guest] Emitting AI_SPEAKING_START.
  2. 멈춤→commit이 1.90초로 고정이면 기저 지연 구조, 그 앞에 user_turn_endpointing_resumed가 붙어 있으면 리셋 누적. 리셋 횟수와 첫 멈춤→commit(보류) 시간을 턴별로 집계한다.
  3. 보류 구간 안에 user_stt_segment가 있으면 STT는 정상 스트리밍(이유담형), 없고 commit 뒤에 user_stt_event_received가 폭주하면 박지완형 — 이때는 서버 로그 없이 원인을 확정하지 말 것.
  4. 보류 중 agent_response_started가 있으면 commit 뒤 TTS의 agentResponseId와 대조해 선생성 응답이 그대로 나갔는지 확인한다.
  5. user_stt_no_final_*·user_turn_dropped·user_stt_unbound_final_dropped는 별도 집계한다. 없다는 것은 "그 오류의 미관측"이지 지연 부재의 증거가 아니다.

관련 문서 · 코드