LiveKit 음성 에이전트 흐름 & 두 SFU 구조 (시각화) 아키텍처

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

LiveKit 음성 에이전트 흐름 & 두 SFU 구조 (시각화) 아키텍처: 입력: 01 런타임 결정 — 어떤 음성 방식으로 갈지, 주요 처리 단계: 03 실시간 미디어 & 마이크 오디오 체인, 결과: TL;DR 한 장 요약 흐름
동작 흐름 요약
  1. 입력: 01 런타임 결정 — 어떤 음성 방식으로 갈지
  2. 주요 처리 단계: 03 실시간 미디어 & 마이크 오디오 체인
  3. 결과: TL;DR 한 장 요약
작성일: 2026-07-24 갱신일: 2026-08-04 (기본 모드 전환 · 입력 정책 · 진단 트레이스) 대상: 아동↔핑퐁이(AI) 실시간 음성 대화의 세션 발급 → dispatch → 미디어 → 제어 전 과정 핵심: livekit_cloud_agent · RoomAgentDispatch · 두 SFU 브리지
💬 대화로 먼저 이해하기 — "두 회의실과 통역사 비유" (비개발자·처음 읽는 사람용)
Q아동이 핑퐁이(AI)랑 대화하려면 우리 서버가 AI를 직접 불러오는 건가요?
A아니요. 아동이 받는 입장권(토큰)에 "이 회의실에 들어오면 통역사 ppi-agent를 불러주세요"라는 예약 메모(RoomAgentDispatch)가 적혀 있어요. 아동이 회의실(LiveKit 룸)에 입장하는 순간 회의실 관리자(LiveKit 서버)가 AI 워커를 대신 소환합니다.
QAI에게 줄 프롬프트나 설정이 클 텐데, 그걸 다 입장권에 적나요?
A아니요. 큰 짐은 물품보관소(Valkey)에 맡기고 보관증 번호(configRef)만 입장권에 적어요. AI 워커는 입장 후 그 번호로 실제 설정을 찾아옵니다.
Q그럼 수업 영상이나 진행자 화면공유도 이 회의실을 지나가나요?
A아니요, 회의실이 두 개예요. AI 대화 전용 방(LiveKit Cloud SFU)과 수업 중계 방(mediasoup SFU). 두 방은 서로의 존재를 모르고, 아동 브라우저만 양쪽 방에 동시에 앉아 AI 목소리와 자기 마이크 소리를 수업 방으로 옮겨 전해요(브리지).
Q그 "옮겨 전하는" 지점에서 문제가 생기면 어떤 증상이 나요?
A"모니터에서 아동 소리만 무음" 같은 증상이 바로 그 브리지 재발행 구간에서 납니다. 아래 §05 두 SFU 구조use-mediasoup-producer.ts 브리지 지점을 보세요.

TL;DR한 장 요약

아동 브라우저가 POST /api/livekit/call로 토큰을 발급받으면, 웹은 AI 워커를 직접 호출하지 않고 토큰 안의 roomConfig.agents(RoomAgentDispatch)로 LiveKit 서버가 ppi-agent 워커를 소환합니다. 큰 프롬프트/설정은 토큰에 싣지 않고 Valkey에 저장 후 configRef(참조 키)만 넘깁니다. 미디어는 LiveKit Cloud SFU를 통해 오가고(mic↑ / TTS↓), 브라우저·워커는 별도 RPC·Data 채널로 턴·VAD·끼어들기를 제어합니다. PPI에는 SFU가 둘(LiveKit Cloud = AI 음성, mediasoup = 수업 중계)이며, 아동 브라우저가 둘을 잇는 브리지입니다.
브라우저 · Web API (apps/web) LiveKit Cloud (SFU / 서버) AI 워커 (apps/livekit-agent) 상태 저장소 (Valkey / S3) mediasoup SFU (apps/socket)

01런타임 결정 — 어떤 음성 방식으로 갈지

세션을 만들기 전 resolveVoiceSessionConfig()가 아동의 대화 모드와 아바타 보이스를 조합해 런타임을 정합니다. LiveKit은 아바타에 Typecast TTS 보이스가 있을 때만 성립하고, 없으면 Realtime으로 폴백합니다.

