LLM 엔지니어링20장 · LLM 평가와 모니터링14 / 22← 이전목차다음 →MetaContents

20장. LLM 평가와 모니터링

실습 1 — 답 만들고 규칙으로 채점하기

19장의 하늘숲 도서관 챗봇을 파일(out_libbot.py)로 만들고, 평가 묶음 22문항에 두 판의 프롬프트로 답하게 합니다. v2 는 "자료로만 답하라", v3 는 거기에 "자료에 없으면 지어내지 말고 안내 데스크를 알려라"를 더했습니다(19장의 프롬프트 레지스트리 예시). 답은 out_lib_answers.csv 로 저장해 실습 2~4가 씁니다. Ollama 와 qwen2.5:3b 가 있어야 합니다. 도서관과 문서는 지어낸 연습용 데이터입니다.

1단계. 챗봇을 파일로 만들기

Python
from pathlib import Path

# 실습 3, 4와 프로젝트가 불러 쓸 챗봇 모듈. r''' 로 감싸 \n 이 그대로 파일에 들어가게 한다
Path("out_libbot.py").write_text(r'''"""하늘숲 도서관 챗봇(20장). 19장의 day4 문서로 검색하고 Ollama qwen2.5:3b 로 답한다."""
import json
import time
import urllib.request
from pathlib import Path

import numpy as np
from sentence_transformers import SentenceTransformer

MODEL = "qwen2.5:3b"
PROMPTS = {
    "v2": "도서관 안내 도우미입니다. 아래 자료로만 답하세요.",
    "v3": ("도서관 안내 도우미입니다. 아래 자료로만 한두 문장으로 답하세요. 자료에 답이 없으면 지어내지 말고 "
           "'안내 자료에 없습니다. 1층 안내 데스크에 문의해 주세요.'라고 답하세요."),
}
retried = {"수": 0}                              # Ollama 오류로 다시 물은 횟수

emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
CHUNKS = []
for f in sorted(Path("library/day4").glob("*.txt")):
    paras = [p.strip() for p in f.read_text(encoding="utf-8").split("\n\n") if p.strip()]
    CHUNKS += [f"{f.stem}: {p}" for p in paras[1:]]          # 첫 문단은 제목
VECS = emb.encode(["passage: " + c for c in CHUNKS], normalize_embeddings=True)


def search(question, k=3):
    """(상위 k 조각, 1등 조각의 점수)"""
    q = emb.encode(["query: " + question], normalize_embeddings=True)[0]
    scores = VECS @ q
    top = np.argsort(-scores)[:k]
    return [CHUNKS[i] for i in top], float(scores[top[0]])


def _post(path, body, timeout=120):
    req = urllib.request.Request("http://127.0.0.1:11434" + path, data=json.dumps(body).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read())


def chat(system, user, fmt=None, max_tokens=200):
    """Ollama /api/chat. fmt 에 JSON 스키마를 주면 그 꼴로 답한다. 오류면 모델을 내렸다 네 번까지 다시 묻는다"""
    body = {"model": MODEL, "stream": False, "keep_alive": "30m",
            "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
            "options": {"temperature": 0, "seed": 42, "num_predict": max_tokens}}
    if fmt:
        body["format"] = fmt
    for _ in range(4):
        try:
            return _post("/api/chat", body)["message"]["content"]
        except OSError:
            retried["수"] += 1
            try:
                _post("/api/generate", {"model": MODEL, "keep_alive": 0})
            except OSError:
                pass
            time.sleep(3)
    raise RuntimeError("Ollama 가 계속 오류를 냅니다")


def chat_json(system, user, schema, max_tokens=250):
    """JSON 스키마로 답을 받아 사전으로 돌려준다. 끊기거나 깨진 JSON 이 오면 모델을 내렸다 다시 묻는다"""
    for _ in range(4):
        text = chat(system, user, fmt=schema, max_tokens=max_tokens)
        try:
            return json.loads(text)
        except ValueError:
            retried["수"] += 1
            try:
                _post("/api/generate", {"model": MODEL, "keep_alive": 0})
            except OSError:
                pass
            time.sleep(3)
    raise RuntimeError("Ollama 가 계속 깨진 JSON 을 돌려줍니다")


def answer(question, version="v3"):
    """(답, 검색한 조각들)"""
    chunks, _ = search(question)
    text = chat(PROMPTS[version], "자료:\n" + "\n".join(chunks) + "\n\n질문: " + question)
    return text.strip(), chunks
''', encoding="utf-8")

