LiveKit agent 오류·프롬프트 상태와 템플릿 감사 로그

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

dcbbd4ab charles-na · 2026-09-08 Feature 16 files +595 −29

사후에 원인을 판별할 수 없던 두 신고 — 토큰 초과 뒤 빈 프롬프트로 AI 세션이 시작되는 경우와 활동 템플릿 삭제 뒤 회기 생성이 실패하는 경우 — 에 대해 기록만 추가한 PR이다. 동작 변경은 없다.

가장 먼저 볼 지점은 agent.pysession.on("error") 구독이다. 지금까지 이 이벤트에 구독자가 아무도 없어 SDK 안에서 오류가 소멸하고 있었다. 그다음은 삭제 감사 로그를 try 밖으로 뺀 이유다.

무엇이, 왜 바뀌었나

S3 진단 로깅 개선 적용 계획의 P0-1(LiveKit 오류·프롬프트 상태) / P0-2(삭제·생성 감사) 범위만 구현했다. 새 전달 경로를 만들지 않고 기존 것을 재사용한다.

흐름 — 지침이 agent에 도달하고, 그 상태가 돌아오는 길

1

지침 스냅샷 저장, 토큰엔 configRef만

lib/voice-agent/livekit-session-config-store.ts

지침 원문은 브라우저를 거치지 않는다. Valkey에 저장하고 짧은 키만 토큰에 넣는다.

2

agent가 Valkey에서 본문 조회

livekit_model_config_store.py

기존에는 modelConfig만 반환하며 같은 스냅샷의 diagnostics를 버렸다.

3

오류·프롬프트 상태를 data channel로 발행

agent.py agent_session_error_events.py

원문 없이 code·status·크기·sha256만.

4

브라우저가 받아 세션 로그 → S3

lib/voice-agent/livekit-agent-diagnostic-fields.ts

두 이벤트 타입에서만 필드를 추출한다.

코드로 보는 핵심 지점

1. 구독자가 없어 사라지던 오류

vendored SDK는 agent_activity.py:2429~2438에서 4가지 오류를 session.emit("error", ...)로 올리지만, PPI 쪽에 리스너가 하나도 없었다. 아래가 그 첫 구독자다.

apps/livekit-agent/agent.py +def _attach_agent_session_error_events(session: AgentSession, room: rtc.Room) -> None: + """SDK가 흡수하고 끝내던 provider 오류를 브라우저 세션 로그까지 전달한다.""" + tasks: set[asyncio.Task[None]] = set() + + @session.on("error") + def _on_session_error(ev) -> None: + payload = build_agent_session_error_payload(ev) + if payload is None: + return + task = asyncio.create_task(_publish_agent_event(room, payload)) + tasks.add(task) + task.add_done_callback(tasks.discard)

2. 오류 원문은 길이만 — 기존 계약을 따라간 자리

초기 구현은 provider 메시지를 200자로 잘라 담았다가, lifecycle_trace.py의 기존 계약(error_message는 "private provider response"가 섞일 수 있어 안전 로그에서 제외)과 그것을 지키는 기존 테스트에 걸려 되돌렸다. 결과적으로 계획서가 요구한 "provider code 허용목록 정규화"와 같아졌다.

apps/livekit-agent/agent_session_error_events.py + # provider 오류 원문에는 입력이 섞일 수 있어 길이만 남긴다. 원인 구분은 + # error_code/error_type/error_status 같은 허용 목록 필드로 한다. + payload: dict[str, Any] = { + "type": "agent_session_error", + "boundary": "agent_session", + "outcome": "failed", + "error_class": type(provider_error).__name__, + "error_message_length": len(str(provider_error)), + }

3. 버려지던 진단값을 살린 배선

build_safe_model_config_log_payload는 만들어져 있고 테스트도 있었지만 어디에서도 호출되지 않았다. 원인은 조회 함수가 modelConfig만 반환하며 같은 스냅샷의 diagnostics를 버렸기 때문.

