PPI-1233 영상 스텝 중 AI 발화 출력 — LiveKit response_created 미연결 원인 분석 및 수정수정 로컬 적용

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

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

2026-08-25 bugs 변재하 10회기 · video_tiki_bridge_end_event_0 PPI-1233

요약

보상전환영상 재생 4.2초 지점부터 약 6초간 티키 발화가 영상 위로 겹쳐 출력된 사례. 스텝 전환·마이크 차단·인터럽트는 모두 정상 동작했고, 뚫린 곳은 "차단 상태에서 새로 시작된 agent 응답을 취소하는 경로" 하나였다.

원인 한 줄 — 비-AI 스텝에서 새 응답을 즉시 취소하는 방어는 response_created 이벤트에 걸려 있는데, 이 이벤트는 OpenAI Realtime의 response.created만 매핑돼 있고 LiveKit 매퍼에는 agent_response_started 분기가 없어 null로 버려졌다. 그래서 LiveKit 런타임에서만 방어가 실행되지 않았다.

신규 정책이 아니라 런타임 간 동작 불일치다. Realtime·TTS 모드에는 원래 이 차단이 있었고(각각 다른 메커니즘으로), LiveKit 전환 과정에서 둘 다 무력화됐다.

관측 — 시간순 인과 체인

기준 t=0은 게스트 Video play event fired(ts 1787640525347). 세션 로그 14,750줄에서 재구성.

-1.57s  티키 response-1 종료 ("자 오늘 배운 내용 꼭 기억해줘! …")
   ↓
-0.73s  아동이 발화 꼬리에 겹쳐 말 시작  AI_SESSION | Child speech started  (turn-1)
   ↓
-0.55s  auto-finish 매칭 → goToNextStep → 6/1 보상전환영상 (full:video)
        Preparing full video auto-transition lock / Response blocked state changed {blocked:true}
   ↓
-0.53s  Muting microphone for non-AI step / Cancelling AI response on non-AI step entry
-0.52s  ~ +0.08s  manual_interrupt 3회 요청·완료 (host_cancel_ai_response)
   ↓
 0.00s  영상 재생 시작 (duration 29.141s)
   ↓
+0.32s  agent: user_turn_terminalization_scheduled
+0.33s  agent: user_turn_pending_started   ← 아동 턴이 interrupt 이후에도 생존
   ↓
+2.22s  agent: on_user_turn_completed_trace ×3  →  +2.22s user_turn_dropped
   ↓
+2.57s  agent_response_started (response-2)   ← 차단 실패 지점
        클라이언트: "Assistant transcript delta ignored during video transition" ×수십
+2.84s  terminal_boundary tts_provider_stream_started
+3.30s  terminal_boundary tts_provider_first_pcm
   ↓
+4.16s ~ +10.04s  AI_SPEAKING_START / STOP ×3  =  영상 위 약 6초 발화
        avatarState=talking 이 진행자 모니터로 전파
   ↓
+29.11s 영상 종료 → 다음 활동(7/0 토론소개)으로 정상 전환
함정 — 클라이언트 로그에는 Assistant transcript delta ignored during video transition가 수십 건 찍힌다. 이것만 보면 "차단이 동작했다"로 읽히지만, 버려진 것은 전사(화면·로그)뿐이고 오디오는 그대로 흘렀다. 차단 성공 여부는 이 로그가 아니라 Cancelling response.created during non-AI step block의 유무로 판별해야 한다(이번 세션 0건).

원인 — 런타임 3종 비교

차단 정책은 use-step-transition.ts가 비-AI 스텝 진입 시 setResponseBlocked(true)로 켠다. 그 뒤 실제로 오디오를 막는 방식이 런타임마다 다르다.

