마지막 업데이트 2026-09-12
LIVEKIT_CLIENT_SESSION | LiveKit agent readiness wait failed 로 AI 세션이 시작되지 못한 사건. 전체 워커 풀에는 빈 슬롯이 11개 있었지만, 그 job 을 처리한 LiveKit 노드에 등록된 워커 11개는 전부 꽉 차 있었다. 그중 3개만 아직 WS_AVAILABLE 로 보고된 상태(막 채워져 status 미갱신)라 후보가 됐고, 셋 다 실제로는 꽉 차서 거절하자 후보가 고갈돼 job 이 폐기됐다. 클라이언트는 통보를 못 받고 10초 뒤 스스로 타임아웃했다.
empty_timeout 으로 적었던 것도 departure_timeout 으로 바로잡았다(6절).
PPI-1237, 커밋 314fb05b) —
① agent.py 에 attach_session_close_job_shutdown() 신설로 사후 점유 20초 제거,
② use-ai-session.ts + livekit-readiness-retry.ts 에 readiness 재시도 1회 + 로깅 추가.
dev 서버 배포 후 검증 예정이며, 확인 항목은 9절 말미에 있다.
노드 간 워커 불균형(11/10/3)은 손대지 않았으므로 근본 원인은 남아 있다.
2026-08-25 기술이슈 일일 리포트에서 LiveKit agent readiness wait 가 긴급으로 2건 잡혔으나, 원본 세션 로그를 열어 보면 실제로는 3건이다. 최시원 세션은 대표 시그니처가 consumer resume 실패로 분류돼 같은 실패가 가려졌다.
| 아동 / 회기 | 클라이언트 타임아웃 | 서버 디스패치 포기 | 아동 체감 |
|---|---|---|---|
| 이나엘 14회기 | 20:10:27.221 | 20:10:17.123 (후보 1개) | - |
| 석우주 6회기 | 20:17:14.612 | 20:17:06.586 (후보 3개) | 20:17:28 "화면/친구가 멈췄어요" 신고 |
| 최시원 6회기 | 20:17:17.742 | 20:17:06.596 (후보 3개) | - |
같은 20:17:06에 944fb00c·4142658b 두 방도 동일하게 폐기됐다. 그 세션들은 다른 이유로 리포트에 안 잡혔을 뿐 같은 사건이다.
// 2026-08-25 20:17 — 석우주·최시원 (서버 시각, ms) 20:17:05.963 AW_64kum ← job 배정 (1→2, 꽉참) 20:17:06.063 AW_KdQyc ← job 배정 (0→1) 20:17:06.133 AW_CzqNH ← job 배정 (1→2, 꽉참) 20:17:06.491 AW_KdQyc ← job 배정 (1→2, 꽉참) ← 623ms 만에 여유 워커 소진 20:17:06.579 석우주 AJ_UTHb5RmiJKKy → AW_64kum worker not available 20:17:06.583 → AW_KdQyc worker not available 20:17:06.586 → AW_CzqNH worker not available 20:17:06.586 no worker available to handle job — no workers with sufficient capacity ↑ 실제 점유는 41/48 (빈 슬롯 7개). 그런데 서버 눈에 AVAILABLE 인 워커는 이 3개뿐이었다 — 나머지는 2.5초 전 보고값 FULL 이 그대로 남아 후보에서 제외 20:17:06.588 최시원 AJ_ZukyvHG3Ckpb → 같은 워커 3개 → 동일하게 폐기 20:17:07.738 AW_yaQqc ← job 배정 다른 방은 1.2초 뒤 정상 수락 — 워커는 있었다 // 클라이언트 (게스트, 세션 로그) 20:17:04.277 LiveKit session descriptor created 20:17:04.524 room connection state = connected 20:17:04.601 livekit_agent_ready_begin ready_probe 전송, 재전송 500/1500/3500ms …에이전트가 방에 들어오지 않으므로 ready_response 없음… 20:17:14.612 LiveKit agent readiness wait failed (LiveKitAgentReadinessTimeoutError) 20:17:14.615 AI_SESSION | Failed to start session 20:17:17.912 UNHANDLED_REJECTION 20:17:28.557 아동: "화면/친구가 멈췄어요"
이나엘 건은 더 단순하다 — AW_tbv3g 가 20:10:16.672/.696 두 번 연속 배정으로 꽉 찬 직후 20:10:17.123 에 그 워커 하나만 후보로 잡히고, 거절당하자 곧바로 폐기됐다. 그 시점 실제 점유는 42/48(빈 슬롯 6개)이었고, 2초 뒤에는 다른 워커 3개가 정상적으로 job을 받았다.
워커 admission 은 CPU 기반 기본 load 함수가 아니라 PPI 자체 함수로 결정된다.
// apps/livekit-agent/agent.py:299-325 PPI_AGENT_MAX_JOBS_PER_WORKER = _parse_positive_int_env(..., 2) PPI_AGENT_LOAD_THRESHOLD = 0.99 def _ppi_agent_load(server): 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, num_idle_processes=PPI_AGENT_MAX_JOBS_PER_WORKER)
워커당 2잡에서 load = 0.99 = threshold → FULL. 실측 ECS 태스크(=워커) 24개이므로 하드 실링은 24 × 2 = 48 동시 세션이다.
| 시점 | 점유 / 48 | 빈 슬롯 | 워커 분포 |
|---|---|---|---|
| 20:10:17 (이나엘 실패) | 42 | 6 | 2×18, 1×6 |
| 20:17:06 (석우주·최시원 실패) | 37 | 11 | 2×14, 1×9, 0×1 |
| 19:30~20:30 최대 | 44 | 4 | - |
assigned job to worker / job ended 로 재구성한 워커별 점유가, CloudWatch 에이전트 로그(ECS 태스크별 received job request / process exiting)로 독립 재구성한 값과 완전히 일치한다 — 20:17:06 = 37/37, 20:10:17 = 42/42. 두 로그는 jobID(AJ_*)를 공유하므로 워커↔태스크가 24/24 모두 1:1로 조인된다.
수요 자체도 스파이크가 아니다. 분당 신규 job 은 최대 24 / 중앙값 11이고, 하루 failed to assign job to worker 57건은 19:00~21:20 수업 피크에 고르게 퍼져 있다(20:17이 10건으로 최다). 문제는 분 단위 총량이 아니라 초 단위 동시성이다 — 1초 창 안에 3건 이상 배정된 경우가 19회 있었고, 실패는 그 창에서 발생했다.
1초 창 동시 배정 건수 분포 (19:30~20:30, n=571) 1건 : 450 2건 : 102 3건 : 15 4건 : 4
prod LiveKit 은 노드 3대로 운영되고, agent worker 는 각자 한 노드에 등록된다. 실패한 job 을 처리한 노드에는 여유 슬롯이 0개였다 — 전체 풀에는 11개가 남아 있었는데도.
| 노드 | 등록 워커 | 실제 여유 슬롯 | 서버가 아는 AVAILABLE |
|---|---|---|---|
ip-172-31-46-118 ← 석우주·최시원 job 처리 | 11개 | 0개 | 3개 (전부 stale) |
ip-172-31-10-236 | 10개 | 5개 | 5개 |
ip-172-31-8-23 | 3개 | 3개 | 3개 |
| 합계 | 24개 | 8개 | 11개 |
AW_64kum·AW_KdQyc·AW_CzqNH)는 모두 46-118 등록 워커이고, 그 노드의 11개는 그 순간 전부 슬롯이 찼다.
46-118 노드 하나다.
그 노드의 AgentHandler 가 순회하는 namespaceWorkers 에는 자기 워커 11개만 들어 있고, 그중 WS_AVAILABLE 은 stale 한 3개뿐이었다.
셋 다 거절하자 availableSum == 0 — 그 노드에게는 정확한 판단이었다.
숫자도 맞아떨어진다: 전체 11 = 진짜 여유 8(다른 노드) + stale 3(그 노드).
워커 24개의 슬롯 점유(서버 로그)와 status 보고(에이전트 로그)를 워커별로 교차검증했다. 21/24 일치, 불일치 3개가 정확히 서버가 시도한 그 3개다.
// 20:17:06.579 시점 교차검증 (일부) ✓ AW_yyWWGxpFtSqx 슬롯 2/2 → 예상 FULL status로그 FULL ✓ AW_tbv3g4aLW5Ps 슬롯 2/2 → 예상 FULL status로그 FULL ✗ AW_64kumGuumT5B 슬롯 2/2 → 예상 FULL status로그 AVAIL ← 20:17:05.963 에 막 참 ✗ AW_KdQycxRaFqV3 슬롯 2/2 → 예상 FULL status로그 AVAIL ← 20:17:06.491 에 막 참 ✗ AW_CzqNH6oz4ifh 슬롯 2/2 → 예상 FULL status로그 AVAIL ← 20:17:06.133 에 막 참 ✓ AW_ov8jJiiuVrLX 슬롯 1/2 → 예상 AVAIL status로그 AVAIL ← 여유 있으나 다른 노드
worker.py:1471 _update_worker_status() 가 상태가 바뀔 때 로그를 남기고, prod 에 실제로 찍힌다 —
"worker is at full capacity, marking as unavailable" {"load": 0.99, "threshold": 0.99} (2잡),
"worker is below capacity, marking as available" {"load": 0.495} (1잡).
agent.py 의 active_jobs / 2 × 0.99 값이 그대로 나온다.
// pkg/service/agentservice.go — JobRequest attempted := make(map[*agent.Worker]struct{}) for { // 상한 없음 selected, err := h.selectWorkerWeightedByLoad(key, attempted) if err != nil { "no worker available to handle job"; return } attempted[selected] = struct{}{} ... } // selectWorkerWeightedByLoad — 후보는 WS_AVAILABLE 인 워커만, 여유(1-load) 비례 가중 랜덤 if availableSum == 0 { return errors.New("no workers with sufficient capacity") }
v1.13.2 로 확정됐다. 기동 로그에 {"msg":"starting LiveKit server","version":"1.13.2","nodeID":"ND_D68geB58ANRL","nodeIP":"3.34.194.144"}(2026-07-28)가 남아 있고, 그 태그 소스의 로그 라인이 prod 로그 caller 와 정확히 일치한다 — 402/412/428 (master 는 415/425/441). 그리고 v1.13.2 의 JobRequest 는 위 master 코드와 동일하다. 재시도 상한은 존재하지 않는다.
// v1.13.2 — pkg/service/agentservice.go :148 namespaceWorkers map[workerKey][]*agent.Worker // AgentHandler(=노드) 로컬 맵 :233 func (h *AgentHandler) HandleConnection(...) // 워커가 WebSocket 으로 붙을 때 :300 h.namespaceWorkers[key] = append(workers, w) // ← 그 노드에만 등록 // selectWorkerWeightedByLoad 는 h.namespaceWorkers 만 순회 = 자기 노드 워커만 본다
// v1.13.2 — 어느 노드가 이 job 을 처리할지 정하는 계산 func (h *AgentHandler) JobRequestAffinity(ctx, job) float32 { var affinity float32 for _, w := range h.workers { if w.Status() == livekit.WorkerStatus_WS_AVAILABLE { affinity += max(0, h.targetLoad-w.Load()) } } return affinity }
46-118 노드는 실제로 슬롯이 다 찼는데 stale AVAILABLE 3개 덕분에 affinity 가 0 이 아니어서 job 을 가져왔다.
② 그 노드 안에서 그 3개가 유일한 후보가 됐고, 실제로는 꽉 차서 전부 거절했다.
WS_AVAILABLE 인 워커가 stale 한 3개뿐이었고, 셋 다 실제로는 꽉 차서 거절했다. attempted 로 제외되자 availableSum == 0 이 되어 job 이 폐기됐다.
| 주장 | 등급 | 근거 |
|---|---|---|
| 워커별 슬롯 점유 수 | 팩트 | 서버·에이전트 두 로그 독립 재구성 일치(37/37, 42/42) |
| 워커 status 전환(FULL/AVAILABLE) | 팩트 | prod 에이전트 로그 405건, load/threshold 값 포함 |
| 거절한 3개가 stale AVAILABLE 이었다 | 팩트 | 슬롯 2/2 vs status AVAIL 불일치가 정확히 그 3개(21/24 일치) |
| 실패 노드의 여유 슬롯 0개 | 추론 | 워커→노드 매핑을 assigned job to worker 가 찍힌 노드의 다수결로 추정했다. 실제 WebSocket 등록 노드를 직접 확인한 것은 아니다 |
| 다른 노드 워커는 후보가 될 수 없다 | 팩트 | namespaceWorkers 가 노드 로컬 맵이고(:148, :300) 후보 선정이 그것만 순회한다 — v1.13.2 소스 |
| 재시도 상한이 없다 / prod 버전 v1.13.2 | 팩트 | 기동 로그의 version 필드 + 해당 태그 소스의 로그 라인이 prod caller(402/412/428)와 일치 |
| 마른 노드가 stale status 때문에 선택됐다 | 추론 | JobRequestAffinity 가 WS_AVAILABLE 워커의 여유 합으로 계산되는 것은 코드 확정. 다만 노드 선택 결과 자체는 로그에 남지 않아 직접 관측하지 못했다 |
아동이 방을 떠난 뒤에도 job 이 워커 슬롯을 정확히 20.1초 더 잡고 있다. 편차가 거의 없다(n=571, 중앙값 20.1s / p10 19.7 / p90 20.5) — LiveKit room departure_timeout 만료로 방이 닫힐 때까지 agent participant 가 살아 있기 때문이다. prod 로그로 확정했다(아래 6절).
// 같은 방(b47e6620) 3개 로그 소스 대조 20:16:02.295 [서버] participant closing user-…b47e6620 CLIENT_REQUEST_LEAVE 20:16:02 [에이전트] closing agent session due to participant disconnect (RoomInputOptions.close_on_disconnect — 세션은 즉시 닫힘) ↓ 20초 공백 — 세션은 끝났는데 job process 는 살아 있고 슬롯을 점유 20:16:22.584 [서버] participant closing agent-AJ_QLqYtvKjYBkk ROOM_CLOSED 20:16:22 [에이전트] process exiting job_id=AJ_QLqYtvKjYBkk 20:16:22.589 [서버] room closed
| 지표 | 실측 |
|---|---|
| 아동 재실 시간 (배정 → 아동 퇴장) | 중앙값 103.0s / 평균 115.0s |
| 사후 점유 (아동 퇴장 → job ended) | 중앙값 20.1s / p10 19.7 / p90 20.5 |
| 전체 슬롯·시간 중 사후 점유 비율 | 14.9% |
| 동시 점유 피크 — 실제 / 즉시 반환 가정 | 44 → 37 |
| 상위 5% 수준 — 실제 / 즉시 반환 가정 | 40 → 34 |
job 프로세스가 끝나는 트리거는 ipc/job_proc_lazy_main.py 에 정확히 셋뿐이다.
# livekit-agents 1.6.4 — ipc/job_proc_lazy_main.py :240 if isinstance(msg, ShutdownRequest): # ① 부모 워커가 종료 요청 (draining 등) ...self._shutdown_fut.set_result(...) :279 @self._room.on("disconnected") # ② 방에서 끊길 때 :280 def _on_room_disconnected(*args): :282 self._shutdown_fut.set_result(_ShutdownInfo(reason="room disconnected")) :289 def _on_ctx_shutdown(reason): # ③ ctx.shutdown() 호출 시 :293 self._shutdown_fut.set_result(_ShutdownInfo(user_initiated=True, ...))
아동이 나갈 때 실제로 실행되는 코드는 이 셋 중 어느 것도 아니다 — 세션만 닫는다.
# voice/room_io/room_io.py — _on_participant_disconnected :404 if (self._options.close_on_disconnect and participant.disconnect_reason in DEFAULT_CLOSE_ON_DISCONNECT_REASONS ...): :411 logger.info("closing agent session due to participant disconnect ...") :420 self._agent_session._close_soon(reason=CloseReason.PARTICIPANT_DISCONNECTED) ↑ 세션만 닫는다. job 은 건드리지 않는다 # voice/agent_session.py:937 _close_soon → _aclose_impl # → ctx.shutdown() 도, ShutdownRequest 도 호출하지 않는다
agent.py 의 ppi_agent() 에는 ctx.shutdown() 호출이 없고(③ 미사용), 평상시 draining 도 아니다(① 미사용). 결국 방이 닫혀 room disconnected 가 발생해야만 job 이 끝나고 워커 슬롯이 반납된다. 그 방이 닫히는 시점이 LiveKit 의 departure_timeout 만료(ROOM_CLOSED)이고, 실측상 마지막 아동 퇴장 +20.1초다.
departure_timeout 으로 확정됐다. 초판은 이를 empty_timeout 으로 적었으나 두 값은 다르다 — empty_timeout(기본 300초)은 아무도 들어온 적 없는 방, departure_timeout(기본 20초)은 다 나간 뒤의 방이다. prod 로그의 reason 필드가 이를 직접 말해 준다(6절 참조).
voice/room_io/types.py:17 의 세 가지뿐이다 —
CLIENT_INITIATED / ROOM_DELETED / USER_REJECTED.
실측에서 본 PEER_CONNECTION_DISCONNECTED(네트워크 순단)는 목록에 없어 세션이 유지되고, 세션이 닫히지 않으면 close 이벤트도 없으므로 아래 수정도 발동하지 않는다.
게다가 SESSION_CLOSE_JOB_SHUTDOWN_REASONS 를 participant_disconnected 하나로 한정했다.
LiveKit 의 departure_timeout 은 "마지막 참가자가 나간 뒤 재접속할 시간을 준다"는 목적의 유예다. 그 목적이 우리 서비스에서 달성되고 있는지 19:30~20:30 전수로 확인했다.
| 지표 | 결과 |
|---|---|
room closed − 마지막 아동 퇴장 | n=575, 중앙값 20.1s (min 19.6 / max 20.7) — 예외 없음 |
| 25초를 넘긴 방 (= 유예 중 재입장 정황) | 0건 |
| 한 방에 job 이 2회 이상 배정된 방 | 0 / 571 |
| 아동 퇴장이 2회 찍힌 방 | 1개 — 재입장이 아니라 PEER_CONNECTION_DISCONNECTED 20초 뒤 CLIENT_REQUEST_LEAVE 로 그대로 이탈 |
close_on_disconnect 로 세션을 닫으므로, 20초 안에 돌아와도 방 껍데기만 있고 대화 상태는 이미 소멸한 뒤다. 새 job 도 배정되지 않는다(위 0/571).
voiceSessionId 기반이라(apps/web/app/api/livekit/call/route.ts:71 createRoomName) 같은 세션 ID 로 다시 요청하면 같은 방으로 들어간다. 이 표본에서 그 경로가 도는 것을 관측하지 못했을 뿐이다.
departure_timeout 확정어디에도 "20초를 기다려라"는 코드는 없다. 20초를 만드는 주체는 LiveKit 서버이고, 에이전트 job 은 그 결과(방이 닫히는 것)를 기다릴 뿐이다.
reason 필드가 직접 말해 준다// Loki {service_name="ppi-livekit", env="prod"} |= "closing idle room" (20:14~20:18, 72건) 20:14:01.590 caller=rtc/room.go:804 msg="closing idle room" reason="departure timeout" 20:14:08.677 caller=rtc/room.go:804 msg="closing idle room" reason="departure timeout" ... 20:17:36.587 reason="departure timeout" room=ppi-7f7291c4-…-taeyang2_room-0eecea6f ← 석우주의 실패한 방 20:17:38.587 reason="departure timeout" room=ppi-98a60cf9-…-newpp06_woosung-746cbe6c ← 최시원의 실패한 방 reason="empty timeout" 은 0건
// github.com/livekit/livekit — pkg/rtc/room.go, CloseIfEmpty() if r.FirstJoinedAt() > 0 && r.LastLeftAt() > 0 { elapsed = time.Now().Unix() - r.LastLeftAt() timeout = r.protoRoom.DepartureTimeout // 누군가 들어왔다가 다 나간 방 ← 우리 케이스 reason = "departure timeout" } else { elapsed = time.Now().Unix() - r.protoRoom.CreationTime timeout = r.protoRoom.EmptyTimeout // 아무도 들어온 적 없는 방 reason = "empty timeout" } if elapsed >= int64(timeout) { r.Close(types.ParticipantCloseReasonRoomClosed) r.logger.Infow("closing idle room", "reason", reason) } // pkg/config/config.go — 기본값 :550 EmptyTimeout: 5 * 60, // 300초 :551 DepartureTimeout: 20, // ← 실측 20.1초와 일치
time.Now().Unix() - r.LastLeftAt() 은 초 단위 정수 연산이라 구조적으로 최대 1초 오차가 생긴다. 실측 분포가 min 19.6 / max 20.7 (n=575)로 정확히 그 폭에 들어온다.
caller=rtc/room.go:804 는 master 의 :805 와 1줄 차이다. 4절에서 추론으로 남겨둔 agentservice.go 쪽(로그 402/412/428 vs master 415/425/441)도 비슷한 정도의 근소한 차이일 가능성이 높아졌다. 다만 그쪽은 여전히 확정이 아니다.
room.go:298~300 은 protoRoom.DepartureTimeout == 0 일 때만 서버 config 값으로 채운다 — 즉 토큰의 RoomConfiguration 이 서버 설정보다 우선이다. 우리는 이미 apps/web/lib/voice-agent/livekit-token.ts:107 에서 RoomConfiguration 을 만들어 싣고 있고(지금은 agents 만), @livekit/protocol 에 departureTimeout 필드가 존재한다. 서버를 건드리지 않고 방별로 낮출 수 있다는 뜻이다.
livekit.yaml 에서 명시 설정한 것인지는 구분할 수 없다(기본값도 20). 다만 동작은 확정됐으므로 실질적 영향은 없다.
| 대상 | 경로 |
|---|---|
| LiveKit 서버 (방·디스패치) | Loki {service_name="ppi-livekit", env="prod"} |
| LiveKit agent (워커·job) | CloudWatch /ppi/livekit/prod/agent, logStream agent/ppi-livekit-agent/<taskId> |
| 클라이언트 (게스트·모니터) | S3 prod/lesson-logs/{userId}/lesson-{n}/archives/lesson-log-YYYYMMDD.jsonl.gz |
/explore 링크가 Home 으로 리다이렉트된다.
커스텀 앱 /a/ppilogbrowser-app/log-browser 를 쓰면 CloudWatch 뿐 아니라 Loki 서비스(ppi-livekit)도 같은 UI 에서 조회되고, URL 파라미터로 바로 재현된다.
?env=prod&service=ppi-livekit&instance=All&from=2026-08-25T20:14:00&to=2026-08-25T20:18:00&search=closing%20idle%20room
(from/to 는 ISO 형식이면 적용되고 epoch ms 는 Invalid date 로 깨진다)
env 라벨을 빼면 dev 가 섞인다. service_name="ppi-livekit" 만으로 조회하면 dev 노드(ip-172-31-1-57) 로그가 함께 나온다. 판별자는 agentName: "ppi-agent-local" + apiKey: "lkdev_…" = dev, agentName: "ppi-agent" = prod. prod 는 노드 2대 이상으로 운영된다.
job ended 가 대응하는 assigned 보다 먼저 처리돼 유실되고, 그 job 이 영원히 활성으로 남는다. 실측 오차 37 → 83 (2.2배). 반드시 전체를 timestamp 로 정렬한 뒤 상태를 재구성한다.
not dispatching agent job since no worker is available 는 노이즈다. JT_PARTICIPANT / JT_PUBLISHER 디스패치에서 정상 방에도 매번 찍힌다(이 프로젝트는 JT_ROOM 만 사용). 실제 신호는 failed to assign job to worker (워커가 거절) 와 no worker available to handle job (서버가 포기) 두 개다.
WS_AVAILABLE status 다. 실제 점유(재구성값)와 서버 시야(status)는 최대 2.5초 어긋난다. 두 값을 같은 것으로 놓으면 원인을 태스크 증설로 오진한다.
LiveKit session descriptor created 의 roomName 을 얻는다 (UUID 접미사가 AI 스텝마다 다르다 — 세션 roomId 로 검색하면 수십 개가 섞인다)assigned job to worker 가 있는지 본다 — 없고 no worker available to handle job 이 있으면 이 사건이다assigned job to worker 타임라인을 뽑아 거절한 워커들이 직전 1초 내에 채워졌는지 확인한다 → 버스트 경합 확정에이전트가 들어오지 않으면 클라이언트는 10초를 기다린 뒤 세션 시작을 포기했다. 실패했다는 통보조차 받지 못한다 — 서버는 job 을 폐기하고 아무것도 알려주지 않는다.
apps/web/lib/voice-agent/livekit-client-session.ts:49 — LIVEKIT_AGENT_READY_TIMEOUT_MS = 10000:306 createLiveKitAgentReadinessGate — 타임아웃 시 LiveKitAgentReadinessTimeoutError:1714~1727 — ready_probe 를 500 / 1500 / 3500ms 에 재전송. 받을 상대가 없으므로 무의미하다:1819 — logger.error("LiveKit agent readiness wait failed") 후 throw → AI_SESSION | Failed to start sessionapps/web/lib/voice-agent/livekit-readiness-handshake.ts (ppi.ready_probe → ppi.ready_response → ppi.ready_ack, topic ppi-agent-control)원인이 노드 포화든 stale status 든 재시도는 원인과 무관하게 동작한다. 실측상 실패 1.2초 뒤에는 다른 방이 정상 수락됐다.
// apps/web/lib/voice-agent/livekit-readiness-retry.ts (신설, 순수 함수) READINESS_RETRY_MAX_ATTEMPTS = 1 READINESS_RETRY_DELAY_MS = 800 shouldRetryAgentReadiness(error, attemptsMade) ① error.name === "LiveKitAgentReadinessTimeoutError" ② attemptsMade < MAX // getUserMedia·네트워크 등 다른 실패는 재시도 안 함 // apps/web/entities/guest-session/model/use-ai-session.ts:4658 handleStartSession startSession() 실패 → readiness_retry_scheduled {attempt, elapsedMs, reason} → 800ms 대기 → generation 변경 확인 // 스텝이 넘어갔으면 readiness_retry_abandoned 후 중단 → startSession() 재호출 createVoiceSessionId() 가 새 UUID → 새 방 → 노드·워커 재선택 → 성공 readiness_retry_succeeded / 최종 실패 readiness_retry_exhausted
voiceSessionId 로 재접속하면 살아있는 방에 혼자 다시 들어가는 것이고, launchRoomAgents 는 방 생성 시 1회만 호출되므로(room.go:310) 새 job 이 디스패치되지 않는다. createVoiceSessionId 가 호출마다 새 UUID 를 반환하는 덕에 이 조건이 자연히 충족된다.
LIVEKIT_AGENT_READY_TIMEOUT_MS 10초는 이번 변경에서 건드리지 않았다. 실측 정상 readiness 는 최대 1.62초(n=29, 중앙 0.70 / p90 1.46)라 6초로 줄일 근거는 있으나, 표본이 3세션이고 단축은 정상 케이스에도 영향을 주므로 별도 판단으로 남긴다.
원인이 노드 단위 포화로 좁혀지면서 우선순위가 바뀌었다. 각 노드의 여유 워커를 늘리는 조치가 유효하고, 클라이언트 재시도가 유일한 확실한 안전망이다.
JobRequestAffinity 도 WS_AVAILABLE 기반이라는 것이 확인되면서 stale status 가 두 경로로 작용한다는 것이 드러났다 — 마른 노드가 job 을 가져오게 만들고(①), 그 노드 안에서 헛후보가 된다(②). ①은 실패를 직접 만든다 — 여유 있는 노드로 갔다면 성공했을 것이다. 따라서 폐기가 아니라 노드 분산 점검 다음 후보로 남긴다. 비용은 여전히 livekit-agents 내부 상수 monkeypatch 이며, job 시작·종료 시 즉시 전송하는 경로는 upstream 에 없다.
| # | 조치 | 위치 | 기대 효과 / 근거 | 리스크 |
|---|---|---|---|---|
| 1 | 워커당 슬롯 2→3 | env PPI_LIVEKIT_AGENT_MAX_JOBS_PER_WORKER코드 수정 불필요 |
2잡일 때 load 가 2/3 × 0.99 = 0.66 < 0.99 라 FULL 진입 자체가 일어나지 않는다 → 2.5초 사각지대에 노출될 일이 없다. 이번 메커니즘을 가장 직접 끊는다 |
워커당 STT·LLM·TTS 3개 동시 → CPU·메모리. num_idle_processes 도 3으로 올라 silero VAD prewarm 메모리 증가. 실측 필요 |
| 2 | 사후 점유 20초 제거 — 세션 종료 시 ctx.shutdown() 로컬 적용 |
agent.py:1709 attach_session_close_job_shutdown() 신설 + :2934 에서 호출테스트 tests/test_session_close_job_shutdown.py 10건 |
점유 42→34 · 37→29, 피크 44→37. FULL 워커 수가 직접 줄어든다. 5절 실측대로 이 20초는 575번 중 한 번도 쓰이지 않았다 | 아동 순단 후 같은 방 재접속 경로 확인 필요. 방 유예는 그대로 두고 워커 슬롯만 먼저 반납하는 형태라 영향은 작을 것으로 본다 |
| 3 | 클라이언트 readiness 실패 시 재시도 로컬 적용 | lib/voice-agent/livekit-readiness-retry.ts 신설(테스트 9건) + use-ai-session.ts:4658 배선 |
1·2 는 확률을 낮출 뿐이고, 실패를 실제로 차단하는 유일한 안전망이다. 실패 1.2초 뒤에는 다른 방이 정상 수락됐으므로 짧은 백오프 1회면 3건 모두 복구됐을 것 | 재시도 중 아동 대기 시간 증가. 현재 10초 타임아웃과의 합산 설계 필요 |
| + | 노드 간 워커 분산 점검 | 인프라 (ECS ↔ LiveKit 노드 연결) | 실측 분포가 11 / 10 / 3 으로 고르지 않았다. 한 노드가 마르면 전체에 여유가 있어도 실패한다 — 이번 사건의 형태가 정확히 그것이다 | 워커가 어느 노드에 붙는지 직접 확인하지 못했다(4절 추론). 먼저 실제 등록 분포부터 확인해야 한다 |
PPI_AGENT_LOAD_THRESHOLD 를 0.99→1.0 으로 올려 꽉 찬 워커의 서버측 가중치를 1−load = 0.01 에서 정확히 0 으로 만드는 안을 검토했으나 효과가 없다. 그 가중치는 이미 WS_AVAILABLE 로 걸러진 워커들 사이의 추첨값일 뿐이고, 이번 문제는 그 앞단(해당 노드에 여유 워커가 없었던 것)에서 발생한다.
departure_timeout 을 낮추지 않고 ctx.shutdown() 을 택했나.
6절대로 토큰의 RoomConfiguration.departureTimeout 으로 방별 제어가 가능하다(livekit-token.ts:107).
그러나 그것은 방 자체를 빨리 닫는 것이라 재접속 유예까지 함께 깎인다.
ctx.shutdown() 은 방 정책은 그대로 두고 job 만 먼저 빠져나오게 하므로 부작용 범위가 좁다.
5절 실측상 유예는 575건 중 한 번도 쓰이지 않았지만, 표본이 한 시간이라 유예 자체를 없애는 쪽은 보류했다.
| 확인할 것 | 어디서 |
|---|---|
job ended − 아동 퇴장 간격이 20초 → 1초 미만 | Loki {service_name="ppi-livekit"} |
requesting job shutdown after session close 로그 | CloudWatch /ppi/livekit/<env>/agent |
readiness_retry_scheduled / _succeeded / _exhausted | 세션 로그 |
| 재시도 시 아동 화면 끊김 여부(새 방으로 LiveKit 재연결) | 실기기 육안 |
| 순단 시 오발동 없음 | 실기기 — 네트워크 차단 후 복구 |
no worker available to handle job 57건, 그중 세션 실패까지 간 것 3건. ①은 앞 숫자를, ②는 뒤 숫자를 줄인다. 두 지표가 층별로 갈리므로 함께 배포해도 각각 측정된다.
같은 사건의 상위 리포트: 2026-08-25 수업 로그 기술이슈 · 수업 로그 기술이슈 리포트 포맷 before/after (8/24 20:14 동시 발생 건을 "에이전트측 사건"으로 처음 지목한 문서)
LiveKit agent 계열 선행 조사: PPI-1227 LiveKit agent AI 미발화·기계음 3계층 진단 로그 · AI 미발화 — 빈 전사 캐시 키가 앞 턴 판정을 재생 · PPI-1233 영상 스텝 중 AI 발화 출력 · LiveKit 방 전환 레이스 — 아동 마이크·AI 오디오 무음