마지막 업데이트 2026-09-12
아동이 말을 끝내고 턴이 정상 커밋되고 전사까지 생성됐는데 핑퐁이가 16.7초간 아무 말도 하지 않았다. 원인은 SFU·전송·TTS가 아니라 agent의 턴 판정 캐시다.
세션 로그(guest 11,828줄 + monitor 860줄)에서 guest_voice_event_normalized를 턴 단위로 재구성했다. 발화 37건 → 커밋 32 / drop 5.
| 지표 | 값 |
|---|---|
| 아동 발화 (user_speech_started) | 37건 |
| 커밋 (user_input_window_stopped) | 32건 |
| drop (user_turn_dropped) | 5건 |
| 훅 실행 (on_user_turn_completed_trace 3연속) | 33회 |
| AI 응답 (agent_response_started) | 45건 = 세션 오프닝 13 + 턴 응답 32 |
| 응답 없는 턴 | 5건 / 37 (13.5%) |
정상 턴의 커밋→응답 지연은 예외 없이 0.37~1.08초다. 이 범위를 벗어난 턴만 이상으로 본다.
| 턴 종료 | 발화 | 종료 형태 | 훅 | STT | 활동 | 메커니즘 |
|---|---|---|---|---|---|---|
| 10:31:52.840 | 1.24s | 커밋 | ✗ | ✓ | 주도48_00_체크인 | B — AI 발화 중 커밋 |
| 10:33:13.131 | 0.85s | DROP | ✗ | ✗ | 주도48_01_워밍업 | A — 재발화 조기 drop |
| 10:33:47.681 | 9.90s | DROP | ✗ | ✗ | 주도48_01_워밍업 | A — 재발화 조기 drop |
| 10:35:40.172 | 1.06s | DROP | ✗ | ✗ | 주도48_02_속마음인터뷰 | A — 재발화 조기 drop |
| 10:50:28.838 | 4.55s | 커밋 | ✓ | ✓ | 주도48_12_마지막인사 | C — 캐시 키 오염 |
drop 5건 중 2건은 응답이 정상으로 나왔다. 이벤트 이름이 아니라 pending→종료 소요시간이 가르는 축이다.
| 유형 | pending→종료 | 훅 | STT | 응답 | 건수 |
|---|---|---|---|---|---|
| 만료 후 drop (무해) | 1.90초 (정상 만료) | ✓ | ✓ | ✓ +0.49~0.54s | 2건 (10:31:47, 10:50:06) |
| 재발화 조기 drop | 0.10 / 0.49 / 1.25초 | ✗ | ✗ | ✗ | 3건 |
아래 사슬은 9절에서 실제 DeferredInputGate로 재현해 prod 로그값과 일치를 확인했다. resolve_completion()은 판정 결과를 정규화된 전사 문자열을 키로 캐시한다. 그런데 훅이 볼 때 전사는 항상 비어 있다 — 세션 전체 트레이스가 예외 없이 transcript_length: 0이고, user_stt_segment는 커밋보다 0.7초 늦게 도착한다. 따라서 모든 턴이 키 "" 하나를 공유한다.
# apps/livekit-agent/input_gate.py 176 def resolve_completion(self, transcript: str) -> bool | None: 177 normalized = self._normalize_transcript(transcript) # == "" (빈 전사) 178 turn_ids = self._transcript_turn_ids.get(normalized) if normalized else None # ↑ normalized 비어서 정확 매칭 스킵 183 else: 184 recent_outcome = self._recent_completion_outcomes.pop(normalized, None) # ↑ pop("") → (turn-3, False) 히트 185 if recent_outcome is not None: 186 turn_id, outcome = recent_outcome 187 self.last_resolved_turn_id = turn_id # ← turn-3 으로 고착 188 return outcome # turn-4 는 resolve조차 안 됨 ... 212 if not normalized: 213 return None # 빈 전사 가드가 여기에만 있다 ... 227 self._recent_completion_outcomes[normalized] = (turn_id, outcome) # ↑ 키 "" 에 (turn-3, False) 저장 = 오염
# apps/livekit-agent/agent.py 1583 if resolved_turn_id and self._input_gate.has_newer_turn_after(resolved_turn_id): 1584 return "superseded_by_new_speech" # turn-4 가 turn-3 보다 최신 → True ... 1632 raise StopResponse() # 응답 생성 취소 = 미발화
10:50:06.367 turn-3 (거부 턴, _turn_acceptance=False) 이 input_gate.py:197 경로로 resolve
→ complete_user_turn(turn-3) → outcome=False
→ :227 _recent_completion_outcomes[""] = (turn-3, False) ★ 오염
→ 이 시점엔 has_newer_turn_after(turn-3)=False → 응답 정상 발화 (+0.54s)
10:50:22.366 아동 turn-4 발화 시작 (4.55초) last_observed_turn_id = turn-4
10:50:26.918 user_turn_pending_started turn-4
10:50:28.817 user_turn_committed turn-4 + 훅 진입 (transcript="")
10:50:28.815 :178 정확 매칭 스킵 → :184 pop("") → (turn-3, False) 히트
:187 last_resolved_turn_id = turn-3 ← turn-4 는 판정되지 않음
:188 return False
agent.py:1583 has_newer_turn_after("turn-3") → True
agent.py:1584 block_reason = "superseded_by_new_speech"
agent.py:1632 raise StopResponse() → 미발화
10:50:29.527 user_stt_segment turn-4 (전사는 정상 생성 — 겉보기 정상의 원인)
✗ agent_response_started 없음 — 8.5초간 agent 이벤트 0건
10:50:38.016 아동 재발화 turn-5
10:50:45.537 agent_response_started (pop("") 이 비어 정상 경로 → 응답 정상)
turn-5·turn-6 트레이스도 resolved=turn-3으로 남아 있다 — resolver가 끝까지 전진하지 못했음을 보여주지만, 판정이 none이라 응답은 통과했다.
phase="resolved" 판정이 none이 아닌 건 전체에서 딱 2회이고, 둘 다 user_turn_dropped가 발생한 세션이다. 그리고 그 직후 턴이 두 번 다 응답을 잃었다.
| 시각 | 세션 | resolved | accepted | observed | publish_decision |
|---|---|---|---|---|---|
| 10:31:47.957 | 699c3007 | turn-4 | False | turn-4 | committed_sdk_only |
| 10:50:06.367 | af371202 | turn-3 | False | turn-3 | committed_sdk_only |
| 10:50:28.815 | af371202 | turn-3 | False | turn-4 | dropped_superseded_by_new_speech_sdk_only |
| 세션 | drop-resolve | 바로 다음 턴 | 결과 |
|---|---|---|---|
| 699c3007 (체크인) | 10:31:47.957 | turn-5 (10:31:52) | 훅 자체 미실행 → 무응답 (메커니즘 B) |
| af371202 (자유대화) | 10:50:06.367 | turn-4 (10:50:28) | 캐시 오염 → 무응답 (메커니즘 C) |
나머지 세션(18795a78·e49b60e7 등)에는 해당 턴의 훅 트레이스가 존재하지 않는다 — 메커니즘 A는 훅에 도달조차 못 한다는 뜻이고, 이것이 A와 C를 가르는 로그 지표다.
prod agent 로그는 Loki가 아니라 CloudWatch Logs Insights에 있고, Grafana 데이터소스 프록시로 뚫는다. Loki service_name에는 ppi-livekit-agent가 없다(있는 compose_service="agent"는 livekit-test 호스트다).
POST https://ppim.dubuhealth.in/api/ds/query
{
"queries": [{
"datasource": { "type": "cloudwatch", "uid": "P034F075C744B399F" },
"queryMode": "Logs", "queryLanguage": "CWLI", "region": "default",
"logGroups": [{ "name": "/ppi/livekit/prod/agent" }],
"expression": "fields @timestamp, @message
| filter @logStream like /^agent\/ppi-livekit-agent\//
| filter @message like \"<voice_session_id>\"
| sort @timestamp asc | limit 2000"
}],
"from": "<epoch_ms>", "to": "<epoch_ms>"
}
LiveKit 서버(service_name="ppi-livekit")는 해당 구간 warn/error 0건, 방 이벤트도 10:49:02~10:51:17 사이 공백이라 전송 경로는 무관함이 배제된다. 같은 구간의 not dispatching agent job since no worker is available는 agentName: ppi-agent-local(개발용) 건으로 무관하다.
4·5절의 사슬은 로그에 직접 찍히지 않는다. _recent_completion_outcomes 상태나 "캐시 히트" 자체를 남기는 로그 라인이 없기 때문이다. 실제 DeferredInputGate 클래스로 재현해 prod 로그값과 대조했다.
# 재현 조건 세 가지만 필요하다 1 훅에 전달하는 전사를 "" 로 고정 # transcript_length: 0 2 turn-3 만 발화 시작 시점에 입력 비활성 # _turn_acceptance[turn-3] = False (거부 턴) 3 나머지 턴은 정상 수락 # 결과 turn-1: resolve_completion('') -> None last_resolved=None turn-2: resolve_completion('') -> None last_resolved=None turn-3: resolve_completion('') -> False last_resolved=turn-3 dict 상태: {'': ('turn-3', False)} ← 빈 키 저장 turn-4: resolve_completion('') -> False last_observed_turn_id = turn-4 last_resolved_turn_id = turn-3 ← 고착 has_newer_turn_after(turn-3) = True block_reason = 'superseded_by_new_speech' publish_decision = 'dropped_superseded_by_new_speech_sdk_only' ← 미발화 turn-5: resolve_completion('') -> None # dict 비어 정상 경로
| 턴 | prod 로그 (CloudWatch) | 로컬 재현 |
|---|---|---|
| turn-1·2 | resolved - / none | 일치 |
| turn-3 | resolved turn-3 / accepted false / committed_sdk_only | 일치 |
| turn-4 | resolved turn-3 / observed turn-4 / accepted false / dropped_superseded_by_new_speech_sdk_only | 일치 |
| turn-5 | resolved turn-3 / accepted None / none | 일치 |
거부 턴(_turn_acceptance = False) 하나를 만들면 다음 턴이 응답을 잃는다. 만드는 방법이 두 가지다.
10:49:55.082 agent_response_completed desired=False effective=False # 핑퐁이 발화 끝 10:49:55.613 input_enabled_updated desired=True effective=True # 입력 재개 10:49:55.922 input_enabled_updated desired=False effective=False ★ 0.31초 뒤 다시 OFF 10:49:56.064 user_state_changed (speaking) turn-3 desired=False effective=False ← 아동이 이 창에서 발화 시작 = 거부 턴 10:49:56.422 input_enabled_updated desired=True effective=True # 0.5초 만에 복귀
즉 핑퐁이 말이 끝나자마자 아동이 즉시 겹쳐 말하기 시작하면 조작 없이도 같은 상태가 된다. 이 ON→OFF→ON 플랩은 세션 전체에서 반복적으로 나타나며 desired까지 함께 뒤집히므로 웹 클라이언트의 듣기 의도 재적용 경로다.
// apps/web/lib/voice-agent/livekit-client-session.ts:1177-1180 const inputStateApplier = createLiveKitAgentOnlyInputStateApplier({ // `듣기 OFF` must not mute the SDK LocalAudioTrack: livekit-client // implements LocalTrack.mute() by flipping the source MediaStreamTrack.enabled, // and that same source feeds the processed host relay output.듣기 정책은 setInputEnabled(agent-only, use-ai-session.ts:976)만 호출하고, 에이전트는 turn_guard.set_input_enabled()로 게이트 플래그만 바꾼다(agent.py:2735) — LiveKit 오디오 입력 스트림은 건드리지 않는다. 그래서 듣기 OFF 상태에서도 STT/VAD가 계속 돌아 user_speech_started가 발생한다.
조작 위치: 모니터 사이드바 / 세션 카드의 듣기 ON·OFF 토글 (monitor-control-sidebar.tsx · session-controls.tsx → host-toggle-microphone → use-guest-controls.ts:99). 아동 세션이 AI 활동 스텝(full:ai)에 진입해 있어야 한다.
| # | 조작 | 기대 로그 | 의미 |
|---|---|---|---|
| 1 | 정상 대화 1턴 (아동 말 → 핑퐁이 응답) | user_turn_committed → agent_response_started (+0.4~1.1s) | 기준선 확보 |
| 2 | 핑퐁이 발화가 끝난 뒤 듣기 OFF | input_enabled_updated desired=false effective=false | 게이트 닫힘 |
| 3 | 아동이 말하기 시작 — 이 상태에서 시작하는 것이 핵심 | user_state_changed + effective=false | 거부 턴 확정 |
| 4 | 듣기 ON 복귀 | input_enabled_updated desired=true | 필수 아님(변형 A·B 모두 재현) — 5단계 응답을 보려면 켜두는 편이 편하다 |
| 5 | 아동이 발화 종료 → 1.9초 대기 | user_turn_pending_started → 1.90초 후 user_turn_dropped → agent_response_started | 여기서는 응답이 정상 발화된다. dict "" 오염 완료 |
| 6 | 핑퐁이 발화가 끝난 뒤 아동이 다시 말함 | 커밋·user_stt_segment 정상, agent_response_started 없음 | 미발화 재현 |
| 7 | 아동이 또 말함 | 정상 응답 | 캐시가 1회용이라 여기서 복구 |
6단계에서 에이전트 로그(/ppi/livekit/prod/agent, 8절 참조)의 on_user_turn_completed_trace phase="resolved"가 아래처럼 나오면 성공이다. turn_id ≠ last_observed_turn_id 가 확진 지표다.
{"turn_id": "…:turn-N", "last_observed_turn_id": "…:turn-N+1",
"accepted_turn": false,
"publish_decision": "dropped_superseded_by_new_speech_sdk_only"}
| 함정 | 대응 |
|---|---|
| 3단계에서 듣기 OFF 전에 아동이 이미 말하기 시작 | 그 턴은 accept=True가 되어 거부 턴이 아니다. user_state_changed 시각이 input_enabled_updated(false) 뒤인지 확인 |
| 5단계를 1.9초 못 기다리고 아동이 다시 말함 | pending 조기 drop(메커니즘 A)이 되어 STT조차 생기지 않고 dict 오염이 일어나지 않는다 — 다른 버그로 빠진다 |
| 핑퐁이 발화 중에 5·6단계 진행 | allow_interruptions=false면 훅 자체가 돌지 않아 메커니즘 B로 빠진다. 반드시 agent_response_completed 뒤에 진행 |
| 2단계 OFF를 오래 유지 | 재현에는 무관하다(변형 A 성립). 다만 아동에게 "말해도 반응 없음"이 길어져 관찰이 헷갈린다 |
이 분석을 가능하게 한 진단 로그: PPI-1227 LiveKit agent AI 미발화·기계음 3계층 진단 로그 추가 — 관측 공백을 메운 PR. 이 문서는 그 로그로 실제 원인을 확정한 후속편이다.
입력 정책 판정 구조: 음성 입력 정책 공통화 (agent-input-policy) — PPI-1165 — input_gate가 속한 판정 레이어.
오디오 흐름 구조: Typecast → LiveKit agent → 게스트 → mediasoup → 모니터 오디오 흐름 시각화
유사 증상 배제 사례: LiveKit 방 전환 레이스 — 아동 마이크·AI 오디오 무음 원인 분석 · PPI-1192 iPad LiveKit AI 발화 무음과 setSinkId — 둘 다 "소리가 안 남"이지만 발화 자체는 있었던 건이다. 이 문서는 발화가 생성되지 않은 건이다.