구조화 출력

구조화 출력 (Structured Outputs)

모델이 자유 형식 텍스트 대신 내가 정의한 스키마에 맞는 JSON을 돌려주길 원할 때가 있어요. 문서 파싱, 엔티티 추출, 리포트 생성 같은 작업이 대표적이죠. 구조화 출력은 이런 요구를 위해 API가 response_format과 JSON Schema를 기반으로 응답을 고정해 주는 기능이에요.

출처: xAI 공식 문서 — Structured Outputs

구조화 출력을 요청하는 두 가지 방법

가장 유연한 방법은 response_format 파라미터를 쓰는 거예요. response_format.type"json_schema"로 두고 response_format.json_schema에 스키마를 정의하면, 모델이 정확히 그 구조로 응답합니다. "json_object"는 특정 구조가 필요 없이 유효한 JSON만 원할 때, "text"는 기본값으로 자유 형식 텍스트를 받을 때 써요.

두 번째 방법은 **도구 호출(tool calling)**을 통한 것이에요. 도구를 정의하면 xAI 모델은 항상 도구의 입력 JSON Schema를 엄격히 따르는 인자를 생성해요(strict 플래그가 암묵적으로 항상 true).

스키마는 Pydantic이나 Zod 같은 라이브러리로 정의할 수 있어요.

JSON Schema 지원 범위

실용적인 JSON Schema 하위 집합을 지원해요. Draft 2020-12 기준으로 작성된 스키마가 가장 잘 맞고, Draft-07도 받아들여집니다.

지원 형식

  • string · number · integer · boolean · null
  • enum · const · array · object · anyOf
  • oneOf (anyOf와 동일하게 동작) · allOf (단일 서브스키마만) · $ref / $defs (비순환 참조만)

additionalProperties는 기본값이 false라서, 열어 두려면 명시적으로 true를 줘야 해요. 필드를 nullable로 만들려면 타입 배열({"type": ["string", "null"]})이나 null을 포함한 anyOf를 쓰면 됩니다. required에 없는 필드는 선택 항목으로 취급돼요.

문자열 format 강제

format 키워드는 date · time · date-time · email · uuid · ipv4 · ipv6 · uri 값에 대해 강제됩니다. 그 외의 format 값은 받아들이지만 강제하진 않아요.

제약 한계

아래 제약은 한계 수치까지는 출력 엔진이 강제해요. 이 한계를 넘는 스키마도 수용되지만, 그 경우 준수 여부는 모델 동작에 의존합니다.

키워드 보장되는 상한
minimum / maximum / exclusiveMinimum / exclusiveMaximum 제한 없음
minLength / maxLength 2,048
minItems / maxItems 256
minProperties / maxProperties 64

Best-effort 키워드

다음 키워드는 받아들이지만 구조적으로 강제하지는 않아요. 실제로는 모델이 대체로 잘 처리하지만, 엄격한 준수가 필요하다면 직접 검증을 권장합니다.

  • not · if / then / else
  • 서브스키마가 여러 개인 allOf
  • 문자열 format 목록에 없는 format
  • 위 한계를 넘는 제약

거부되는 스키마 (400 오류)

  • 변형이 하나도 없는 enum 또는 anyOf
  • 스키마가 truefalse인 프로퍼티
  • maxContains / minContains
  • 배열인 items (튜플 검증에는 prefixItems 사용)

정규식 (pattern) 지원

pattern 키워드에서는 ECMAScript 정규식(ECMA-262)의 실용 하위 집합을 지원해요.

지원: 리터럴·문자 클래스([abc], [a-z]), . (개행 포함 모든 유니코드 코드포인트), 택일 |·그룹 (...)·비캡처 그룹 (?:...), 수량자 * + ? ·반복 범위 {n} {n,} {n,m}, 단축 클래스 \d \w \s (그 부정 \D \W \S), 공통 이스케이프 \n \t \r \f \xHH \uHHHH \u{HHHHHH}

미지원: 역참조(\1, \k<name>), 유니코드 프로퍼티 이스케이프(\p{L}, \P{Letter}), 단어 경계(\b \B), 룩어헤드/룩비하인드((?=...), (?<=...)), 인라인 수정자((?i), (?m)), 조건식 등의 고급 구조

