포스트

프롬프트 엔지니어링 기초 (6) - Evaluation: 프롬프트를 채점하는 코드

예시용 데이터와 채점용 데이터를 나누고 정답이 있는 작업과 없는 작업을 각각 채점하는 harness를 작성하며 LLM judge를 붙이는 방법을 다룹니다.

프롬프트 엔지니어링 기초 (6) - Evaluation: 프롬프트를 채점하는 코드

프롬프트 엔지니어링 기초 시리즈의 6편입니다. 전체 목차는 0편에 있습니다.

눈으로 보는 것의 한계

프롬프트를 고치는 흔한 방식은 이렇습니다. 결과가 마음에 안 드는 입력 하나를 골라 프롬프트를 손보고, 그 입력에서 좋아지면 배포합니다.

이 방식의 문제는 고친 프롬프트가 다른 입력에서 나빠졌는지 알 수 없다는 것입니다. 2편에서 “두 문장 이내”를 추가했더니 길이는 잡혔지만 주문번호가 빠지기 시작하는 식의 교환이 실제로 일어납니다. 입력 하나만 보면 이 교환이 보이지 않습니다.

필요한 것은 프롬프트를 고칠 때마다 전체 데이터에 돌려 점수를 내는 스크립트입니다.

데이터를 나눈다

3편의 실습에는 결함이 하나 있었습니다. few-shot 예시로 쓴 문의 세 건이 채점 데이터에도 들어 있어서, 모델이 답을 이미 본 상태로 채점을 받았습니다.

데이터를 두 몫으로 나눕니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# data.py
DEV = [   # 프롬프트를 고치면서 들여다보는 몫. few-shot 예시도 여기서 고른다
    {"text": "배송이 늦어서 취소하고 환불받고 싶어요.", "label": "환불"},
    {"text": "오늘 주문하면 모레까지 받을 수 있나요?", "label": "배송"},
    {"text": "세금계산서 발행 부탁드립니다.", "label": "기타"},
    {"text": "재입고 예정이 있는지 궁금합니다.", "label": "상품문의"},
]

TEST = [  # 점수를 낼 때만 쓰는 몫. 프롬프트에 절대 넣지 않는다
    {"text": "주문한 지 일주일인데 아직 배송 중으로만 뜹니다.", "label": "배송"},
    {"text": "송장번호가 조회되지 않아요.", "label": "배송"},
    {"text": "받아보니 색상이 사진과 달라서 반품하고 싶습니다.", "label": "환불"},
    {"text": "결제 취소했는데 카드 승인이 아직 안 취소됐어요.", "label": "환불"},
    {"text": "개봉했는데도 환불이 되나요?", "label": "환불"},
    {"text": "이 이어폰 배터리는 몇 시간 가나요?", "label": "상품문의"},
    {"text": "270mm 신는데 이 운동화는 어떤 사이즈를 골라야 하나요?", "label": "상품문의"},
    {"text": "회원 등급은 어떻게 올라가나요?", "label": "기타"},
]

DEV에서 틀린 사례를 few-shot 예시로 승격시키고, 판단은 TEST 점수로 합니다. TEST를 들여다보며 프롬프트를 고치기 시작하면 그 순간부터 TEST도 DEV가 되고, 점수는 실제 성능보다 높게 나옵니다.

정답이 있는 작업 채점

분류나 추출처럼 정답이 정해진 작업은 문자열 비교로 끝납니다. 4편의 스키마 강제를 쓰면 정규화 코드도 필요 없습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# evaluate.py
import json, time
from anthropic import Anthropic
from data import TEST, LABELS

client = Anthropic()

SCHEMA = {
    "type": "object",
    "properties": {"label": {"type": "string", "enum": LABELS}},
    "required": ["label"],
    "additionalProperties": False,
}

def run_case(system: str, text: str):
    message = client.messages.create(
        model="claude-opus-5",
        max_tokens=2048,
        system=system,
        output_config={"format": {"type": "json_schema", "schema": SCHEMA}, "effort": "low"},
        messages=[{"role": "user", "content": text}],
    )
    return json.loads(text_of(message))["label"], message.usage

def evaluate(system: str, name: str):
    correct, in_tok, out_tok = 0, 0, 0
    errors = []
    started = time.time()
    for row in TEST:
        pred, usage = run_case(system, row["text"])
        in_tok += usage.input_tokens
        out_tok += usage.output_tokens
        if pred == row["label"]:
            correct += 1
        else:
            errors.append((row["text"], row["label"], pred))

    cost = in_tok / 1e6 * 5 + out_tok / 1e6 * 25      # Opus 5 단가
    print(f"[{name}] 정확도 {correct}/{len(TEST)}  "
          f"토큰 {in_tok}/{out_tok}  비용 ${cost:.4f}  {time.time()-started:.1f}")
    for text, gold, pred in errors:
        print(f"   {text[:24]}... 정답 {gold} / 예측 {pred}")
    return correct / len(TEST)

