MINERVA 에러 전파 경로 분석

try/except 경계, 무음 실패, 클라이언트 노출 범위

MINERVA에서 에러는 어디서 발생하고 어디까지 전파되는가. agent.py, 라우터, 프론트엔드 각 계층의 try/except 경계를 추적하고 현재 구현이 노출하는 취약 지점을 진단한다.

Agent
저자

Kwangmin Kim

공개

2026년 05월 05일

1 개요

에러 처리는 “어디서 잡느냐”보다 “어디서 잡지 않느냐”를 파악하는 것이 더 중요하다. MINERVA의 에러 전파를 세 레이어로 나눠 분석한다.

[Agent Layer]    → [Router Layer]    → [Frontend Layer]
  agent.py          routers/            QnaChatbot.tsx
                    qna_chatbot.py

2 Agent 계층 에러 처리

2.1 stream() — 명시적 이중 방어

QnaChatbotAgent.stream()은 두 단계에 걸쳐 에러를 포착한다.

# src/agents/qna_chatbot/agent.py

def stream(self, query: Query) -> Iterator[StreamEvent]:
    start = time.perf_counter()

    # --- 1단계: _prepare() 실패 포착 ---
    # retrieval, prompt 로드, LLM init 어느 곳이든 예외 발생 시
    try:
        docs, chain, chain_inputs, model_used = self._prepare(query)
    except Exception as e:
        logger.warning(
            "⚠️ [stream] qna_chatbot prepare failed: %s: %s",
            type(e).__name__, e,
        )
        yield StreamEvent(type="error", error=f"{type(e).__name__}: {e}")
        return  # generator 종료

    # --- 2단계: chain.stream() 실패 포착 ---
    # LLM 스트리밍 도중 timeout / rate-limit / network 오류
    accumulated: list[str] = []
    try:
        for chunk in chain.stream(chain_inputs):
            if chunk:
                accumulated.append(chunk)
                yield StreamEvent(type="token", text=chunk)
    except Exception as e:
        logger.warning(
            "⚠️ [stream] qna_chatbot chain.stream failed after %d tokens: %s: %s",
            len(accumulated), type(e).__name__, e,
        )
        yield StreamEvent(type="error", error=f"{type(e).__name__}: {e}")
        return  # type="done" 없이 종료

    # 정상 완료 시에만 type="done" 전송
    yield StreamEvent(type="done", response=self._build_response(...))

에러 발생 시 type="done" 이벤트를 전송하지 않는 것이 핵심이다. 클라이언트가 done을 받지 못하면 응답이 불완전하다는 것을 알 수 있다.

2단계에서 부분 토큰이 이미 전송된 경우: accumulated에 일부 토큰이 쌓인 상태에서 에러가 발생하면, 클라이언트는 이미 부분 응답을 렌더링한 상태다. type="error" 이벤트는 전송되지만, 그 전에 렌더링된 부분 응답을 어떻게 처리할지는 프론트엔드 책임이다.

2.2 run() — 에러 처리 없음

def run(self, query: Query) -> Response:
    start = time.perf_counter()
    docs, chain, chain_inputs, model_used = self._prepare(query)  # 예외 미처리
    answer = chain.invoke(chain_inputs)                            # 예외 미처리
    latency_ms = int((time.perf_counter() - start) * 1000)
    return self._build_response(answer, docs, query, ...)

run()은 try/except가 없다. 예외는 호출자인 라우터로 그대로 전파된다.

2.3 _prepare() — 에러 전파 체인

_prepare() 내부에서 발생 가능한 예외 원인을 추적한다.

def _prepare(self, query: Query):
    self._ensure_initialized()   # LLM/prompt 초기화 실패 가능
    docs = self._retrieve_docs(  # Azure Search 연결 실패 가능
        query.text, document_id, source_filter=source_filter
    )
    chain_inputs = self._build_chain_inputs(query, docs)  # 거의 실패 없음
    llm, model_used = self._get_llm_for_query(query)     # 잘못된 모델명 실패 가능
    chain = self._prompt | llm | StrOutputParser()
    return docs, chain, chain_inputs, model_used