표준 JS 정규식과의 의미 차이: .이 개행을 매칭하고, ^$는 암묵적으로 항상 전체 문자열 매칭이며, 캡처 그룹 (...)은 의미가 없고(비캡처처럼 동작), 유니코드 지원으로 평가돼요.

예시: 인보이스 파싱

구조화 출력의 대표 사용처는 문서 파싱이에요. 인보이스에는 판매자 정보·금액·날짜 같은 구조적 데이터가 있는데, 원시 텍스트에서 뽑아내기 어렵죠. 구조화 출력을 쓰면 추출 결과가 미리 정의한 스키마와 일치하도록 보장됩니다.

1단계: 스키마 정의

from datetime import date
from enum import Enum

from pydantic import BaseModel, Field

class Currency(str, Enum):
    USD = "USD"
    EUR = "EUR"
    GBP = "GBP"

class LineItem(BaseModel):
    description: str = Field(description="Description of the item or service")
    quantity: int = Field(description="Number of units", ge=1)
    unit_price: float = Field(description="Price per unit", ge=0)

class Address(BaseModel):
    street: str = Field(description="Street address")
    city: str = Field(description="City")
    postal_code: str = Field(description="Postal/ZIP code")
    country: str = Field(description="Country")

class Invoice(BaseModel):
    vendor_name: str = Field(description="Name of the vendor")
    vendor_address: Address = Field(description="Vendor's address")
    invoice_number: str = Field(description="Unique invoice identifier")
    invoice_date: date = Field(description="Date the invoice was issued")
    line_items: list[LineItem] = Field(description="List of purchased items/services")
    total_amount: float = Field(description="Total amount due", ge=0)
    currency: Currency = Field(description="Currency of the invoice")

2단계: 프롬프트 준비

시스템 프롬프트는 출력 JSON의 필드를 나열하지 않고 작업 자체에 집중할 수 있어요. 스키마는 별도로 정의되어 있으니까요.

Given a raw invoice, carefully analyze the text and extract the relevant invoice data into JSON format.

3단계: parse로 구조화 파싱

SDK의 parse() 메서드를 쓰면 응답 객체와 파싱된 Pydantic 객체를 함께 받을 수 있어요.

import os
from datetime import date
from enum import Enum

from pydantic import BaseModel, Field

from xai_sdk import Client
from xai_sdk.chat import system, user

# ... Pydantic 스키마 (위와 동일) ...

client = Client(api_key=os.getenv("XAI_API_KEY"))
chat = client.chat.create(model="grok-4.6")

chat.append(system("Given a raw invoice, carefully analyze the text and extract the invoice data into JSON format."))
chat.append(
user("""
Vendor: Acme Corp, 123 Main St, Springfield, IL 62704
Invoice Number: INV-2025-001
Date: 2025-02-10
Items: - Widget A, 5 units, $10.00 each - Widget B, 2 units, $15.00 each
Total: $80.00 USD
""")
)

# parse 메서드는 (전체 응답 객체, 파싱된 pydantic 객체) 튜플을 돌려줍니다.
response, invoice = chat.parse(Invoice)
assert isinstance(invoice, Invoice)

print(invoice.vendor_name)
print(invoice.invoice_number)
print(invoice.invoice_date)
print(invoice.line_items)
print(invoice.total_amount)
print(invoice.currency)

# 응답 객체의 content는 파싱된 인보이스의 JSON 스키마 표현
print(response.content)

4단계: 타입 안전한 출력

지원되는 스키마 기능을 쓰면 출력은 타입 안전하고 입력 스키마를 따릅니다. Invoice.model_validate_json으로 JSON 문자열을 다시 Pydantic 모델로 되돌릴 수도 있어요.

구조화 출력 + 도구 호출

도구와 구조화 출력을 조합하면 도구로 정보를 모으고 결과를 예측 가능한 강타입 형식으로 돌려받는 워크플로가 가능해요. 다음 두 가지 모두와 함께 동작합니다.

  • 에이전트 도구 호출: 모델이 자율적으로 조율하는 서버 측 도구(웹 검색, X 검색, 코드 실행)
  • 함수 호출: 사용자가 직접 정의하고 도구 실행을 처리하는 커스텀 함수

response_format에 Pydantic 모델을 넘기면 parse() 대신 sample()로도 쓸 수 있고, stream()과 조합해 JSON 문자열이 조금씩 쌓이는 스트리밍 구조화 출력도 가능해요.

더 알아보기