AI 미발화 원인 확정 — 빈 전사 캐시 키가 앞 턴 판정을 재생해 superseded_by_new_speech 오판 분석미수정

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

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

2026-08-24 세션 7c0175be… 70회기 (10:29~10:52 KST) apps/livekit-agent

결론 — 앞 턴의 판정 결과가 다음 턴에 그대로 재생됐다

아동이 말을 끝내고 턴이 정상 커밋되고 전사까지 생성됐는데 핑퐁이가 16.7초간 아무 말도 하지 않았다. 원인은 SFU·전송·TTS가 아니라 agent의 턴 판정 캐시다.

겉으로 보이는 것: user_turn_committed ✓ · on_user_turn_completed_trace ✓ · user_stt_segment ✓ — 전 단계가 정상이라 클라이언트 로그만으로는 정상 턴과 구별되지 않는다.
실제로 일어난 것: resolve_completion()빈 전사("")를 캐시 키로 쓰는 탓에, 직전 거부 턴(turn-3)의 판정 결과를 turn-4의 결과로 재생했다. turn-4가 turn-3보다 최신이므로 has_newer_turn_after("turn-3")가 참이 되어 superseded_by_new_speech로 판정 → StopResponse() → 응답 생성 취소.
발동 조건: 같은 세션에서 user_turn_dropped가 한 번 발생하면(= 거부된 턴이 terminal 처리되면) 캐시 키 ""가 오염되고, 바로 다음 아동 발화가 응답을 잃는다. 이 수업에서 훅 33회 중 조건 성립 2회 / 미발화 2회로 2/2 적중했다.

1. 무엇을 확인했나 — 아동 발화 37턴 전수 검증

