4장. 의도 분류기 - 구조화 출력
실습문제와 해답
분류기를 여러 각도에서 시험해 봅니다. 먼저 스스로 해 본 뒤 해답을 펼쳐 보세요. 해답은 새 파일로 만들어 실행합니다.
문제 1 — 문의를 바꿔 분류 결과 확인하기
lesson04_intent_classifier.py의 classify()를 가져와, 아래 다섯 문의의 의도와 주문번호를 출력하는 파일 ex04_my_inquiries.py를 만드세요.
- "스테인리스 텀블러 민트색 재고 있어요?"
- "HR20260620069 배송지 변경하고 싶어요."
- "하루포인트는 언제 적립되나요?"
- "사이즈가 안 맞아서 교환하고 싶은데 배송비는 누가 내나요?"
- "오늘 날씨 좋네요."
실행하기 전에 각 문의가 일곱 라벨 중 어디로 갈지 CLASSIFY_SYSTEM의 규칙을 보고 먼저 적어 보세요.
- 힌트:
from lesson04_intent_classifier import classify로 가져오면if __name__ == "__main__":아래의 시연은 실행되지 않습니다.
해답 보기
from lesson04_intent_classifier import classify
inquiries = [
"스테인리스 텀블러 민트색 재고 있어요?",
"HR20260620069 배송지 변경하고 싶어요.",
"하루포인트는 언제 적립되나요?",
"사이즈가 안 맞아서 교환하고 싶은데 배송비는 누가 내나요?",
"오늘 날씨 좋네요.",
]
for q in inquiries:
r = classify(q)
print(f"문의: {q}")
print(f" → intent={r.intent.value} / order_id={r.order_id}")
$ python ex04_my_inquiries.py
실행 결과
문의: 스테인리스 텀블러 민트색 재고 있어요?
→ intent=제품문의 / order_id=None
문의: HR20260620069 배송지 변경하고 싶어요.
→ intent=주문배송조회 / order_id=HR20260620069
문의: 하루포인트는 언제 적립되나요?
→ intent=멤버십적립금 / order_id=None
문의: 사이즈가 안 맞아서 교환하고 싶은데 배송비는 누가 내나요?
→ intent=환불교환 / order_id=None
문의: 오늘 날씨 좋네요.
→ intent=기타 / order_id=None
해설 — 다섯 문의가 각각 다른 라벨로 갔습니다. 이 교재를 준비하며 세 번 실행했을 때 세 번 모두 같은 결과였습니다. 각 결과를 CLASSIFY_SYSTEM의 줄과 맞춰 봅니다.
| 문의 | 결과 | 근거가 된 규칙 |
|---|---|---|
| 텀블러 민트색 재고 | 제품문의 | "상품의 사양·재고·재입고 관련이면 제품문의" |
| 배송지 변경 | 주문배송조회 | "주문 상태·배송 위치·배송지 변경 관련이면 주문배송조회" |
| 하루포인트 적립 | 멤버십적립금 | "하루클럽 등급·하루포인트·적립금 관련이면 멤버십적립금" |
| 교환 + 배송비 | 환불교환 | "환불/반품/교환 관련이면 … 환불교환" |
| 날씨 인사 | 기타 | "판단이 어려우면 기타" |
2번에서는 문장 맨 앞의 HR20260620069를 찾아 order_id 칸에 넣었습니다. 나머지 넷은 주문번호가 없어 None입니다.
분류기를 고칠 때는 이렇게 문의를 넣기 전에 기대하는 라벨을 먼저 적고, 결과와 맞춰 봅니다. 다음 문제에서 이것을 40건으로 늘립니다.
문제 2 — 분류기의 성적 매기기
1장에서 분석한 data/inquiries.csv에는 문의 40건과, 상담팀이 붙여 둔 유형(category 열)이 들어 있습니다. 이것을 정답으로 삼아 분류기가 40건 중 몇 건을 맞히는지 세는 파일 ex04_evaluate.py를 만드세요.
from lesson04_intent_classifier import classify로 분류기를 가져옵니다.- 로그의 표기("환불·교환")와 분류기의 라벨("환불교환")이 다르므로 대응표가 필요합니다.
- 틀린 문의는 내용과 함께 "상담팀의 유형 / 분류기의 답"을 출력합니다.
해답 보기
import pandas as pd
from config import DATA_DIR
from lesson04_intent_classifier import classify
# 로그의 유형 표기("환불·교환")와 분류기 라벨("환불교환")을 잇는 대응표
LABEL_MAP = {
"제품 문의": "제품문의", "주문·배송 조회": "주문배송조회", "환불·교환": "환불교환",
"멤버십·적립금": "멤버십적립금", "계정·결제": "계정결제", "상담원 연결": "상담원연결",
}
df = pd.read_csv(DATA_DIR / "inquiries.csv")
correct, wrong = 0, []
for _, row in df.iterrows():
pred = classify(row["content"]).intent.value # 분류기의 답
gold = LABEL_MAP[row["category"]] # 상담팀이 붙여 둔 유형
if pred == gold:
correct += 1
else:
wrong.append((row["inquiry_id"], row["content"], gold, pred))
print(f"정확도: {correct}/{len(df)} = {correct / len(df) * 100:.0f}%")
print("틀린 문의:")
for inquiry_id, content, gold, pred in wrong:
print(f" {inquiry_id} {content}")
print(f" 상담팀={gold} / 분류기={pred}")
$ python ex04_evaluate.py
LLM을 40번 호출하므로 시간이 조금 걸립니다. 끝날 때까지 기다립니다.
실행 결과
정확도: 40/40 = 100%
틀린 문의:
해설 — 40건을 모두 맞혔습니다. 틀린 문의: 아래가 비어 있는 것은 틀린 건이 없다는 뜻입니다. 이 교재를 준비하며 열 번 넘게 실행했을 때 매번 40/40이었습니다.
이 숫자는 CLASSIFY_SYSTEM에 라벨마다 규칙을 한 줄씩 적어 둔 결과입니다. 로그 40건에는 "품절 상품 재입고 알림은 어디서 신청하나요?"처럼 어느 라벨인지 망설여지는 문의가 들어 있는데, "상품의 사양·재고·재입고 관련이면 제품문의"라는 줄이 그 문의의 갈 곳을 정해 줍니다.
이 파일의 쓸모는 한 번 재는 데서 끝나지 않습니다.
- 규칙이나 설명을 고친 뒤에는 이 파일을 다시 돌려 40건이 유지되는지 확인합니다. 한 줄을 고칠 때 다른 라벨의 결과가 함께 바뀔 수 있기 때문입니다.
- 새로운 유형의 문의가 생기면 로그에 정답과 함께 추가하고 다시 잽니다.
값이 정해진 출력이어야 이렇게 정답과 비교해 숫자로 말할 수 있습니다. 문장으로 답하는 분류기는 이렇게 셀 수 없습니다.
문제 3 — 빈 문의는 LLM을 부르지 않고 처리하기
입력창에는 아무것도 적지 않은 빈 문의나 공백뿐인 문의도 들어옵니다. 분류할 내용이 없는 입력에 LLM을 부를 이유가 없습니다. 어떤 입력이 와도 IntentResult를 돌려주는 함수 classify_safe()를 ex04_safe.py에 만드세요.
- 빈 문자열이나 공백뿐인 입력은 LLM을 부르지 않고 "기타 / 낮음"을 돌려줍니다.
- 지나치게 긴 입력은 앞 2,000자만 보냅니다.
- 호출이나 검증에서 예외가 나면 "기타 / 보통"을 돌려줍니다. 힌트: API 호출의 예외는
from google.genai import errors의errors.APIError로 받습니다.
해답 보기
from google.genai import errors
from pydantic import ValidationError
from lesson04_intent_classifier import Intent, IntentResult, Urgency, classify
def classify_safe(inquiry: str) -> IntentResult:
"""어떤 입력이 와도 IntentResult 를 돌려준다."""
text = inquiry.strip()
if not text: # 빈 입력은 LLM 을 부르지 않고 코드가 처리
return IntentResult(intent=Intent.OTHER, urgency=Urgency.LOW, summary="빈 문의 입력")
try:
return classify(text[:2000]) # 지나치게 긴 입력은 잘라서 보낸다
except (ValidationError, errors.APIError) as e:
return IntentResult(intent=Intent.OTHER, urgency=Urgency.NORMAL,
summary=f"자동 분류 실패({type(e).__name__}): {text[:30]}")
for q in ["", " ", "오늘 날씨 좋네요", "배송 언제 와요?"]:
r = classify_safe(q)
print(f" {q!r:<14} → {r.intent.value} / {r.urgency.value} / {r.summary}")
$ python ex04_safe.py
실행 결과 (요약 문장은 실행마다 다릅니다)
'' → 기타 / 낮음 / 빈 문의 입력
' ' → 기타 / 낮음 / 빈 문의 입력
'오늘 날씨 좋네요' → 기타 / 낮음 / 날씨 관련 일반적인 안부 문의
'배송 언제 와요?' → 주문배송조회 / 보통 / 배송 일정 문의
해설 — 네 입력 모두 IntentResult가 돌아왔습니다.
- 위의 두 줄(빈 문자열, 공백)은 요약이 "빈 문의 입력"입니다. 코드가 처리했고 LLM은 불리지 않았습니다. 빈 입력인지 확인하는 데 LLM은 필요 없고, 호출 비용도 들지 않습니다.
- 아래 두 줄은
classify()가 분류했습니다. 잡담은 기타로, 배송 문의는 주문배송조회로 갔습니다.
classify_safe()는 두 겹입니다.
- 코드가 먼저 거릅니다. 코드로 확실히 판단할 수 있는 것은 코드가 합니다.
- 호출이나 검증에서 예외가 나도 "기타"로 돌려줍니다. 분류기는 모든 문의가 지나가는 첫 관문이어서, 뒤의 단계가 언제나
IntentResult를 받을 수 있게 해 둡니다.
예외가 났을 때 "기타"로 보내는 이유도 봅니다. 분류를 확신할 수 없을 때 가장 안전한 것은 아무 라벨이나 붙이는 것이 아니라 "모르겠다"에 해당하는 라벨로 보내는 것입니다.