20장. LLM 평가와 모니터링
프로젝트 1 — 챗봇 품질 대시보드
도서관 챗봇을 맡은 팀이 매주 월요일에 보는 품질 대시보드를 만들어 보세요. 운영 기록에서 주간 지표와 드리프트 신호를 뽑고, 알림을 붙이고, Streamlit 화면으로 보여 줍니다. 처음 보는 질문에는 챗봇이 실제로 어떻게 답하는지 확인해 다음 할 일(문서 보태기)을 정합니다. 운영 기록, 도서관, 문서는 지어낸 연습용 데이터입니다.
| 파일 | 생김새 |
|---|---|
chat_log.csv | 운영 기록 8주. 시각, 질문, 지연초, 답토큰, 오류, 모름(0/1), 피드백(좋아요, 싫어요, 없음). 1,712줄 |
library/day4/ | 지금 챗봇이 쓰는 안내 문서(19장 밤 4의 판) |
out_libbot.py | 실습 1의 챗봇(검색과 Ollama 답) |
요구사항은 다섯 가지입니다.
- 주마다 질문 수, 지연 p95, 오류율, 모름·싫어요 비율, 주제 PSI, 처음 보는 질문 비율을 담은 주간 표를 만들어
out_weekly.csv로 저장합니다. - 주마다 문턱을 넘은 지표를 알림으로 적습니다(p95 6초, 오류율 5%, 모름 0.12, 싫어요 0.08, PSI 0.1).
- Streamlit 화면(
out_dashboard.py)에 주를 고르는 상자, 지표 넷과 기준 기간 대비 변화, 알림, 그 주의 처음 보는 질문을 보입니다.AppTest로 화면을 시험합니다. - 처음 보는 질문 가운데 자주 나온 것을 챗봇에 실제로 물어, 문서를 보탤 후보를 정합니다.
- 주간 지표를 한 장의 그림으로 그리고, 이번 주에 할 일을 적습니다.
단계별 힌트입니다.
- 실습 5의 PSI, 처음 보는 질문 판정과 실습 6의 지표 계산을 함수 하나(
weekly_report)로 묶습니다. - 기준 기간(1~4주)의 평균을 함께 저장하면 화면에서 변화(
delta)를 보일 수 있습니다. st.selectbox(..., key="week")처럼 키를 주면AppTest에서at.selectbox(key="week")로 찾습니다(14장).- 알림이 있으면
st.error, 없으면st.success로 보이면 시험에서 세기 쉽습니다. - 챗봇에 물을 때는 실습 1의
answer(질문, "v3")를 씁니다.
확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.
실제 데이터로 해 보려면
실제 챗봇의 운영 기록은 개인정보가 섞여 공개된 것이 드뭅니다. 공개된 대화·질문 자료로 질문 분포의 드리프트를 연습할 수 있습니다. 내려받기 전에 라이선스와 약관을 확인하고, 자기 서비스의 기록을 쓸 때는 개인정보를 가린 뒤 씁니다.
| 자료 | 구하는 곳 | 내용 |
|---|---|---|
| AI Hub 민원(콜센터) 질의응답 데이터 | aihub.or.kr (회원가입 뒤 신청) | 분야별 민원 질문과 답. 분야를 주제로 기간을 나눠 PSI 를 잰다 |
| 공공데이터포털의 기관 FAQ | data.go.kr | 기관 FAQ 질문과 답. 평가 묶음의 참조 답으로 쓴다 |
| KorQuAD 1.0 | korquad.github.io (CC BY-ND 2.0 KR) | 문단과 질문, 답. 평가 묶음과 LLM 채점자를 연습한다 |
코드의 흐름
예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.
주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)는 weekly_report() 로 지연 p95, 오류율, 주제 PSI 등을 담은 주간 표를 만들어 out_weekly.csv 로 저장하고, 2단계(마젠타)는 st.selectbox 로 주를 고르는 화면 파일 out_dashboard.py 를 씁니다. 3단계(다홍)는 AppTest 로 주를 골라 보며 화면을 시험하고, 4단계(빨강)는 out_libbot 챗봇에 처음 보는 질문을 물어 답을 봅니다. 5단계(짙은 청록)는 plt.subplots 로 주간 그림을 그리고 8주 알림과 문서를 보탤 후보로 이번 주에 할 일을 정리합니다.
결과 보기 (예시 풀이)
1단계. 주간 보고표 만들기
import json
import sys
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
sys.path.insert(0, ".")
import out_libbot as bot # 검색(e5)과 답. 임베딩 모델도 여기서 빌려 쓴다
plt.rcParams["font.family"] = "Malgun Gothic" # 한글 글꼴(5단계의 그래프)
plt.rcParams["axes.unicode_minus"] = False
RULES = {"p95": 6.0, "오류율": 0.05, "모름": 0.12, "싫어요": 0.08, "PSI": 0.1}
DOCS = [c.split(":")[0] for c in bot.CHUNKS] # 조각마다의 문서 이름
def psi(base, now, eps=1e-4):
keys = sorted(set(base.index) | set(now.index))
b = base.reindex(keys, fill_value=0).to_numpy() + eps
n = now.reindex(keys, fill_value=0).to_numpy() + eps
return float(((n - b) * np.log(n / b)).sum())
def weekly_report(log, base_weeks=4):
"""운영 기록 → (주간 표, 처음 보는 질문 표, 기준 기간 평균)"""
log = log.copy()
log["주"] = (log["시각"] - log["시각"].min().normalize()).dt.days // 7 + 1
uniq = log["질문"].unique()
Q = bot.emb.encode(["query: " + q for q in uniq], normalize_embeddings=True)
log["주제"] = log["질문"].map(dict(zip(uniq, [DOCS[i] for i in (Q @ bot.VECS.T).argmax(axis=1)])))
base = log[log["주"] <= base_weeks]
base_q = Q[np.isin(uniq, base["질문"].unique())]
log["처음"] = log["질문"].map(dict(zip(uniq, (Q @ base_q.T).max(axis=1)))) < 0.95
p0 = base["주제"].value_counts(normalize=True)
g = log.groupby("주")
table = pd.DataFrame({
"질문": g.size(), "p95": g["지연초"].quantile(0.95), "오류율": g["오류"].mean(), "모름": g["모름"].mean(),
"싫어요": g["피드백"].apply(lambda s: (s == "싫어요").mean()),
"PSI": g["주제"].apply(lambda s: psi(p0, s.value_counts(normalize=True))), "처음 보는 비율": g["처음"].mean(),
})
table["알림"] = [", ".join(k for k, v in RULES.items() if row[k] > v) for _, row in table.iterrows()]
new_q = log[log["처음"]].groupby(["주", "질문"]).size().rename("수").reset_index()
return table, new_q, table.loc[:base_weeks].mean(numeric_only=True)
log = pd.read_csv("chat_log.csv", parse_dates=["시각"])
weekly, new_q, base_mean = weekly_report(log)
weekly.round(4).to_csv("out_weekly.csv", encoding="utf-8-sig")
new_q.to_csv("out_new_questions.csv", index=False, encoding="utf-8-sig")
# 결과: 주간 표
print(weekly.round(3).to_string()) 질문 p95 오류율 모름 싫어요 PSI 처음 보는 비율 알림
주
1 216 3.200 0.014 0.056 0.056 0.028 0.000
2 215 3.130 0.014 0.042 0.056 0.012 0.000
3 221 3.170 0.014 0.059 0.018 0.030 0.000
4 208 3.152 0.005 0.038 0.024 0.058 0.000
5 213 2.954 0.005 0.038 0.042 0.046 0.000
6 196 3.298 0.015 0.148 0.056 0.099 0.122 모름
7 206 7.868 0.010 0.194 0.136 0.069 0.218 p95, 모름, 싫어요
8 237 3.242 0.008 0.270 0.131 0.209 0.333 모름, 싫어요, PSI2단계. Streamlit 화면 만들기
# 화면 파일. 위의 표 두 개를 읽어 그린다(모델을 부르지 않아 빨리 뜬다)
Path("out_dashboard.py").write_text('''import pandas as pd
import streamlit as st
st.set_page_config(page_title="도서관 챗봇 품질", layout="wide")
weekly = pd.read_csv("out_weekly.csv", index_col="주").fillna({"알림": ""})
new_q = pd.read_csv("out_new_questions.csv")
base = weekly.loc[:4].mean(numeric_only=True) # 기준 기간(1~4주)의 평균
st.title("도서관 챗봇 품질 대시보드")
week = st.selectbox("주", weekly.index.tolist(), index=len(weekly) - 1, key="week")
row = weekly.loc[week]
cols = st.columns(4)
for col, (name, label) in zip(cols, [("p95", "지연 p95(초)"), ("오류율", "오류율"), ("모름", "모름 비율"), ("싫어요", "싫어요 비율")]):
col.metric(label, f"{row[name]:.3f}", delta=f"{row[name] - base[name]:+.3f}", delta_color="inverse")
st.caption(f"질문 {int(row['질문'])}개 · 주제 PSI {row['PSI']:.3f} · 처음 보는 질문 {row['처음 보는 비율']:.3f} (변화는 1~4주 평균 대비)")
if row["알림"]:
for name in row["알림"].split(", "):
st.error(f"알림: {name} 이(가) 문턱을 넘었습니다")
else:
st.success("알림 없음")
st.subheader("주마다의 지표")
st.line_chart(weekly[["모름", "싫어요", "처음 보는 비율"]])
st.subheader("이번 주에 처음 보는 질문")
st.dataframe(new_q[new_q["주"] == week].sort_values("수", ascending=False).head(5), hide_index=True)
''', encoding="utf-8")
print("화면 파일:", Path("out_dashboard.py").stat().st_size, "바이트")화면 파일: 1395 바이트3단계. AppTest 로 화면 시험하기
from streamlit.testing.v1 import AppTest
at = AppTest.from_file(str(Path("out_dashboard.py").resolve()), default_timeout=60).run()
# 결과: 처음 열면 마지막 주(8주)가 골라져 있다
print("고른 주:", at.selectbox(key="week").value)
print("지표:", [(m.label, m.value, m.delta) for m in at.metric])
print("알림:", [e.value for e in at.error], "· 표 행 수:", len(at.dataframe[0].value))
for week in [3, 7]:
at.selectbox(key="week").set_value(week).run()
print(f"{week}주 →", [e.value for e in at.error] or [s.value for s in at.success])고른 주: 8
지표: [('지연 p95(초)', '3.242', '+0.079'), ('오류율', '0.008', '-0.003'), ('모름 비율', '0.270', '+0.221'), ('싫어요 비율', '0.131', '+0.092')]
알림: ['알림: 모름 이(가) 문턱을 넘었습니다', '알림: 싫어요 이(가) 문턱을 넘었습니다', '알림: PSI 이(가) 문턱을 넘었습니다'] · 표 행 수: 5
3주 → ['알림 없음']
7주 → ['알림: p95 이(가) 문턱을 넘었습니다', '알림: 모름 이(가) 문턱을 넘었습니다', '알림: 싫어요 이(가) 문턱을 넘었습니다']4단계. 처음 보는 질문을 챗봇에 물어 보기
top_new = new_q.groupby("질문")["수"].sum().sort_values(ascending=False).head(8)
rows = []
for q, n in top_new.items():
text, chunks = bot.answer(q, "v3")
rows.append({"질문": q, "수": int(n), "1등 문서": chunks[0].split(":")[0], "답": text[:60]})
todo = pd.DataFrame(rows)
# 결과: 자주 나온 처음 보는 질문과 챗봇의 실제 답
print(todo.to_string(index=False))
print("다시 물음", bot.retried["수"]) 질문 수 1등 문서 답
지하철역 스마트 도서관은 어떻게 이용해요? 28 전자책 안내 자료에 없습니다.
스마트 도서관에는 어떤 책이 있어요? 23 전자책 안내 자료에 없습니다.
기말고사 기간에 열람실 연장 운영하나요? 19 열람실 열람실 연장 운영은 가능하며, 한 번에 4시간 사용한 후, 끝나기 30분 전부터 연장할 수 있습니다. 기말고
Can I borrow books with a foreign ID? 19 회원증 안내 자료에 없습니다.
역에 있는 무인 대출기에서 회원증이 안 읽혀요. 17 회원증 안내 자료에 없습니다. 1층 안내 데스크에 문의해 주세요.
시험 기간에는 열람실을 밤새 여나요? 15 열람실 안내 자료에 없습니다. 1층 안내 데스크에 문의해 주세요.
스마트 도서관에서 빌린 책은 어디에 반납해요? 15 전자책 스마트 도서관에서 빌린 책은 도서관이 문을 닫은 시간에는 정문 옆 무인 반납함에 넣으면 됩니다. 무인 반납함
What time does the library close? 12 휴관일 도서관은 매주 월요일과 법정 공휴일에 휴관합니다. 따라서 그 외의 날짜와 시간에 대해서는 자료에 명시되어 있
다시 물음 85단계. 주간 그림과 이번 주에 할 일
fig, axes = plt.subplots(1, 3, figsize=(12, 3.4))
weekly["p95"].plot(ax=axes[0], marker="o", title="지연 p95(초)")
axes[0].axhline(RULES["p95"], linestyle="--", color="gray")
weekly[["모름", "싫어요"]].plot(ax=axes[1], marker="o", title="모름·싫어요 비율")
axes[1].axhline(RULES["모름"], linestyle="--", color="gray")
weekly[["PSI", "처음 보는 비율"]].plot(ax=axes[2], marker="o", title="드리프트 신호")
axes[2].axhline(RULES["PSI"], linestyle="--", color="gray")
for ax in axes:
ax.set_xlabel("주")
plt.tight_layout()
plt.show()
# 이번 주(8주)에 할 일: 알림이 난 지표와, 답하지 못한 처음 보는 질문
last = weekly.iloc[-1]
print("8주 알림:", last["알림"] or "없음")
print("문서를 보탤 후보:", todo[todo["답"].str.contains("안내 자료에 없")]["질문"].tolist())8주 알림: 모름, 싫어요, PSI
문서를 보탤 후보: ['지하철역 스마트 도서관은 어떻게 이용해요?', '스마트 도서관에는 어떤 책이 있어요?', 'Can I borrow books with a foreign ID?', '역에 있는 무인 대출기에서 회원증이 안 읽혀요.', '시험 기간에는 열람실을 밤새 여나요?']
주간 표로 보면 6주째부터 모름이 문턱을 넘었고, 7주째에 지연 p95(7.9초)와 싫어요가, 8주째에 주제 PSI(0.209)가 더해졌습니다. 화면도 이 표 그대로 동작해, AppTest 로 고른 3주에는 "알림 없음", 7주와 8주에는 알림 셋이 나왔습니다. 한 가지는 주간 표에 드러나지 않았습니다. 10월 21일 하루의 오류율 0.069 는 7주 전체로 보면 0.010 이라 문턱 아래였습니다. 주간 화면은 흐름을 보는 데 좋고, 하루짜리 사고는 실습 6의 날마다 알림이 잡습니다.
처음 보는 질문 가운데 자주 나온 여덟 개를 챗봇에 실제로 물으니, 다섯 개는 "안내 자료에 없습니다"라고 답했습니다. 지하철역 스마트 도서관(이용법, 있는 책, 무인 대출기 오류), 외국인 신분증으로 빌리기, 시험 기간 밤샘 운영입니다. 이 다섯은 안내 문서에 보탤 후보입니다. 더 위험한 것은 나머지 셋이었습니다. "기말고사 기간에 열람실 연장 운영하나요?"에는 좌석을 4시간마다 연장하는 규정을 들어 연장 운영이 된다는 듯이 답했습니다. "스마트 도서관에서 빌린 책은 어디에 반납해요?"에는 하늘숲 도서관의 무인 반납함을 안내했습니다. "What time does the library close?"에는 쉬는 날만 말하고 시간은 자료에 없다고 했습니다. 이 셋은 모름 비율에도 잡히지 않는 틀린 답이라, 5단계의 "문서를 보탤 후보" 목록에도 빠졌습니다. 그래서 이번 주에 할 일은 셋입니다. 스마트 도서관과 시험 기간 운영 안내를 문서에 보태고 19장의 갱신 작업으로 색인을 바꿉니다. 처음 보는 질문 여덟 개를 참조 답과 함께 평가 묶음에 넣습니다. 7주째의 지연과 오류는 날마다 알림의 기록(10월 20~22일)과 서버 기록을 맞춰 원인을 찾습니다. 운영 기록은 지어낸 것이고, 챗봇의 답은 이 PC 에서 실제로 받은 것입니다. 답은 실행마다 조금씩 다를 수 있습니다.
이 프로젝트가 뜻하는 것
이 프로젝트는 매주 월요일 아침 회의에 쓸 가게의 주간 보고판을 만드는 일과 같습니다. 한 주의 장부를 표 하나로 정리하고(1단계), 그 표를 보여 주는 보고판 화면을 만들고(2단계), 보고판의 버튼을 직접 눌러 보며 시험하고(3단계), 처음 온 손님들의 주문을 직접 받아 본 뒤(4단계), 주간 그림과 이번 주에 할 일을 적습니다(5단계). 여기서 주간 장부가 out_weekly.csv, 보고판이 Streamlit 화면 out_dashboard.py, 버튼 눌러 보기가 AppTest, 처음 온 손님의 주문이 처음 보는 질문입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 주간 표 | 6주 모름, 7주 p95, 모름, 싫어요, 8주 모름, 싫어요, PSI | 1~5주는 알림이 없고 6주째부터 알림이 붙었습니다. 7주째의 p95 는 7.868초입니다 |
| 2. 화면 파일 | 1395 바이트 | 화면 코드가 파일로 저장되었습니다. 표 두 개를 읽기만 해서 빨리 뜹니다 |
| 3. 8주 지표 | 모름 비율 0.270, 변화 +0.221 | 기준 기간(1~4주) 평균보다 모름이 0.221 늘었다는 뜻입니다. p95 는 3.242초로 평소와 거의 같습니다(+0.079) |
| 3. 주 바꾸기 | 3주 알림 없음, 7주와 8주 알림 셋 | 주를 바꿔 고르면 화면의 알림도 주간 표대로 바뀝니다. 화면이 표와 맞게 동작한다는 확인입니다 |
| 4. 챗봇에 묻기 | 여덟 질문 가운데 다섯이 안내 자료에 없습니다 | 모르는 것을 모른다고 답했습니다. 이 다섯이 문서를 보탤 후보입니다 |
| 4. 나머지 셋 | 열람실 연장, 스마트 도서관 반납, 닫는 시간(영어) | 그럴듯하지만 질문에 맞지 않는 답입니다. 모름 비율에는 잡히지 않는 틀린 답입니다 |
| 5. 할 일 | 8주 알림: 모름, 싫어요, PSI, 후보 다섯 | 그림에는 주간 지표 세 칸과 문턱 점선이 그려집니다. 후보 목록에는 "안내 자료에 없"다고 답한 질문만 들어갔습니다 |
한 줄로 줄이면, 실습 5, 6의 신호를 주간 화면 하나에 모으고, 처음 보는 질문을 직접 물어 "문서를 보태라"는 할 일로 바꾼 프로젝트입니다. 주간 화면은 흐름을 보기에 좋지만 10월 21일 하루의 오류율 같은 짧은 사고는 묻히므로, 실습 6의 날마다 알림과 함께 씁니다. 또 화면의 숫자는 모른다고 답한 것만 세므로, 자신 있게 틀린 세 답은 사람이 읽어 찾아야 합니다. 운영 기록은 지어낸 것이고, 챗봇의 답은 실행마다 조금씩 다를 수 있습니다.