세션 로그(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초다. 이 범위를 벗어난 턴만 이상으로 본다.

2. 응답 없는 5턴 — 메커니즘 3종

턴 종료발화종료 형태STT활동메커니즘
10:31:52.8401.24s커밋주도48_00_체크인B — AI 발화 중 커밋
10:33:13.1310.85sDROP주도48_01_워밍업A — 재발화 조기 drop
10:33:47.6819.90sDROP주도48_01_워밍업A — 재발화 조기 drop
10:35:40.1721.06sDROP주도48_02_속마음인터뷰A — 재발화 조기 drop
10:50:28.8384.55s커밋주도48_12_마지막인사C — 캐시 키 오염
A (재발화 조기 drop) — 3건. pending 대기창은 일정하게 1.90초다. 그 안에 아동이 다시 말을 시작하면 대기 중 턴이 폐기되고 폐기된 오디오는 새 턴으로 승계되지 않는다user_stt_segment가 아예 생성되지 않아 내용이 전량 유실된다. pending→drop 소요가 0.10 / 0.49 / 1.25초로 전부 1.90초 미만이다. 10:33:47 건은 9.9초 발화가 통째로 사라졌다.
B (AI 발화 중 커밋) — 1건. 10:31:48에 시작된 AI 발화가 끝나기 전(10:31:52.840) 턴이 커밋됐다. allow_interruptions=false 구간이라 훅이 실행되지 않았고, 4초 뒤 auto-finish로 스텝이 전환되며 세션째로 묻혔다. 뒤이은 10:33:00 응답은 다음 세션(주도48_01)의 오프닝 멘트로, 이 턴에 대한 답이 아니다(voiceSessionId 상이).
C (캐시 키 오염) — 1건. 아래 4·5절에서 코드까지 확정한 건이 이것이다.

3. user_turn_dropped 이벤트 이름만 보면 오진한다

drop 5건 중 2건은 응답이 정상으로 나왔다. 이벤트 이름이 아니라 pending→종료 소요시간이 가르는 축이다.

유형pending→종료STT응답건수
만료 후 drop (무해)1.90초 (정상 만료)✓ +0.49~0.54s2건 (10:31:47, 10:50:06)
재발화 조기 drop0.10 / 0.49 / 1.25초3건
함정: 10:50:06의 turn-3은 user_turn_dropped가 찍혔지만 0.54초 뒤 응답이 정상 발화됐다. 이 지점을 미발화로 지목하면 22초 뒤의 진짜 미발화(turn-4)를 놓친다. 그리고 역설적으로 이 무해한 drop이 다음 턴의 미발화를 만든 원인이다.

4. 근본 원인 — 캐시 키가 전사 텍스트인데 전사가 항상 비어 있다

아래 사슬은 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()                            # 응답 생성 취소 = 미발화
방어선이 둘 다 뚫린다.
  • input_gate.py:150-153 remember_final_transcript()if not normalized: return 이라 빈 전사는 등록되지 않는다 → _transcript_turn_ids가 영구히 비어 정확 매칭 경로를 절대 타지 못한다.
  • input_gate.py:212-213 빈 전사 가드는 가장 깊은 else 안에만 있다 → :184 캐시 히트 경로는 그보다 위에서 이미 return 해버린다.
결과적으로 _recent_completion_outcomes는 "직전 거부 턴의 결과를 다음 턴에 물려주는" 장치로 동작한다.

5. 시간순 인과 사슬

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이라 응답은 통과했다.

6. 발동 빈도 — 수업 전체 훅 33회 중 1회

phase="resolved" 판정이 none이 아닌 건 전체에서 딱 2회이고, 둘 다 user_turn_dropped가 발생한 세션이다. 그리고 그 직후 턴이 두 번 다 응답을 잃었다.

시각세션resolvedacceptedobservedpublish_decision
10:31:47.957699c3007turn-4Falseturn-4committed_sdk_only
10:50:06.367af371202turn-3Falseturn-3committed_sdk_only
10:50:28.815af371202turn-3Falseturn-4dropped_superseded_by_new_speech_sdk_only
세션drop-resolve바로 다음 턴결과
699c3007 (체크인)10:31:47.957turn-5 (10:31:52)훅 자체 미실행 → 무응답 (메커니즘 B)
af371202 (자유대화)10:50:06.367turn-4 (10:50:28)캐시 오염 → 무응답 (메커니즘 C)

나머지 세션(18795a78·e49b60e7 등)에는 해당 턴의 훅 트레이스가 존재하지 않는다 — 메커니즘 A는 훅에 도달조차 못 한다는 뜻이고, 이것이 A와 C를 가르는 로그 지표다.

7. 판별법 — 다음 신고 때 이 순서로

  1. 세션 로그(guest)에서 턴 원장을 만든다. guest_voice_event_normalizedturnId별로 user_speech_starteduser_speech_stoppeduser_input_window_starteduser_input_window_stopped | user_turn_dropped 를 묶고, 종료 시각 뒤 같은 voiceSessionId 안에서 agent_response_started가 있는지 본다. 세션 경계를 넘겨 매칭하면 다음 세션 오프닝 멘트를 응답으로 오인한다.
  2. pending→종료 소요시간이 1.90초면 정상 만료, 그보다 짧으면 재발화 조기 drop(메커니즘 A).
  3. 커밋됐는데 무응답이면 agent 로그의 on_user_turn_completed_trace phase="resolved"를 본다. turn_id(=last_resolved_turn_id)가 last_observed_turn_id다르면 캐시 오염(메커니즘 C) 확정이다.
  4. publish_decisiondropped_*_sdk_onlyStopResponse()로 응답이 취소된 것이다.
  5. 훅 트레이스 자체가 없으면 훅 미실행(메커니즘 A 또는 B) — AI 발화 중 커밋인지 allow_interruptions·직전 agent_response_completed 시각으로 가른다.
정상 기준선: 커밋→응답 0.37~1.08초 / pending→커밋 1.90초 / on_user_turn_completed_trace 3연속(entered · after_super · resolved).

8. 조회 경로 — prod agent 로그

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>"
}
함정 3개.
  • strcontains()MalformedQueryException으로 컴파일 실패한다 → like "문자열"을 쓴다.
  • DescribeLogGroups는 권한 거부지만 StartQuery는 통과한다 — 로그그룹 이름을 알고 있으면 조회된다. 로그그룹 목록을 못 봐서 막혔다고 판단하지 말 것.
  • 같은 이벤트가 4개 포맷으로 중복 기록된다(agent event / voice_lifecycle_publish_started / …succeeded × 2계열). event_id로 dedupe하고, 상세 필드는 message=="agent event" 레코드에만 있다.

LiveKit 서버(service_name="ppi-livekit")는 해당 구간 warn/error 0건, 방 이벤트도 10:49:02~10:51:17 사이 공백이라 전송 경로는 무관함이 배제된다. 같은 구간의 not dispatching agent job since no worker is availableagentName: ppi-agent-local(개발용) 건으로 무관하다.

9. 재현 확인 — 로컬 테스트로 확정

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·2resolved - / none일치
turn-3resolved turn-3 / accepted false / committed_sdk_only일치
turn-4resolved turn-3 / observed turn-4 / accepted false / dropped_superseded_by_new_speech_sdk_only일치
turn-5resolved turn-3 / accepted None / none일치
영향이 정확히 한 턴에 그치는 이유는 방어선이 두 겹이다.
  • :184pop이라 캐시가 1회용이다 — turn-4가 꺼내 쓰면 dict이 빈다.
  • last_resolved_turn_id는 turn-5 시점에도 turn-3으로 남아 has_newer_turn_after는 여전히 True지만, agent.py:1607-1614accepted_turn is None이면 publish_decision: "none"으로 먼저 return해 block 검사에 도달하지 않는다.