import sys
sys.path.insert(0, ".")                        # 작업 폴더(data)에 만든 out_libbot.py 를 찾게 한다
import out_libbot as bot
# 결과: 조각 수, 그리고 질문 하나의 검색 결과
chunks, top = bot.search("대출 기간은 며칠이에요?")
print(len(bot.CHUNKS), "조각 ·", f"1등 점수 {top:.3f} ·", chunks[0])
출력
36 조각 · 1등 점수 0.878 · 대출과반납: 책은 한 사람이 7권까지 빌릴 수 있습니다. 대출 기간은 14일입니다.

2단계. 두 판으로 답 만들기

Python
import json
import time

import pandas as pd

evalset = [json.loads(line) for line in Path("library_eval.jsonl").read_text(encoding="utf-8").splitlines()]
bot.chat("짧게 답하세요.", "안녕하세요")      # 몸풀기: 모델을 GPU 에 올려 둔다
rows = []
t = time.perf_counter()
for e in evalset:
    for version in ["v2", "v3"]:
        text, chunks = bot.answer(e["질문"], version)
        rows.append({"번호": e["번호"], "종류": e["종류"], "판": version, "질문": e["질문"], "답": text,
                     "자료": "\n".join(chunks)})
answers = pd.DataFrame(rows)
answers.to_csv("out_lib_answers.csv", index=False, encoding="utf-8-sig")
# 결과: 답의 수, 걸린 시간, Ollama 오류로 다시 물은 횟수
print(len(answers), "개 답 ·", f"{time.perf_counter() - t:.0f}초 · 다시 물음 {bot.retried['수']}")
print(answers[answers["번호"].isin([2, 20])][["번호", "판", "답"]].to_string(index=False))
출력
44 개 답 · 88초 · 다시 물음 3
 번호  판                                                                                                                           답
  2 v2                                                                                                              대출 기간은 14일입니다.
  2 v3                                                                                                              대출 기간은 14일입니다.
 20 v2 죄송합니다, 노트북 빌려드리지 않습니다. 도서관 서비스는 주로 책, 잡지, 전자책 등 서적 형태의 자료를 대여하는 것에 집중하고 있습니다. 컴퓨터 관련 자료는 대여하지 않고, 대신 복사와 출력 서비스를 제공하고 있습니다.
 20 v3                                                                                                                안내 자료에 없습니다.

3단계. 규칙으로 채점하기

Python
import re

keys = {e["번호"]: e["열쇠"] for e in evalset}


def rule_check(answer, key):
    return {"열쇠": any(k in answer for k in key.split("|")),
            "형식": re.search(r"[A-Za-z]{3,}", answer) is None,     # 영문 낱말(3글자 이상)이 섞였나
            "길이": 0 < len(answer) <= 120}


checks = pd.DataFrame([rule_check(a, keys[n]) for a, n in zip(answers["답"], answers["번호"])])
scored = pd.concat([answers[["번호", "종류", "판"]], checks], axis=1)
scored["규칙"] = scored[["열쇠", "형식", "길이"]].all(axis=1)
# 결과: 판과 종류마다 규칙을 모두 통과한 답의 수
print(scored.groupby(["판", "종류"])[["열쇠", "형식", "길이", "규칙"]].sum())
출력
       열쇠  형식  길이  규칙
판  종류
v2 답   18  18  18  18
   모름   1   4   2   0
