4장. 의도 분류기 - 구조화 출력
따라하기 — 의도 분류기 만들기
목표
고객 문의 한 문장을 넣으면 의도, 긴급도, 주문번호, 요약 네 칸이 채워진 객체를 돌려주는 classify() 함수를 만들어 실행합니다. 성격이 다른 문의 다섯 개를 넣어, 문장이 아니라 코드가 바로 쓸 수 있는 값이 나오는지 확인합니다.
0. 실습 준비
VS Code에서 haru-market 폴더를 열고 터미널에서 환경을 켭니다.
$ conda activate myenv
이번 장은 새로 설치할 것이 없습니다. Pydantic은 2장에서 requirements.txt로 함께 설치했습니다.
1. 파일 만들기
haru-market 폴더 맨 위에 새 파일을 만듭니다.
lesson04_intent_classifier.py
아래 코드 전체를 복사해 붙여 넣고 저장합니다.
# -*- coding: utf-8 -*-
"""[4장] 문의 의도 분류기 — 구조화 출력 (Structured Output)
문제 상황: 고객의 자유로운 문장을 뒤 단계 '코드'가 써야 한다.
"지난주에 산 신발 아직도 안 왔는데 환불되나요?"
→ intent가 뭔지, 주문번호가 있는지, 급한 건인지 — 코드가 판단할 수 있는 JSON 필요.
해결: Gemini의 JSON 응답 모드 (response_mime_type + response_schema) + Pydantic 검증.
이 분류기의 classify() 는 18장·19장 LangGraph 워크플로우가 가져다 쓴다.
실행: python lesson04_intent_classifier.py
"""
import enum
from google.genai import types
from pydantic import BaseModel, Field, ValidationError
from config import MODEL, get_client
client = get_client()
# ── 1. 분류 라벨 체계 — 1장 문의 유형 6종 + 기타 ────────────────────
class Intent(str, enum.Enum):
PRODUCT = "제품문의"
ORDER = "주문배송조회"
REFUND = "환불교환"
MEMBERSHIP = "멤버십적립금"
ACCOUNT = "계정결제"
HUMAN = "상담원연결"
OTHER = "기타"
class Urgency(str, enum.Enum):
LOW = "낮음"
NORMAL = "보통"
HIGH = "높음"
# ── 2. 응답 스키마를 Pydantic 모델로 정의 ────────────────────────────
# 필드 설명(description)이 곧 모델에게 주는 지시다. 정성껏 쓴다.
class IntentResult(BaseModel):
intent: Intent = Field(description="문의의 핵심 의도 분류")
urgency: Urgency = Field(description="처리 긴급도. 아래 [긴급도 기준]을 따른다")
order_id: str | None = Field(
default=None,
description="문의에 포함된 주문번호 (HR로 시작). 없으면 null",
)
summary: str = Field(description="상담원이 한눈에 볼 수 있는 한 줄 요약 (한국어)")
CLASSIFY_SYSTEM = """당신은 하루마켓 고객 문의 분류기입니다.
고객 문의를 읽고 지정된 JSON 스키마로만 답합니다.
- 사람을 직접 요구하면 상담원연결
- 환불/반품/교환 관련이면 주문·재고 이야기가 섞여 있어도 환불교환
- 하루클럽 등급·하루포인트·적립금 관련이면 멤버십적립금
- 주문 상태·배송 위치·배송지 변경 관련이면 주문배송조회
- 상품의 사양·재고·재입고 관련이면 제품문의
- 로그인·회원정보·결제 수단·결제 오류 관련이면 계정결제
- 판단이 어려우면 기타
[긴급도 기준]
- 높음: 배송 지연·결제 오류처럼 문제가 이미 생겼거나, 재촉하거나, 사람을 요구하는 문의
- 보통: 환불·교환·변경처럼 처리를 요청하는 문의
- 낮음: 상품 정보·이용 방법·정책을 단순히 묻는 문의"""
def classify(inquiry: str) -> IntentResult:
"""고객 문의 → IntentResult. 파싱 실패 시 ValidationError를 던진다."""
response = client.models.generate_content(
model=MODEL,
contents=inquiry,
config=types.GenerateContentConfig(
system_instruction=CLASSIFY_SYSTEM,
temperature=1.0, # 분류는 항상 같은 답이어야 한다
response_mime_type="application/json", # JSON 모드 켜기
response_schema=IntentResult, # Pydantic 모델을 스키마로 전달
),
)
# response.parsed 에 Pydantic 인스턴스가 들어온다.
if response.parsed is not None:
return response.parsed
# 혹시 parsed가 비면 원문 JSON을 직접 검증 (파싱 실패 처리)
return IntentResult.model_validate_json(response.text)
if __name__ == "__main__":
test_inquiries = [
"지난주에 산 운동화 아직도 안 왔는데 환불되나요?",
"주문번호 HR20260701023 지금 어디쯤인가요? 내일까지 꼭 필요해요.",
"무드등 밝기 조절이 되나요?",
"카드 결제가 세 번이나 실패했어요. 빨리 해결해 주세요.",
"됐고요, 사람 바꿔주세요.",
]
for q in test_inquiries:
try:
r = classify(q)
print(f"문의: {q}")
print(f" → intent={r.intent.value} / urgency={r.urgency.value} / "
f"order_id={r.order_id}")
print(f" → 요약: {r.summary}\n")
except ValidationError as e:
# 스키마와 다른 JSON 이 오면 여기서 잡는다 — 운영에서는 재시도 or 기타 처리
print(f"문의: {q}\n → [파싱 실패] {e}\n")
print("──────────────────────────────────────────────")
print("시나리오의 [의도 파악] 단계 완성.")
print("이 classify() 는 18장 / 19장 워크플로우에서 재사용된다.")
2. 코드에서 볼 곳
파일은 위에서부터 네 덩어리입니다.
| 덩어리 | 무엇인가 | 누가 정했나 |
|---|---|---|
Intent, Urgency |
허용 값의 목록(열거형) | 개발자 |
IntentResult |
응답의 네 칸과 각 칸의 설명 | 개발자 |
CLASSIFY_SYSTEM |
분류기의 역할과 경계 규칙 | 개발자 |
classify() |
위 셋을 묶어 모델을 호출하는 함수 | 호출은 코드, 판정은 모델 |
classify()의 호출 부분만 다시 봅니다. 3장의 호출에 아래 두 줄이 더해진 것이 전부입니다.
config=types.GenerateContentConfig(
system_instruction=CLASSIFY_SYSTEM,
temperature=1.0, # Gemini 3 계열 권장 기본값; 형식은 스키마로 제한
response_mime_type="application/json", # JSON 모드 켜기
response_schema=IntentResult, # Pydantic 모델을 스키마로 전달
),
그리고 파일 맨 아래의 이 줄을 눈여겨봅니다.
if __name__ == "__main__":
이 줄 아래의 코드는 이 파일을 직접 실행할 때만 돕니다. 다른 파일이 from lesson04_intent_classifier import classify로 함수만 가져갈 때는 실행되지 않습니다. 뒤의 장에서 이 분류기를 부품으로 가져다 쓰기 때문에 이렇게 나눠 두었습니다.
3. 실행하기
실행하기 전에 짐작해 보세요.
- 첫 문의 "지난주에 산 운동화 아직도 안 왔는데 환불되나요?"는 주문배송조회일까요, 환불교환일까요?
- 다섯 문의 중 주문번호 칸이 채워지는 것은 몇 개일까요?
$ python lesson04_intent_classifier.py
LLM을 다섯 번 호출하므로 결과가 한 줄씩 차례로 나옵니다.
실행 결과 (요약 문장은 실행할 때마다 달라집니다)
문의: 지난주에 산 운동화 아직도 안 왔는데 환불되나요?
→ intent=환불교환 / urgency=높음 / order_id=None
→ 요약: 배송 지연된 운동화 환불 요청
문의: 주문번호 HR20260701023 지금 어디쯤인가요? 내일까지 꼭 필요해요.
→ intent=주문배송조회 / urgency=높음 / order_id=HR20260701023
→ 요약: 내일까지 필요한 주문(HR20260701023)의 배송 위치 확인 요청
문의: 무드등 밝기 조절이 되나요?
→ intent=제품문의 / urgency=낮음 / order_id=None
→ 요약: 무드등 밝기 조절 기능 여부 문의
문의: 카드 결제가 세 번이나 실패했어요. 빨리 해결해 주세요.
→ intent=계정결제 / urgency=높음 / order_id=None
→ 요약: 카드 결제 3회 실패로 인한 빠른 해결 요청
문의: 됐고요, 사람 바꿔주세요.
→ intent=상담원연결 / urgency=높음 / order_id=None
→ 요약: 상담원 연결을 강하게 요구하는 문의
──────────────────────────────────────────────
시나리오의 [의도 파악] 단계 완성.
이 classify() 는 18장 / 19장 워크플로우에서 재사용된다.
4. 무엇을 관찰했나
문장이 아니라 값이 왔다
다섯 번 모두 intent에는 일곱 라벨 중 하나가 글자 그대로 왔습니다. "배송 및 환불/취소 문의" 같은 즉석 이름도, ```json 포장도 없습니다. 출력문의 r.intent.value, r.urgency.value, r.order_id는 문자열을 잘라 낸 것이 아니라 객체의 칸을 읽은 것입니다.
경계 사례가 규칙대로 갔다
첫 문의는 배송과 환불이 섞여 있습니다. 결과는 환불교환입니다. 모델이 알아서 고른 것이 아니라, CLASSIFY_SYSTEM의 "환불/반품/교환 관련이면 주문·재고 이야기가 섞여 있어도 환불교환"이라는 우리 규칙을 따른 것입니다.
주문번호를 찾아 넣었고, 없으면 비웠다
두 번째 문의에서만 order_id=HR20260701023이 채워졌습니다. 나머지 넷은 None입니다. 주문번호가 없는 문의에 번호를 지어내지 않았습니다. str | None으로 비워 둘 자리를 주고, 설명에 "없으면 null"이라고 적은 결과입니다.
한 가지 짚어 둡니다. 모델은 문장에서 번호를 옮겨 적었을 뿐입니다. HR20260701023이라는 주문이 실제로 있는지는 확인하지 않았습니다. 그것은 주문 데이터를 조회해야 알 수 있습니다(5장, 6장).
긴급도도 기준대로 붙었다
다섯 문의의 긴급도를 [긴급도 기준]과 맞춰 봅니다.
| 문의 | 긴급도 | 해당하는 기준 |
|---|---|---|
| "운동화 아직도 안 왔는데 환불되나요?" | 높음 | 문제가 이미 생겼다(배송 지연) |
| "…내일까지 꼭 필요해요." | 높음 | 재촉한다(기한을 말했다) |
| "무드등 밝기 조절이 되나요?" | 낮음 | 상품 정보를 단순히 묻는다 |
| "카드 결제가 세 번이나 실패했어요. 빨리…" | 높음 | 문제가 이미 생겼다(결제 오류) |
| "됐고요, 사람 바꿔주세요." | 높음 | 사람을 요구한다 |
다섯 개 모두 적어 준 기준 그대로 붙었습니다. 같은 파일을 여러 번 실행해도 intent와 urgency는 같은 값이 나옵니다. 이 값을 정해 준 것은 temperature가 아니라 기준을 글로 적어 준 것입니다.
요약은 매번 다르다
summary는 자유 문장이어서 실행마다 달라집니다. 이 칸은 사람(상담원)이 읽는 용도이므로 달라져도 괜찮습니다. 코드가 분기에 쓰는 칸은 열거형으로, 사람이 읽는 칸은 문자열로 둔 것입니다.
5. 지금 폴더의 모습
haru-market/
├── config.py
├── lesson02_first_call.py
├── lesson03_prompt.py
└── lesson04_intent_classifier.py ← 이번 장
핵심 정리
config=에response_mime_type과response_schema두 줄을 더해 출력을 구조로 받았습니다.intent는 다섯 번 모두 약속한 라벨 그대로 왔습니다.- 섞인 문의는 우리가 적은 경계 규칙대로 분류됐습니다.
- 주문번호는 있으면 옮겨 적고, 없으면 비웠습니다. 실제 주문인지는 아직 모릅니다.
- 긴급도는 시스템 프롬프트에 적은 [긴급도 기준] 대로 붙었고, 다시 실행해도 같은 값이 나옵니다.
- 이번 장의 산출물은
classify()입니다.