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