구조화된 출력


먼저 문제의 형태를 짚어보겠습니다. **구조화된 출력(Structured output)**이란 모델이 여러분의 코드가 직접 읽을 수 있는 데이터를 반환하게 만드는 작업입니다. 사람이 손으로 파싱해야 하는 문단이 아니라, 조건문에 바로 쓸 수 있는 필드를 얻는 것이죠. 이번 장에서는 이것을 신뢰할 수 있게 만드는 방법을 다룹니다.
지금까지 모델은 텍스트를 돌려줬습니다. 사람이 읽을 때는 괜찮지만, 소프트웨어는 문단만으로는 할 수 있는 일이 별로 없습니다. 모델의 답변을 코드에서 활용하려면 데이터 형태여야 합니다. 읽을 수 있는 필드, 조건문에 쓸 수 있는 값, 저장할 수 있는 레코드로 말이죠.
이 전환은 작아 보이지만 그 이후의 모든 것을 바꿔놓습니다. 모델이 "고객이 불만스러워 보인다"는 식의 문장 대신 { "sentiment": "negative" }를 돌려주는 순간, 여러분의 코드는 티켓을 라우팅하거나, 대시보드를 업데이트하거나, 알림을 보낼 수 있습니다. 흥미로운 부분은 텍스트를 예측하는 것밖에 하지 못하는 모델에게서 깨끗한 데이터를 뽑아내는 방법이며, 그 답은 여러분이 미처 몰랐던 지렛대를 하나 드러내 줍니다.
JSON으로 요청하기
첫 번째로 떠오르는 방법은 프롬프트에서 그냥 요청하는 것입니다. "JSON 형식으로 답해줘"라고 말이죠. JSON은 소프트웨어가 데이터를 주고받을 때 쓰는 평문 형식으로, 중괄호 안에 필드와 값을 나열해서 작성합니다. 실제로 해보면 대체로 잘 됩니다. 바로 그게 함정입니다. 모델은 여전히 그럴듯한 텍스트를 예측하는 일밖에 하지 않기 때문에, 가끔은 그 그럴듯한 텍스트가 코드 펜스이거나, 데이터 앞에 붙는 다정한 "네, 여기 있습니다!" 같은 말이거나, 데이터 뒤에 붙는 주석일 수 있습니다. 이런 것들은 JSON을 읽는 코드를 전부 망가뜨립니다. 정중하게 요청하는 것은 예측을 JSON 쪽으로 기울일 뿐, 강제하지는 않습니다.
그래서 제공업체들은 진짜 지렛대를 제공합니다. response_format으로 JSON 모드를 켜는 것은 단순히 요청하는 것 이상입니다. 모델을 제약해서 문법적으로 유효한 JSON이 나오게 만듭니다.
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": 'Extract the city and the temperature. Reply as JSON: { "city": string, "tempC": number }.'},
{"role": "user", "content": "It's about 18 degrees in Oslo right now."},
],
response_format={"type": "json_object"}, # 출력을 유효한 JSON 문법으로 제약함
)
data = json.loads(response.choices[0].message.content)
print(data["city"], data["tempC"]) # "Oslo" 18response_format={"type": "json_object"}는 출력을 유효한 JSON 문법으로 만들어주므로, json.loads가 엉뚱한 산문 때문에 막히지 않습니다. 그래도 원하는 필드는 프롬프트에서 직접 설명해야 합니다. JSON 모드가 보장하는 것은 유효한 JSON이라는 점뿐이지, 여러분이 생각한 특정 필드가 아니기 때문입니다. "JSON이 아예 아닌 것"은 배제하지만, "형태가 잘못된 JSON"은 배제하지 못합니다. 그리고 이것도 보장하지 못하는 게 하나 있습니다. 응답이 중간에 끊기면 절반짜리 JSON 객체를 받을 수도 있으므로, 이것은 신뢰할 수 있는 문법이지 모든 경우에 대한 절대적인 보장은 아닙니다.
response_format을 JSON 모드로 설정하면 문법이 제약되어 유효한 JSON으로 돌아옵니다. 다만 요청한 필드와 일치한다는 보장은 아니고, 중간에 끊긴 응답은 반쪼가리 객체로 올 수도 있으니, 원하는 형태는 프롬프트에서 설명하고 실제로 도착한 결과를 확인하세요. 스키마로 형태를 고정하기
JSON 모드는 문법을 유효하게 유지해주지만, 모델이 여전히 필드 이름을 바꾸거나, 빠뜨리거나, 다르게 중첩시킬 수 있습니다. 이 여지를 없애려면 **스키마**를 줘야 합니다. 출력이 반드시 가져야 할 필드와 타입을 정확히 기술한 것, 즉 모델이 채워 넣어야 하는 양식입니다. strict: true를 쓰면 출력이 그 양식과 일치한다는 것이 보장됩니다.
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": "Extract the contact details from the message."},
{"role": "user", "content": "Hi, I'm Mara Lin, reach me at [email protected]."},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "contact",
"strict": True, # 스키마를 정확히 강제함
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
},
"required": ["name", "email"],
"additionalProperties": False,
},
},
},
)
contact = json.loads(response.choices[0].message.content)
# {"name": "Mara Lin", "email": "[email protected]"}모델이 토큰 예측만 하는데 스키마가 어떻게 절대적인 보장이 될 수 있을까요? 그 이유는 제공업체가 모델이 예측할 수 있는 토큰 자체를 제한하기 때문입니다. 매 단계마다 모델은 가능한 다음 토큰 전체에 여전히 순위를 매기지만, 시스템이 스키마를 깨뜨릴 토큰들을 미리 제거하고, 모델은 남은 것들 중에서만 고릅니다.
스키마상 다음 필드가 email이어야 한다면, 다른 필드를 시작하는 토큰들은 모델이 선택하기 전에 이미 판에서 사라집니다. 형태를 벗어나는 토큰들이 제거되기 때문에, 모델은 형태를 벗어날 수가 없습니다.
알아두면 좋은 부수 효과가 하나 있습니다. 여러분이 고른 필드 이름은 모델이 값을 예측할 때 함께 읽는 정보가 되므로, 명확한 이름이 더 좋은 답으로 이어집니다. tempC를 채우라고 하면 모델은 섭씨 숫자 쪽으로 기울지만, value를 채우라고 하면 참고할 정보가 훨씬 줄어듭니다. 필드 이름을 명확하게 짓는 것은 스키마 작업이면서 동시에 지시문 작업이기도 합니다.
strict: true를 쓰면 정확한 필드와 타입을 얻게 됩니다. 시스템이 형태를 깨뜨릴 토큰을 모델이 고르기 전에 미리 제거하기 때문이죠. 이건 강제이지 정중한 요청이 아닙니다. 필드 이름도 명확하게 지으세요. 필드 이름은 모델이 값을 예측할 때 읽는 맥락이니까요. tempC 같은 이름이 실제로 일을 한다는 걸 믿기까지 저도 시간이 좀 걸렸습니다. 그래도 검증은 해야 합니다
스키마가 형태를 제약해주더라도, 출력은 여전히 외부에서 온 데이터로 취급해야 합니다. 형태는 유효한데 내용은 틀릴 수 있습니다. 추출된 이메일이 사실은 오타이거나, 모델이 그냥 추측한 숫자이거나, 입력에 없어서 비워둔 필드일 수 있습니다. 그리고 호출 자체가 평범한 방식으로 실패할 수도 있습니다. max_tokens에 걸려 잘려서 파싱할 수 없는 JSON으로 도착하는 경우처럼요. 방어적으로 파싱하세요. 유효한 형태가 곧 올바른 값을 뜻하지는 않습니다.
import json
def parse_contact(raw):
try:
data = json.loads(raw)
if not data.get("name") or not data.get("email"):
return None # 존재하지만 비어 있음
return data
except json.JSONDecodeError:
return None # 유효한 JSON이 아님 (예: 잘린 응답)
contact = parse_contact(response.choices[0].message.content)
if not contact:
pass # 실패 처리: 재시도, 다시 요청, 또는 친절한 오류 표시이것은 LLM의 작동 방식에서 본 환각(hallucination) 이야기가 새로운 옷을 입은 것입니다. 형태가 맞다고 값도 맞는 것은 아닙니다. 스키마는 name 필드가 존재한다는 것을 보장하지만, 그 이름이 맞다는 것을 보장하지는 않습니다. 파싱 주변에 try/except를 두고 값을 확인하는 것은, 형태는 맞지만 여전히 틀린 응답에 대한 값싼 보험입니다.
try/except로 감싸고 신뢰하기 전에 값을 확인하세요. 검증이 실패했을 때 무엇을 할지(재시도인지 친절한 오류인지) 미리 정해두면 나중에 허둥댈 일이 없습니다. 두 가지 패턴: 분류와 추출
구조화된 출력 작업은 대부분 두 가지 형태 중 하나입니다.
**분류(Classification)**는 입력을 고정된 레이블 집합 중 하나로 정리합니다. 스키마 안의 enum이 같은 토큰 가지치기 기법을 이용해 출력을 정확히 그 레이블들로만 제약합니다. 레이블 위치에서는 허용된 값만 예측될 수 있으므로, 모델은 목록에 없는 범주를 만들어낼 수 없습니다.
# 분류용 스키마 조각
{"type": "string", "enum": ["billing", "technical", "general"]}**추출(Extraction)**은 앞서 나온 연락처 예시처럼 자유 텍스트에서 특정 필드를 뽑아냅니다. 이름, 날짜, 금액, 제품명 목록 같은 것들이죠. 분류와 추출을 합치면 실제 AI 기능의 상당 부분을 다룰 수 있습니다. 고객 지원 티켓 라우팅, 콘텐츠 태깅, 이메일을 레코드로 변환하기, 영수증 읽기 등이 그렇습니다. 두 가지 모두 흐릿한 텍스트 생성기를 여러분의 코드가 믿고 쓸 수 있는 컴포넌트로 바꿔줍니다.
enum으로 고정해두면 모델이 목록에 없는 범주를 지어내지 못합니다. 추출은 이름 붙은 필드들을 자유 텍스트에서 뽑아 레코드로 만듭니다. 둘 다 흐릿한 텍스트 생성기를 신뢰할 수 있는 컴포넌트로 바꿔주고, 둘을 합치면 실용적인 AI 기능의 상당 부분을 커버합니다. 실전에서
지저분한 고객 지원 이메일을 구조화된 티켓으로 바꾸는 예시입니다. 하나의 스키마에서 분류와 추출을 함께 씁니다.
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": "Turn the support email into a ticket."},
{"role": "user", "content": email_text},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "ticket",
"strict": True,
"schema": {
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["billing", "technical", "general"]},
"urgency": {"type": "string", "enum": ["low", "medium", "high"]},
"summary": {"type": "string"},
},
"required": ["category", "urgency", "summary"],
"additionalProperties": False,
},
},
},
)
ticket = json.loads(response.choices[0].message.content)
# {"category": "billing", "urgency": "high", "summary": "Double-charged for last month's subscription."}한 번의 호출로 이메일을 두 가지 기준으로 분류하고 요약을 추출해서, 여러분의 코드가 라우팅하고 저장할 수 있는 레코드를 돌려줍니다. 형태는 보장되지만 값은 여전히 확인할 만합니다. 지금까지는 텍스트가 들어가고 텍스트가 나오는 흐름뿐이었습니다. 다음 장인 임베딩에서는 모델에게 완전히 다른 감각을 줍니다. 텍스트가 의미로 비교할 수 있는 숫자가 되는 것인데, 이는 검색과 여러분 자신의 문서를 다루는 작업의 기초가 됩니다.