아동 conversationMode+ 아바타 보이스
resolveVoiceSessionConfig()런타임 분기 판정
livekit 요청 & ttsVoice 있음
runtime = livekitdeployment: livekit_cloud_agent
POST /api/livekit/call 진행 →
ttsVoice 없음 → 폴백
runtime = realtimeOpenAI Realtime WebRTC
tts 모드
runtime = tts
근거resolver.ts:113–165 — 런타임 3종 분기 · VAD 병합 resolveLiveKitVad(). call route는 runtime !== "livekit"이면 409 LIVEKIT_NOT_AVAILABLE 반환.
2026-08-03부터 기본값이 바뀌었습니다. DEFAULT_CONVERSATION_MODE"tts""livekit"로 전환되어, 신규 사용자 생성 폼(user-form.tsx)의 기본 대화 모드가 LiveKit입니다. 즉 LiveKit이 예외 경로가 아니라 기본 경로이며, Realtime/TTS는 아바타 보이스 부재 폴백 또는 명시적 설정일 때만 사용됩니다. conversation-mode.ts · b50cefa2
모드 UI진행자 화면의 음성 모드 배지UserConversationModeBadge로 분리되어 세션 카드·세션 헤더·포커스뷰에서 normalizeConversationMode(user.conversationMode) 값을 표시합니다(27c12b5c). 사용자 폼에서 음성 수업 유형을 켜거나 모니터링 유형을 바꾸면 응답 속도(VAD) 변경 확인 모달(voice-vad-change-modal.tsx)이 떠서, 취소 시 기존 응답 속도를 유지하고 수업 유형만 변경합니다(user-form-voice-vad.ts · 1a225ff6).

02세션 수립 & 에이전트 dispatch

웹은 AI 워커를 직접 호출하지 않습니다. 발급하는 JWT 안의 roomConfig.agents(RoomAgentDispatch)에 ppi-agent를 지정해두면, 아동이 룸에 입장하는 순간 LiveKit 서버가 이름으로 워커를 소환합니다. 큰 프롬프트/설정은 토큰에 싣지 않고 Valkey에 저장한 뒤 configRef만 전달합니다.

1브라우저Web APIPOST /api/livekit/calluserId · avatarId · voiceSessionId · activityId · lessonIndex
2Web APIinternal세션 인증 · activity/avatar 검증resolveVoiceSessionConfig → runtime = livekit
3Web APIValkeysetLiveKitSessionConfigSnapshot()모델설정 저장 (SETEX, TTL 7200s)
4ValkeyWeb APIconfigRef 반환키: ppi:livekit:session-config:{STAGE}:{voiceSessionId}
5Web APIinternalcreateLiveKitParticipantToken()JWT + roomConfig.agents=[ppi-agent] · metadata = configRef
6Web API브라우저LiveKitCallDescriptorserverUrl · roomName · participantToken · browserAudioProfile
7브라우저LiveKitroom.connect(token) + publishTrack(mic)
🛰️ 토큰의 RoomAgentDispatch로, 브라우저 입장 시 LiveKit 서버가 agent_name=ppi-agent 워커를 named dispatch
8LiveKitAI 워커job dispatch (agent_name = ppi-agent)
9AI 워커ValkeyconfigRef로 모델설정 로드토큰엔 참조만 — 실제 설정은 Valkey에서 복원
10AI 워커internalAgentSession.start()OpenAI Realtime STT/LLM + Typecast TTS + VAD
11AI 워커브라우저data (topic: agent-event-log)ppi.agent_ready
12브라우저internalwaitUntilAgentReady() 해제→ autoStart · setIsActive(true)
준비 신호워커 _publish_ppi_agent_ready() → 브라우저 RoomEvent.DataReceived 수신 → agentReadiness.markReady(). 10초 내 미수신 시 readiness timeout. agent-name은 웹/워커 양쪽 기본값 ppi-agent로 일치해야 매칭됩니다.
Valkey 계약 분리세션 설정 저장소는 socket/peer용 Redis와 배포 계약이 분리되었습니다(8f9ae393). 워커는 범용 AGENT_REDIS_URL이 아니라 SSM 파라미터 /ppi/livekit/{stage}/redis-urlAGENT_LIVEKIT_SESSION_CONFIG_REDIS_TLS를 사용하며(get_livekit_session_config_redis()), dev는 파라미터가 없을 때만 bootstrap 값으로 1회 생성하고 기존 값은 절대 덮어쓰지 않습니다. web 쪽 대응 모듈은 livekit-session-config-store.ts입니다.
근거app/api/livekit/call/route.ts · livekit-token.ts:56–89 · livekit-client-session.ts:1364–1375 · apps/livekit-agent/agent.py · livekit_model_config_store.py

03실시간 미디어 & 마이크 오디오 체인

