Skip to content
Published on

LLM Structured Output 실전 가이드 — JSON Mode, Tool Use, Pydantic 스키마 검증

공유하기
Authors
Structured Output JSON Mode

들어가며

LLM의 출력을 프로그래밍적으로 처리하려면 구조화된 형식(JSON, XML 등)이 필수입니다. "응답을 JSON으로 줘"라고 프롬프트에 넣는 것만으로는 부족합니다 — 스키마 불일치, 누락 필드, 잘못된 타입 등 다양한 문제가 발생합니다.

이 글에서는 주요 LLM 프로바이더의 Structured Output 기능을 비교하고, 프로덕션에서 안정적으로 사용하는 방법을 다룹니다.

프로바이더별 Structured Output 비교

OpenAI: response_format + Structured Outputs

from openai import OpenAI
from pydantic import BaseModel
from typing import List, Optional

client = OpenAI()

# 방법 1: JSON Mode (기본)
response = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "응답을 JSON으로 반환하세요."},
        {"role": "user", "content": "서울의 유명 맛집 3곳 추천해줘"}
    ],
    response_format={"type": "json_object"}
)
# JSON은 보장되지만, 스키마는 보장되지 않음

# 방법 2: Structured Outputs (스키마 보장)
class Restaurant(BaseModel):
    name: str
    cuisine: str
    price_range: str
    rating: float
    address: str

class RestaurantList(BaseModel):
    restaurants: List[Restaurant]
    total_count: int

response = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "서울의 유명 맛집을 추천해주세요."},
        {"role": "user", "content": "한식 맛집 3곳"}
    ],
    response_format=RestaurantList
)

result = response.choices[0].message.parsed
print(result.restaurants[0].name)  # 타입 안전!

Anthropic: Tool Use로 Structured Output

import anthropic
from typing import List

client = anthropic.Anthropic()

# Anthropic은 Tool Use를 활용한 Structured Output
response = client.messages.create(
    model="claude-sonnet-4-20250514",
    max_tokens=1024,
    tools=[
        {
            "name": "extract_restaurants",
            "description": "맛집 정보를 구조화된 형식으로 추출",
            "input_schema": {
                "type": "object",
                "properties": {
                    "restaurants": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "name": {"type": "string"},
                                "cuisine": {"type": "string"},
                                "price_range": {
                                    "type": "string",
                                    "enum": ["$", "$$", "$$$", "$$$$"]
                                },
                                "rating": {"type": "number"},
                                "address": {"type": "string"}
                            },
                            "required": ["name", "cuisine", "price_range"]
                        }
                    },
                    "total_count": {"type": "integer"}
                },
                "required": ["restaurants", "total_count"]
            }
        }
    ],
    tool_choice={"type": "tool", "name": "extract_restaurants"},
    messages=[
        {"role": "user", "content": "서울 한식 맛집 3곳 추천해줘"}
    ]
)

# Tool Use 결과에서 구조화된 데이터 추출
tool_use = next(
    block for block in response.content
    if block.type == "tool_use"
)
restaurants = tool_use.input["restaurants"]

Google Gemini: responseSchema

import google.generativeai as genai

genai.configure(api_key="YOUR_API_KEY")

model = genai.GenerativeModel(
    "gemini-2.0-flash",
    generation_config=genai.GenerationConfig(
        response_mime_type="application/json",
        response_schema={
            "type": "object",
            "properties": {
                "restaurants": {
                    "type": "array",
                    "items": {
                        "type": "object",
                        "properties": {
                            "name": {"type": "string"},
                            "cuisine": {"type": "string"},
                            "rating": {"type": "number"}
                        }
                    }
                }
            }
        }
    )
)

response = model.generate_content("서울 한식 맛집 3곳")
import json
data = json.loads(response.text)

2026년 8월 기준으로 달라진 것

위 예제는 2026년 3월에 쓴 것입니다. 대부분 지금도 돌아가고, 그래서 지우지 않고 남겨 뒀습니다. 다만 그 사이 세 프로바이더 중 두 곳이 권장 경로를 바꿨습니다. 아래는 2026-08-16에 각 문서에서 확인한 내용입니다.

Anthropic — 네이티브 Structured Outputs가 GA