실패 지점 예외 유형 빈도
_ensure_initialized() FileNotFoundError (프롬프트 파일 없음) 드묾
_retrieve_docs()hybrid_search() AzureError, ConnectionError 네트워크 장애 시
_get_llm_for_query() OpenAIError (잘못된 모델명) 설정 오류 시
chain.stream() RateLimitError, Timeout 부하 집중 시

3 라우터 계층 에러 처리

3.1 /stream 엔드포인트

@router.post("/stream")
def stream(req: RunRequest) -> StreamingResponse:
    # --- 에러 처리 없음 ---
    agent, exp_name, arm_id, overrides = _build_agent(
        user_id=req.user_id, force_arm=force_arm
    )
    query = _build_query(req, exp_name, arm_id)

    def event_generator():
        final_response = None
        for event in agent.stream(query):
            yield f"data: {event.model_dump_json()}\n\n"
            if event.type == "done":
                final_response = event.response
        if final_response is not None:
            log_run(record_from_response(...))

    return StreamingResponse(event_generator(), media_type="text/event-stream")

agent.stream()이 generator이고 내부에서 에러를 StreamEvent(type="error")로 변환하기 때문에, event_generator() 자체는 예외를 던지지 않는다. 그러나 _build_agent() 실패는 처리되지 않는다.

3.2 _build_agent() 실패 시나리오

def _build_agent(user_id=None, force_arm=None):
    # resolve_config_for_user()가 실패하면? → 예외 전파
    config, exp_name, arm_id, overrides = resolve_config_for_user(
        "qna_chatbot", user_id=user_id, force_arm=force_arm,
    )
    # ...

resolve_config_for_user()가 실험 YAML 파일을 읽지 못하거나 파싱에 실패하면, 예외가 FastAPI 기본 핸들러까지 전파된다. 결과는 HTTP 500 응답으로 SSE 스트림 없이 JSON 에러 페이로드가 반환된다. 이 경우 프론트엔드의 SSE 파서가 non-event-stream 응답을 받게 된다.

3.3 /run 엔드포인트

@router.post("/run", response_model=RunResponse)
def run(req: RunRequest) -> RunResponse:
    agent, exp_name, arm_id, overrides = _build_agent(...)  # 에러 처리 없음
    query = _build_query(req, exp_name, arm_id)
    response = agent.run(query)                              # 에러 처리 없음

    log_run(record_from_response(...))
    return RunResponse(response=response, experiment_id=exp_name, arm_id=arm_id)

/run은 에러 처리가 전혀 없다. agent.run() 실패는 HTTP 500으로 직결된다. 응답 바디에 에러 상세가 노출될 수 있다.

3.4 워밍업 — 의도적 무음 실패

def warmup() -> None:
    try:
        agent, exp_name, arm_id, _overrides = _build_agent(user_id=None)
        # ...
    except Exception as e:
        # 실패해도 서비스 기동은 계속
        print(f"⚠️  [warmup] qna_chatbot failed (continuing): {type(e).__name__}: {e}")

워밍업 실패는 의도적으로 삼킨다. 서비스 가용성을 우선하는 선택이지만, 워밍업 실패가 조용히 지나가면 첫 실 사용자가 cold start를 경험한다.

3.5 문서 엔드포인트 — 명시적 HTTPException

@router.get("/documents/{document_id}/outline")
def document_outline(document_id: str):
    if document_id not in DEFAULT_DOCUMENTS:
        raise HTTPException(status_code=404, detail=f"unknown document_id: {document_id}")
    path = Path(DEFAULT_DOCUMENTS[document_id])
    if not path.exists():
        raise HTTPException(status_code=500, detail=f"document file missing: {path.name}")

문서 관련 엔드포인트만 명시적 HTTPException을 사용한다. 핵심 추론 경로(/run, /stream)에는 동일한 패턴이 적용되지 않았다.

4 프론트엔드 에러 처리

4.1 SSE 에러 이벤트 수신

for await (const event of streamEvents(req)) {
  if (event.type === "error") {
    setMessages(prev => {
      const next = [...prev];
      next[next.length - 1] = {
        role: "assistant",
        content: `오류: ${event.error}`,
        isStreaming: false,
      };
      return next;
    });
  }
}

백엔드가 type="error" 이벤트를 보내면 프론트엔드는 마지막 assistant 메시지를 에러 텍스트로 교체한다.