한 번 만들어두면 프롬프트를 바꿀 때마다 evaluate(NEW_PROMPT, "v3") 한 줄로 결과를 얻습니다. 정확도만이 아니라 토큰과 비용과 시간을 함께 찍는 것이 중요합니다. 정확도가 같은데 토큰이 절반이면 그쪽이 더 좋은 프롬프트입니다.

정답이 없는 작업 채점

요약이나 답변 생성처럼 정답 문자열이 없는 작업은 사람이 채점하거나 다른 모델에게 채점을 맡깁니다. 후자를 LLM-as-judge라고 부릅니다.

핵심은 judge에게 “좋은가?”를 묻지 않는 것입니다. 좋다는 기준은 사람마다 다르고 실행마다 다릅니다. 항목을 나눠 각각 판정하게 하고, 결과를 스키마로 받습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
JUDGE_SCHEMA = {
    "type": "object",
    "properties": {
        "two_sentences": {"type": "boolean"},
        "has_order_number": {"type": "boolean"},
        "no_hallucination": {"type": "boolean"},
        "reason": {"type": "string"},
    },
    "required": ["two_sentences", "has_order_number", "no_hallucination", "reason"],
    "additionalProperties": False,
}

JUDGE_SYSTEM = """너는 요약 결과를 채점한다. <원문>과 <요약>을 읽고 각 항목을 판정한다.

<항목>
- two_sentences: 요약이 두 문장 이내인가
- has_order_number: 원문에 주문번호가 있다면 요약에도 있는가 (원문에 없으면 true)
- no_hallucination: 원문에 없는 사실이 요약에 추가되지 않았는가
</항목>

판정은 요약에 실제로 적힌 내용만 근거로 한다. 추측하지 않는다."""

def judge(source: str, summary: str) -> dict:
    message = client.messages.create(
        model="claude-opus-5",
        max_tokens=2048,
        system=JUDGE_SYSTEM,
        output_config={"format": {"type": "json_schema", "schema": JUDGE_SCHEMA}},
        messages=[{"role": "user", "content": f"<원문>\n{source}\n</원문>\n\n<요약>\n{summary}\n</요약>"}],
    )
    return json.loads(text_of(message))

judge를 쓸 때 지킬 것이 세 가지 있습니다.

  • 항목을 쪼갭니다. 종합 점수 하나보다 boolean 세 개가 재현성이 높고, 어느 항목이 깨졌는지 바로 보입니다
  • judge도 검증합니다. 사람이 채점한 사례 열 건 정도로 judge의 판정이 사람과 일치하는지 먼저 확인합니다. judge가 틀리면 그 위에 쌓은 모든 점수가 무의미합니다
  • 기계적으로 셀 수 있는 것은 코드로 셉니다. 문장 수는 2편처럼 정규식으로 세면 되고 그편이 정확합니다. judge는 코드로 표현하기 어려운 항목에만 씁니다

결과를 기록한다

점수를 화면에만 찍으면 지난주 프롬프트가 몇 점이었는지 기억에 의존하게 됩니다. 프롬프트와 점수를 같이 파일로 남깁니다.

1
2
3
4
5
6
7
8
9
import csv, hashlib, datetime

def log_result(name, system, accuracy, cost):
    digest = hashlib.sha256(system.encode()).hexdigest()[:8]
    with open("runs.csv", "a", newline="") as f:
        csv.writer(f).writerow([
            datetime.datetime.now().isoformat(timespec="seconds"),
            name, digest, f"{accuracy:.3f}", f"{cost:.4f}",
        ])

프롬프트 본문은 prompts/classify_v3.txt처럼 파일로 분리해 코드와 함께 버전 관리하고, 로그에는 해시만 남깁니다. 이렇게 두면 “정확도가 떨어진 날 프롬프트의 어느 줄이 바뀌었는가”를 git diff로 답할 수 있습니다.

어디까지 자동화하나

확인 대상방법
라벨, 숫자, 추출 필드문자열 비교
길이, 형식, 금지 표현정규식
JSON 스키마 준수파싱 성공 여부 (4편)
사실성, 근거 준수, 어투LLM judge
최종 사용자 만족도사람

전부를 자동화할 필요는 없습니다. 회귀를 잡는 것이 목적이므로, 자동 채점이 가능한 항목만으로도 프롬프트를 고칠 때 안심할 근거가 생깁니다.

다음 글: 프롬프트 엔지니어링 기초 (7) - Operations: caching, 비용, 버전 관리

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.