apps/livekit-agent/agent.py — _fetch_model_config - config = await fetch_model_config_from_valkey(redis, ref) + snapshot = await fetch_model_config_snapshot_from_valkey(redis, ref) + config = snapshot["modelConfig"] logger.info( "model config loaded from valkey", + **build_safe_model_config_log_payload(snapshot), - return config + return config, snapshot

4. 감사 로그가 성공한 삭제를 500으로 뒤집지 않게

처음에는 logger.infotry 안에 있었다. 그러면 로깅 예외가 catch로 떨어져 이미 지워진 템플릿에 대해 500을 반환하고, 클라이언트는 실패로 보고 재시도해 404를 받는다. 코드리뷰에서 잡아 try 밖으로 옮겼다.

apps/web/app/api/activity-templates/[id]/route.ts + let deletedAudit: ActivityTemplateDeleteAudit; try { await deleteActivityTemplate(id); + deletedAudit = buildActivityTemplateDeleteAudit({ template, actor: request.member }); } catch (error) { ... return 500 } + + // 감사 기록 실패가 이미 성공한 삭제를 500으로 뒤집지 않도록 try 밖에서 남긴다 + logger.info("활동 템플릿 삭제", deletedAudit); + return NextResponse.json({ success: true });

5. 가장 잦은 로그 줄은 넓히지 않는다

DataReceived는 모든 agent 이벤트마다 돈다. 여기에 진단 필드를 무조건 붙이면 전사 delta 같은 이벤트 로그까지 넓어진다. 두 타입에서만 붙인다.

apps/web/lib/voice-agent/livekit-agent-diagnostic-fields.ts + if (type === LIVEKIT_AGENT_SESSION_ERROR_EVENT) { return compact({ errorCode, errorStatus, recoverable, ... }); } + if (type === LIVEKIT_AGENT_PROMPT_STATE_EVENT) { return compact({ instructionBytes, instructionSha256, instructionEmpty, ... }); } + + return {};

실제로 남는 로그

S3 세션 로그 (아동 브라우저 경유) — 실행해 뽑은 형태:

{"ts":...,"level":"debug","ctx":"LIVEKIT_CLIENT_SESSION","msg":"LiveKit data received", "data":"{... \"eventType\":\"agent_session_error\",\"outcome\":\"failed\", \"errorCode\":\"string_above_max_length\",\"errorStatus\":400, \"recoverable\":false,\"sourceProvider\":\"openai\"}"} {"ts":...,"data":"{... \"eventType\":\"agent_prompt_state\",\"outcome\":\"configured\", \"instructionCharCount\":16162,\"instructionBytes\":41287, \"instructionSha256\":\"9f2c1b7d...\",\"instructionEmpty\":false}"}

Loki ppi-web-prod — 두 줄이 공통 templateId로 이어진다:

{"level":"info","prefix":"ACTIVITY_TEMPLATE_API","msg":"활동 템플릿 삭제", "data":{"event":"activity_template_deleted","templateId":"b3f1c2d4-…", "templateTitle":"친구에게 인사하기","stepCount":4, "actorId":"member-042","actorRole":"developer"}} {"level":"error","prefix":"LESSON_API","msg":"회기 활동 계획 생성 실패", "data":{"code":"ACTIVITY_TEMPLATE_NOT_FOUND","userId":"child-77","lessonIndex":3, "lessonTemplateId":"lesson-tpl-12", "missingActivityTemplateIds":["b3f1c2d4-…"]}}

서버 로그는 S3로 가지 않는다. apps/web/logger.js의 서버 분기가 console 출력 후 return하고, S3 싱크는 브라우저 분기에만 있다. 그래서 P0-2는 Loki 전용이다.

계획서 전제 정정

조사 과정에서 원본 계획 문서의 전제 3개가 실제 코드와 달랐다. 후속 작업 시 계획서보다 이쪽을 신뢰한다.

계획서 전제실제
마스킹이 [REDACTED] 노이즈 원인 dbb120fc(PPI-1273, 9/4)로 이미 자격증명 key만 가리도록 축소됨. 9/3~9/6 Daily Report 시그니처는 그 커밋 로그. 범위에서 제외했다.
프롬프트 길이·fingerprint 신규 수집 필요 build_safe_model_config_log_payload와 생산측 livekit-session-config-store.ts에 이미 있었고 호출만 안 되고 있었다. 신규 설계가 아니라 배선.
대표 시그니처 AUTO_TRANSITION|stepUpdate web 소스에 존재하지 않는 문자열. 분석기 쪽 라벨이다. 실제 경로는 video-end-watchdog.tsguest-step-publisher.ts이고, 후자에는 revision·ack·retry가 이미 있다.

레이어별 변경 요약

레이어파일핵심 변경
agentagent_session_error_events.py 신규ErrorEvent → 진단 payload 정규화, 프롬프트 상태 builder. 원문 미포함
agentagent.pysession.on("error") 첫 구독, ready 후 프롬프트 상태 1회 발행, _fetch_model_config 반환 dicttuple
agentlivekit_model_config_store.pyfetch_model_config_snapshot_from_valkey 신설. 기존 함수는 래퍼로 축소
agentlifecycle_trace.py안전 로그 허용목록에 신규 필드 12개. error_message는 기존대로 제외 유지
web · APIactivity-templates/[id]/route.ts삭제 성공 감사 기록. try 밖에서 남겨 응답 뒤집힘 차단
web · APIlessons/[userId]/[index]/route.ts로그 없이 400만 반환하던 ActivityPlanError 경로에 구조화 로그
web · liblesson-utils.tsActivityPlanError.detailslessonTemplateId·누락 ID 목록
web · libactivity-template-audit.ts 신규감사 레코드 builder. member.name 미포함
web · voice-agentlivekit-agent-diagnostic-fields.ts 신규두 이벤트 타입에서만 필드 추출
web · voice-agentlivekit-event-fields.ts 신규stringField/numberField/booleanField 공용화 (중복 제거)
테스트5개 파일agent pytest +8, web vitest +8. 원문 미유출 3건을 테스트로 고정

리뷰 관전 포인트

동작 확인

실 세션에서 두 이벤트가 실제로 S3에 남는지는 검증되지 않았다. 수업 1건 진행 후 세션 로그에서 eventType:agent_prompt_state를, 지침을 한도 초과로 만들어 agent_session_errorerrorCode를 확인해야 한다.

사각지대

ctx.connect()session.start() (agent.py:4530→4533)라, config fetch나 session.start 자체의 실패는 data channel이 없어 python 로그로만 남는다. 순서 변경은 로깅 범위를 넘는 동작 변경이라 손대지 않았고, 계획서 P0-3의 몫이다.

구조

이벤트 타입 문자열(agent_session_error·agent_prompt_state)이 Python·TS 양쪽에 리터럴로 있다. 한쪽만 바꾸면 필드가 조용히 사라진다. 기존 AGENT_EVENT_TOPIC과 같은 패턴이라 그대로 뒀다.

구조

fetch_model_config_from_valkey는 이제 프로덕션 호출자가 없고 테스트 2곳에서만 참조된다. 계약 유지용으로 남겼으나 정리 후보다.

영향 범위

동작 변경 없음. 세션 생성·시작·greeting 순서와 API 응답·상태코드가 모두 그대로다. 공유 계약 2건(_fetch_model_config 반환 타입, ActivityPlanError 파라미터)은 각각 호출자 동시 수정과 기본값 부여로 처리해 기존 호출 12곳은 무변경.

미완

계획서 4장의 반복 로그 축소는 하지 않았다. baseline 없이 지우지 않기로 했다. 이번 PR은 순수 추가라 로그량이 늘기만 한다(세션당 프롬프트 상태 1건 + 오류 시 1건).

검증

대상결과
apps/livekit-agent pytest342 passed (baseline 334 + 신규 8)
apps/web vitest14 passed (lesson-utils 7 / audit 3 / diagnostic-fields 4)
livekit-client-session.test.tspassed
tsc --noEmit · prettier통과

사전 존재 실패 2건은 HEAD d235a6e0에서 임시 worktree로 재현해 이 변경과 무관함을 확인했다 — lesson-session.service.disconnect-race.test.ts(sessionDao.createSession mock 누락), host-disconnected.tsx TS2307(next-env.d.ts 미생성). next lint는 이 환경에서 CLI 인자 오류로 실행하지 못했다.

관련 문서