31차시. 예외 처리 설계
7. 실습 ③ — 운영 로그 남기기
출처: 원본 PDF 31차시_방어적코딩실습.pdf 15~16쪽 · 쪽 그림 15쪽 16쪽
지금까지 log는 사람이 읽는 문자열이었습니다. 집계할 수 있게 바꿉니다.
7-1) 구조화 로그
📝 src/logger.py
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) 구조화 로그」과 이어집니다.
# ==============================================================
# 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) 집계로 확인할 것들
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) 집계로 확인할 것들」과 이어집니다.
# ==============================================================
# 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(— 결과 출력
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.