아동 마이크는 브라우저 내 WebAudio 체인(게이트 → EQ → 컴프레서 → 리미터)을 거친 뒤 두 갈래로 나뉩니다. 하나는 SFU를 통해 AI 워커로 가는 processedTrack, 다른 하나는 진행자 모니터로 가는 relayProcessedTrack입니다.

🎙️ 아동 마이크MediaStreamTrack
LiveKitAudioChainProcessor · 브라우저 WebAudio
source noise gate EQHP · Peak · LP compressor limiter outputGain
① AI로 가는 경로
agentInputGain듣기 게이트 agentDestination= processedTrack
LiveKit SFU AI 워커STT · LLM
② 진행자 릴레이 경로
relayDestination= relayProcessedTrack
진행자 모니터 릴레이
AI 워커Typecast TTS LiveKit SFUTrackSubscribed audioElement+ onRemoteAudioStream
"듣기 OFF"의 정밀 제어트랙을 mute하지 않고 agentInputGain만 0으로 낮춰 AI로 가는 입력만 차단하고 진행자 릴레이는 유지합니다. 다른 모드는 트랙 mute라 릴레이까지 함께 죽습니다.
증폭 정책 공통화체인 마지막 outputGain은 런타임별 산식이 아니라 computeAudioOutputGain() 한 곳에서 수동 inputGain × autoGain 배수 × 리미터 ceiling으로 계산됩니다 (lib/audio-processing/output-gain-policy.ts · 8e3613ec). autoGain은 speechFloorDb ≤ 입력레벨 < activationDb 구간에서만 targetDb까지 끌어올리고 maxGain으로 클램프합니다. 브라우저 오디오 프로파일 결정도 resolveBrowserAudioProfileForRuntime()로 합쳐져, LiveKit이면 GET /api/livekit/config를 읽고 실패 시 코드 기본값(DEFAULT_LIVEKIT_RECOGNITION_CONFIG)으로 폴백합니다.
주의LiveKit 런타임에서는 shouldAttachRealtimeRemoteAudio()false — Realtime PeerConnection의 remote 오디오를 붙이지 않고, AI 음성은 오직 LiveKit TrackSubscribed 경로로만 재생됩니다. livekit-audio-chain-processor.ts:196–209

04양방향 제어 & 이벤트 채널

연결이 서면 브라우저와 워커는 미디어와 별개로 RPC · Data 채널로 대화합니다. 브라우저는 입력/VAD/끼어들기를 RPC로 제어하고, 워커는 전사·턴 상태를 agent-event-log 토픽으로 흘려보내 브라우저가 VoiceSessionEvent로 정규화해 자막·입력 게이트를 구동합니다.

방향채널 / 메서드용도
브라우저 → 워커RPC agent.setInputEnabledAI로 가는 입력 on/off (워치독 강제 종료 포함)
브라우저 → 워커RPC agent.setVadOptionsVAD 임계 · 엔드포인팅 · 프리픽스 패딩 실시간 변경
브라우저 → 워커RPC agent.setAllowInterruptions아동 발화의 AI 끼어들기 허용 토글
브라우저 → 워커RPC agent.interrupt진행자의 AI 응답 수동 중단
브라우저 → 워커data · topic ppi-user-text텍스트 메시지 주입 (응답 지시 포함 가능)
워커 → 브라우저data · topic agent-event-loguser_stt_segment · agent_transcript_delta/done · user_state_changed · user_turn_committed/dropped → VoiceSessionEvent
워커 → 브라우저ActiveSpeakersChangedAI 발화 시작/종료 감지 → onAiTalkingChange
신뢰 판정isTrustedLiveKitAgentData() = 참가자 kind가 AGENT이고 topic이 agent-event-log일 때만 신뢰. 원격 참가자(워커)가 아직 없으면 RPC는 pending 큐에 쌓였다가 ParticipantConnected 시 재전송됩니다.

05두 SFU 구조 — 브라우저가 브리지

PPI에는 서로 독립된 SFU가 둘 있습니다. AI 음성은 LiveKit Cloud SFU(관리형), 수업 미디어(아동 영상·화면공유·소리 중계)는 자체 mediasoup SFU(apps/socket)가 담당합니다. 두 SFU는 직접 통신하지 않고, 아동 브라우저가 양쪽에 동시에 참여하며 다리를 놓습니다.

