RAG2026-5일31차시 · 예외 처리 설계10 / 19← 이전목차다음 →MetaContents

31차시. 예외 처리 설계

7. 실습 ③ — 운영 로그 남기기

출처: 원본 PDF 31차시_방어적코딩실습.pdf 15~16쪽 · 쪽 그림 15쪽 16쪽

지금까지 log는 사람이 읽는 문자열이었습니다. 집계할 수 있게 바꿉니다.

7-1) 구조화 로그

📝 src/logger.py

Python
import json, time, uuid
from pathlib import Path

LOG_PATH = Path("logs/queries.jsonl")
LOG_PATH.parent.mkdir(exist_ok=True)

def log_query(question, final_state, elapsed):
    record = {
        "id":        str(uuid.uuid4())[:8],
        "ts":        time.strftime("%Y-%m-%d %H:%M:%S"),
        "question":  question,
        "intent":    final_state.get("intent"),
        "grade":     final_state.get("grade"),
        "verified":  final_state.get("grade") == "pass",
        "retries":   final_state.get("retries", 0),
        "rewrites":  final_state.get("rewrites", 0),
        "n_docs":    len(final_state.get("documents", [])),
        "top_score": (final_state.get("scores") or [None])[0],
        "fail_reason":   final_state.get("fail_reason", ""),
        "fallback_kind": final_state.get("fallback_kind", ""),
        "node_error":    final_state.get("node_error", ""),
        "elapsed":   round(elapsed, 2),
        "answer_len": len(final_state.get("answer", "")),
    }
    with LOG_PATH.open("a", encoding="utf-8") as f:
        f.write(json.dumps(record, ensure_ascii=False) + "\n")
    return record

실습 코드 · 31/logger.py

실습 코드: 실습/src/31/logger.py · 위의 원본 PDF 31차시 15쪽 「7-1) 구조화 로그」과 이어집니다.

Python
# ==============================================================
# 31차시. PPT 31차시_예외처리설계. 코드 폴더는 src/31
# PPT: 31차시 안전 실행과 운영 로그
# 역할: 질문 처리가 끝난 State를 logs/queries.jsonl에 한 줄 JSON으로 추가합니다.
# 읽는 키: intent, grade, retries, rewrites, node_error 와 인자 question, elapsed
# 쓰는 키: 파일에 한 줄 추가. 반환값은 방금 저장한 record dict
# 그래프:
#   ask() 끝
#     |
#     v
#   log_query --> logs/queries.jsonl 에 한 줄 추가
# --------------------------------------------------------------
# 파이썬만 아는 분을 위한 말
# State는 노드들이 함께 쓰는 딕셔너리입니다. 함수는 state를 읽고, 바꿀 키만 return 합니다.
# return에 적지 않은 키는 이전 값이 남습니다.
# log와 tried_queries는 리스트라서, 새로 return한 항목이 뒤에 이어 붙습니다.
# Retriever: 검색 담당. 질문과 비슷한 문서 조각만 고릅니다. 답 문장은 만들지 않습니다.
# Generator: 고른 문서를 근거로 답 문장을 만듭니다. 문서가 없으면 안내 문장만 돌려줍니다.
# Verifier: 답이 근거와 맞는지 검사하고 grade를 적습니다.
#   grade=pass     답을 확정하고 그래프를 끝냅니다.
#   grade=retry    같은 문서로 답 문장만 다시 씁니다. retries가 1 늘어납니다.
#   grade=research 문서가 부족합니다. 검색어를 바꾼 뒤 다시 찾습니다. rewrites가 늘어납니다.
#   grade=giveup   더 진행하지 않고 fallback으로 안내 문장만 돌려줍니다.
# fallback: 실패했을 때 답을 지어내지 않고 안내 문장을 반환하는 노드입니다.
# intent: 질문 종류. greeting 인사, calc 계산, scope 문서 밖, document 문서 검색.
# rewrite: 사용자가 친 question은 유지하고, 검색에 쓰는 query만 바꿉니다.
# safe_node: 노드를 try로 감쌉니다. 오류가 나도 그래프는 멈추지 않고 node_error에 적습니다.
# JSONL은 한 줄에 JSON 객체 하나입니다. 질문을 처리할 때마다 한 줄이 뒤에 붙습니다.
# elapsed는 초 단위이며 소수 둘째 자리로 반올림합니다.
# 이 함수를 실행하면 현재 작업 폴더에 logs 폴더를 만들고 파일을 엽니다. 그래서 실행하지 않았습니다.
# ==============================================================

