구조화된 출력

먼저 문제의 형태부터 살펴봅시다. 구조화된 출력은 코드가 직접 읽을 수 있는 데이터를 모델에서 반환하도록 하는 관행으로, 손으로 파싱해야 하는 문단 대신 분기할 수 있는 필드입니다. 이 장은 그것을 신뢰할 수 있게 만드는 방법에 관한 것입니다.
지금까지 모델은 텍스트를 전달해주는데, 이는 사람이 읽을 때는 괜찮습니다. 하지만 소프트웨어는 문단으로 많은 일을 할 수 없습니다. 모델의 답변에 따라 코드에서 조치를 취하려면 데이터 형태여야 합니다: 읽을 수 있는 필드, 분기할 수 있는 값, 저장할 수 있는 레코드입니다.
변화는 작지만 그 이후로는 모든 것이 바뀝니다. 모델이 고객이 불행해 보인다는 문장 대신 { "sentiment": "negative" }를 반환하면, 코드는 티켓을 라우팅하고, 대시보드를 업데이트하거나, 경고를 보낼 수 있습니다. 흥미로운 부분은 텍스트만 예측하는 모델이 깨끗한 데이터를 생성하도록 만드는 방법인데, 그 답변은 당신이 몰랐던 레버를 드러냅니다.
JSON 요청
첫 번째 본능은 프롬프트에서 요청하는 것입니다: "**JSON**으로 답장하세요", 소프트웨어가 중괄호 안의 필드와 값으로 작성된 데이터를 전달하는 데 사용하는 일반 텍스트 형식입니다. 시도하면 대부분 작동하는데, 이것이 함정입니다. 모델은 여전히 확률 높은 텍스트를 예측하는 것뿐이며, 때로 확률 높은 텍스트는 코드 펜스이거나, 데이터 앞의 친절한 "물론이죠!"이거나, 그 뒤의 후행 주석입니다. 그 중 어느 것이든 JSON을 읽는 코드를 깨뜨립니다. 친절하게 요청하면 예측이 JSON으로 편향되지만, 강제하지는 않습니다.
따라서 제공업체는 실제 레버를 제공합니다. response_format으로 JSON 모드를 켜면 요청할 뿐만 아니라 모델을 제한하여 구문이 유효한 JSON으로 돌아옵니다.
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": '도시와 온도를 추출합니다. JSON으로 답장하세요: { "city": string, "tempC": number }.'},
{"role": "user", "content": "지금 오슬로는 약 18도입니다."},
],
response_format={"type": "json_object"}, # 출력을 유효한 JSON 구문으로 제한합니다
)
data = json.loads(response.choices[0].message.content)
print(data["city"], data["tempC"]) # "오슬로" 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": "메시지에서 연락처 정보를 추출합니다."},
{"role": "user", "content": "안녕하세요, 저는 마라 임입니다. mara@example.com으로 연락하세요."},
],
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": "마라 임", "email": "mara@example.com"}모델이 토큰만 예측할 때 스키마가 어떻게 어려운 보장일 수 있습니까? 제공업체는 예측할 수 있는 토큰을 제한하기 때문입니다. 각 단계에서 모델은 여전히 가능한 모든 다음 토큰의 순위를 매기지만, 시스템은 스키마를 깨뜨릴 수 있는 것들을 제거하고, 모델은 남은 것에서 선택합니다.
스키마가 다음 필드가 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이 작동하는 방식의 환각 교훈을 새로운 옷입니다: 형태가 맞는 것이 값이 맞다는 것을 만들지 않습니다. 스키마는 name 필드를 얻는다는 것을 보장합니다; 그것은 이름이 맞다는 것을 보장하지 않습니다. 파싱 주위에 try/except와 값의 검사는 잘 형성되고 여전히 잘못된 응답에 대한 저렴한 보험입니다.
try/except로 파싱을 감싸고 신뢰하기 전에 값을 확인하세요. 검증이 실패하면 어떤 일이 발생하는지 미리 결정합니다, 재시도 또는 친절한 오류. 그래서 나중에 스크램블이 아닙니다. 두 가지 패턴: 분류 및 추출
대부분의 구조화된 출력 작업은 두 가지 형태 중 하나입니다.
**분류**는 입력을 고정된 라벨 집합 중 하나로 정렬합니다. 스키마의 enum은 동일한 토큰 가지치기 트릭을 사용하여 출력을 정확히 그 라벨로 제한합니다: 라벨 위치에서, 허용된 값만 예측될 수 있으므로, 모델이 목록에 없는 범주를 만들 수 없습니다.
# 분류를 위한 스키마 조각
{"type": "string", "enum": ["billing", "technical", "general"]}추출은 무료 텍스트에서 특정 필드를 꺼냅니다, 예를 들어 이전의 연락처 예제처럼: 이름, 날짜, 금액, 제품 이름 목록입니다. 함께 분류와 추출은 실제 AI 기능의 큰 공유를 다룹니다: 지원 티켓 라우팅, 콘텐츠 태깅, 이메일을 레코드로 바꾸기, 영수증 읽기입니다. 둘 다 흐릿한 텍스트 생성기를 당신의 코드가 구축할 수 있는 신뢰할 수 있는 컴포넌트로 변환합니다.
실제로
지저분한 지원 이메일을 하나의 스키마에서 분류와 추출을 결합하여 구조화된 티켓으로 변환합니다:
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": "지원 이메일을 티켓으로 변환합니다."},
{"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": "지난달 구독에서 이중 청구되었습니다."}하나의 호출이 이메일을 두 가지 방식으로 분류하고 요약을 추출하여, 당신의 코드가 라우팅하고 저장할 수 있는 레코드를 반환합니다, 형태가 보장되고 값은 여전히 확인할 가치가 있습니다. 지금까지 모든 것이 텍스트로 흘러들어가고 텍스트로 나갔습니다. 다음으로 당신은 모델에게 완전히 다른 의미를 제공합니다: 임베딩에서, 텍스트는 의미로 비교할 수 있는 숫자가 되는데, 이것은 검색과 당신의 문서로 작업하기 위한 기초입니다.