4.2 HTTP 레벨 실패 (연결 오류, 500)

async function handleSend(text: string) {
  setStreaming(true);
  try {
    for await (const event of streamEvents(req)) {
      // ...
    }
  } catch (err) {
    // fetch 자체 실패 또는 SSE 파싱 오류
    setMessages(prev => {
      const next = [...prev];
      next[next.length - 1] = {
        role: "assistant",
        content: "연결 오류가 발생했습니다.",
        isStreaming: false,
      };
      return next;
    });
  } finally {
    setStreaming(false);
  }
}

네트워크 단절이나 서버 500 응답은 catch 블록에서 포착되어 일반 오류 메시지로 표시된다. 에러 유형을 구분하지 않으므로 사용자에게는 동일한 메시지가 표시된다.

5 무음 실패 (Silent Failures)

5.1 log_run()의 의도적 예외 흡수

메트릭 로깅은 응답 경로에 영향을 주지 않도록 모든 예외를 삼킨다.

# src/core/metrics_logger.py
def log_run(record: dict, path: Optional[Path] = None) -> None:
    target = path or RUNS_PATH
    try:
        target.parent.mkdir(parents=True, exist_ok=True)
        line = json.dumps(record, ensure_ascii=False, default=str) + "\n"
        with _write_lock:
            with open(target, "a", encoding="utf-8") as f:
                f.write(line)
    except Exception as e:
        logger.warning("metrics log_run failed (%s): %s", type(e).__name__, e)

의도: 디스크 가득 참, 파일 권한 오류 등으로 응답이 막히면 안 된다.

위험: 디스크 문제가 발생해도 알림이 없다. 로그가 누락되면 사후 추적이 불가능하다. 별도 알람(Sentry, CloudWatch)이 없으면 운영팀이 인지하지 못한다.

5.2 record_from_error()는 정의되어 있지만 호출되지 않는다

metrics_logger.py에는 실패 케이스용 레코드 빌더가 있다.

def record_from_error(
    agent_name, query, error,
    *, experiment_name=None, arm_id=None, elapsed_ms=None,
) -> dict:
    return {
        "timestamp": datetime.now().isoformat(timespec="seconds"),
        "run_id": uuid.uuid4().hex,
        "agent_name": agent_name,
        "query": query.text,
        "answer": "",
        "total_time_ms": elapsed_ms,
        "success": False,
        "error_type": type(error).__name__,
        "error_message": str(error),
        ...
    }

그러나 routers/qna_chatbot.py/run/stream 어디서도 이 함수를 호출하지 않는다. 결과:

  • agent.run() 실패 → HTTP 500 → JSONL에 레코드 없음
  • agent.stream() 도중 에러 → SSE error 이벤트 전송 → JSONL에 레코드 없음 (성공 done 이벤트만 기록함)

영향: 에러율을 JSONL에서 측정할 수 없다. 성공 케이스만 누적되므로 분모가 작아져 오해가 생긴다.

권장 패치:

# /stream의 event_generator() 내부
def event_generator():
    final_response = None
    error_event = None
    start = time.perf_counter()
    for event in agent.stream(query):
        yield f"data: {event.model_dump_json()}\n\n"
        if event.type == "done":
            final_response = event.response
        elif event.type == "error":
            error_event = event

    if final_response is not None:
        log_run(record_from_response("qna_chatbot", query, final_response, extras=extras))
    elif error_event is not None:
        elapsed = int((time.perf_counter() - start) * 1000)
        log_run(record_from_error(
            "qna_chatbot", query, RuntimeError(error_event.error),
            experiment_name=exp_name, arm_id=arm_id, elapsed_ms=elapsed,
        ))

5.3 Parent 매핑 무음 강등

_map_to_parents_with_child_score()parent_store에서 parent를 찾지 못하면 child로 폴백한다.

# src/core/rag/retriever.py
parent_doc = self.parent_store.get(parent_id)
if parent_doc:
    seen[parent_id] = parent_doc
    parents.append(parent_doc)
else:
    print(f"⚠️ parent_id='{parent_id}' → parent_store에 없음 (child fallback)")
    fallback_children.append(doc)
...
if not parents and fallback_children:
    print(f"⚠️ parent 매핑 전체 실패 → child {len(fallback_children)}개 직접 반환")
    return fallback_children[:k]