# ============================================================
# Logger
# RAG가 질문을 처리한 결과를 운영 로그로 저장합니다.
# 질문, 판정, 재시도 횟수, 오류, 실행 시간 등을 JSONL 파일에 기록합니다.
# ============================================================

import json
import os
import time

# ============================================================
# 질문 처리 결과를 로그 파일에 저장합니다.
# ============================================================

def log_query(question, state, elapsed):

    # --------------------------------------------------------
    # logs 폴더 준비
    # --------------------------------------------------------

    # logs 폴더가 없으면 새로 만듭니다.
    os.makedirs(
        "logs",
        exist_ok=True
    )

    # --------------------------------------------------------
    # 저장할 로그 데이터 만들기
    # --------------------------------------------------------

    record = {

        # 질문을 처리한 시간을 저장합니다.
        "time": time.strftime("%Y-%m-%d %H:%M:%S"),

        # 사용자가 입력한 질문을 저장합니다.
        "question": question,

        # 분류된 질문 종류를 저장합니다.
        "intent": state.get("intent", ""),

        # 검증 결과를 저장합니다.
        "grade": state.get("grade", ""),

        # 답변을 다시 생성한 횟수를 저장합니다.
        "retries": state.get("retries", 0),

        # 검색어를 다시 작성한 횟수를 저장합니다.
        "rewrites": state.get("rewrites", 0),

        # 노드에서 발생한 오류를 저장합니다.
        "node_error": state.get("node_error", ""),

        # 전체 실행 시간을 초 단위로 저장합니다.
        "elapsed": round(elapsed, 2)
    }

    # --------------------------------------------------------
    # 로그 파일에 저장
    # --------------------------------------------------------

    # JSON 데이터를 한 줄씩 파일에 추가합니다.
    with open(
        "logs/queries.jsonl",
        "a",
        encoding="utf-8"
    ) as file:

        # 딕셔너리를 JSON 문자열로 변환합니다.
        text = json.dumps(
            record,
            ensure_ascii=False
        )

        # JSON 한 건을 한 줄에 저장합니다.
        file.write(text + "\n")

    # --------------------------------------------------------
    # 저장한 로그 데이터를 반환
    # --------------------------------------------------------

    return record

실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)

출력
===== 실행 결과 샘플 =====
예시 출력 (API 키 필요, 실행하지 않음)
저장 모양만 기록된 값으로 적습니다. time 시각은 실행 시각이라 적지 않습니다.
{"question":"안녕하세요","intent":"greeting","grade":"pass","retries":0,"rewrites":0,"elapsed":0.01}
{"question":"10 + 20","intent":"calc","grade":"pass","retries":0,"rewrites":0,"elapsed":0.0}
{"question":"오늘 날씨","intent":"scope","grade":"pass"}
{"question":"환불은 며칠 이내에 신청해야 하나요?","intent":"document","grade":"pass","retries":0,"rewrites":0,"elapsed":6.69}
다른 실행 elapsed=2.39 n_docs=2 top_score=0.056 (n_docs와 top_score는 이 함수가 저장하지 않습니다)
{"question":"대표이사가 누구인가요?","intent":"document","grade":"giveup","rewrites":2,"elapsed":3.41}
fail_reason=low_score top_score=-0.219 는 이 로그 함수의 필드가 아닙니다.

코드 해설

질문 처리가 끝난 State를 logs/queries.jsonl에 한 줄 JSON으로 추가합니다.