기존 동작 검증 테스트 2건 추가apps/livekit-agent/tests/test_input_gate.py
  • test_empty_transcript_replays_previous_rejected_turn_outcome — 위 사슬 전체를 고정한다. 수정하면 이 기대값이 바뀌므로 수정 여부가 테스트로 드러난다.
  • test_empty_transcript_replay_affects_only_one_following_turn — 영향 범위가 한 턴임을 고정한다.
python -m unittest tests.test_input_gate 23건 통과(기존 21 + 신규 2). 전체 스위트는 120건 중 3건 실패이나 develop에서도 동일하게 실패하는 기존 항목(test_agent_response_terminal_trace.TtsNodeAudioOutputLogTest 3건)으로 이 변경과 무관하다.

10. 재현 절차 — 실기기 / 모니터 UI

거부 턴(_turn_acceptance = False) 하나를 만들면 다음 턴이 응답을 잃는다. 만드는 방법이 두 가지다.

실제 사례가 만들어진 조건 — 발화 종료 직후 0.5초 입력 플랩

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까지 함께 뒤집히므로 웹 클라이언트의 듣기 의도 재적용 경로다.

모니터 UI로 그 0.5초 창을 사람이 조작 가능한 길이로 늘린다

선행 확인 — 듣기 OFF는 마이크 트랙을 끄지 않는다. 이것이 성립하지 않으면 VAD가 멈춰 거부 턴이 만들어지지 않는다. 코드에 의도가 명시돼 있다.
// 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.tsxhost-toggle-microphoneuse-guest-controls.ts:99). 아동 세션이 AI 활동 스텝(full:ai)에 진입해 있어야 한다.

#조작기대 로그의미
1정상 대화 1턴 (아동 말 → 핑퐁이 응답)user_turn_committedagent_response_started (+0.4~1.1s)기준선 확보
2핑퐁이 발화가 끝난 뒤 듣기 OFFinput_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_droppedagent_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_idlast_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 성립). 다만 아동에게 "말해도 반응 없음"이 길어져 관찰이 헷갈린다
남은 미검증 항목: 실제 사례의 입력 OFF는 host 토글이 아니라 내부 0.5초 플랩이었다. host 토글 경로는 guest-microphone-changed를 함께 emit하므로(use-guest-page-session.ts:2019) 그 부수 효과는 실기기에서 한 번 확인이 필요하다. 트랙 mute는 위 코드로 배제됐으므로 남은 리스크는 낮다.

11. 수정 방향 (미적용)

핵심: _recent_completion_outcomes의 키가 전사 텍스트라는 설계는 "훅이 빈 전사를 본다"는 이 구성에서 성립하지 않는다. 둘 중 하나다.
  • input_gate.py:184 캐시 조회 앞으로 빈 전사 가드를 올린다 — if not normalized: recent_outcome = None. 최소 변경이고 오염 재생만 끊는다.
  • 캐시 키를 전사 대신 turn_id로 바꾼다. 근본적이지만 resolve_completion 전체 분기와 tests/test_input_gate.py 기대값에 파급이 있다.
미적용 이유: 이 영역은 아동↔핑퐁이 실대화 흐름(턴 수락·중단 정책)에 직접 닿아 실기기 대화 검증 없이는 사이드 이펙트를 확정할 수 없다. 메커니즘 A(pending 1.90초 창 안 재발화로 발화 전량 유실)는 별개 사안으로, 이 수정으로 해결되지 않는다.

관련 문서

이 분석을 가능하게 한 진단 로그: PPI-1227 LiveKit agent AI 미발화·기계음 3계층 진단 로그 추가 — 관측 공백을 메운 PR. 이 문서는 그 로그로 실제 원인을 확정한 후속편이다.

입력 정책 판정 구조: 음성 입력 정책 공통화 (agent-input-policy) — PPI-1165input_gate가 속한 판정 레이어.

오디오 흐름 구조: Typecast → LiveKit agent → 게스트 → mediasoup → 모니터 오디오 흐름 시각화

유사 증상 배제 사례: LiveKit 방 전환 레이스 — 아동 마이크·AI 오디오 무음 원인 분석 · PPI-1192 iPad LiveKit AI 발화 무음과 setSinkId — 둘 다 "소리가 안 남"이지만 발화 자체는 있었던 건이다. 이 문서는 발화가 생성되지 않은 건이다.