이 글에서 가장 크게 틀린 부분입니다. 위에서 Anthropic은 Tool Use를 우회로로 쓴다고 썼는데, 그 패턴은 여전히 동작하지만 더 이상 필요하지 않습니다. 스키마를 직접 넘기는 output_config 파라미터가 정식 출시(GA)됐고, 베타 헤더도 붙이지 않습니다.

from anthropic import Anthropic

client = Anthropic()

response = client.messages.create(
    model="claude-opus-5",
    max_tokens=16000,
    messages=[{"role": "user", "content": "..."}],
    output_config={
        "format": {
            "type": "json_schema",
            "schema": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "plan": {"type": "string"},
                },
                "required": ["name", "plan"],
                "additionalProperties": False,
            },
        }
    },
)

Tool 정의도, 응답 블록에서 tool_use를 꺼내는 코드도 없어집니다. Pydantic을 쓴다면 헬퍼가 하나 더 있습니다. client.messages.parse()output_format으로 모델 클래스를 넘기면, 검증까지 끝난 인스턴스가 response.parsed_output에 담겨 옵니다.

from pydantic import BaseModel

class Account(BaseModel):
    name: str
    plan: str

response = client.messages.parse(
    model="claude-opus-5",
    max_tokens=16000,
    messages=[{"role": "user", "content": "..."}],
    output_format=Account,
)

account = response.parsed_output  # 검증을 통과한 Account 인스턴스

이름이 겹쳐서 한 번은 틀리는 지점이 있습니다. .parse() 헬퍼의 인자 이름은 output_format이 맞지만, .create()의 정식 파라미터는 output_config 아래의 format입니다. .create()의 예전 최상위 output_format은 이것으로 대체되면서 deprecated 됐습니다.

Tool Use를 계속 쓴다면 strict 키는 input_schema 안이 아니라 name, description, input_schema와 같은 레벨의 최상위 키입니다.

tools = [
    {
        "name": "extract_restaurants",
        "description": "맛집 정보를 구조화된 형식으로 추출",
        "strict": True,  # input_schema 안이 아니라 여기
        "input_schema": {
            "type": "object",
            "properties": {
                "restaurants": {"type": "array", "items": {"type": "object"}},
                "total_count": {"type": "integer"},
            },
            "required": ["restaurants", "total_count"],
            "additionalProperties": False,
        },
    }
]

가장 자주 걸리는 차이는 required입니다. Anthropic은 모든 속성을 required에 넣으라고 요구하지 않습니다. OpenAI는 요구합니다.

OpenAI — 페이로드 모양이 API마다 다르다

OpenAI 쪽은 기능보다 페이로드 모양에서 사고가 납니다. 같은 설정을 어디에 넣느냐가 두 API에서 다릅니다.

# Chat Completions — json_schema가 한 겹 더 중첩되고, 이름이 안쪽에 있다
"response_format": {
    "type": "json_schema",
    "json_schema": {
        "name": "person",
        "strict": True,
        "schema": {"type": "object", "properties": {}},
    },
}

# Responses API — text.format 아래에 평평하게 놓이고, 이름이 type과 같은 레벨이다
"text": {
    "format": {
        "type": "json_schema",
        "name": "person",
        "strict": True,
        "schema": {"type": "object", "properties": {}},
    }
}

예제를 복사해 두고 호출하는 API만 바꾸면 설정이 조용히 무시되거나 400이 납니다. 공식 권고는 Chat Completions도 계속 지원하지만 새 프로젝트는 Responses를 쓰라는 것입니다.

SDK 헬퍼도 정리됐습니다. 위 본문의 client.beta.chat.completions.parse는 현재 SDK에 없습니다. 지금은 client.chat.completions.parse를 씁니다. Responses 쪽 대응물은 client.responses.parse이고 스키마는 text_format으로 넘깁니다. 어느 버전에서 beta 경로가 사라졌는지는 확인하지 못했으니 버전 번호로 단정하지 말고 직접 import해서 확인하세요.

from openai import OpenAI

client = OpenAI()

# Chat Completions — beta 경로가 아니라 여기
completion = client.chat.completions.parse(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "한식 맛집 3곳"}],
    response_format=RestaurantList,
)
result = completion.choices[0].message.parsed

# Responses API — 새 프로젝트 권장 경로
response = client.responses.parse(
    model="gpt-4o-mini",
    input=[{"role": "user", "content": "한식 맛집 3곳"}],
    text_format=RestaurantList,
)
# 파싱 결과를 꺼내는 속성 이름은 SDK 버전에 따라 다릅니다.
# 정확한 API는 사용 중인 버전의 문서에서 확인하세요.