코드 앞에서 고정해 두는 말입니다.

  • 읽는 키: intent, grade, retries, rewrites, node_error 와 인자 question, elapsed
  • 쓰는 키: 파일에 한 줄 추가. 반환값은 방금 저장한 record dict
  • 그래프:
  • ask() 끝
  • |
  • v
  • log_query --> logs/queries.jsonl 에 한 줄 추가
  • 파이썬만 아는 분을 위한 말

함수와 클래스로 나누어 읽습니다.

  • log_query: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.

파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.

7-2) 집계로 확인할 것들

Python
import json
from collections import Counter

rows = [json.loads(l) for l in open("logs/queries.jsonl",
                                    encoding="utf-8")]

print("총 질문:", len(rows))
print("검증 통과율:",
      f"{sum(r['verified'] for r in rows)/len(rows)*100:.0f}%")
print("유형 분포:", Counter(r["intent"] for r in rows))
rewrite_count, error_count, fallback_kinds = 0, 0, []
for r in rows:
    if r["rewrites"] > 0: rewrite_count += 1
    if r["node_error"]: error_count += 1
    if r["fallback_kind"]: fallback_kinds.append(r["fallback_kind"])
print("재작성 발생률:", f"{rewrite_count/len(rows)*100:.0f}%")
print("평균 응답:", f"{sum(r['elapsed'] for r in rows)/len(rows):.1f}초")
print("오류 발생:", error_count)
print("실패 사유:", Counter(fallback_kinds))

실행 결과 예시 (코드를 읽고 만든 예상 화면입니다. 실제 실행 결과, 특히 모델의 답은 다를 수 있습니다)

출력
총 질문: 6
검증 통과율: 67%
유형 분포: Counter({'document': 3, 'greeting': 1, 'calc': 1, 'scope': 1})
재작성 발생률: 17%
평균 응답: 1.9초
오류 발생: 0
실패 사유: Counter({'exhausted': 1})

📊 이 숫자들로 알 수 있는 것
검증 통과율이 낮다 → 검증 기준이 엄격하거나 근거가 부실
재작성 발생률이 높다 → MIN_SCORE가 높거나 문서 용어 불일치
exhausted가 많다 → 문서 범위를 넓혀야 함
node_error가 잦다 → 특정 노드에 문제가 있음
평균 응답이 길다 → 33차시 최적화 필요
운영은 감이 아니라 로그로 합니다.

실습 코드 · 31/analyze_logs.py

실습 코드: 실습/src/31/analyze_logs.py · 위의 원본 PDF 31차시 15~16쪽 「7-2) 집계로 확인할 것들」과 이어집니다.

Python
# ==============================================================
# 31차시. PPT 31차시_예외처리설계. 코드 폴더는 src/31
# PPT: 31차시 안전 실행과 운영 로그
# 역할: logs/queries.jsonl을 읽어 질문 수, 통과율, intent 분포, 재작성 비율, 평균 시간, 오류 수를 출력합니다.
# 읽는 키: 파일의 intent, grade, rewrites, elapsed, node_error
# 쓰는 키: 화면 출력만. State 없음
# 그래프:
#   logs/queries.jsonl
#     |
#     v
#   집계 print
# --------------------------------------------------------------
# 각 줄은 logger.py가 저장한 딕셔너리입니다.
# grade가 pass인 줄 수를 전체 줄 수로 나누면 검증 통과율입니다.
# rewrites가 0보다 큰 줄의 비율이 재작성 발생률입니다.
# node_error가 비어 있지 않은 줄이 오류 발생 건수입니다.
# API를 부르지 않고 로컬 jsonl만 읽습니다.
# ==============================================================

# ============================================================
# RAG 실행 로그를 읽어 간단한 통계를 출력합니다.
# ============================================================

import json
# Counter는 같은 값이 몇 번 나왔는지 세어 주는 딕셔너리입니다.
from collections import Counter

# 로그 읽기
# 한 줄이 JSON 하나이므로 줄마다 json.loads로 딕셔너리로 바꿉니다.
# line.strip()이 빈 문자열인 줄(맨 끝 빈 줄)은 건너뜁니다.
with open("logs/queries.jsonl", encoding="utf-8") as f:
    rows = [json.loads(line) for line in f if line.strip()]

