포스트

프롬프트 엔지니어링 기초 (3) - Few-shot: 예시를 넣고 정확도를 측정하기

정답이 붙은 문의 분류 데이터셋을 만들고 zero-shot과 few-shot 프롬프트의 정확도를 직접 채점해 비교하며, 예시를 고르는 기준을 정리합니다.

프롬프트 엔지니어링 기초 (3) - Few-shot: 예시를 넣고 정확도를 측정하기

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

채점할 데이터부터

프롬프트 비교는 정답이 있어야 가능합니다. 고객 문의 12건에 카테고리를 붙인 파일을 만듭니다. 경계에 걸치는 문의를 일부러 섞었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# data.py
LABELS = ["배송", "환불", "상품문의", "기타"]

DATA = [
    {"text": "주문한 지 일주일인데 아직 배송 중으로만 뜹니다.", "label": "배송"},
    {"text": "송장번호가 조회되지 않아요.", "label": "배송"},
    {"text": "오늘 주문하면 모레까지 받을 수 있나요?", "label": "배송"},
    {"text": "받아보니 색상이 사진과 달라서 반품하고 싶습니다.", "label": "환불"},
    {"text": "결제 취소했는데 카드 승인이 아직 안 취소됐어요.", "label": "환불"},
    {"text": "개봉했는데도 환불이 되나요?", "label": "환불"},
    {"text": "이 이어폰 배터리는 몇 시간 가나요?", "label": "상품문의"},
    {"text": "270mm 신는데 이 운동화는 어떤 사이즈를 골라야 하나요?", "label": "상품문의"},
    {"text": "재입고 예정이 있는지 궁금합니다.", "label": "상품문의"},
    {"text": "회원 등급은 어떻게 올라가나요?", "label": "기타"},
    {"text": "세금계산서 발행 부탁드립니다.", "label": "기타"},
    {"text": "배송이 늦어서 취소하고 환불받고 싶어요.", "label": "환불"},
]

마지막 항목이 핵심입니다. 배송 지연을 말하고 있지만 고객이 원하는 것은 환불이므로 정답은 환불입니다. 사람도 헷갈리는 이런 사례가 프롬프트의 성능 차이를 드러냅니다.

zero-shot 기준선

예시 없이 규칙만 적은 프롬프트로 먼저 점수를 냅니다. 이 점수가 기준선이 됩니다.

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
from anthropic import Anthropic
from data import DATA, LABELS

client = Anthropic()

def text_of(message):
    return "".join(b.text for b in message.content if b.type == "text")

ZERO_SHOT = """너는 온라인 쇼핑몰의 문의 분류기다.
문의를 배송, 환불, 상품문의, 기타 중 하나로 분류한다.
라벨 하나만 출력하고 다른 말은 쓰지 않는다."""

def classify(system: str, text: str) -> str:
    message = client.messages.create(
        model="claude-opus-5",
        max_tokens=1024,
        system=system,
        output_config={"effort": "low"},
        messages=[{"role": "user", "content": text}],
    )
    return text_of(message).strip()

def score(system: str, name: str):
    wrong = []
    for row in DATA:
        pred = classify(system, row["text"])
        if pred != row["label"]:
            wrong.append((row["text"], row["label"], pred))
    correct = len(DATA) - len(wrong)
    print(f"{name}: {correct}/{len(DATA)} 정답")
    for text, gold, pred in wrong:
        print(f"  틀림: {text[:20]}... (정답 {gold}, 예측 {pred})")

score(ZERO_SHOT, "zero-shot")

max_tokens=1024는 라벨 한 단어에 비해 커 보이지만, thinking이 이 상한을 함께 쓴다는 1편의 이유 때문에 필요합니다. effortlow로 둔 것은 분류처럼 짧고 정해진 작업에서 불필요한 추론 토큰을 줄이기 위해서입니다.

돌려보면 대개 9~10건을 맞히고, 틀리는 것은 마지막 두세 건에 몰립니다. 배송 지연으로 인한 환불 요청을 배송으로 분류하고, 세금계산서를 상품문의로 분류하는 식입니다.

예시를 넣는 두 가지 방법

예시를 프롬프트에 넣는 방법은 두 가지입니다.

방법 1. system 안에 텍스트로 나열

1
2
3
4
5
6
7
8
9
10
11
12
FEW_SHOT_TEXT = ZERO_SHOT + """

<예시>
문의: 배송이 늦어서 취소하고 환불받고 싶어요.
라벨: 환불

문의: 오늘 주문하면 모레까지 받을 수 있나요?
라벨: 배송

문의: 세금계산서 발행 부탁드립니다.
라벨: 기타
</예시>"""