JSON Mode도 정리해 둡니다. json_object 타입은 유효한 JSON만 보장하고 스키마는 보장하지 않으며, 대화 안에 JSON이라는 단어가 있어야 동작합니다. 공식 문서는 Structured Outputs를 JSON mode의 진화형이라고 표현하지만, JSON Mode가 deprecated 된 것은 아닙니다.

strict를 켜면 additionalPropertiesfalse로 두는 것에 더해 모든 속성을 required에 넣어야 합니다. 선택 필드는 타입을 문자열과 null의 조합으로 만들어 흉내 냅니다.

Google — 이주가 두 번 있었다

Gemini 쪽은 두 세대가 밀려 있습니다. 첫 번째는 SDK입니다. 위 예제의 google-generativeai는 적극적으로 관리되지 않는 상태이고, 레거시 라이브러리들은 2025년 11월 30일자로 deprecated 됐습니다. 지금 설치할 것은 google-genai이며 패키지 이름과 import 경로가 전부 바뀝니다. 두 번째는 API입니다. 2026년 6월에 Interactions API가 GA 됐고, 구조화 출력 문서가 이제 이쪽을 먼저 보여 줍니다.

from google import genai

client = genai.Client()

interaction = client.interactions.create(
    model="gemini-3.6-flash",
    input=prompt,
    response_format={
        "type": "text",
        "mime_type": "application/json",
        "schema": Recipe.model_json_schema(),  # Pydantic 모델을 그대로 넘길 수 없다
    },
)

recipe = Recipe.model_validate_json(interaction.output_text)

generateContent API는 여전히 완전히 지원되지만 이제는 레거시로 간주된다고 명시돼 있고, 권고는 새 개발에 모두 Interactions API를 쓰라는 것입니다.

함정이 하나 있습니다. Interactions 경로에서는 Pydantic 모델을 그대로 넘길 수 없어 .model_json_schema()로 딕셔너리를 만들어야 합니다. 반대로 레거시 generate_content 경로에서는 BaseModel을 그대로 넘길 수 있습니다.

from google import genai
from google.genai import types

client = genai.Client(api_key="...")

# 레거시 경로 — 계속 지원되며, 여기서는 Pydantic 모델을 그대로 넘길 수 있다
response = client.models.generate_content(
    model="gemini-3.6-flash",
    contents=prompt,
    config=types.GenerateContentConfig(
        response_mime_type="application/json",
        response_schema=CountryInfo,
    ),
)

확인하지 못한 것도 적어 둡니다. response_schemaresponse_json_schema가 별개로 공존하는 필드인지는 SDK 문서 사이트와 GitHub README의 설명이 서로 달라 확정하지 못했습니다. 응답에서 파싱된 객체를 바로 꺼내는 속성이 있는지도 마찬가지입니다. 정확한 API는 사용 중인 버전의 문서에서 확인하세요.

Pydantic으로 스키마 검증 자동화

기본 패턴

from pydantic import BaseModel, Field, validator
from typing import List, Optional, Literal
from enum import Enum
import json

class PriceRange(str, Enum):
    CHEAP = "$"
    MODERATE = "$$"
    EXPENSIVE = "$$$"
    VERY_EXPENSIVE = "$$$$"

