Python Instructor로 LLM 구조화 출력 구현하기: Pydantic 기반 데이터 추출과 Validation
Instructor로 LLM 출력을 구조화하는 방법: Pydantic과 Structured Output 활용하기#
1.1 들어가며: 이 과장의 끝나지 않는 야근, 복붙 지옥#
월요일 밤 10시.
고객 지원팀의 이 과장은 퀭한 눈으로 모니터를 바라보고 있었다.
화면에는 두 개의 창이 나란히 떠 있었다.
왼쪽에는 고객 문의 이메일이 가득한 받은 편지함이 있었고, 오른쪽에는 이슈 트래킹 시스템의 새로운 티켓 등록 화면이 열려 있었다.
이 과장의 업무는 겉보기에는 단순했다.
왼쪽 이메일을 읽고 필요한 정보를 찾아 오른쪽 양식에 입력하면 된다.
문제는 이 작업을 하루에도 수십, 수백 번 반복해야 한다는 것이었다.
1.1.1 사람이 읽기에는 쉬운 정보#
예를 들어 고객이 다음과 같은 이메일을 보냈다고 해보자.
제목: 주문한 거 언제 와요??
아니 저번주 수요일에 시킨 P0012 에어컨 필터 아직도 배송준비중이네요.
주문번호는 2024-ABC-7891 이고요.
너무 늦는 거 아닙니까? 빨리 확인 좀 해주세요. 급해요.
아 그리고 제 아이디는 happy_dev 입니다.사람이라면 이 이메일을 읽는 데 몇 초밖에 걸리지 않는다.
머릿속에서는 자연스럽게 다음과 같은 정보가 정리된다.
문의 유형 → 배송
긴급도 → 높음
고객 ID → happy_dev
주문 번호 → 2024-ABC-7891
제품 → P0012 에어컨 필터
요약 → P0012 제품 배송 지연 문의하지만 컴퓨터 입장에서는 상황이 완전히 다르다.
이메일은 특정한 데이터베이스 스키마를 따르는 데이터가 아니다.
고객마다 표현 방식도 다르고, 문장 순서도 다르며, 같은 정보를 서로 다른 단어로 표현하기도 한다.
어떤 고객은 다음과 같이 쓸 수 있다.
주문번호 2024-ABC-7891 배송 언제 되나요?다른 고객은 이렇게 쓸 수 있다.
오더 넘버가 2024-ABC-7891인데 아직 안 왔습니다.또 다른 고객은 이렇게 표현할 수도 있다.
지난번에 산 물건 있잖아요. 그거 아직 배송이 안 됐어요.사람에게는 모두 같은 의미지만, 전통적인 프로그램에서는 각각 다른 입력이다.
1.1.2 정규표현식으로 시작한 자동화의 한계#
개발팀은 처음에는 정규표현식으로 문제를 해결하려고 할 수 있다.
import re
order_number = re.search(
r"주문번호[:\s]*([A-Z0-9-]+)",
email
)간단한 이메일에서는 잘 작동한다.
하지만 고객이 다음과 같이 쓰는 순간 문제가 생긴다.
주문 번호는 2024-ABC-7891입니다.또는
오더 넘버: 2024-ABC-7891또는
주문한 건 2024-ABC-7891이에요.정규표현식을 계속 추가하다 보면 프로그램은 점점 복잡해진다.
if "주문번호" in text:
...
elif "주문 번호" in text:
...
elif "오더 넘버" in text:
...
elif "주문한 건" in text:
...그리고 실제 업무에서는 여기서 끝나지 않는다.
하나의 이메일 안에 환불 요청과 배송 문의가 동시에 들어올 수도 있고, 여러 개의 제품이 언급될 수도 있으며, 주문 번호가 생략될 수도 있다.
결국 개발팀은 끝없이 예외를 추가해야 한다.
이것이 전통적인 비정형 텍스트 처리의 어려움이다.
1.2 비정형 텍스트를 구조화한다는 것#
LLM이 등장하면서 이 문제를 해결하는 접근 방식 자체가 달라졌다.
LLM은 정규표현식처럼 특정 문자열만 찾는 것이 아니라 문장의 의미와 문맥을 이해할 수 있다.
따라서 다음과 같이 요청할 수 있다.
이 이메일을 분석해서 다음 정보를 JSON으로 만들어줘.
category
urgency
customer_id
order_number
mentioned_products
summary그런데 여기에도 문제가 있다.
LLM에게 JSON을 요청한다고 해서 애플리케이션이 원하는 정확한 데이터 구조가 항상 보장되는 것은 아니다.
1.2.1 JSON과 Schema는 다르다#
다음 두 응답은 모두 문법적으로는 JSON이다.
{
"user": "happy_dev",
"order": "2024-ABC-7891"
}그리고
{
"customer_id": "happy_dev",
"order_number": "2024-ABC-7891"
}둘 다 유효한 JSON이다.
하지만 애플리케이션이 다음과 같은 구조를 기대하고 있었다면 이야기가 달라진다.
class SupportTicket(BaseModel):
customer_id: str | None
order_number: str | None첫 번째 JSON은 JSON으로서는 정상적이지만 애플리케이션의 스키마와 일치하지 않는다.
즉 다음 두 개념을 구분해야 한다.
JSON
↓
문법적으로 올바른 데이터 형식
Schema
↓
어떤 필드가 존재해야 하고
각 필드에는 어떤 타입과 값이 들어갈 수 있는지 정의한 구조이 차이가 LLM 기반 애플리케이션을 실제 시스템에 연결할 때 매우 중요하다.
1.3 Instructor란 무엇인가#
Instructor는 LLM의 응답을 Pydantic 모델에 맞춰 구조화하고 검증하기 위한 Python 라이브러리다.
핵심 아이디어는 단순하다.
비정형 텍스트
↓
LLM
↓
Pydantic Schema
↓
Validation
↓
구조화된 객체Instructor는 Pydantic 모델을 response_model로 전달하고, 모델의 구조를 기반으로 LLM 출력을 생성한 뒤 Pydantic을 이용해 결과를 검증한다. 검증에 실패하면 설정된 재시도 횟수에 따라 다시 요청할 수 있다. ([GitHub][1])
현재 Instructor 문서에서는 여러 제공자를 동일한 방식으로 사용할 수 있는 from_provider() 방식이 권장되고 있다. 과거 예제에서 많이 볼 수 있는 instructor.patch(client) 방식도 지원되지만, 새로운 코드에서는 from_provider()를 우선적으로 고려하는 편이 좋다. ([Instructor][2])
1.3.1 Instructor가 해결하는 문제#
Instructor의 역할을 단순하게 표현하면 다음과 같다.
LLM에게
"JSON으로 만들어줘"
라고 부탁하는 것
↓
LLM에게
"이 Pydantic 모델의 구조에 맞는 결과를 만들어줘"
↓
결과를 Pydantic으로 검증
↓
검증 실패
↓
재시도
↓
검증된 Pydantic 객체 반환따라서 애플리케이션 입장에서는 문자열을 직접 파싱하고 조건문으로 검사하는 작업을 상당 부분 줄일 수 있다.
1.4 일반적인 JSON 출력과 구조화된 출력의 차이#
OpenAI와 같은 최신 LLM API에는 현재 Pydantic 모델을 이용한 Structured Outputs 기능도 제공된다. OpenAI Python SDK 역시 Responses API에서 Pydantic 모델을 이용해 구조화된 출력을 파싱하는 기능을 제공한다. ([GitHub][3])
그렇다면 굳이 Instructor를 사용할 필요가 있을까?
이 질문은 매우 중요하다.
1.4.1 JSON 모드#
단순한 JSON 출력은 다음과 같은 문제를 가질 수 있다.
LLM
↓
JSON 생성
↓
JSON 파싱
↓
애플리케이션에서 직접 검증
↓
오류 처리예를 들어 다음과 같은 코드가 필요할 수 있다.
if "customer_id" not in response:
...
if "order_number" not in response:
...
if response["urgency"] not in ["낮음", "중간", "높음"]:
...
if not isinstance(response["mentioned_products"], list):
...스키마가 복잡해질수록 검증 코드도 증가한다.
1.4.2 Schema 기반 출력#
Pydantic을 사용하면 데이터 구조 자체를 코드로 표현할 수 있다.
class SupportTicket(BaseModel):
category: TicketCategory
urgency: UrgencyLevel
customer_id: str | None = None
order_number: str | None = None
mentioned_products: list[str]
summary: str이제 개발자는 JSON 문자열 자체보다 어떤 데이터가 필요한가에 집중할 수 있다.
Instructor는 이 Pydantic 모델을 LLM 출력의 기준으로 사용한다. ([Instructor][4])
1.5 실전 프로젝트: 고객 이메일을 티켓으로 변환하기#
이제 이 과장을 실제로 구해보자.
목표는 간단하다.
고객 이메일
↓
LLM
↓
Instructor
↓
Pydantic Validation
↓
SupportTicket최종적으로 다음과 같은 객체를 얻는 것이 목표다.
{
"category": "배송",
"urgency": "높음",
"customer_id": "happy_dev",
"order_number": "2024-ABC-7891",
"mentioned_products": [
"P0012 에어컨 필터"
],
"summary": "P0012 에어컨 필터의 배송 지연에 대한 문의"
}1.6 1단계: Pydantic으로 데이터 모델 설계하기#
먼저 애플리케이션에서 필요한 데이터 구조를 정의한다.
from enum import Enum
from pydantic import BaseModel, Field
class TicketCategory(str, Enum):
SHIPPING = "배송"
REFUND = "환불"
PRODUCT_INQUIRY = "제품 문의"
TECHNICAL_SUPPORT = "기술 지원"
ETC = "기타"
class UrgencyLevel(str, Enum):
LOW = "낮음"
MEDIUM = "중간"
HIGH = "높음"
class SupportTicket(BaseModel):
category: TicketCategory = Field(
description="이메일의 주요 문의 유형을 하나 선택한다."
)
urgency: UrgencyLevel = Field(
description="고객의 표현과 상황을 고려해 긴급도를 판단한다."
)
customer_id: str | None = Field(
default=None,
description="본문에서 확인되는 고객 ID 또는 계정명"
)
order_number: str | None = Field(
default=None,
description="주문 번호. 주문번호, 주문 코드, 오더 넘버 등 다양한 표현을 포함한다."
)
mentioned_products: list[str] = Field(
default_factory=list,
description="본문에 언급된 모든 제품명 또는 제품 코드를 추출한다."
)
summary: str = Field(
description="고객 문의의 핵심 내용을 한 문장으로 요약한다."
)이 코드에서 중요한 부분은 단순히 타입을 선언하는 것만이 아니다.
Enum, Field, description이 각각 LLM과 애플리케이션에 중요한 의미를 제공한다.
1.6.1 Enum으로 허용 가능한 값을 제한한다#
다음과 같이 정의했다고 하자.
class UrgencyLevel(str, Enum):
LOW = "낮음"
MEDIUM = "중간"
HIGH = "높음"그러면 다음과 같은 값은 스키마 관점에서 허용되지 않는다.
매우 급함
긴급
최우선
빨리 처리
Critical물론 LLM이 이런 표현을 이해하는 것은 가능하다.
하지만 최종 데이터에는 우리가 정한 값 중 하나가 들어가야 한다.
낮음
중간
높음이렇게 애플리케이션 내부의 데이터 표현을 통일할 수 있다.
1.6.2 Field description은 단순한 주석이 아니다#
다음 코드를 보자.
order_number: str | None = Field(
default=None,
description="주문 번호. 주문번호, 주문 코드, 오더 넘버 등 다양한 표현을 포함한다."
)이 설명은 개발자에게만 필요한 것이 아니다.
Instructor는 모델의 필드 설명과 타입 정보를 활용해 LLM이 어떤 데이터를 생성해야 하는지 이해하도록 한다. ([Instructor][4])
따라서 LLM 기반 애플리케이션에서는 description을 대충 작성하기보다 데이터 추출 규칙을 명확하게 설명하는 것이 중요하다.
1.7 2단계: Instructor 설치하기#
기본적인 환경에서는 다음 패키지를 설치할 수 있다.
pip install instructor openai pydantic python-dotenv그리고 API 키를 환경 변수로 관리한다.
OPENAI_API_KEY=your-api-keyAPI 키를 소스 코드에 직접 작성하지 않는 것이 좋다.
client = OpenAI(
api_key=os.getenv("OPENAI_API_KEY")
)1.8 3단계: Instructor 클라이언트 만들기#
현재 Instructor에서는 다음과 같은 from_provider() 방식으로 클라이언트를 구성할 수 있다.
import instructor
client = instructor.from_provider(
"openai/gpt-4o-mini"
)이 방식은 제공자별 클라이언트 설정을 하나의 인터페이스로 다루기 쉽게 만든다. Instructor는 OpenAI뿐 아니라 Anthropic, Google, Mistral, Groq, Ollama, DeepSeek 등 다양한 제공자와 통합할 수 있다. ([GitHub][1])
기존 코드에서 다음과 같은 패턴을 볼 수도 있다.
from openai import OpenAI
import instructor
openai_client = OpenAI()
client = instructor.patch(openai_client)이 방식 역시 Instructor의 동작 원리를 이해하는 데 도움이 된다.
다만 현재 문서에서는 새로운 프로젝트에서 from_provider() 방식을 사용하는 것을 권장한다. ([Instructor][2])
1.9 4단계: 이메일을 SupportTicket으로 변환하기#
이제 핵심 코드를 작성한다.
import instructor
from models import SupportTicket
client = instructor.from_provider(
"openai/gpt-4o-mini"
)
def parse_email_to_ticket(
email_content: str
) -> SupportTicket:
ticket = client.chat.completions.create(
response_model=SupportTicket,
messages=[
{
"role": "system",
"content": (
"고객 지원 이메일을 분석하는 전문가다. "
"이메일에 존재하는 정보를 바탕으로 "
"SupportTicket 구조에 맞는 데이터를 추출한다."
),
},
{
"role": "user",
"content": (
"다음 이메일을 분석해줘.\n\n"
f"{email_content}"
),
},
],
)
return ticket가장 중요한 부분은 다음 한 줄이다.
response_model=SupportTicket개발자는 JSON 문자열을 직접 다루지 않는다.
대신 원하는 결과의 형태를 Pydantic 모델로 선언한다.
그리고 결과는 SupportTicket 객체로 받는다.
1.10 이메일 하나를 실제로 처리해보기#
다음 이메일을 입력해보자.
email = """
제목: 주문한 거 언제 와요??
아니 저번주 수요일에 시킨 P0012 에어컨 필터
아직도 배송준비중이네요.
주문번호는 2024-ABC-7891 이고요.
너무 늦는 거 아닙니까?
빨리 확인 좀 해주세요. 급해요.
아 그리고 제 아이디는 happy_dev 입니다.
"""그리고 다음과 같이 호출한다.
ticket = parse_email_to_ticket(email)
print(ticket)결과는 다음과 같은 형태가 된다.
SupportTicket(
category=<TicketCategory.SHIPPING: '배송'>,
urgency=<UrgencyLevel.HIGH: '높음'>,
customer_id='happy_dev',
order_number='2024-ABC-7891',
mentioned_products=['P0012 에어컨 필터'],
summary='P0012 에어컨 필터의 배송 지연에 대한 문의'
)필요하다면 다시 JSON으로 변환할 수도 있다.
print(
ticket.model_dump_json(
indent=2,
ensure_ascii=False
)
)결과:
{
"category": "배송",
"urgency": "높음",
"customer_id": "happy_dev",
"order_number": "2024-ABC-7891",
"mentioned_products": [
"P0012 에어컨 필터"
],
"summary": "P0012 에어컨 필터의 배송 지연에 대한 문의"
}이제 이 데이터는 데이터베이스에 저장하거나 티켓 시스템 API로 전달할 수 있다.
1.11 복잡한 이메일도 처리할 수 있을까#
이번에는 조금 더 복잡한 이메일을 살펴보자.
안녕하세요.
어제 받은 D-101 세탁기랑 X-998 노트북 환불하고 싶어요.
주문 코드는 ORD-2024-XYZ-9988입니다.
세탁기는 소음이 너무 심하고
노트북은 화면에 불량화소가 있네요.
그리고 혹시 Z-001 스마트폰 재고는
언제쯤 들어오는지도 알려주세요.이 이메일에는 서로 다른 의도가 섞여 있다.
환불 요청
+
제품 문의또한 제품도 여러 개다.
D-101 세탁기
X-998 노트북
Z-001 스마트폰이런 데이터를 사람이 직접 입력하면 다음과 같은 판단이 필요하다.
주요 문의 유형 → 환불
주문 번호 → ORD-2024-XYZ-9988
제품 → 3개
고객 ID → 없음
요약 → 환불 요청 + 재고 문의LLM은 이런 문맥을 분석하고, Instructor는 그 결과를 Pydantic 모델의 구조에 맞춰 검증하는 역할을 담당한다.
예상되는 결과는 다음과 같다.
{
"category": "환불",
"urgency": "중간",
"customer_id": null,
"order_number": "ORD-2024-XYZ-9988",
"mentioned_products": [
"D-101 세탁기",
"X-998 노트북",
"Z-001 스마트폰"
],
"summary": "D-101 세탁기와 X-998 노트북의 환불을 요청하고 Z-001 스마트폰의 재고 일정을 문의함."
}여기서 중요한 점이 하나 있다.
이 결과가 항상 의미적으로 완벽하다는 뜻은 아니다.
Pydantic 검증은 데이터가 우리가 정의한 타입과 구조를 만족하는지를 검사한다.
즉 다음은 서로 다른 문제다.
구조가 올바른가?
↓
Pydantic이 검증
내용이 사실인가?
↓
별도의 검증 로직 필요이 차이를 이해하는 것이 프로덕션 AI 시스템에서 매우 중요하다.
1.12 Validation을 추가하면 무엇이 달라지는가#
단순한 타입 검증만으로는 부족할 때가 있다.
예를 들어 회의록에서 Action Item을 추출한다고 생각해보자.
우리는 다음과 같은 규칙을 원한다.
Action Item에는 반드시
과제
담당자
기한
이 있어야 한다.이를 Pydantic 모델로 표현할 수 있다.
from datetime import date
from pydantic import BaseModel, Field
class ActionItem(BaseModel):
task: str = Field(
description="수행해야 할 구체적인 과제"
)
owner: str = Field(
description="과제를 책임지고 수행할 담당자"
)
due_date: date = Field(
description="과제 완료 기한"
)
class MeetingSummary(BaseModel):
meeting_title: str
action_items: list[ActionItem]이제 action_items 안의 각 항목은 다음 구조를 가져야 한다.
task
owner
due_date1.13 사용자 정의 Validation 규칙 만들기#
Pydantic의 field_validator를 이용하면 애플리케이션만의 규칙을 추가할 수 있다.
from pydantic import BaseModel, Field, field_validator
class MeetingSummary(BaseModel):
meeting_title: str
action_items: list[ActionItem]
@field_validator("action_items")
@classmethod
def must_have_action_items(cls, value):
if not value:
raise ValueError(
"최소 하나 이상의 Action Item이 필요합니다."
)
return value이제 LLM이 다음과 같은 결과를 만들었다고 생각해보자.
{
"meeting_title": "신제품 출시 전략 회의",
"action_items": []
}JSON 문법은 정상이다.
하지만 우리 시스템의 비즈니스 규칙에는 맞지 않는다.
Pydantic이 이를 검증하면서 오류를 발생시킨다.
1.14 Instructor의 Retry와 Self-Correction#
여기서 Instructor의 중요한 기능이 등장한다.
summary = client.chat.completions.create(
response_model=MeetingSummary,
messages=[
{
"role": "user",
"content": transcript
}
],
max_retries=2,
)검증에 실패하면 Instructor는 설정된 재시도 범위 안에서 다시 요청할 수 있다. Instructor 문서에서는 Pydantic validation 실패와 자동 retry를 주요 기능으로 설명하고 있다. ([GitHub][1])
개념적으로는 다음과 같은 흐름이다.
회의록
↓
LLM
↓
첫 번째 결과
↓
Pydantic Validation
↓
실패
↓
검증 오류를 반영한 Retry
↓
LLM
↓
두 번째 결과
↓
Pydantic Validation
↓
성공
↓
MeetingSummary여기서 중요한 것은 재시도가 모델을 학습시키는 것은 아니라는 점이다.
한 번의 요청에서 모델의 가중치가 변경되는 것이 아니다.
대신 실패 정보를 새로운 요청의 문맥에 반영해 다시 생성하게 만드는 것이다.
즉 학습이 아니라 재생성 과정이다.
1.15 상대 날짜와 타입 변환#
회의록에 다음과 같은 문장이 있다고 해보자.
김 부장:
마케팅 시안은 박 대리님이 이번 주 금요일까지
마무리해주세요.
이 과장:
저는 개발팀과 최종 스펙을
다음주 수요일까지 확정하겠습니다.회의 날짜가 2024년 4월 15일이라면 사람은 다음과 같이 이해할 수 있다.
이번 주 금요일
→ 2024-04-19
다음주 수요일
→ 2024-04-24Pydantic 모델에서는 다음과 같이 날짜 타입을 지정할 수 있다.
class ActionItem(BaseModel):
task: str
owner: str
due_date: dateLLM이 자연어 표현을 날짜 형태로 변환할 수 있다면 결과는 다음과 같이 구조화될 수 있다.
{
"task": "마케팅 시안 마무리",
"owner": "박 대리",
"due_date": "2024-04-19"
}하지만 여기서 반드시 기억해야 할 것이 있다.
날짜 계산의 정확성을 LLM에 전적으로 맡겨서는 안 된다.
상대 날짜는 회의 날짜, 타임존, 회사의 영업일 규칙 등에 따라 결과가 달라질 수 있다.
따라서 중요한 업무 시스템에서는 다음과 같이 역할을 분리하는 것이 안전하다.
LLM
→ "이번 주 금요일"이라는 의미를 추출
애플리케이션
→ 기준 날짜와 캘린더 규칙을 이용해 실제 날짜 계산
Pydantic
→ 최종 날짜 타입 검증LLM은 의미 이해에 사용하고, 결정론적으로 계산할 수 있는 부분은 일반 코드가 담당하게 만드는 것이다.
1.16 Validation은 만능 안전장치가 아니다#
Instructor를 사용하면 구조화된 데이터를 훨씬 쉽게 만들 수 있지만, 이것을 100% 정확한 데이터 추출 시스템으로 이해하면 안 된다.
예를 들어 다음 결과는 구조적으로 완벽하다.
{
"category": "배송",
"urgency": "높음",
"order_number": "2024-ABC-7891"
}하지만 실제 이메일에는 주문 번호가
2024-ABC-7892였을 수도 있다.
Pydantic은 문자열 형식이 맞는지를 검사할 수 있지만, 그 값이 실제 주문 시스템에 존재하는지는 알 수 없다.
따라서 프로덕션 환경에서는 다음과 같은 검증 계층이 필요하다.
LLM
↓
구조화
↓
Pydantic Validation
↓
비즈니스 규칙 검증
↓
외부 시스템 조회
↓
최종 승인예를 들어 주문 번호를 추출했다면 실제 주문 시스템 API를 조회할 수 있다.
LLM:
2024-ABC-7891
↓
주문 API:
해당 주문 존재?
↓
Yes → 정상 처리
No → 검토 필요이렇게 해야 AI가 추출한 데이터와 실제 시스템의 사실을 구분할 수 있다.
1.17 Retry에는 비용이 따른다#
max_retries를 크게 설정한다고 해서 무조건 좋은 것도 아니다.
재시도는 새로운 LLM 호출을 발생시킬 수 있다.
따라서 다음과 같은 비용이 발생할 수 있다.
1차 요청
+
2차 요청
+
3차 요청호출 시간이 증가할 수도 있고 API 비용도 증가할 수 있다.
또한 모델이 계속 동일한 오류를 반복한다면 재시도 횟수를 늘리는 것만으로는 해결되지 않는다.
이럴 때는 다음을 점검해야 한다.
스키마가 너무 복잡한가?
description이 모호한가?
프롬프트가 충돌하고 있는가?
모델이 해당 작업에 충분한가?
Validation 규칙이 현실적인가?
입력 데이터에 필요한 정보가 실제로 존재하는가?즉 Retry는 오류를 해결하는 마지막 안전망이지, 나쁜 스키마를 대신 설계해주는 기능이 아니다.
1.18 Schema 설계가 결과 품질을 결정한다#
LLM 구조화에서 가장 중요한 것은 의외로 모델 자체가 아닐 수 있다.
잘 설계된 Schema가 중요하다.
예를 들어 다음과 같은 모델은 너무 단순하다.
class Ticket(BaseModel):
data: str반면 다음과 같이 의미를 분리하면 시스템이 훨씬 명확해진다.
class Ticket(BaseModel):
category: TicketCategory
urgency: UrgencyLevel
customer_id: str | None
order_number: str | None
mentioned_products: list[str]
summary: str좋은 Schema는 다음 질문에 답할 수 있어야 한다.
무엇을 추출하는가?
어떤 타입인가?
허용되는 값은 무엇인가?
없을 수 있는가?
여러 개일 수 있는가?
어떤 의미로 해석해야 하는가?1.19 Description을 AI 시대의 주석으로 바라보기#
전통적인 프로그램에서 주석은 개발자를 위한 설명이었다.
하지만 LLM을 사용하는 애플리케이션에서는 Schema의 설명이 모델에게 전달되는 지침의 일부가 될 수 있다. Instructor 문서에서도 docstring과 field annotation이 모델의 출력 생성에 활용된다고 설명한다. ([Instructor][4])
따라서 다음 두 코드는 의미가 다르다.
order_number: str | None그리고
order_number: str | None = Field(
default=None,
description=(
"고객 이메일에 언급된 주문 번호를 추출한다. "
"주문번호, 주문 코드, 오더 넘버 등의 표현을 모두 고려한다."
)
)두 번째 모델은 사람에게도 훨씬 이해하기 쉽지만, 동시에 LLM에게도 더 명확한 지침을 제공한다.
이것이 AI 시대의 Schema-first 개발에서 중요한 변화다.
1.20 Instructor가 잘 맞는 문제#
Instructor는 특히 다음과 같은 작업에 적합하다.
1.20.1 정보 추출#
고객 이메일
→ 고객 정보
→ 주문 정보
→ 제품 정보1.20.2 분류#
문의
→ 배송
→ 환불
→ 기술 지원
→ 제품 문의1.20.3 문서 구조화#
회의록
→ 회의 제목
→ 참석자
→ 결정 사항
→ Action Item1.20.4 콘텐츠 분석#
리뷰
→ 감정
→ 제품
→ 불만 사항
→ 핵심 요약1.20.5 레거시 데이터 변환#
오래된 로그
오래된 API 응답
자유 형식 업무 문서
고객 문의
CSV의 비정형 컬럼
↓
LLM + Instructor
↓
새로운 표준 Schema이 영역은 레거시 시스템 현대화에서도 상당히 중요한 활용 방식이다.
1.21 Instructor와 OpenAI Structured Outputs의 관계#
현재 OpenAI API 자체도 Pydantic 모델을 이용한 Structured Outputs를 지원한다. 예를 들어 Python SDK에서는 Responses API의 responses.parse()와 Pydantic 모델을 함께 사용할 수 있다. ([GitHub][3])
따라서 OpenAI만 사용하고 단순한 구조화 출력이 필요하다면 OpenAI의 네이티브 기능만으로 충분한 경우도 있다.
반면 Instructor의 장점은 LLM 제공자와 구조화 추출 로직을 하나의 인터페이스로 다루기 쉽다는 점에 있다.
Instructor는 다양한 LLM 제공자와 통합하고, Pydantic 기반 validation, retry, streaming 등의 기능을 제공한다. ([GitHub][1])
따라서 선택 기준은 다음처럼 생각할 수 있다.
OpenAI 중심
+
단순 Structured Outputs
↓
OpenAI 네이티브 기능
여러 LLM 제공자
+
Pydantic 중심 추출
+
Validation
+
Retry
↓
Instructor둘 중 하나가 무조건 우월한 것이 아니다.
애플리케이션의 구조와 사용하려는 모델 제공자에 따라 선택하면 된다.
1.22 로컬 LLM에서도 활용할 수 있다#
Instructor의 장점 중 하나는 특정 클라우드 모델에만 묶이지 않는다는 것이다.
Ollama와 같은 로컬 LLM 환경에서도 구조화 출력과 Pydantic 기반 검증을 사용할 수 있다. Instructor 문서에서는 Ollama 및 llama-cpp-python과 같은 로컬 환경에서 JSON Schema 기반 구조화 출력을 사용하는 방법도 제공한다. ([Instructor][5])
예를 들어 사내에서 민감한 고객 데이터를 외부 API로 보내기 어려운 상황이라면 다음과 같은 구조를 고려할 수 있다.
사내 이메일
↓
사내 서버
↓
로컬 LLM
↓
Instructor
↓
Pydantic
↓
사내 티켓 시스템이렇게 하면 외부 API로 원문 데이터를 전송하지 않는 구조를 만들 수 있다.
물론 실제 운영에서는 로컬 모델의 품질, 추론 속도, 하드웨어 비용, 개인정보 보호 정책 등을 함께 검토해야 한다.
1.23 운영 환경에서는 Human-in-the-Loop가 필요하다#
모든 AI 결과를 무조건 자동 실행하는 것이 항상 좋은 것은 아니다.
예를 들어 고객 환불 요청은 다음과 같은 구조로 만들 수 있다.
고객 이메일
↓
Instructor
↓
Pydantic
↓
환불 요청 추출
↓
환불 금액 확인
↓
업무 규칙 검사
↓
고위험 요청인가?
↓
Yes → 사람 승인
No → 자동 처리이렇게 하면 AI는 반복적인 정보 추출을 담당하고, 중요한 의사결정은 사람이 최종 확인할 수 있다.
AI 시스템에서 중요한 것은 AI에게 모든 것을 맡기는 것이 아니라 어떤 작업을 AI에게 맡기고 어떤 작업을 코드와 사람이 담당할 것인지 경계를 설계하는 것이다.
1.24 최종 코드 정리#
지금까지 만든 예제를 하나의 코드로 정리하면 다음과 같다.
import instructor
from enum import Enum
from pydantic import BaseModel, Field
class TicketCategory(str, Enum):
SHIPPING = "배송"
REFUND = "환불"
PRODUCT_INQUIRY = "제품 문의"
TECHNICAL_SUPPORT = "기술 지원"
ETC = "기타"
class UrgencyLevel(str, Enum):
LOW = "낮음"
MEDIUM = "중간"
HIGH = "높음"
class SupportTicket(BaseModel):
category: TicketCategory = Field(
description="이메일의 주요 문의 유형"
)
urgency: UrgencyLevel = Field(
description="고객 문의의 긴급도"
)
customer_id: str | None = Field(
default=None,
description="고객 ID 또는 계정명"
)
order_number: str | None = Field(
default=None,
description="주문 번호 또는 주문 코드"
)
mentioned_products: list[str] = Field(
default_factory=list,
description="본문에 언급된 모든 제품명 또는 제품 코드"
)
summary: str = Field(
description="문의 내용을 한 문장으로 요약"
)
client = instructor.from_provider(
"openai/gpt-4o-mini"
)
def parse_email_to_ticket(
email_content: str
) -> SupportTicket:
return client.chat.completions.create(
response_model=SupportTicket,
messages=[
{
"role": "system",
"content": (
"고객 지원 이메일을 분석하고 "
"SupportTicket 구조에 맞는 데이터를 추출한다."
),
},
{
"role": "user",
"content": email_content,
},
],
max_retries=2,
)
email = """
제목: 주문한 거 언제 와요??
P0012 에어컨 필터를 주문했는데
아직 배송준비중입니다.
주문번호는 2024-ABC-7891이고
빨리 확인해주세요.
제 아이디는 happy_dev입니다.
"""
ticket = parse_email_to_ticket(email)
print(
ticket.model_dump_json(
indent=2,
ensure_ascii=False
)
)이제 애플리케이션에서 사용하는 데이터는 단순 문자열이 아니다.
SupportTicket 객체라는 명확한 타입을 갖는다.
그리고 이 객체는 다음 시스템으로 전달할 수 있다.
SupportTicket
↓
Database
↓
Ticket API
↓
Slack / Email
↓
CRM
↓
Workflow Engine1.25 결론: 이제 의미를 코딩한다#
Instructor가 중요한 이유는 단순히 JSON을 예쁘게 만들어주기 때문이 아니다.
진짜 변화는 LLM의 자연어 능력과 일반 프로그램의 엄격한 데이터 구조를 연결한다는 것에 있다.
전통적인 프로그램에서는 다음과 같은 코드를 작성해야 했다.
문자열 검색
↓
정규표현식
↓
if-else
↓
예외 처리
↓
파싱
↓
검증
↓
변환LLM 기반 시스템에서는 다음과 같은 구조를 만들 수 있다.
비정형 텍스트
↓
LLM
↓
Pydantic Schema
↓
Validation
↓
Retry
↓
구조화된 데이터개발자가 작성해야 하는 것은 단순한 파싱 규칙이 아니다.
class SupportTicket(BaseModel):
...이 모델은 곧 시스템이 이해해야 하는 업무 개념의 정의다.
category: TicketCategory는 문의 유형의 범위를 정의한다.
urgency: UrgencyLevel은 긴급도의 허용 범위를 정의한다.
order_number: str | None은 주문 번호가 존재하지 않을 수도 있다는 업무 현실을 표현한다.
mentioned_products: list[str]은 하나의 문의에 여러 제품이 포함될 수 있다는 사실을 표현한다.
그리고
Field(
description="..."
)는 그 데이터가 어떤 의미를 가져야 하는지 설명한다.
결국 개발자의 역할은 조금씩 달라진다.
과거에는
어떤 문자열을 어떻게 파싱할 것인가?를 고민했다면,
AI 시대에는
우리 시스템에서 이 데이터는 무엇을 의미하는가?
어떤 구조로 표현해야 하는가?
어떤 규칙을 반드시 지켜야 하는가?
어떤 경우에는 사람이 확인해야 하는가?를 설계해야 한다.
이것이 Schema-first AI 개발의 핵심이다.
그리고 이렇게 만들어진 구조화 데이터는 단순히 데이터베이스에 저장하는 것으로 끝나지 않는다.
고객 문의
↓
Instructor
↓
SupportTicket
↓
문의 유형 분석
↓
적절한 API 선택
↓
업무 시스템 호출이제 다음 단계가 남아 있다.
구조화된 데이터를 얻었다면 그다음에는 어떤 시스템으로 보낼 것인지 결정해야 한다.
배송 문의라면 배송 API로 보내고, 환불 요청이라면 환불 시스템으로 보내며, 기술 지원이라면 기술 지원 시스템으로 전달해야 한다.
즉 다음 문제는 구조화가 아니라 라우팅이다.
#관련 참고 도서#
레거시 스파게티코드 AI로 심폐소생술#
10년 넘게 쌓인 레거시 코드와 기술 부채를 무작정 재구축하지 않고, AI를 활용해 기존 시스템을 유지하면서 점진적으로 현대화하는 방법을 다루는 실전 가이드입니다.
레거시 코드 분석부터 데이터 구조화, 자연어 기반 데이터 접근, 로컬 AI, AI 에이전트를 활용한 운영 자동화까지 기존 시스템에 AI를 접목하는 다양한 방법을 살펴봅니다.