방법 2. messages에 가짜 대화로 넣기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def classify_with_examples(text: str) -> str:
    message = client.messages.create(
        model="claude-opus-5",
        max_tokens=1024,
        system=ZERO_SHOT,
        output_config={"effort": "low"},
        messages=[
            {"role": "user", "content": "배송이 늦어서 취소하고 환불받고 싶어요."},
            {"role": "assistant", "content": "환불"},
            {"role": "user", "content": "오늘 주문하면 모레까지 받을 수 있나요?"},
            {"role": "assistant", "content": "배송"},
            {"role": "user", "content": "세금계산서 발행 부탁드립니다."},
            {"role": "assistant", "content": "기타"},
            {"role": "user", "content": text},
        ],
    )
    return text_of(message).strip()

방법 2가 실제 대화 형태와 같아서 형식 전달이 더 확실합니다. 대신 예시가 대화 기록에 들어가므로 멀티턴 대화에서는 관리가 번거롭습니다. 단발 분류라면 방법 2, 대화형 애플리케이션이면 방법 1을 씁니다.

대화 중간에 assistant 메시지를 넣는 것은 지금도 됩니다. 다만 마지막 메시지assistant로 두고 답의 앞부분을 미리 채워 넣는 prefill 기법은 Claude 4.6 이후 모델에서 400으로 거절됩니다. 예전 자료에서 {"role": "assistant", "content": "{"} 같은 코드를 보게 되면 그 이유입니다. 출력 형식을 강제하는 대체 방법은 4편에 있습니다.

점수 비교

두 프롬프트를 같은 데이터에 돌립니다.

1
2
3
4
5
6
7
score(ZERO_SHOT, "zero-shot")
score(FEW_SHOT_TEXT, "few-shot (예시 3개)")

# zero-shot: 10/12 정답
#   틀림: 배송이 늦어서 취소하고... (정답 환불, 예측 배송)
#   틀림: 세금계산서 발행 부탁드립... (정답 기타, 예측 상품문의)
# few-shot (예시 3개): 12/12 정답

숫자는 실행할 때마다 조금 달라집니다. 확인할 것은 절대 점수가 아니라, 예시가 어떤 종류의 오답을 없앴는가 입니다. 위에서 넣은 예시 세 개는 우연히 고른 것이 아니라 zero-shot이 틀린 사례를 그대로 넣은 것입니다. 규칙 문장으로 “요청의 결과를 기준으로 분류하라”고 설명하는 것보다, 헷갈리는 사례 하나를 보여주는 편이 짧고 정확합니다.

예시를 고르는 기준

  • 경계 사례를 넣습니다. 명확한 사례는 예시가 없어도 맞힙니다. 틀린 것부터 예시로 승격시킵니다
  • 라벨을 골고루 넣습니다. 예시 다섯 개 중 네 개가 배송이면 모델이 배송 쪽으로 기웁니다
  • 형식을 정확히 맞춥니다. 예시의 출력이 환불이면 실제 출력도 환불이 됩니다. 예시에 “라벨: 환불”이라고 쓰면 실제 출력에도 “라벨: “이 붙습니다
  • 개수는 3~5개에서 시작합니다. 예시를 늘려도 어느 지점부터는 정확도가 오르지 않고 입력 토큰만 늘어납니다. 늘릴 때마다 점수를 확인합니다

함정

예시가 정답을 오염시키는 경우 테스트에 쓸 문의를 예시에 그대로 넣으면 점수가 올라가지만 실제 성능은 아닙니다. 위 실습에서도 예시 세 건은 채점 데이터에 포함되어 있어 점수가 낙관적으로 나옵니다. 실제로는 예시용 데이터와 채점용 데이터를 나눠야 합니다. 이 분리를 6편에서 다룹니다.

형식만 배우고 내용은 못 배우는 경우 예시가 전부 짧은 문장이면 긴 문의가 들어왔을 때 대응하지 못합니다. 예시의 길이와 어투도 실제 입력과 비슷하게 맞춥니다.

라벨 문자열이 흔들리는 경우 모델이 환불이 아니라 환불 요청이나 [환불]을 내놓으면 채점이 전부 실패합니다. few-shot으로도 완전히 막히지는 않습니다. 문자열 일치가 중요한 작업이라면 다음 편의 구조화된 출력을 씁니다.

다음 글: 프롬프트 엔지니어링 기초 (4) - Structured Output: 출력 형식을 강제하기

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