실무 Multi-Agent 오케스트레이션 4장 · 의도 분류기 - 구조화 출력 7 / 7 ← 이전목차다음 → TechLead Cro

4장. 의도 분류기 - 구조화 출력

실습문제와 해답

분류기를 여러 각도에서 시험해 봅니다. 먼저 스스로 해 본 뒤 해답을 펼쳐 보세요. 해답은 새 파일로 만들어 실행합니다.


문제 1 — 문의를 바꿔 분류 결과 확인하기

lesson04_intent_classifier.py의 classify()를 가져와, 아래 다섯 문의의 의도와 주문번호를 출력하는 파일 ex04_my_inquiries.py를 만드세요.

  1. "스테인리스 텀블러 민트색 재고 있어요?"
  2. "HR20260620069 배송지 변경하고 싶어요."
  3. "하루포인트는 언제 적립되나요?"
  4. "사이즈가 안 맞아서 교환하고 싶은데 배송비는 누가 내나요?"
  5. "오늘 날씨 좋네요."

실행하기 전에 각 문의가 일곱 라벨 중 어디로 갈지 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를 받을 수 있게 해 둡니다.

예외가 났을 때 "기타"로 보내는 이유도 봅니다. 분류를 확신할 수 없을 때 가장 안전한 것은 아무 라벨이나 붙이는 것이 아니라 "모르겠다"에 해당하는 라벨로 보내는 것입니다.

← 이전 절정리와 체크리스트5장 →왜 도구가 필요한가 — 모델은 우리 주문 데이터를 모른다
오명운 · macro@prag-ai.com