쉬운 설명 · Architecture · 2026-09-16

핑퐁이가 방에 들어오기까지

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

아동이 AI 스텝에 들어가면, 그 몇 초 사이에 방이 생기고 · 일감이 만들어지고 · 대기하던 AI 일꾼이 그 방에 입장합니다. 방 생성 · 워커 배정 · job · AI 세션 연결의 전 구간을 그림으로 봅니다.

기준 코드: develop 7b6b1a75대상: LiveKit agent 모드 (V2 수업)

한 줄 요약. 웹서버는 입장권(토큰)만 만들고, 아동이 그 입장권으로 들어가는 순간 이 생기고, 토큰 안의 호출벨을 본 LiveKit 서버가 대기 중인 워커에게 job을 던지면, 워커가 같은 방에 참가자로 들어와 AI 세션을 켭니다. — 방을 미리 만드는 코드는 어디에도 없습니다.

01전체 그림

시간은 위에서 아래로 흐릅니다. 웹서버는 ③까지만 관여하고, 이후 대화는 브라우저와 워커가 LiveKit 방 안에서 직접 주고받습니다.

아동 브라우저 PPI 웹서버 Valkey LiveKit 서버 Agent worker ① 세션 요청 ② 지침·목소리 설정 저장 ③ 입장권(토큰) 발급 ④ 입장 — 이때 방이 생긴다 ⑤ 일감(job) 배정 ⑥ 저장해 둔 설정 읽기 ⑦ 같은 방에 입장 ⑧ “준비됐어?” / “응” 악수 대화 시작
그림 1. 세션 연결 전 구간. ①~③은 HTTP, ④ 이후는 전부 LiveKit 방 안에서 일어납니다.

02방을 예약하지 않는다, 이름만 정한다

아동이 AI 스텝에 들어가면 브라우저가 POST /api/livekit/call을 부릅니다. 웹서버는 로그인 세션과 활동·아바타가 서로 맞는지 확인한 뒤, 방 이름을 문자열로 조립합니다.

방 이름 규칙ppi-{userId}-{avatarId}-{voiceSessionId}
LiveKit에 “방 만들어줘”라고 요청하는 API 호출은 코드 어디에도 없습니다. 이 이름으로 누군가 접속하면 그때 방이 생깁니다.

03긴 지침은 사물함에 넣고, 번호표만 들려 보낸다

활동 지침(프롬프트)·TTS 목소리·VAD 설정을 한 덩어리(modelConfig)로 만들어 Valkey에 2시간(7200초) 짜리로 저장합니다. 토큰에는 원문 대신 사물함 번호만 담습니다. 지침이 수만 자라 토큰에 실을 수 없기 때문입니다.

사물함 번호 = configRefppi:livekit:session-config:{stage}:{voiceSessionId}
web과 agent가 같은 Valkey를 봐야 합니다. 웹이 쓴 키를 agent가 읽는 구조라, 서로 다른 Redis를 보면 agent가 설정을 못 찾아 세션이 시작되지 않습니다.

04입장권 하나가 세 가지 일을 한다

웹서버가 돌려주는 토큰은 단순한 출입증이 아닙니다. 들어갈 방과 권한, 사물함 번호, 그리고 “이 방엔 ppi-agent를 불러줘”라는 호출벨이 함께 들어 있습니다.

입장권 (AccessToken · JWT) 1. 들어갈 방과 권한 room · canPublish · canSubscribe 2. 사물함 번호 metadata.configRef 3. 호출벨 roomConfig.agents = [ppi-agent] Valkey 설정 스냅샷 지침 · 목소리 · VAD 워커 호출 이름이 같은 워커에게만
그림 2. 토큰이 실어 나르는 세 가지. 호출벨(RoomAgentDispatch)이 없으면 방은 만들어지지만 AI는 영원히 들어오지 않습니다.

05일꾼은 미리 와서 기다리고 있다

Agent worker는 ECS/Fargate에서 항상 떠 있고, 부팅하면서 LiveKit 서버에 ppi-agent라는 이름으로 등록합니다. 음성 감지기(Silero VAD)까지 미리 올려둔 빈 프로세스 2개를 품고 대기합니다. 방이 생기면 그중 여유 있는 워커에게 일감이 갑니다.

LiveKit 서버 새 방 = 새 job 워커 1개당 동시 처리 상한 2 worker A 가득 참 worker B 자리 1칸 worker C 비어 있음 job 하나 = 방 하나 빈 프로세스에 꽂혀 실행
그림 3. 배정 기준은 CPU가 아니라 진행 중인 job 수입니다(_ppi_agent_load). CPU는 늦게 올라서, 그대로 두면 한 워커가 방을 여러 개 떠안습니다.
자주 밟는 함정. 로컬 워커와 개발 서버 워커가 같은 이름으로 등록되면 내 방이 남의 워커에게 갈 수 있고, 그 워커는 내 로컬 Valkey를 못 읽어 세션이 실패합니다. 로컬 테스트는 LIVEKIT_AGENT_NAME=ppi-agent-local로 이름을 나눕니다.

06일감을 받은 워커의 첫 일 — 사물함 열기

job이 시작될 때 워커가 아는 것은 방 이름뿐입니다. 그래서 먼저 방의 참가자 목록을 조회해 아동 토큰에 적힌 사물함 번호를 찾고, Valkey에서 이번 수업용 설정을 꺼냅니다. 그 설정의 pipeline_mode 한 줄이 AI의 몸을 결정합니다.