class Restaurant(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    cuisine: str = Field(..., description="음식 종류")
    price_range: PriceRange
    rating: float = Field(..., ge=0.0, le=5.0)
    address: Optional[str] = None
    tags: List[str] = Field(default_factory=list, max_length=10)

    @validator('rating')
    def round_rating(cls, v):
        return round(v, 1)

class RestaurantResponse(BaseModel):
    restaurants: List[Restaurant] = Field(..., min_length=1, max_length=20)
    query: str
    total_count: int

# LLM 응답 파싱 + 검증
def parse_llm_response(raw_json: str) -> RestaurantResponse:
    """LLM 응답을 파싱하고 Pydantic으로 검증"""
    try:
        data = json.loads(raw_json)
        return RestaurantResponse(**data)
    except json.JSONDecodeError as e:
        raise ValueError(f"Invalid JSON: {e}")
    except Exception as e:
        raise ValueError(f"Schema validation failed: {e}")

재시도 패턴 (Self-Healing)

from tenacity import retry, stop_after_attempt, retry_if_exception_type

class StructuredOutputParser:
    def __init__(self, client, model: str, schema: type[BaseModel]):
        self.client = client
        self.model = model
        self.schema = schema

    @retry(
        stop=stop_after_attempt(3),
        retry=retry_if_exception_type(ValueError)
    )
    def parse(self, prompt: str) -> BaseModel:
        """스키마 검증 실패 시 에러 메시지를 포함하여 재시도"""
        schema_json = self.schema.model_json_schema()

        messages = [
            {
                "role": "system",
                "content": f"다음 JSON 스키마에 맞게 응답하세요:\n{json.dumps(schema_json, indent=2)}"
            },
            {"role": "user", "content": prompt}
        ]

        # 이전 시도의 에러가 있으면 포함
        if hasattr(self, '_last_error'):
            messages.append({
                "role": "user",
                "content": f"이전 응답에서 에러가 발생했습니다: {self._last_error}\n올바른 JSON으로 다시 응답해주세요."
            })

        response = self.client.chat.completions.create(
            model=self.model,
            messages=messages,
            response_format={"type": "json_object"}
        )

        raw = response.choices[0].message.content
        try:
            data = json.loads(raw)
            result = self.schema(**data)
            if hasattr(self, '_last_error'):
                del self._last_error
            return result
        except Exception as e:
            self._last_error = str(e)
            raise ValueError(str(e))

# 사용
parser = StructuredOutputParser(client, "gpt-4o", RestaurantResponse)
result = parser.parse("서울 한식 맛집 3곳 추천")

LiteLLM으로 프로바이더 통합

import litellm
from pydantic import BaseModel

class ExtractedInfo(BaseModel):
    summary: str
    key_points: list[str]
    sentiment: str
    confidence: float

# OpenAI
response = litellm.completion(
    model="gpt-4o",
    messages=[{"role": "user", "content": "Kubernetes 1.35 릴리스 요약해줘"}],
    response_format=ExtractedInfo
)

# Anthropic (자동으로 Tool Use 변환)
response = litellm.completion(
    model="claude-sonnet-4-20250514",
    messages=[{"role": "user", "content": "Kubernetes 1.35 릴리스 요약해줘"}],
    response_format=ExtractedInfo
)

# Gemini
response = litellm.completion(
    model="gemini/gemini-2.0-flash",
    messages=[{"role": "user", "content": "Kubernetes 1.35 릴리스 요약해줘"}],
    response_format=ExtractedInfo
)

# 동일한 코드로 3개 프로바이더 사용 가능!

Instructor 라이브러리 활용

# pip install instructor
import instructor
from openai import OpenAI
from pydantic import BaseModel
from typing import List

client = instructor.from_openai(OpenAI())

class Step(BaseModel):
    explanation: str
    output: str

class MathSolution(BaseModel):
    steps: List[Step]
    final_answer: str
    confidence: float

# Pydantic 모델을 직접 response_model로 사용
solution = client.chat.completions.create(
    model="gpt-4o",
    response_model=MathSolution,
    messages=[
        {"role": "user", "content": "2x + 5 = 15를 풀어줘"}
    ],
    max_retries=3  # 자동 재시도
)

print(solution.steps[0].explanation)
print(f"답: {solution.final_answer}")

# Anthropic도 동일하게 지원
import anthropic

anthropic_client = instructor.from_anthropic(anthropic.Anthropic())

solution = anthropic_client.messages.create(
    model="claude-sonnet-4-20250514",
    response_model=MathSolution,
    max_tokens=1024,
    messages=[
        {"role": "user", "content": "3x - 7 = 20을 풀어줘"}
    ]
)

프로덕션 파이프라인 구축

FastAPI + Structured Output

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List
import instructor
from openai import OpenAI

app = FastAPI()
client = instructor.from_openai(OpenAI())

class ProductReview(BaseModel):
    sentiment: str  # positive, negative, neutral
    score: float
    key_phrases: List[str]
    summary: str
    language: str

class ReviewRequest(BaseModel):
    text: str
    model: str = "gpt-4o-mini"

@app.post("/analyze", response_model=ProductReview)
async def analyze_review(request: ReviewRequest):
    try:
        result = client.chat.completions.create(
            model=request.model,
            response_model=ProductReview,
            messages=[
                {
                    "role": "system",
                    "content": "제품 리뷰를 분석하세요."
                },
                {"role": "user", "content": request.text}
            ],
            max_retries=2
        )
        return result
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

배치 처리 파이프라인

import asyncio
from typing import List
from openai import AsyncOpenAI
import instructor

async_client = instructor.from_openai(AsyncOpenAI())

class ExtractedEntity(BaseModel):
    name: str
    entity_type: str
    confidence: float

class EntityExtractionResult(BaseModel):
    entities: List[ExtractedEntity]
    text_length: int

async def extract_entities(text: str) -> EntityExtractionResult:
    return await async_client.chat.completions.create(
        model="gpt-4o-mini",
        response_model=EntityExtractionResult,
        messages=[
            {"role": "system", "content": "텍스트에서 엔티티를 추출하세요."},
            {"role": "user", "content": text}
        ]
    )

async def batch_extract(texts: List[str], concurrency: int = 5):
    """동시성 제한으로 배치 처리"""
    semaphore = asyncio.Semaphore(concurrency)

    async def limited_extract(text):
        async with semaphore:
            return await extract_entities(text)

    tasks = [limited_extract(text) for text in texts]
    results = await asyncio.gather(*tasks, return_exceptions=True)

    successes = [r for r in results if not isinstance(r, Exception)]
    failures = [r for r in results if isinstance(r, Exception)]

    print(f"성공: {len(successes)}, 실패: {len(failures)}")
    return successes

# 실행
texts = ["서울에 새로운 AI 스타트업이...", "삼성전자가 반도체...", ...]
results = asyncio.run(batch_extract(texts))

실행하면 실제로 무슨 일이 벌어지나

스키마를 붙인 요청은 평범한 요청과 다르게 동작합니다. 이 차이를 모르면 정상 동작을 장애로 오해하고, 진짜 장애를 정상으로 넘깁니다.

첫 요청은 느리다

두 프로바이더가 같은 이야기를 합니다. OpenAI 문서의 표현은 어떤 스키마든 첫 요청은 API가 스키마를 처리하는 동안 추가 지연이 발생하지만 이후 요청에는 없다는 것입니다. Anthropic도 첫 요청에서 문법 컴파일 지연이 있다고 안내합니다. 컴파일된 문법은 마지막 사용 시점부터 24시간 캐시되고, 스키마 구조나 tool 집합이 바뀌면 무효화되지만 name이나 description만 고쳤을 때는 무효화되지 않습니다.

지연 시간을 측정할 때 첫 호출은 버려야 합니다. 그러지 않으면 p99가 스키마 컴파일 시간을 그대로 반영합니다.

응답에서 반드시 봐야 하는 필드

Anthropic에서 stop_reasonrefusal이면 출력이 스키마와 맞지 않을 수 있습니다. max_tokens면 JSON이 잘렸다는 뜻이고 파싱이 반드시 실패합니다. 답은 재시도가 아니라 max_tokens를 올리는 것입니다. 같은 프롬프트를 다시 던지면 같은 길이에서 또 잘립니다.

OpenAI Chat Completions에서는 finish_reasonstop, length, content_filter, tool_calls 중 하나로 옵니다. 어시스턴트 메시지에는 문자열이거나 null인 refusal 필드가 따로 있습니다. parse() 헬퍼는 길이 초과면 LengthFinishReasonError를, 콘텐츠 필터면 ContentFilterFinishReasonError를 던집니다.

조용한 함정은 Responses API입니다. 여기엔 finish_reason이 없습니다. 대신 response.statusincomplete인지, response.incomplete_details.reasonmax_output_tokens인지 봐야 합니다. Chat Completions 시절 코드를 그대로 옮기면 그 분기는 영원히 참이 되지 않습니다.

# 같은 사고를 세 경로에서 각각 잡는 법

# Chat Completions
if completion.choices[0].finish_reason == "length":
    raise RuntimeError("출력이 잘렸다 — max_tokens를 올려야 한다")

refusal = completion.choices[0].message.refusal
if refusal:
    raise RuntimeError(f"모델이 거부했다: {refusal}")

# Responses API — finish_reason이 아예 없다
if response.status == "incomplete":
    if response.incomplete_details.reason == "max_output_tokens":
        raise RuntimeError("출력이 잘렸다 — max_output_tokens를 올려야 한다")

# Anthropic
if message.stop_reason == "max_tokens":
    raise RuntimeError("JSON이 잘렸다 — max_tokens를 올려야 한다")
if message.stop_reason == "refusal":
    raise RuntimeError("거부 응답 — 출력이 스키마와 맞지 않을 수 있다")

엔드투엔드 예제: 지원 티켓 분류기

조각을 하나로 붙여 보겠습니다. 지원 티켓을 받아 카테고리, 심각도, 한 줄 요약, 사람이 봐야 하는지 여부를 뽑는 분류기입니다. 앞의 두 필드는 값 집합이 고정돼 있고 다운스트림에서 그 값으로 라우팅을 하기 때문에, 오타 하나가 티켓을 통째로 잃어버리게 만듭니다.

1. 스키마 정의

from typing import Literal, Optional
from pydantic import BaseModel, Field

class TicketTriage(BaseModel):
    category: Literal["billing", "bug", "feature_request", "account", "other"]
    severity: Literal["p0", "p1", "p2", "p3"]
    summary: str = Field(description="한 문장 요약, 60자 이내")
    needs_human: bool
    suggested_owner: Optional[str] = None

Literal을 쓴 두 필드가 핵심입니다. JSON Schema로 내려갈 때 enum이 되고, 제약 디코딩이 그 값 외에는 생성하지 못하게 막습니다. 프롬프트는 부탁이고 enum은 강제입니다.

2. 호출

from anthropic import Anthropic

client = Anthropic()

TICKET = """
결제가 두 번 청구됐습니다. 카드 명세서에 8월 3일자로 같은 금액이
두 건 찍혀 있어요. 주문번호는 A-91823입니다. 환불 부탁드립니다.
"""

response = client.messages.parse(
    model="claude-sonnet-5",
    max_tokens=2000,
    messages=[
        {"role": "user", "content": f"다음 지원 티켓을 분류하세요.\n\n{TICKET}"}
    ],
    output_format=TicketTriage,
)

if response.stop_reason == "max_tokens":
    raise RuntimeError("잘림 — max_tokens를 올려라")

triage = response.parsed_output
print(triage.category, triage.severity, triage.needs_human)

3. 돌아오는 것

response.parsed_output은 이미 검증을 통과한 TicketTriage 인스턴스입니다. 직렬화하면 이런 모양입니다.

{
  "category": "billing",
  "severity": "p1",
  "summary": "8월 3일 동일 금액 이중 청구, 주문번호 A-91823, 환불 요청",
  "needs_human": true,
  "suggested_owner": "billing-ops"
}

category가 정의한 다섯 값 안에 있는 것은 제약 디코딩이 보장합니다. severity가 합리적인지는 보장되지 않습니다. 형식만 보장될 뿐 판단의 품질은 별개입니다.

suggested_owner는 프로바이더별로 갈립니다. 같은 TicketTriage를 OpenAI에 넘기면 strict 규칙 때문에 이 필드도 required에 들어가고, 대신 타입이 문자열과 null을 함께 허용하는 형태가 됩니다. OpenAI에서는 키가 항상 있고 값이 null일 수 있으며, Anthropic에서는 키 자체가 빠질 수 있습니다. 딕셔너리 대괄호로 읽으면 한쪽에서만 KeyError가 납니다. Pydantic 인스턴스로 받으면 양쪽 다 None으로 정규화됩니다.

4. 같은 스키마를 OpenAI Responses에서

from openai import OpenAI

client = OpenAI()

response = client.responses.parse(
    model="gpt-4o-mini",
    input=[{"role": "user", "content": f"다음 지원 티켓을 분류하세요.\n\n{TICKET}"}],
    text_format=TicketTriage,
)

if response.status == "incomplete":
    if response.incomplete_details.reason == "max_output_tokens":
        raise RuntimeError("잘림 — max_output_tokens를 올려라")

바뀐 것은 클라이언트, 인자 이름(messagesinput, output_formattext_format), 잘림을 감지하는 방법뿐입니다. TicketTriage 정의는 한 글자도 건드리지 않았습니다. 스키마를 Pydantic 모델 하나로 유지하고 호출부만 얇게 감싸면 프로바이더 교체 비용이 이 정도로 줄어듭니다.

스키마 제약 — 프로바이더마다 지원 범위가 다르다

JSON Schema는 표준이지만 두 프로바이더가 지원하는 부분집합이 서로 다르고, 겹치지도 않습니다. enumanyOf는 양쪽 다 됩니다. 나머지가 문제입니다.

기능OpenAIAnthropic
allOf미지원지원(내부 참조와 함께는 불가)
minimum / maximum지원미지원
multipleOf지원미지원
minLength / maxLength미지원미지원
minItems지원0 또는 1만 허용
maxItems지원지원 목록에 없음
additionalPropertiesfalse 필수false 외의 값 미지원

Anthropic은 문자열 format으로 date-time, date, duration, email, hostname, uri, ipv4, ipv6, uuid를 지원하고 constdefault도 됩니다. 내부 참조는 되지만 외부 참조는 안 되고 재귀 스키마도 지원하지 않습니다. 트리처럼 자기 자신을 참조하는 모델은 다른 방법을 찾아야 합니다. OpenAI 쪽에는 명시된 상한이 있습니다. 객체 속성 전체 5000개, 중첩 10단계, 문자열 길이 총합 120,000자, enum 값은 전부 합쳐 1000개입니다.

결론은 비대칭입니다. OpenAI는 숫자 제약을 지원하지만 문자열 길이 제약은 지원하지 않고, Anthropic은 둘 다 지원하지 않습니다. 한쪽에서 멀쩡히 통과하던 스키마가 다른 쪽에서는 400으로 거절될 수 있습니다. 멀티 프로바이더라면 스키마를 두 범위의 교집합으로 좁히고, 길이나 범위 검증은 Pydantic 단계에서 거는 편이 이식성이 좋습니다.

실패 사례 — 증상에서 원인으로

로그에 먼저 뜨는 것이 증상이고, 원인은 항상 그 뒤에 있습니다.

워밍업을 했는데도 지연이 계속 튄다

캐시가 매번 무효화되는 것입니다. 요청마다 스키마 딕셔너리를 새로 만들면서 키 순서가 달라지거나, 사용자 입력에 따라 enum 목록을 동적으로 채우는 코드가 대표적입니다. 스키마는 시작 시 한 번 만들어 상수로 들고 있어야 합니다.

400 — additionalProperties 관련 에러

OpenAI에서 strict를 켰는데 additionalProperties가 없거나 true인 경우입니다. false로, 그것도 중첩된 모든 객체에 각각 명시해야 하고 모든 속성이 required에 들어가야 합니다. Anthropic에서 잘 돌던 스키마를 그대로 가져왔을 때 가장 먼저 터지는 지점입니다.

400 — citations와 함께 쓸 때

Anthropic에서 구조화 출력과 citations는 함께 쓸 수 없고 400이 납니다. 근거 추적이 필요한 단계와 구조화가 필요한 단계를 한 호출로 합치려는 시도가 문제의 시작입니다.

JSON 파싱이 간헐적으로 실패한다

입력이 길거나 결과 배열이 클 때만 실패한다면 거의 확실히 잘림입니다. 앞 절의 세 가지 신호를 순서대로 확인하고, 출력 토큰 상한을 올린 뒤에도 모자라면 한 번에 뽑는 항목 수를 줄여 배치를 쪼갭니다. 반대로 파싱은 되는데 값이 이상하다면 stop_reasonrefusal인 경우를 놓친 것입니다.

LiteLLM로 바꿨더니 검증이 사라졌다

Instructor와 LiteLLM은 비슷해 보이지만 돌려주는 것이 다릅니다. Instructor는 검증까지 끝낸 객체를 줍니다. LiteLLM의 completionresponse_format으로 Pydantic 모델을 넘기면 통과는 되지만 돌아오는 것은 JSON 텍스트이고, 검증은 직접 해야 합니다.

from litellm import completion, get_supported_openai_params, supports_response_schema

resp = completion(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "..."}],
    response_format=EventsList,
)