SFU ① LiveKit Cloud · 핑퐁이 AI 음성
관리형 SFU — 브라우저와 워커가 같은 룸의 참가자로 붙음
아동 브라우저apps/web · client-guest LiveKit Cloud SFU AI 워커STT · LLM · TTS
mic processedTrack → 워커 STT/LLM Typecast TTS → TrackSubscribed → aiAudioStream
SFU ② mediasoup · 수업 미디어
자체 운영 SFU (apps/socket) — 아동↔진행자 실시간 중계
아동 브라우저apps/web · client-guest mediasoup SFUapps/socket 진행자monitor-dashboard
브라우저 producers ↑ · mic-audio(아동 릴레이) · ai-audio(핑퐁이) · camera-video ↓ 진행자 화면공유 → 아동
브리지 지점LiveKit onRemoteAudioStreamsetAiAudioStream, 오디오 체인의 relayProcessedTrackguestMicStream. 이 둘이 mediasoup producer ai-audio·mic-audio로 재발행됩니다 (use-mediasoup-producer.ts:634,743). 두 SFU는 서로를 알지 못하며 브라우저만 양쪽에 물려 있습니다.
역할 분리LiveKit = AI 대화 전용(턴·VAD·끼어들기 제어) · mediasoup = 수업 중계 전용(영상·화면공유·소리 모니터링). AI 없이도 수업 미디어는 mediasoup로 독립 동작합니다.
이슈 무대이 브리지 재발행 구간은 "모니터 아동 소리만 무음"·ai-audio producer outbound stalled 계열 증상이 발생하는 지점입니다 (LiveKit→브라우저 수신은 정상인데 브라우저→mediasoup 재발행 트랙이 무음).

06입력 정책 — "AI가 지금 들어도 되는가"의 단일 판정

PPI-1165부터 듣기 ON/OFF의 최종 판정이 런타임 밖으로 빠졌습니다. agent-input-policy.ts가 provider-neutral 상태 머신으로 실효 입력(effective input)을 한 번 계산하고, LiveKit·Realtime·TTS는 그 결과만 적용합니다. LiveKit gate는 턴 lifecycle 전담으로 역할이 좁아졌습니다.

effectiveInputEnabled = listeningIntent && !aecBlocked && (!isAiSpeaking || allowInterruptions)
네 입력(듣기 의도 · AEC 차단 · AI 발화 중 · 끼어들기 허용)만으로 결정되며, "첫 AI 멘트"라는 예외 분기가 없습니다. 첫 응답도 "AI 발화 중 + 끼어들기 OFF" 행과 동일하게 닫힌 상태로 유지됩니다.
진행자 듣기 토글 / 부재 fallback
listening_intent_changed
ActiveSpeakers · TTS 재생
ai_speaking_changed
끼어들기 토글
allow_interruptions_changed
AEC 안정화 대기(300ms)
aec_blocked_changed
effectiveInputEnabled변경될 때만 effect 발행
LiveKit
agentInputGain 0/1 → RPC agent.setInputEnabled
Realtime · TTS
MediaStreamTrack.enabled + 입력 버퍼 정리
듣기 의도AI 발화끼어들기AEC실효 입력
OFFfalse (항상)
ONidle해제true
ON발화 중ON해제true
ON발화 중OFF해제false ← 첫 멘트 포함
ON차단false
gate의 새 역할livekit-input-gate.ts는 이제 정책을 재판정하지 않고 desired(=listening intent)와 actual(=effective)을 정책 결과로 반영하며, speech started/stopped · turn pending/committed/dropped · stale 턴 보호 · disconnect 정리만 담당합니다. 이미 accepted된 턴은 듣기가 꺼져도 보존됩니다.
이 변경이 막은 결함이전에는 활동 전환 후 예약된 마이크 ON(scheduleMicOnAfterFirstResponse)이 300ms AEC 지연 뒤 AI 첫 응답 도중에 입력을 열어 VAD/STT가 AI 목소리를 수집할 수 있었습니다 (Realtime 경로에만 "끼어들기 OFF && AI 발화 중" 차단이 따로 있었기 때문). 상세는 음성 입력 정책 공통화 문서.

07응답 진단 트레이스 & 응답 차단

"핑퐁이가 대답을 안 한다 / 중간에 끊겼다"를 사후 판별하기 위해, 워커가 응답 1건의 생애를 경계(boundary) 단위로 남기고 전사는 TTS를 기다리지 않고 먼저 표시합니다.

응답 종료 진단 트레이스 a02d829e

전사 선표시agent_transcript_events.py의 delta/done payload를 분리해 LLM 텍스트가 나오는 즉시 브라우저로 보냅니다. 브라우저는 transcript-upsert.ts로 같은 agent_response_id를 갱신(upsert)하므로, TTS 합성이 끝나기 전에도 진행자 화면에 AI 응답 로그가 먼저 뜹니다(fab379a6 · PPI-1139).

final STT 응답 차단 67bf85cb

관련 문서