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

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() 입니다.
← 이전 절분류 라벨 설계 — 무엇으로 나눌지는 사람이 정한다다음 절 →정리와 체크리스트
오명운 · macro@prag-ai.com