# 여기까지는 문자열이다. 검증은 내 몫.
events = EventsList.model_validate_json(resp.choices[0].message.content)

이 차이를 모르고 갈아타면 검증 단계가 통째로 사라진 채 프로덕션에 나갑니다. 타입 힌트는 그대로라 정적 분석에도 걸리지 않습니다. LiteLLM에는 get_supported_openai_paramssupports_response_schema 헬퍼가 있으니 시작 시점에 한 번 확인해 두세요.

Instructor의 현재 권장 진입점은 instructor.from_provider입니다. from_openaifrom_litellm도 그대로 있고, from_anthropic이나 from_genai는 해당 패키지가 설치돼 있을 때 씁니다. instructor.patch는 남아 있고 deprecated도 아니지만, 문서는 수동 패치를 권하지 않습니다.

언제 스키마를 쓰지 않나

스키마는 공짜가 아닙니다. 안 맞는 자리에 억지로 넣으면 출력 품질이 떨어지고 디버깅이 어려워집니다.

자유로운 생성이 목적일 때. 글이나 요약처럼 결과물 자체가 산문인 작업에 스키마를 씌우면 모델이 형식을 맞추느라 내용에 쓸 여유를 잃습니다. 산문 필드 하나를 감싸는 스키마는 대부분 껍데기입니다.