v3 답   17  18  18  17
   모름   0   4   4   0

4단계. 규칙이 내린 판정 들여다보기

Python
view = answers.merge(scored[["번호", "판", "열쇠", "형식", "규칙"]], on=["번호", "판"])
# 모름 질문의 답: 열쇠(안내 데스크|문의)로 매긴 판정과 실제 답
print(view[view["종류"] == "모름"][["번호", "판", "규칙", "답"]].to_string(index=False))
# 형식 규칙에 걸린 답: 엉뚱한 영어가 섞였다(없으면 빈 표)
print(view[~view["형식"]][["번호", "판", "답"]].to_string(index=False))
출력
 번호  판    규칙                                                                                                                                                          답
 19 v2 False                                                                                              죄송합니다, 자료에는 도와드릴 수 없는 정보입니다. 이 정보는 도서관 웹사이트나 앱에서 확인하실 수 있습니다.
 19 v3 False                                                                                                                                               안내 자료에 없습니다.
 20 v2 False                                죄송합니다, 노트북 빌려드리지 않습니다. 도서관 서비스는 주로 책, 잡지, 전자책 등 서적 형태의 자료를 대여하는 것에 집중하고 있습니다. 컴퓨터 관련 자료는 대여하지 않고, 대신 복사와 출력 서비스를 제공하고 있습니다.
 20 v3 False                                                                                                                                               안내 자료에 없습니다.
 21 v2 False                                                                                                                                    도서관 안에 카페는 없는 것으로 보입니다.
 21 v3 False                                                                                                                                               안내 자료에 없습니다.
 22 v2 False 도서관에서 책을 기증하려면, 도서관에 직접 방문하여 기증을 요청하거나, 도서관에 설치된 기증 상자에 책을 직접 기증하실 수 있습니다. 기증을 원하시는 책의 수량과 종류에 따라, 도서관에서 적절한 처리 방안을 제시할 수 있습니다. 기증을 원하시는 경우, 도서관에 문의해 주세요.
 22 v3 False                                                                                                                                               안내 자료에 없습니다.
Empty DataFrame
Columns: [번호, 판, 답]
Index: []

답을 직접 읽으면 두 판의 차이가 뚜렷합니다. v2 는 모름 질문 넷 가운데 셋에서 자료에 없는 말을 지어냈습니다. 노트북은 빌려주지 않는다(20번), 카페는 없는 것 같다(21번), 기증은 기증 상자에 넣으면 된다(22번)는 말이 모두 자료에 없습니다. v3 는 넷 모두 "안내 자료에 없습니다."라고만 답했습니다. 그런데 규칙은 두 판의 모름 답을 모두 틀렸다고 했습니다. v3 는 프롬프트에 적은 안내 문장의 앞부분만 써서 열쇠(안내 데스크|문의)가 없었습니다. 반대로 v2 의 22번은 지어낸 답인데도 끝에 "문의해 주세요"가 들어 열쇠를 통과했고, 길이(120자)에서만 떨어졌습니다. 열쇠 하나로는 지어낸 답과 바른 거절을 가리지 못한 것입니다. v3 에는 다른 문제도 있었습니다. 답이 있는 4번(예약한 책을 찾아갈 기한)에도 "안내 자료에 없습니다"라고 거절해 열쇠에서 떨어졌습니다. 이번 실행에서는 영문이 섞인 답이 없어 형식 표가 비었습니다. 교안을 만들며 먼저 돌렸을 때는 v3 의 답 둘에 "oze", "Consists of" 같은 영어가 섞였습니다. 같은 설정이라도 실행마다 답이 조금씩 다릅니다.

이 실습이 뜻하는 것