런타임응답 생성 취소전사 드롭 = 무음결과
Realtime
(OpenAI WebRTC)
response_createdresponse.cancel + output_audio_buffer.clear — (오디오가 원격 트랙이라 무관, 취소로 커버) 차단됨
TTS 모드
(클라이언트 합성)
cancelResponsettsPlayer.stop() ✅ 오디오를 transcript delta로 합성하므로 delta가 버려지면 appendDelta가 호출조차 안 됨 차단됨
LiveKit
(agent 서버측 TTS)
response_created를 emit하지 않음 ❌ TTS가 agent에서 나와 published track으로 도착 — 전사를 버려도 오디오는 재생 영상 위로 발화
핵심 — 예전 두 런타임에서 오디오 차단을 담당했던 두 메커니즘이 LiveKit에서는 각각 미연결(매퍼에 분기 없음)과 무효화(서버측 TTS라 전사 드롭이 소리를 막지 못함)로 바뀌었다. 남아 있던 전사 가드는 화면·로그용 cosmetic만 하고 있었다.

코드 근거

① 방어는 원래 있었고 의도도 명시돼 있다

apps/web/entities/guest-page-session/model/use-step-transition.ts:186 주석이 정책과 그 역사(PPI-907 revert 사유)를 그대로 적어 두었다.

// 응답 차단 모드 ON: 이후 server_vad가 자동 생성하는 response.created를
// 핸들러에서 즉시 cancel하고 audio element 자동 unmute를 막는다.
// cancelResponse만으로는 사용자 발화 중(speech_started) 진입 시 미래 response를
// 막지 못해 영상/이미지 위로 AI 발화가 흐르는 회귀가 있었다 (PPI-907 revert 사유).
aiSessionRef.current.setResponseBlocked?.(true);

② 소비자 쪽 방어 (실행되지 않았다)

apps/web/entities/guest-session/model/use-ai-session.ts:4281

case "response_created":
  if (responseBlockedRef.current) {
    logger.info("Cancelling response.created during non-AI step block", { … });
    cancelResponseRef.current?.({ skipTruncate: true });   // ← LiveKit에서 도달 불가
    break;
  }

③ 이벤트가 버려지던 지점

apps/web/lib/voice-agent/livekit-client-session.tsliveKitDataToVoiceSessionEvent. agent_response_startedisAgentVoiceEventType 목록에는 있어 신뢰 검사는 통과하지만, 매퍼에 분기가 없어 함수 끝의 return null로 떨어졌다. session-events.ts:278response_created 생성은 OpenAI response.created 전용이다.

확진 조합 (이번 세션 실측)

· Cancelling response.created during non-AI step block0건

· agent_response_started 이후 tts_provider_first_pcm 정상 도달 — 오디오가 실제 생성됨

· ai-audio mediasoup producer(7abba663)가 −20.1s ~ +29.2s 계속 live — 진행자·녹음 경로는 어떤 차단도 받지 않음

수정 — 2곳 (동작 변경) + 3곳 (헬퍼·테스트·로그)

① 매퍼에 분기 신설 핵심

livekit-client-session.ts:642agent_response_startedresponse_created로 정규화한다. agent_response_id·source_user_turn_id는 있을 때만 실어 보낸다.

if (event.type === "agent_response_started") {
  const responseId = stringField(event, "agent_response_id");
  const sourceUserTurnId = stringField(event, "source_user_turn_id");
  return {
    type: "response_created",
    source: "livekit",
    conversationRuntime: "livekit",
    ...(responseId ? { conversationItemId: responseId, responseId } : {}),
    ...(sourceUserTurnId ? { previousConversationItemId: sourceUserTurnId } : {}),
    ...(lifecycleTrace ? { lifecycleTrace } : {}),
    raw: event,
  };
}

agent_response_id가 없어도 이벤트를 버리지 않는 것이 중요하다 — 차단 판정은 응답 id에 의존하지 않으므로, id 없는 이벤트를 매퍼가 버리면 차단이 다시 뚫린다. 이 경로를 별도 테스트로 고정했다.

② pending 부기를 realtime 계열로 한정

①만 하면 새 함정이 생긴다 — LiveKit은 response_done을 발행하지 않는다(agent_response_completedassistant_audio_stopped로만 매핑). 새로 유입되는 response_createdsetIsResponsePending(true)를 켜면 끌 이벤트가 없어 진행자 모니터의 응답대기 표시가 영구히 남는다.
// session-events.ts — 순수 헬퍼로 분리(테스트 가능 + 이유를 코드에 기록)
export function shouldTrackResponsePending(source: VoiceSessionEventSource): boolean {
  return source !== "livekit";
}