카디널리티가 높은 추출일 때. 값의 종류가 수백 수천 가지인 필드를 enum으로 고정하려는 시도는 대개 실패합니다. 목록이 길어질수록 스키마가 프롬프트를 밀어냅니다. 자유 문자열로 뽑고 뒤에서 사전 대조나 임베딩 매칭으로 정규화하는 편이 정확합니다.

평범한 프롬프트에 재시도 한 번이 더 싼 경우. 하루 몇 번 도는 스크립트나 사람이 눈으로 확인하는 분석이라면 스키마 설계와 유지 비용이 얻는 것보다 큽니다. 스키마가 값을 하는 것은 결과가 사람을 거치지 않고 바로 다음 시스템으로 흘러갈 때입니다.

스키마가 작업과 싸울 때. 모델이 알 수 없는 정보를 필수 필드로 강제하면 빈칸을 비워 두지 못하고 그럴듯한 값을 지어냅니다. 제약 디코딩은 문법에 맞는 토큰만 통과시키므로 모르는 필드에도 형식만 맞는 답을 반드시 만들어 냅니다. 스키마가 만든 환각입니다. 확실하지 않은 필드는 선택 필드로 두거나 신뢰도 필드를 함께 둬야 합니다.

중첩이 깊을 때. 10단계 제한에 근접하는 스키마는 대체로 설계가 잘못된 것입니다. 단계를 나눠 각 호출이 얕은 스키마를 다루게 하는 편이 정확도와 디버깅 양쪽에서 낫습니다.

