response_created 미연결 원인 분석 및 수정수정 로컬 적용마지막 업데이트 2026-09-12
보상전환영상 재생 4.2초 지점부터 약 6초간 티키 발화가 영상 위로 겹쳐 출력된 사례. 스텝 전환·마이크 차단·인터럽트는 모두 정상 동작했고, 뚫린 곳은 "차단 상태에서 새로 시작된 agent 응답을 취소하는 경로" 하나였다.
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건).
차단 정책은 use-step-transition.ts가 비-AI 스텝 진입 시 setResponseBlocked(true)로 켠다. 그 뒤 실제로 오디오를 막는 방식이 런타임마다 다르다.
| 런타임 | 응답 생성 취소 | 전사 드롭 = 무음 | 결과 |
|---|---|---|---|
| Realtime (OpenAI WebRTC) |
✅ response_created → response.cancel + output_audio_buffer.clear |
— (오디오가 원격 트랙이라 무관, 취소로 커버) | 차단됨 |
| TTS 모드 (클라이언트 합성) |
✅ cancelResponse → ttsPlayer.stop() |
✅ 오디오를 transcript delta로 합성하므로 delta가 버려지면 appendDelta가 호출조차 안 됨 |
차단됨 |
| LiveKit (agent 서버측 TTS) |
❌ response_created를 emit하지 않음 |
❌ TTS가 agent에서 나와 published track으로 도착 — 전사를 버려도 오디오는 재생 | 영상 위로 발화 |
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.ts의 liveKitDataToVoiceSessionEvent. agent_response_started는 isAgentVoiceEventType 목록에는 있어 신뢰 검사는 통과하지만, 매퍼에 분기가 없어 함수 끝의 return null로 떨어졌다. session-events.ts:278의 response_created 생성은 OpenAI response.created 전용이다.
확진 조합 (이번 세션 실측)
· Cancelling response.created during non-AI step block — 0건
· agent_response_started 이후 tts_provider_first_pcm 정상 도달 — 오디오가 실제 생성됨
· ai-audio mediasoup producer(7abba663)가 −20.1s ~ +29.2s 계속 live — 진행자·녹음 경로는 어떤 차단도 받지 않음
livekit-client-session.ts:642 — agent_response_started를 response_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 없는 이벤트를 매퍼가 버리면 차단이 다시 뚫린다. 이 경로를 별도 테스트로 고정했다.
response_done을 발행하지 않는다(agent_response_completed는 assistant_audio_stopped로만 매핑). 새로 유입되는 response_created가 setIsResponsePending(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파일 (매핑 계약 + 부기 판정 회귀)
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
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" 확인| 판별 | 지표 |
|---|---|
| 차단이 실제로 걸렸는지 | Cancelling response.created during non-AI step block 유무. transcript … ignored during video transition은 화면 차단만 뜻하므로 근거가 되지 않는다 |
| 오디오가 실제로 생성됐는지 | agent_response_started 뒤 tts_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_started → tts_provider_first_pcm 간격. 이 세션 실측 730ms — 이 여유 안에 interrupt가 닿아야 소리가 나기 전에 끊긴다 |
agent.setResponseBlocked RPC를 추가해 _response_block_reason에 사유를 넣고 StopResponse()로 떨어뜨리는 방식이다(agent.py:1576, 기존 RPC 4종과 동일 패턴). LLM·TTS가 아예 돌지 않으므로 오디오가 생성되지 않는다. 이번 범위 밖.pause() 또는 소스 트랙 enabled=false가 후보이나, 이 트랙은 LiveKit 원격 트랙의 clone이라 과거 clone 무음·스톨 이슈 재발 여부를 실기기로 확인해야 한다.user_turn_dropped(+2.22s)로 떨궜는데도 350ms 뒤 response-2가 시작됐다. on_user_turn_completed_trace가 드롭 마커보다 먼저 찍혀 있어 판정 시점에 드롭 사유가 아직 기록되지 않은 경합으로 보이지만, 클라이언트 로그에 publish_decision이 실리지 않아 확정하지 못했다. agent 로그(Grafana service=ppi-livekit-agent) 대조 필요. 위의 RPC 방식은 세션 레벨 플래그라 이 경합을 우회한다.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 → 모니터 오디오 흐름 시각화