20장. LLM 평가와 모니터링
대시보드와 알림
화재경보기가 토스트 냄새에도 울리면, 사람들은 결국 경보기 건전지를 빼 버립니다. 알림은 꼭 볼 일에만 울려야 합니다. 대시보드는 늘 보는 계기판, 알림은 사람을 부르는 경보기입니다.
1번에서 평소의 값(기준선)을 기록에서 잽니다. 2번에서 기준선보다 얼마나 나빠지면 부를지 문턱을 정합니다. 3번에서 문턱을 넘으면 알립니다. 4번에서 무엇을 했는지 적어, 다음에 문턱을 고칠 때 씁니다. 너무 예민한 경보기가 알림 피로입니다.
알림 규칙의 예
| 지표 | 문턱(이 장) | 까닭 |
|---|---|---|
| 하루 지연 p95 | 6초 넘음 | 평소 p95 는 3초 안팎 |
| 하루 오류율 | 5% 넘음 | 평소 1% 안팎 |
| 7일 이동 모름 비율 | 0.12 넘음 | 하루 값은 흔들려 이동 평균을 본다 |
| 7일 이동 싫어요 비율 | 0.08 넘음 | 같은 까닭 |
| 주간 PSI | 0.1 넘음 | 질문 분포의 변화 |
서버에 계기를 다는 꼴
실제 서비스에서는 API 서버가 지표를 내보내고, Prometheus 가 모으고, Grafana 가 그립니다. 아래는 이 PC 에서 실행하지 않은 안내 코드입니다. FastAPI 서버에 prometheus_client 로 계기를 달고, Prometheus 의 알림 규칙을 적은 꼴입니다.
# api.py: 요청마다 지연과 결과를 센다
from prometheus_client import Counter, Histogram, make_asgi_app
REQS = Counter("chatbot_requests_total", "요청 수", ["result"]) # result: ok, error, dont_know
LAT = Histogram("chatbot_latency_seconds", "응답 시간")
app.mount("/metrics", make_asgi_app())
# alert.rules.yml: 10분 동안 p95 가 6초를 넘으면 알린다
- alert: ChatbotSlow
expr: histogram_quantile(0.95, rate(chatbot_latency_seconds_bucket[10m])) > 6
for: 10m서버를 띄운 뒤 /metrics 를 열었을 때의 예상 출력(실행하지 않고 유추한 예시)입니다. 숫자는 요청 수에 따라 다릅니다.
curl http://127.0.0.1:8000/metrics
chatbot_requests_total{result="ok"} 약 수백
chatbot_requests_total{result="error"} 약 수
chatbot_latency_seconds_bucket{le="2.5"} 약 수백
chatbot_latency_seconds_count 약 수백이어진 날을 하나의 알림으로
같은 문제로 날마다 알림이 울리면 피로가 쌓입니다. 이어진 날을 한 사건으로 묶습니다.
import pandas as pd
days = pd.date_range("2026-10-18", periods=7, freq="D")
over = pd.Series([False, False, True, True, True, False, True], index=days) # 문턱을 넘은 날
# 앞날과 상태가 바뀔 때마다 번호를 하나씩 올리면, 이어진 날은 같은 번호가 된다
group = (over != over.shift()).cumsum()
events = [(g.index[0].date(), g.index[-1].date()) for _, g in over[over].groupby(group[over])]
print("알림 사건:", events)코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
pd.date_range("2026-10-18", periods=7, freq="D") | 날짜 줄을 만든다. periods(개수)는 7개, freq(frequency, 간격)의 "D" 는 day(하루)다 |
pd.Series(values, index=days) | 값 목록에 날짜 이름표를 붙인 한 줄짜리 표를 만든다 |
over.shift() | 값을 한 칸 아래로 민다. 날마다 옆에 앞날의 값이 오게 된다 |
over != over.shift() | 앞날과 상태가 다르면 True 다 |
.cumsum() | cumulative sum(누적 합)이다. 위에서부터 차례로 더해 간 값을 둔다 |
over[over] | True 인 날, 곧 문턱을 넘은 날만 남긴다 |
.groupby(group[over]) | 같은 번호끼리 묶는다 |
g.index[0].date(), g.index[-1].date() | 묶음의 첫날과 끝날에서 시각을 떼고 날짜만 꺼낸다 |
이 코드가 뜻하는 것
택배 회사가 날마다 "배송 지연" 여부를 달력에 적어 둔다고 생각해 봅니다. 사흘 이어서 지연되었다면 고객에게 사흘 내내 문자를 보내지 않고, "지연 사건 하나"로 묶어 한 번 알립니다. 여기서 달력의 지연 표시가 over, 사건 번호가 group 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
days = pd.date_range(...) | 10월 18일부터 7일 | 일주일 달력 |
over = pd.Series([...], index=days) | 날마다 문턱을 넘었는지 | 달력의 지연 표시 |
group = (over != over.shift()).cumsum() | 상태가 바뀔 때마다 번호를 올린다 | 지연이 끊기거나 시작될 때 새 사건 번호 |
events = [...] | 넘은 날을 번호별로 묶어 첫날과 끝날을 꺼낸다 | 사건마다 시작일과 끝일 |
결과의 사건은 둘입니다. 10월 20일부터 22일까지 사흘이 한 사건이고, 10월 24일 하루가 또 한 사건입니다. 알림을 날마다 울렸다면 네 번이었을 것을 두 번으로 줄인 셈입니다.
작게 줄여서 눈으로 보기
번호가 어떻게 매겨지는지 한 칸씩 표로 펼쳐 봅니다. 날짜 대신 0부터 6까지의 줄 번호를 씁니다.
import pandas as pd
over = pd.Series([False, False, True, True, True, False, True])
table = pd.DataFrame({
"넘음": over,
"앞날": over.shift(), # 한 칸 아래로 밀어 앞날 값을 옆에 둔다
"바뀜": over != over.shift(), # 앞날과 다르면 True
"번호": (over != over.shift()).cumsum(), # True 를 차례로 더한다
})
print(table) 넘음 앞날 바뀜 번호
0 False NaN True 1
1 False False False 1
2 True False True 2
3 True True False 2
4 True True False 2
5 False True True 3
6 True False True 4| 결과 | 읽는 법 |
|---|---|
| 앞날 칸 | shift 로 한 칸 밀었다. 첫날은 앞날이 없어 NaN(Not a Number, 값 없음)이다 |
| 바뀜 칸 | 앞날과 다르면 True 다. 첫날은 NaN 과 견주니 True 가 된다 |
| 번호 칸 | 바뀜의 True 를 위에서부터 더해 1, 1, 2, 2, 2, 3, 4 가 된다 |
넘음이 True 인 줄 | 2~4번 줄은 번호 2, 6번 줄은 번호 4 다. 번호가 둘이니 사건도 둘이다 |
이어진 날은 상태가 바뀌지 않으니 번호도 그대로입니다. 이 성질 하나로 "이어진 날 묶기"를 반복문 없이 풉니다. 문턱을 넘지 않은 날(번호 1, 3)은 over[over] 에서 빠집니다.
주의할 점
- 처음에는 문턱을 느슨하게 잡고, 알림이 울릴 때마다 진짜 문제였는지 적어 문턱을 고칩니다.
- 대시보드에는 지표마다 기준선을 함께 그립니다. 숫자 하나만 보면 좋은지 나쁜지 알 수 없습니다.