마무리

Structured Output은 LLM을 프로덕션 시스템에 통합하는 핵심 기술입니다:

  1. OpenAI: response_format + Structured Outputs로 스키마 100% 보장
  2. Anthropic: Tool Use를 활용한 간접적 방식이지만 안정적
  3. Instructor/LiteLLM: 프로바이더 통합으로 코드 재사용
  4. Pydantic: 스키마 정의 + 검증의 표준
  5. 재시도 패턴: Self-healing으로 안정성 확보

덧붙이면, 이 분야는 여섯 달이면 문서가 바뀝니다. 이 글의 앞부분도 다섯 달 만에 두 프로바이더에서 권장 경로가 달라졌습니다.

참고 자료

이 글을 고쳐 쓰면서 확인한 문서들입니다. 확인 날짜에서 멀어질수록 원문을 먼저 보세요.

설명한 버전은 openai 3.1.0, instructor 1.15.4, litellm 1.96.2, google-genai입니다. 모델 ID는 claude-opus-5, claude-sonnet-5, claude-haiku-4-5, claude-fable-5를 썼습니다. 여기 없는 메서드나 필드는 추측하지 말고, 정확한 API는 사용 중인 버전의 문서에서 확인하세요.


퀴즈

📝 퀴즈 (6문제)

Q1. OpenAI의 JSON Mode와 Structured Outputs의 차이는? JSON Mode는 유효한 JSON만 보장, Structured Outputs는 지정한 스키마까지 보장

Q2. Anthropic에서 Structured Output을 구현하는 방식은? Tool Use (Function Calling)를 활용하여 input_schema로 구조화된 출력을 받음

Q3. Pydantic의 Field(ge=0.0, le=5.0)은 무엇을 의미하는가? 값이 0.0 이상 5.0 이하여야 한다는 검증 조건

Q4. instructor 라이브러리의 max_retries 기능은 무엇인가? 스키마 검증 실패 시 자동으로 재시도하여 올바른 형식을 얻음

Q5. 배치 처리에서 asyncio.Semaphore의 역할은? 동시 API 호출 수를 제한하여 rate limit 초과를 방지

Q6. LiteLLM을 사용하면 얻는 가장 큰 이점은? 동일한 코드로 OpenAI, Anthropic, Gemini 등 여러 프로바이더를 전환 가능