현상: parent chunk(~1500자) 대신 child chunk(~400자)가 LLM 컨텍스트로 전달된다. 응답 품질이 저하되지만 에러는 없다.

관측 어려움: print()로만 경고를 낸다. structured logger도 아니고, 메트릭도 아니다. 운영자는 응답이 갑자기 짧아진 것만 보고 원인을 추정해야 한다.

5.4 Azure 검색 Fallback Cascade

Azure 검색은 다단계 폴백이 적용되어 있다.

# src/core/rag/retriever.py
def _hybrid_search_with_vectors(self, query, k):
    try:
        # 1차: Azure SDK 직접 호출 (vector 캐시 시도)
        results = vs.client.search(...)
        return docs, query_vec
    except Exception as e:
        # 2차: LangChain 표준 래퍼
        print(f"⚠️ hybrid_search_with_vectors 실패, 표준 래퍼로 폴백: {e}")
        return vs.hybrid_search(query=query, k=k), None

def hybrid_search(self, query, k=4):
    if not isinstance(self.vectorstore, AzureSearch):
        # 3차: similarity_search 폴백
        print("⚠️ hybrid_search 미지원 vectorstore → similarity_search로 fallback")
        return self.similarity_search(query, k)

    if self._reranker_type == "azure_semantic":
        return self._hybrid_search_with_semantic(query, k)
    ...

def _hybrid_search_with_semantic(self, query, k):
    try:
        return self.vectorstore.semantic_hybrid_search_with_score(...)
    except Exception as e:
        # 4차: 일반 hybrid 폴백
        print(f"⚠️ Azure Semantic search 실패, 일반 hybrid로 fallback: {e}")
        ...

문제: 폴백 단계가 깊어질수록 검색 품질이 저하되지만, 에러로 표면화되지 않는다. “최근 응답이 부정확해졌다”는 사용자 피드백 없이는 운영자가 알 수 없다.

관측 강화 방안: 폴백 발생 시 metric counter를 증가시키고, 임계치 초과 시 알람을 보낸다.

# 권장 패턴
try:
    results = vs.client.search(...)
    METRICS.fallback_used.labels(stage="primary").inc(0)  # 정상
except Exception as e:
    METRICS.fallback_used.labels(stage="langchain_wrapper").inc()
    logger.warning("falling back to LangChain wrapper", extra={"error": str(e)})
    return vs.hybrid_search(query=query, k=k), None

6 Data Standardizer — Supervisor 패턴의 에러 경로

QnA Chatbot은 단일 LCEL 체인이라 에러 경계가 비교적 단순했지만, Data Standardizer는 sub_agent 4개(RagRecommender, post_processing/tables, post_processing/code, DomainAuditor)가 직렬·조건부로 호출되므로 각 sub_agent마다 다른 fallback 정책이 필요하다.

# src/agents/data_standardizer/agent.py — 단순화
def run(self, query: Query) -> Response:
    recommender = self._ensure_recommender()
    mode = _resolve_mode(query)

    raw_text, docs = recommender.run(query.text, mode=mode)   # ① 필수
    processed = _apply_post_processing(raw_text, ...)         # ② 부분 강등 가능
    if _is_full_format(processed):
        processed = self._ensure_auditor().audit_and_fix(processed)  # ③ 선택적
    return Response(text=processed, ...)
단계 실패 시나리오 현재 정책 영향
① RagRecommender (RAG + LLM) Azure Search 장애, LLM rate-limit QnA와 동일 — 예외 전파 응답 자체 불가
② ALBERT 도메인 분류 (classify_domain_group) 모델 가중치 미로드, GPU OOM try/except(None, 0.0) 반환, 표 도메인 컬럼 비움 표는 출력되지만 도메인 분류 누락
② 물리명 생성 (AbbreviationManager) sg-data-standardization 미설치, 사전 누락 함수 내 try → 원본 영문명 그대로 표기 표준화 결과 품질 저하, 에러 표면화 안 됨
③ DomainAuditor (LLM 호출) LLM 호출 실패, JSON 파싱 실패 try/except → 원본 표 반환 감사 안 한 표 그대로 노출