이 실습은 새 상담원 두 명에게 같은 시험지를 풀게 하고 채점표로 채점하는 일과 같습니다. 상담원이 앉을 자리를 마련하고(1단계), 같은 문제 22개를 두 사람에게 물어 답지를 받고(2단계), "정답 낱말이 있나, 영어가 섞였나, 너무 길지 않나"라는 세 칸짜리 채점표로 매긴 뒤(3단계), 채점표가 맞게 매겼는지 답지를 직접 읽어 봅니다(4단계). 여기서 두 상담원이 프롬프트의 두 판(v2, v3), 시험지가 평가 묶음, 세 칸짜리 채점표가 규칙 채점입니다. 비유와 다른 점은, 사람 상담원과 달리 챗봇은 같은 문제를 다시 물으면 답이 조금씩 달라질 수 있다는 것입니다.

단계출력뜻
1. 챗봇 파일36 조각 · 1등 점수 0.878안내 문서를 36조각으로 나눴고, "대출 기간" 질문에 대출 규정 조각이 1등(0.878)으로 찾아졌습니다. 검색이 제대로 돈다는 확인입니다
2. 답 만들기44 개 답 · 88초 · 다시 물음 322문항을 두 판으로 물어 답 44개를 88초에 받았습니다. Ollama 오류로 세 번 다시 물었습니다
2. 20번 답v2 긴 설명, v3 안내 자료에 없습니다.노트북 대여는 자료에 없는 질문입니다. v2 는 그럴듯한 설명을 지어냈고, v3 는 자료에 없다고 답했습니다
3. 답 있는 질문v2 규칙 18, v3 규칙 17답이 있는 18문항에서 세 규칙을 모두 통과한 답의 수입니다. 두 판이 거의 같습니다
3. 모름 질문두 판 모두 규칙 0자료에 없는 4문항은 두 판 모두 하나도 통과하지 못했습니다. 바르게 거절한 v3 도 0개입니다
4. 모름 답 읽기8개 모두 Falsev3 의 거절은 열쇠 낱말(안내 데스크|문의)이 없어 떨어졌습니다. v2 의 22번은 지어낸 답인데 열쇠는 통과하고 길이에서만 떨어졌습니다
4. 형식 규칙Empty DataFrame이번 실행에서는 영어가 섞인 답이 하나도 없었다는 뜻입니다

한 줄로 줄이면, 규칙 채점은 빠르고 돈이 들지 않지만 "지어낸 답"과 "바른 거절"을 가리지 못한다는 것을 확인한 실습입니다. 답을 읽으면 v3 가 덜 지어냈는데, 규칙 점수(모름 0과 0)에는 그 차이가 드러나지 않습니다. 이 빈틈을 실습 2의 뜻 채점과 실습 3의 LLM(Large Language Model, 대규모 언어 모델) 채점자로 메웁니다. 답은 실행마다 조금씩 달라지므로 숫자 하나하나보다 흐름으로 읽어야 합니다.

코드 설명

  • Path(...).write_text(r'''...''') 는 챗봇 코드를 모듈 파일로 씁니다. 앞에 r 을 붙여 코드 안의 \n 이 글자 그대로 들어가게 합니다.
  • out_libbot.answer(질문, 판) 은 e5 로 조각 셋을 찾고, 그 판의 시스템 프롬프트로 Ollama 에 묻습니다. 판만 바꾸고 나머지는 같게 해서 프롬프트의 차이만 잽니다.
  • chat() 은 오류가 나면 모델을 내렸다(keep_alive: 0) 다시 묻고 그 수를 retried 에 셉니다(16장). chat_json() 은 JSON 이 깨져 읽히지 않을 때도 같은 일을 합니다(실습 3, 4가 쓴다).
  • rule_check() 는 열쇠 포함, 영문 낱말 섞임, 길이를 따로 봅니다. 규칙마다 나눠 두어야 어느 규칙에서 떨어졌는지 알 수 있습니다.
  • 4단계에서 모름 질문의 답을 직접 읽어, 열쇠 규칙의 판정이 실제 답과 맞는지 확인합니다.
← 이전 절20장 · 실습 지도다음 절 →20장 · 실습 2 — 뜻으로 채점하기