LiveKit 도입 — voice-agent 추상화 전체 플로우

마지막 업데이트 2026-07-22

9fc5b813 David · 2026-07-14 Feature 150 files +22,919 −1,800

게스트 음성 세션에 transport 추상화 레이어(apps/web/lib/voice-agent/)를 도입해, 기존 브라우저→OpenAI Realtime 직결 외에 self-hosted LiveKit 서버 + Python agent(apps/livekit-agent, ECS 배포) 경로를 추가한 대형 변경이다(PR #821 → revert #873 → reapply #875 → #878, 순변경 dea36200...9fc5b813). 리뷰 시 가장 먼저 볼 지점은 두 가지: ① 기본 대화 방식이 livekit으로 바뀌었다conversationMode 미설정 사용자 + typecast 보이스 아바타 조합은 전부 LiveKit으로 흐른다(conversation-mode.tsDEFAULT_CONVERSATION_MODE), ② 세션 시작이 ppi.agent_ready(10초 타임아웃) 하드 의존이 됐다 — agent/LiveKit 서버 장애가 곧바로 세션 시작 실패다.

무엇이, 왜 바뀌었나

기존 구조는 아동(게스트) 브라우저가 OpenAI Realtime API에 WebRTC로 직결하고, 브라우저가 세션의 모든 상태(VAD, 끼어들기, TTS 재생, 전사)를 직접 관리했다. 이 방식은 iOS 덕킹·클라이언트 오디오 체인 등 기기별 이슈에 취약했다. 이번 변경은 AI 세션의 두뇌를 서버측 Python agent로 옮기는 경로를 추가한다: 브라우저는 LiveKit room에 참가자로 접속만 하고, Python agent가 같은 room에 AI 참가자로 들어와 OpenAI Realtime(LLM+STT) + Typecast TTS를 서버에서 돌린다.

머지 이력

PR내용머지
#821LiveKit agent self-hosted 배포 경로 추가 (하위 PR #867 VAD 기본값 · #868 gpt-4o-transcribe · #869 staging 인프라 · #871 speaking 진단 로그 · #872 SSM secret 단일화 포함)7/13 18:28
#873#821 revert7/13 18:51
#875reapply (+#876 turn terminalization 보장)7/14 00:24
#878agent prod 배포 워크플로우 정리 (composite action 추출)7/14 01:02

아키텍처 큰 그림 — 기존 경로 vs LiveKit 경로

[기존 Realtime/TTS 경로 — 유지] 게스트 브라우저 ──WebRTC(SDP)──▶ OpenAI Realtime API │ 마이크(WebAudio 체인: gate/EQ/comp/limiter) └──mediasoup SFU──▶ 진행자 모니터 (relay/녹음) [신규 LiveKit 경로 — conversationMode=livekit + 아바타 ttsVoice 있을 때] 게스트 브라우저 ──/api/livekit/call──▶ Next.js (JWT + modelConfig metadata) │ │ RoomAgentDispatch("ppi-agent") ▼ ▼ LiveKit room ◀──join── Python agent (ECS, apps/livekit-agent) │ ▲ │ OpenAI Realtime(LLM+STT: gpt-4o-transcribe) │ │ RPC: setInputEnabled 등 │ Typecast TTS (서버측 합성) │ │ data: agent-event-log │ Silero/ai-coustics VAD │ └── ppi-user-text ───────────┘ └──mediasoup SFU(relay 트랙 분리)──▶ 진행자 모니터 (기존 유지)

핵심 설계: 마이크 원본 트랙은 LiveKitAudioChainProcessoragent용 트랙과 host relay용 트랙으로 분리해서, "듣기 OFF"는 agent RPC(agent.setInputEnabled)로만 게이팅하고 SFU relay/녹음은 계속 흐르게 한다. LiveKit 경로에서 클라이언트 WebAudio 체인(gate/EQ/comp/limiter)은 전부 비활성화(LIVEKIT_BROWSER_AUDIO_PROFILE)하고 서버측 처리에 맡긴다.

흐름 1 — 게스트 세션 시작 (LiveKit 경로)

1

transport 결정

lib/voice-agent/resolver.ts

resolveVoiceSessionConfig가 user.conversationMode(기본 livekit) + 아바타 ttsVoice 유무로 runtime 확정. typecast 보이스 없으면 realtime 폴백(avatar_livekit_tts_voice_missing)

2

토큰 발급 + agent 디스패치

POST /api/livekit/call

세션 소유자·lessonIndex·avatarId 3중 검증 → 서버에서 resolver 재실행(불일치 시 409) → 프롬프트+modelConfig를 JWT metadata에 실어 RoomAgentDispatch(agentName="ppi-agent")

3

Realtime PC 폐기 분기

entities/guest-session/model/use-ai-session.ts

isLiveKitCallDescriptor(answer)면 방금 만든 RTCPeerConnection/DataChannel을 close하고 LiveKit 경로로 전환

4

room 연결 + 오디오 체인

lib/voice-agent/livekit-client-session.ts

Room.connect → 마이크 publish → LiveKitAudioChainProcessor로 agent용/host relay용 트랙 분리. agent 오디오는 TrackSubscribed에서 audio element attach

5

agent 준비 대기

createLiveKitAgentReadinessGate

ppi.agent_ready를 10초 타임아웃으로 대기. 못 받으면 startSession throw → 세션 시작 실패 (Realtime엔 없던 실패 지점)

6

이벤트 정규화 → 공통 처리

lib/voice-agent/session-events.ts

agent-event-log 데이터를 liveKitDataToVoiceSessionEvent로 공통 VoiceSessionEvent로 변환 → Realtime과 동일한 handleVoiceSessionEvent에서 전사 저장/표시/auto-finish

흐름 2 — Python agent 세션 수명주기 (apps/livekit-agent)

1

job 수신 + config fetch

agent.py:2302 ppi_agent

dispatch name ppi-agent로 room job 수신 → 참가자 token metadata(JSON)에서 modelConfig 읽기(_fetch_model_config). metadata.sessionId가 voice_session_id

2

세션 구성

agent.py:769 _build_half_cascade_session

기본 half-cascade: OpenAI RealtimeModel(LLM+STT gpt-4o-transcribe/ko) + 별도 Typecast TTS 플러그인(typecast_tts.py). VAD는 Silero/ai-coustics

3

room join + RPC 등록

agent.py:2330-2337

session.start()ctx.connect() → 텍스트 transport(ppi-user-text) + RPC 4종(interrupt/setAllowInterruptions/setVadOptions/setInputEnabled) 등록 → ppi.agent_ready publish

4

turn 게이팅 + terminalization

input_gate.py · agent.py:1431

DeferredInputGate가 발화 시작 시점의 듣기 상태를 turn에 고정, 발화 종료 후 endpointing 지연 뒤 committed/dropped로 확정(#876). drop이면 StopResponse로 LLM 응답 차단

5

이벤트 publish

agent.py:946 _publish_agent_event

turn/response/STT segment/interruption을 agent-event-log data channel + Loki 로그로 발행. lifecycle_trace.py가 voice_session_id/event_id 시퀀스 부여

흐름 3 — 설정 → 런타임 (관리 UI)

1

사용자 폼: 대화 방식 3-way

components/sections/user-form.tsx

라디오 2개("설정값 따름"/"Realtime") → 3개("TTS 분리"/"LiveKit"/"Realtime"). 초기값·신규값 모두 livekit

2

아바타 폼: TTS 보이스 필수화

components/sections/avatar-form.tsx

Realtime voice는 선택으로 강등, typecast ttsVoice가 필수로 역전. 저장 시 Avatar.ttsVoice = { provider: "typecast", voiceId, options }

3

서버 검증 + DB 저장

/api/users · /api/avatars

validateVoiceAgentUserFields/AvatarFields 게이트(400) → DynamoDB. updateAvatar에 ttsVoice null 시 REMOVE 절 신설

4

인식게이트 전역 설정

components/sections/livekit-recognition-gate-section.tsx

신규 448줄. /api/livekit/config로 S3 단일 전역 문서(recognitionGate/서버 소음처리/브라우저 constraints/audioChain) GET/PUT — 새로 시작되는 LiveKit 활동부터 적용

5

세션 시작 시 소비

POST /api/livekit/call

buildLiveKitModelConfig가 user.vadPreferences.livekit + 아바타 ttsVoice + 전역 config를 합쳐 agent용 modelConfig로 직렬화

흐름 4 — 배포 파이프라인 (livekit-agent → ECS)

1

이미지 빌드/푸시

.github/actions/deploy-livekit-agent/action.yml

composite action(#878): 태그 패턴 검증 + latest 거부 → GHCR ppi-livekit-agent 푸시(non-SHA 태그 덮어쓰기 거부, buildcache)

2

ECS 롤링 배포

ppi-livekit-{env}-agent

task definition 이미지 교체 → register → update-service --force-new-deployment → services-stable 대기. staging/prod는 SHA 비교(check-livekit-agent-changes)로 변경시에만

3

web에 시크릿 주입 (SSM 단일화 #872)

/ppi/livekit/{env}/*

SSM에서 URL/API key/secret을 --with-decryption + ::add-mask::로 로드해 EC2 .env에 기록. prod는 dev 프로젝트 URL·비-wss 거부 가드 포함

4

worker 용량 제어

agent.py:231 _ppi_agent_load

active job 수 기반 합성 load로 worker당 최대 2세션(PPI_LIVEKIT_AGENT_MAX_JOBS_PER_WORKER). 예약 수업 기반 scheduled scaling 전제

코드로 보는 핵심 지점

1. transport 분기 — 기본값 livekit, typecast 보이스 없으면 realtime 폴백

apps/web/lib/voice-agent/resolver.ts:113~131 +let runtime: VoiceRuntime = requestedMode; // requestedMode 기본 "livekit" +let fallbackReason: VoiceFallbackReason | undefined; +if (requestedMode === "livekit" && !ttsCapable && realtimeCapable) { + runtime = "realtime"; + fallbackReason = "avatar_livekit_tts_voice_missing"; +} else if (requestedMode === "tts" && !ttsCapable && realtimeCapable) { + runtime = "realtime"; + fallbackReason = "avatar_tts_voice_missing"; +} else if (requestedMode === "realtime" && !realtimeCapable && ttsCapable) { + runtime = "tts"; + fallbackReason = "avatar_realtime_voice_missing"; +} apps/web/types/db/user.types.ts -export type ConversationMode = "follow" | "realtime"; +export type ConversationMode = "livekit" | "tts" | "realtime";

2. use-ai-session의 분기점 — LiveKit이면 방금 만든 Realtime용 PC/DC를 폐기

apps/web/entities/guest-session/model/use-ai-session.ts:2726~2738 +if (isLiveKitCallDescriptor(answer)) { + dataChannelRef.current = null; + dataChannelOpenRef.current = false; + dc.close(); + pc.close(); // Realtime용으로 만든 offer/PC를 그대로 버림 + peerConnectionRef.current = null; + const liveKitSession = await connectLiveKitBrowserSession({ descriptor: answer, ... });

3. "듣기 OFF"가 로컬 트랙을 mute하지 않는 이유 — host relay 보호

apps/web/lib/voice-agent/livekit-client-session.ts:986~995 // `듣기 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. apps/web/entities/guest-session/model/use-ai-session.ts:919~928 (applyActualInputState) +if (resolvedVoiceRuntimeRef.current !== "livekit") { + mediaStreamRef.current?.getAudioTracks().forEach((t) => { t.enabled = enabled; }); +} +// LiveKit은 track.enabled 대신 agent RPC(setInputEnabled)로만 게이팅

4. agent worker load cap — task당 동시 세션 상한 (#821 핵심)

apps/livekit-agent/agent.py:231~248 +def _ppi_agent_load(server: AgentServer) -> float: + active_jobs = len(server.active_jobs) + return min(active_jobs / PPI_AGENT_MAX_JOBS_PER_WORKER, 1.0) * PPI_AGENT_LOAD_THRESHOLD + +server = AgentServer( + load_fnc=_ppi_agent_load, + load_threshold=PPI_AGENT_LOAD_THRESHOLD, # 0.99 + num_idle_processes=PPI_AGENT_MAX_JOBS_PER_WORKER, # env, 기본 2 +)

5. LiveKit 오디오 프로파일 — 클라이언트 WebAudio 체인 전체 무해화

apps/web/hooks/audio-processing-presets.ts +export const LIVEKIT_BROWSER_AUDIO_PROFILE: BrowserAudioRuntimeProfile = { + runtime: "livekit", + mediaConstraints: { echoCancellation: true, noiseSuppression: true, + autoGainControl: true, voiceIsolation: false, opusDtxEnabled: false }, + chain: { ...LEGACY_BROWSER_AUDIO_PROFILE.chain, + gateEnabled: false, eqEnabled: false, + compressorEnabled: false, limiterEnabled: false, /* ... */ }, +}; +export function resolveBrowserAudioRuntimeProfile(runtime) { + return runtime === "livekit" ? LIVEKIT_BROWSER_AUDIO_PROFILE : LEGACY_BROWSER_AUDIO_PROFILE; +}

6. /api/users 조회 권한 축소 — admin/developer 한정

apps/web/app/api/users/route.ts -export const GET = withAuthMember(getHandler); +export const GET = withAuthMember(getHandler, SENSITIVE_ADMIN_ROLES); // admin/developer만 apps/web/app/api/users/[id]/route.ts +if (member && !SENSITIVE_ADMIN_ROLES.includes(member.role)) { + return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 }); +}

웹 ↔ agent 계약 (data channel / RPC)

이벤트명·payload가 livekit-client-session.tsagent.py 사이 1:1 계약이다. 한쪽만 바꾸면 조용히 깨진다.

방향채널이름용도
web → agentdata topicppi-user-text사용자 텍스트 입력 주입 (ppi.user_text_message)
web → agentRPCagent.setInputEnabled듣기 ON/OFF 게이팅 (track mute 대신)
web → agentRPCagent.setAllowInterruptions · agent.interrupt · agent.setVadOptions끼어들기 토글 / 수동 중단 / 런타임 VAD·endpointing 조정
agent → webdata topicagent-event-loglifecycle 이벤트: ppi.agent_ready, user_state_changed, user_stt_segment, agent_response_started/completed, user_turn_committed/dropped, user_interruption_detected
agent → webLiveKit 표준TranscriptionReceivedassistant 최종 전사
web → socketSocket.ioGUEST_SPEAKING_START/STOP + tracespeaking 이벤트에 turnId/lifecycle trace 부착 → 서버 검증(speaking-payload.ts) 후 모니터로 relay
web ↔ STT 서버WebSockettarget_turn_id · speaker · request_id/client_request_idexternal STT finalize 결과를 특정 LiveKit turn에 귀속 (Go 서버가 echo)

레이어별 변경 요약

레이어파일핵심 변경
Python agent
(신규 서비스)
apps/livekit-agent/agent.py (2,346줄)세션 구성(half-cascade/voice-pipeline), 이벤트 로거, RPC 4종, 텍스트 transport, load cap, turn terminalization(#876)
input_gate.py · lifecycle_trace.py발화 시작 시점 듣기 상태를 turn에 고정하는 DeferredInputGate / voice_session_id·event_id 시퀀스 trace
typecast_tts.py · typecast_sanitize.py · wav_stream.pyTypecast 스트리밍 TTS LiveKit 플러그인 (web sanitize.ts의 Python 포팅 + 스트리밍 WAV 파서)
tests/ 11개 (1,449줄)terminalization/input gate/lifecycle trace 중심 순수 로직 단위 테스트
웹 세션 코어lib/voice-agent/ 16개 모듈 (신규)resolver·conversation-mode·session-events(공통 이벤트 스키마)·livekit-client-session(1,622줄)·input-gate·audio-chain-processor·token·config·validation 등
entities/guest-session/model/use-ai-session.ts (+1,893)transport 분기, LiveKit lifecycle, 통합 handleVoiceSessionEvent, 입력게이트 연동
app/api/livekit/call/route.ts · config/route.ts (신규)토큰 발급(3중 권한 검증 + 서버측 resolver 재실행) / 전역 인식게이트 config GET·PUT
shared/lib/conversation-order.ts (리네임)realtime-conversation-order를 runtime-neutral 정렬로 일반화(legacy 폴백 유지)
entities/guest-socket/use-guest-socket.ts · lib/external-stt-observer.tsspeaking 이벤트 trace 부착 / STT finalize turn 타깃팅(requestId echo 매칭)
관리 UI / 타입components/sections/ user-form·avatar-form·livekit-recognition-gate-section(신규 448줄) 등대화 방식 3-way UI, TTS 보이스 필수화 역전, 전역 인식게이트 조정 화면
types/db/ user·avatar·session-logConversationMode 재정의, LiveKitVadPreferences, AvatarTtsVoiceBinding, runtime-neutral 로그 필드(conversationItemId)
lib/tts/ · lib/vad-presets.ts · lib/typecast-voices.tsTypecast volume 옵션 제거 + target_lufs -25 상시, 분당 rate limit, LiveKit VAD 프리셋(#867), 보이스 65종 카탈로그
socket / STT 서버apps/socket/.../speaking-payload.ts (신규) · session-handlers.tsspeaking relay에 입력 검증(role/room/peer 3중 소유권) + turn 가드 상태머신 + serverSequence 진단 로그(#871)
apps/stt/.../websocket.go · messages.gofinalize/batch_result에 target_turn_id/speaker/client_request_id 상관키 왕복(하위호환 래퍼 유지)
오디오 체인hooks/audio-processing-presets.ts (신규)legacy/livekit 프로파일 분리 — LiveKit은 클라 체인 전체 OFF, 기존 경로 기본값 불변
hooks/use-audio-processing-chain.ts (+341)하드코딩 상수 → settings 주입, 비활성 노드 무해화(allpass/ratio=1)
lib/audio-level-observer.ts (신규) · use-mediasoup-producer.tsRMS/peak 진단 프로브 / mic producer micEnabled 토글(기본 true)
인프라 / CI.github/actions/deploy-livekit-agent/ + 워크플로우 5종GHCR 빌드(태그 가드) → ECS 롤링 배포 composite action(#878), dev/staging/prod 경로
terraform/branch-deploy/LiveKit SSM 파라미터 4변수(/ppi/ prefix validation) + 부팅 시 .env 주입(#872)
Dockerfile.dev.{web,socket}builder에 python3/make/g++ (node-gyp 네이티브 빌드)

리뷰 관전 포인트

P1 · 영향 범위

기본 대화 방식 livekit 전환의 롤아웃 범위. normalizeConversationMode가 미설정/구값(follow)을 전부 livekit으로 해석한다. typecast 보이스가 설정된 아바타와 매칭되는 기존 사용자는 즉시 LiveKit 경로로 라우팅된다. 운영 아바타의 ttsVoice 설정 현황 기준으로 실제 몇 %가 LiveKit으로 가는지, 기존 사용자 마이그레이션 전략(일괄 realtime 고정 여부)을 확인해야 한다.

P1 · 동작 확인

세션 시작이 ppi.agent_ready(10초) 하드 의존. Python agent 디스패치/LiveKit 서버 헬스가 세션 시작의 blocking 의존성이 됐고, 타임아웃 시 폴백 없이 startSession이 throw한다. Realtime엔 없던 실패 지점 — agent 미기동/ECS 스케일 부족 시 아동측 UX(무한 로딩/에러 표시)를 확인할 것.

P1 · 동작 확인

"듣기 OFF"가 마이크 트랙을 더 이상 mute하지 않는다. LiveKit 경로에서는 agent RPC로만 게이팅하고 SFU relay/녹음으로는 아동 음성이 계속 흐른다(host relay 트랙 보호가 이유). 기존 Realtime은 track.enabled=false로 완전 차단했으므로 기대동작/개인정보 관점 차이가 크다. 기획 의도와 일치하는지 명시적 확인 필요.

P1 · 사이드 이펙트

/api/users 권한 축소로 manager 화면 조용한 기능 저하 가능. class-mgmt.tsxfetch("/api/users") 실패를 .catch(() => {})로 삼키는데, 이제 admin/developer 외에는 403이라 manager 롤에서 lessonTypes/대화방식 배지가 조용히 사라진다. 진행자 화면이 이 데이터에 의존하지 않는지 검증 필요.

P1 · 구조

turn↔transcript↔response 상태 머신의 복잡도. agent 쪽 input_gate.resolve_completion·terminalize_user_turn·_select_transcribed_turn_id가 여러 컬렉션(pending/terminalized/awaiting_transcript)으로 turn을 사후 연결한다. 동시 발화/빠른 재발화/STT 지연 시 turn id 오귀속 → 잘못된 drop 또는 응답 누락이 가장 큰 리스크 영역(538줄 테스트가 방증).

P1 · 동작 확인

agent의 session.start()ctx.connect()보다 먼저 호출된다. RPC/텍스트 transport/agent_ready publish는 connect 이후 등록되므로, connect 완료 전 도착하는 초기 데이터/RPC 유실 여지와 브라우저의 agent_ready 대기 순서 계약이 안정적인지 확인 필요.

P2 · 사이드 이펙트

speaking relay 검증 강화가 기존 Realtime 경로에도 적용된다. GUEST_SPEAKING_START/STOP이 이제 role=guest + roomId/peerId 일치 + room join 상태를 모두 통과해야 relay되고, 불일치 시 조용히 드롭(socket_speaking_rejected warn만)된다. 모니터의 발화 표시가 안 뜨는 사이드 이펙트로 이어질 수 있어 실기기 확인 대상. 재접속으로 소켓이 갈리면 SpeakingRelayGuard turn 상태가 초기화되는 점도 진행자 새로고침 계열 이슈와 상호작용 가능.

P2 · 동작 확인

Typecast volume 옵션 제거 + target_lufs -25 상시 강제. 기존에 volume으로 보이스별 음량 보정을 하던 아바타는 보정이 무시되고 -25 LUFS로 정규화된다. iPad loopback 음량 튜닝(0.25 확정) 이력과의 상호작용을 실기기에서 확인할 것.

P2 · 동작 확인

아바타 필수 필드 역전으로 기존 데이터 편집 마찰. ttsVoice가 비어 있는 기존 아바타는 다른 필드만 고쳐 저장하려 해도 "TTS 보이스를 선택해주세요"로 막힌다. 또 realtime voice의 required 해제로 빈 문자열 저장이 가능해져, realtime 모드 아바타의 빈 voice 런타임 처리를 확인해야 한다.

P2 · 검증 확인

STT 상관키 JSON 태그 비대칭. Go STT 서버의 inbound는 request_id, outbound는 client_request_id다. 의도된 비대칭이지만 웹 파서(consumeFinalizeTarget)가 client_request_id로 읽는 계약이 어긋나면 turn 귀속이 조용히 깨진다. 마찬가지로 client/server가 각각 resolver를 실행하는 이중 해석 구조에서 두 시점 사이 설정이 바뀌면 409(LIVEKIT_NOT_AVAILABLE)를 폴백 없이 throw한다.

P2 · 검증 없음

untrusted 참가자의 data도 voice 이벤트로 처리된다. isTrustedLiveKitAgentData(participant kind=AGENT + topic 일치)가 false여도 debug 로그만 남기고 이벤트 변환을 계속 진행한다. room명이 세션별 유니크라 실제 노출은 낮지만 신뢰 검사가 게이트로 동작하지 않는다. agent 쪽도 data_received를 위치 기반 인자 파싱(args[0]/args[3])으로 받아 SDK 시그니처 변화에 취약하고, 텍스트 입력 처리가 fire-and-forget task라 예외가 조용히 소실될 수 있다.

info · 구조

남은 정리 거리. ① dev.yml은 composite action을 안 쓰고 ECS 롤아웃 로직을 인라인 중복 보유(드리프트 위험). ② 인식게이트 config는 전역 단일 문서 + last-write-wins(동시 편집 충돌 감지 없음), 진행 중 세션 미반영. ③ LiveKit 경로에서도 Realtime용 offer/PC를 만들고 즉시 폐기(불필요 비용). ④ micEnabled producer 토글이 clone 트랙 enabled를 만지므로 iPad WebKit clone 무음 계열과 겹치지 않는지 확인 권장(기본 true라 기존 경로 불변).

관련 문서