// use-ai-session.ts:4295 — 차단 검사는 그대로 타고, 부기만 건너뛴다
if (!shouldTrackResponsePending(event.source)) break;
setIsResponsePending(true);

③ 차단 로그에 런타임 소스 기록

로그 문구가 response.created(OpenAI 용어)여서 어느 런타임에서 취소됐는지 구분되지 않았다. payload에 source를 추가해 운영 로그에서 이번 수정의 동작 여부를 판별할 축을 만들었다.

변경 파일 — 5파일 +81 / −2

· lib/voice-agent/livekit-client-session.ts (핵심 · 1 hunk)

· entities/guest-session/model/use-ai-session.ts (3 hunk)

· lib/voice-agent/session-events.ts (헬퍼 · 1 hunk)

· *.test.ts 2파일 (매핑 계약 + 부기 판정 회귀)

검증

통과 — runtime 수정 전에 harness를 먼저 추가해 RED → GREEN 확인. tsx --test lib/voice-agent/*.test.ts 59 pass · vitest run livekit-client-session-diagnostics 14 pass · tsc --noEmit 0 error · next build 성공 · prettier --check clean
사전 존재 실패 2건 (baseline 대조 확인, 이번 변경과 무관) — pnpm test:audit API 라우트 메트릭 정규화 미등록 11건(/api/lesson-logs/*, /api/reports/* 등) · pnpm lint Next 16에서 next lint 제거 + eslint config 순환 참조
실기기 확인 필요
  • 영상 스텝 중 아동이 말했을 때 발화가 출력되지 않는지 — 로그에서 Cancelling response.created during non-AI step block + source: "livekit" 확인
  • 영상 종료 후 다음 AI 스텝에서 응답이 정상 복귀하는지
  • 진행자 모니터의 응답대기 표시가 stuck 되지 않는지
  • 동작 변화 — 비-AI 스텝(이미지·영상)에서 진행자 프리셋 발화도 이제 LiveKit에서 취소된다. Realtime과 동일한 기존 정책이지만 LiveKit에선 새 동작이므로, 그 흐름을 실사용 중이면 함께 확인

재사용할 판별법

판별지표
차단이 실제로 걸렸는지Cancelling response.created during non-AI step block 유무. transcript … ignored during video transition화면 차단만 뜻하므로 근거가 되지 않는다
오디오가 실제로 생성됐는지agent_response_startedtts_provider_first_pcm / tts_node_first_audio_frame 도달 여부. 이후 AI_SPEAKING_START/STOP 쌍의 간격이 발화 길이
진행자·녹음에 들어갔는지ai-audio mediasoup producer 생존 구간(AI audio producer created ~ AI audio track ended)이 영상 재생 구간과 겹치는지
취소 타이밍 여유agent_response_startedtts_provider_first_pcm 간격. 이 세션 실측 730ms — 이 여유 안에 interrupt가 닿아야 소리가 나기 전에 끊긴다

남은 범위 · 미확정

상태 — 🔵 원인 확정 · 코드 수정 로컬 적용(브랜치 PPI-1233, 미커밋). 실기기 대화 검증 후 커밋.

관련 문서

같은 계열 — LiveKit 이벤트 누락으로 클라이언트 로직이 끊긴 사례: 박승율 1회기 종료멘트 자동전환 실패 — LiveKit 완료 이벤트 누락 (그쪽은 agent_response_completed 누락으로 auto-finish가 안 걸린 사례, 이 문서는 agent_response_started가 매퍼에서 버려져 차단이 안 걸린 사례 — 같은 이벤트 계약의 반대쪽 끝)

같은 턴 경합 계열: AI 미발화 원인 확정 — 빈 전사 캐시 키가 앞 턴 판정을 재생해 superseded 오판 (input_gate.resolve_completion·StopResponse 경로가 이 문서의 "남은 범위" RPC 안이 손대는 지점)

영상 스텝 전환 계열: 영상 종료 자동전환 미발생 — 세션 로그 원인 분류와 watchdog 분석 · 자동전환 OFF 대기 중 설정 영상 자동전환 원인 분석

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