이 구조에서 가장 위험한 것은 ②의 부분 강등이다. RagRecommender가 표를 반환하고 ALBERT가 조용히 실패하면 사용자가 받는 표에는 도메인 컬럼만 비어 있다 — 에러 메시지가 없으므로 사용자는 “도메인 분류가 원래 비어 있는 표”인지 “장애로 빠진 것”인지 구분할 수 없다.

ALBERT 가중치 미로드는 cold-path 위험

ALBERT(~200MB)는 lifespan warmup에서 로드되지만, warmup이 실패해도 서비스는 기동한다(앞서 언급한 의도적 무음). 첫 사용자 요청이 도착했을 때 classify_domain_group()이 lazy import로 모델을 다시 시도하면, FileNotFoundError나 OSError가 발생할 수 있다. 현재는 결과 dict의 group=None으로 표면화되지만, 운영자가 이 신호를 감지하려면 runs.jsonlextras 또는 별도 메트릭으로 누락률을 노출해야 한다.

DomainAuditor의 fallback 정책이 다른 이유: 감사는 선택적 품질 개선이다. 감사 LLM이 실패해도 사용자가 받는 표 자체는 유효하므로, 원본을 그대로 반환하는 것이 합리적이다. 반면 RagRecommender는 응답의 본문 그 자체를 만들므로 실패가 곧 응답 불가다.

7 에러 전파 지도

[원인]                  [발생 위치]           [처리 위치]        [사용자 노출]
────────────────────────────────────────────────────────────────────────────
Azure Search 오류   → _prepare()        → stream() try1   → "error" 이벤트
LLM rate-limit      → chain.stream()    → stream() try2   → "error" 이벤트
LLM timeout (블록)  → chain.invoke()    → (없음) → router  → HTTP 500
실험 YAML 파싱 실패 → resolve_config()  → (없음) → FastAPI  → HTTP 500
잘못된 document_id  → _get_retriever()  → stream() try1   → "error" 이벤트
네트워크 단절       → fetch()           → FE catch         → 일반 오류 메시지
프롬프트 파일 없음  → _ensure_init()   → stream() try1   → "error" 이벤트

8 취약 지점 요약

P1 — /run 에러 처리 부재: agent.run()에서 발생하는 모든 예외가 HTTP 500으로 노출된다. 응답 바디에 스택 트레이스가 포함될 수 있어 내부 경로 정보가 누출된다.

P2 — _build_agent() 실패 시 SSE 스트림 오염: /stream 엔드포인트가 StreamingResponse를 반환하기 전에 _build_agent()가 실패하면, 클라이언트는 SSE 형식이 아닌 JSON 에러를 SSE 파서로 읽으려 시도한다. 파싱 오류로 이어질 수 있다.

P3 — 부분 스트리밍 후 에러: chain.stream() 도중 에러 발생 시 부분 토큰이 이미 렌더링된다. 프론트엔드는 type="error" 수신 후 마지막 메시지를 교체하지만, 교체 전 사용자가 부분 응답을 읽는 경우 혼란이 생긴다.

P4 — 재시도 없음: 어느 레이어에도 재시도 로직이 없다. LLM rate-limit처럼 일시적 오류에서도 즉시 에러를 반환한다.

P5 — 에러 로깅 누락: /run 경로에서 에러 발생 시 log_run()이 호출되지 않는다. 실패한 요청이 JSONL에 기록되지 않아 에러율 추적이 불가능하다.

9 부분 스트리밍 실패 타임라인

chain.stream() 도중 에러가 발생하면 클라이언트가 보는 시퀀스를 시각화한다.

T=0ms     사용자 "데이터 표준화는?" 전송
T=200ms   _prepare() 완료 (RAG 검색·context 조립)
T=1500ms  첫 토큰 yield: "데이터"
T=1600ms  두번째 토큰: " 표준화는"
T=1700ms  세번째 토큰: " 다음과 같"
T=1800ms  네번째 토큰: "은 원칙을"
                ▼ Azure OpenAI 429 Rate Limit
T=1850ms  chain.stream() raise RateLimitError
T=1850ms  yield StreamEvent(type="error", error="RateLimitError: ...")
                ▼ generator return — done 이벤트 없음
T=1850ms  event_generator() 종료
T=1850ms  StreamingResponse 종료 (HTTP 연결 닫힘)