# 로그가 없으면 종료
# 아래에서 len(rows)로 나누므로, 0건이면 여기서 멈춰야 나눗셈 오류가 나지 않습니다.
if not rows:
    print("로그가 없습니다.")
    raise SystemExit

# 기본 통계
print("총 질문:", len(rows))

# grade가 pass인 줄만 1씩 더해 개수를 셉니다.
pass_count = sum(
    1 for r in rows
    if r.get("grade") == "pass"
)

# 통과 수를 전체로 나누고 100을 곱해 백분율로 보여 줍니다. :.0f는 소수점 없이 표시합니다.
print(
    "검증 통과율:",
    f"{pass_count / len(rows) * 100:.0f}%"
)

# intent 값마다 몇 건인지 셉니다. 키가 없는 줄은 빈 문자열로 셉니다.
print(
    "유형 분포:",
    Counter(r.get("intent", "") for r in rows)
)

# 재작성 및 오류 횟수
# 재작성 횟수가 아니라 "재작성이 한 번이라도 일어난 질문 수"를 셉니다.
rewrite_count = sum(
    1 for r in rows
    if r.get("rewrites", 0) > 0
)

# node_error가 빈 문자열이 아니면 오류가 난 질문입니다.
error_count = sum(
    1 for r in rows
    if r.get("node_error", "")
)

# 결과 출력
print(
    "재작성 발생률:",
    f"{rewrite_count / len(rows) * 100:.0f}%"
)

# elapsed를 모두 더해 질문 수로 나누면 한 질문당 평균 응답 시간(초)입니다.
print(
    "평균 응답:",
    f"{sum(r.get('elapsed', 0) for r in rows) / len(rows):.1f}초"
)

print("오류 발생:", error_count)

실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)

출력
===== 실행 결과 샘플 =====
실제 실행 (API 호출 없음)
작업 폴더를 D:\RAG_Project\src\31 로 두고 이 파일을 실행한 출력입니다.
총 질문: 6
검증 통과율: 67%
유형 분포: Counter({'document': 3, 'greeting': 1, 'calc': 1, 'scope': 1})
재작성 발생률: 17%
평균 응답: 1.9초
오류 발생: 0
이 스크립트는 top_score를 출력하지 않습니다.
집계 6줄은 로그 파일 전체입니다. 교안에 숫자로 남긴 질문은 아래입니다.
안녕하세요 intent=greeting grade=pass elapsed=0.01
10 + 20 intent=calc grade=pass elapsed=0.0
오늘 날씨 intent=scope grade=pass
환불은 며칠 이내에 신청해야 하나요? intent=document grade=pass retries=0 rewrites=0 elapsed=6.69
  다른 실행 elapsed=2.39 n_docs=2 top_score=0.056
대표이사가 누구인가요? intent=document grade=giveup rewrites=2 elapsed=3.41 fail_reason=low_score top_score=-0.219

코드 해설

logs/queries.jsonl을 읽어 질문 수, 통과율, intent 분포, 재작성 비율, 평균 시간, 오류 수를 출력합니다.

코드 앞에서 고정해 두는 말입니다.

  • 읽는 키: 파일의 intent, grade, rewrites, elapsed, node_error
  • 쓰는 키: 화면 출력만. State 없음
  • 그래프:
  • logs/queries.jsonl
  • |
  • v
  • 집계 print
  • 각 줄은 logger.py가 저장한 딕셔너리입니다.

함수로 나누지 않고 위에서 아래로 실행됩니다.

  • with open("logs/queries.jsonl", encoding="utf-8") as f: — 로그 읽기
  • if not rows: — 로그가 없으면 종료
  • print("총 질문:", len(rows)) — 기본 통계
  • rewrite_count = sum( — 재작성 및 오류 횟수
  • print( — 결과 출력

파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.

← 이전 절31차시 · 6. 실습 ② — 타임아웃과 재시도다음 절 →31차시 · 8. 실습 ④ — 장애 시뮬레이션