half_cascade 아동 목소리 OpenAI Realtime 듣기 + 생각을 한 덩어리로 Typecast TTS 핑퐁이 voice_pipeline 아동 목소리 Soniox STT 듣기 GPT 생각 Typecast TTS 핑퐁이
그림 4. 차이는 가운데 한 칸입니다. 위는 듣기와 생각이 붙어 있어 빠르고, 아래는 둘을 떼어놔 갈아 끼우기 쉽습니다. 말을 소리로 바꾸는 마지막 단계는 양쪽 다 같습니다.

07세션을 켜고, 그제서야 방에 들어간다

순서가 중요합니다. AI 세션을 먼저 켜 둔 뒤(session.start()) 방에 접속합니다(ctx.connect()). 접속하자마자 아동 목소리가 쏟아지는데 받을 준비가 안 돼 있으면 첫마디를 놓치기 때문입니다.

입장 후에는 진행자가 누르는 버튼들 — 듣기 켜기/끄기, 끼어들기 허용, 활동 전환, 강제 중단 — 을 받을 RPC 창구를 열고, 마지막으로 ppi.agent_ready를 방에 방송합니다.

08“너 맞아?” — 악수로 짝을 확인한다

브라우저는 그 방송을 그냥 믿지 않습니다. 직접 “준비됐어?”를 던지고, 돌아온 답이 지금 이 시도의 것인지 네 가지 값으로 대조합니다. 재접속이 잦은 환경에서 이전 시도의 늦은 응답을 진짜로 착각하면, 이미 끝난 세션으로 대화를 시작하게 됩니다.

브라우저 지금 agent 준비됐어? (ppi.ready_probe) 0.5초 · 1.5초 · 3.5초 뒤 재시도 응, 나야 (ppi.ready_response) 확인했어 (ppi.ready_ack) 이전 시도의 늦은 응답 네 값이 안 맞으면 그냥 버림
그림 5. 대조하는 네 값: 방 이름 · 음성 세션 ID · 세대(generation) · 시도 ID(sessionAttemptId). 이 악수가 끝나야 아동 마이크가 열립니다.

09끝나면 자리를 비운다

아동이 나가면(participant_disconnected) job이 닫히고, 워커의 두 자리 중 하나가 다시 빕니다. 다음 수업이 그 자리로 들어옵니다. Valkey에 넣어둔 설정은 2시간 뒤 알아서 사라집니다.

단, 워커 자체는 안 죽습니다. 방과 job은 매번 새로 태어나지만 워커는 ECS에 며칠씩 살아 있습니다. 그 “안 바뀌는 쪽”이 느려지는 현상은 워커 열화 현상 문서에서 다룹니다.

10자주 헷갈리는 세 가지

질문
방은 누가 만드나?아무도 만들지 않습니다. 토큰에 적힌 이름으로 아동이 접속하는 순간 생깁니다.
AI는 누가 부르나?웹서버가 아니라 LiveKit 서버입니다. 토큰 안의 RoomAgentDispatch를 보고 이름이 같은 워커에게 job을 넘깁니다.
지침은 어디로 가나?토큰이 아니라 Valkey로 갑니다. 토큰엔 사물함 번호(configRef)만 있고, 워커가 직접 열어 봅니다.

11코드에서 어디를 보면 되나

무엇위치
세션 요청 · 권한 확인apps/web/app/api/livekit/call/route.ts:85 POST()
방 이름 생성apps/web/app/api/livekit/call/route.ts:71 createRoomName():212
modelConfig 조립apps/web/lib/voice-agent/livekit-token.ts:125 buildLiveKitModelConfig()
설정 사물함 저장 (TTL 7200s)apps/web/lib/voice-agent/livekit-session-config-store.ts:201 setLiveKitSessionConfigSnapshot(), 키 규칙 :148
토큰 · 호출벨apps/web/lib/voice-agent/livekit-token.ts:89 createLiveKitParticipantToken(), :113 RoomConfiguration({ agents: [RoomAgentDispatch] })
디스패치 이름apps/web/lib/voice-agent/livekit-token.ts:24 LIVEKIT_AGENT_NAME (기본 ppi-agent)
워커 등록 · 빈 프로세스 2개apps/livekit-agent/agent.py:370 AgentServer(...), :377 prewarm()
배정 기준 (CPU 아님)apps/livekit-agent/agent.py:355 _ppi_agent_load(), 상한 PPI_AGENT_MAX_JOBS_PER_WORKER 기본 2
job 진입점apps/livekit-agent/agent.py:4493 @server.rtc_session(agent_name=PPI_AGENT_NAME)
사물함 열기apps/livekit-agent/agent.py:384 _fetch_model_config() — 참가자 metadata → Valkey
세션 기동 후 입장 순서apps/livekit-agent/agent.py:4563 session.start():4567 ctx.connect():4576 ppi.agent_ready
브라우저 접속 · 마이크 발행apps/web/lib/voice-agent/livekit-client-session.ts:3609 room.connect()
ready 악수 (브라우저)apps/web/lib/voice-agent/livekit-readiness-handshake.ts:112, 재시도 livekit-client-session.ts:3625
ready 악수 (agent)apps/livekit-agent/readiness_transport.py:46 build_ready_response()
호출 시작점 (게스트)apps/web/entities/guest-session/model/use-ai-session.ts:270

관련 문서