클라이언트 측 상태:

// 사용자가 본 화면
"데이터 표준화는 다음과 같은 원칙을"  // 부분 텍스트 (4 토큰 누적)
↓ error 이벤트 수신
"오류: RateLimitError: ..."          // 메시지 교체

클라이언트 책임: error 이벤트를 받으면 마지막 메시지를 교체. 받지 못하면(예: 클라이언트가 error 핸들러 미구현) 부분 텍스트가 그대로 남는다.

현재 구현의 약점: 에러 메시지가 영문 예외 클래스명 그대로 노출된다. RateLimitError: 429 ... 같은 메시지는 사용자에게 의미가 없다.

// 권장: 에러 유형별 사용자 친화 메시지
function userFriendlyError(error: string): string {
  if (error.includes("RateLimitError")) return "잠시 후 다시 시도해주세요.";
  if (error.includes("TimeoutError")) return "응답 시간이 초과되었습니다.";
  if (error.includes("ConnectionError")) return "연결이 일시적으로 불안정합니다.";
  return "일시적인 오류가 발생했습니다.";
}

10 Phase별 에러 책임 매트릭스

단계 에러 발생 가능성 현재 처리 권장 처리
_load_env_files() 낮음 누락 시 print, 계속 진행 그대로 유지 (시스템 env 폴백)
lifespan warmup 중간 print, 서비스 기동 계속 그대로 유지 (가용성 우선)
resolve_config_for_user() 낮음 미처리 → HTTP 500 router 수준 try/except
_build_agent() 낮음 미처리 → HTTP 500 router 수준 try/except
_prepare() (run) 중간 미처리 → HTTP 500 try/except + record_from_error
_prepare() (stream) 중간 error 이벤트 + JSONL 누락 error 이벤트 + record_from_error
chain.invoke() (run) 높음 미처리 → HTTP 500 retry + record_from_error
chain.stream() 높음 error 이벤트 + JSONL 누락 error 이벤트 + record_from_error
log_run() 낮음 의도적 흡수 그대로 유지 + 알람 추가
_hybrid_search_with_vectors() 중간 print + 폴백 metrics counter + 알람
_map_to_parents_with_child_score() 중간 print + child 폴백 metrics counter + 임계치 알람
FE streamEvents() 높음 catch + 일반 메시지 에러 유형 분류

11 개선 방향

우선순위 항목 방법
높음 /run 에러 핸들러 추가 try/except + HTTPException(500, detail=...)
높음 _build_agent() 실패 포착 라우터 수준 try/except
중간 에러 시에도 log_run() 호출 record_from_error() 사용
중간 LLM 재시도 Tenacity 또는 LangChain with_retry()
중간 sub_agent 부분 강등 신호화 agent_data["degraded"] = ["albert", "abbreviation"] 노출
낮음 에러 유형별 프론트엔드 메시지 event.error 파싱 후 사용자 친화 문구

11.1 사용자 피드백으로 무음 강등 감지

ALBERT 미동작이나 폴백 cascade처럼 에러 없이 품질만 떨어지는 경우는 코드 시그널로 잡기 어렵다. 이때 운영적으로 가장 빠른 신호가 feedback.jsonl의 helpful=False 비율 변화다. runs.jsonlfeedback.jsonlrun_id로 join한 뒤 시간 윈도우(1시간·1일)로 helpful=False 비율을 비교하면, 에러 없이도 품질이 강등된 구간을 식별할 수 있다. 이 신호가 임계값을 넘으면 자동으로 extras.fallback_used·agent_data.degraded 같은 부가 필드와 교차 분석해 어떤 sub_agent가 문제였는지 좁힐 수 있다 — 다음 Phase C-9 관측성 글에서 다룰 운영 패턴이다.

12 정리

MINERVA의 에러 처리는 stream() 경로에 집중되어 있고, /run 경로와 라우터 초기화 단계는 무방비 상태다. 에러가 전파되는 경로를 계층별로 파악했으므로, 다음 포스트에서는 구성 시스템이 런타임 동작에 어떻게 영향을 미치는지 .env → RAGConfig → 실험 override 흐름을 추적한다.

다음: Config 의존성 추적

Subscribe

Enjoy this blog? Get notified of new posts by email: