테마 전환

LLM 구조화 출력: JSON Schema 강제와 도구 호출 안정성 확보

Easton editorial illustration: central JSON Schema gate, three incoming provider-output cards, one validated tool-call object

프로덕션 환경에서 알림이 울렸습니다. Agent 도구 호출이 실패했고, 5번 연속 재시도했지만 전부 매개변수 형식 오류였습니다. 로그를 열어 보니 city 필드는 원래 "北京"이어야 했는데, LLM은 {"name": "北京", "id": null}을 반환했습니다. 파서가 무너지고 전체 데이터 처리 파이프라인이 멈췄습니다.

작년에 제대로 밟았던 큰 함정이었습니다.

그 뒤로 LLM의 구조화 출력 문제를 체계적으로 파고들기 시작했습니다. OpenAI의 Structured Outputs부터 Anthropic의 Tool Use, Instructor의 자동 재시도, Outlines의 제약 디코딩까지 살펴봤습니다. 처음에는 그저 “프롬프트를 더 잘 쓰면 해결되는” 문제라고 생각했지만, 나중에야 알았습니다. 이건 프롬프트 문제가 아니라 신뢰성 아키텍처의 문제였습니다.

이 글에서는 제가 정리한 “3계층 신뢰성 보장 아키텍처”를 공유합니다. 매개변수 검증 계층, 실패 재시도 계층, 제약 디코딩 계층입니다. 글 마지막에서는 OpenAI, Claude, Gemini 세 회사의 방식을 가로로 비교하고, 어떤 상황에서 무엇을 선택해야 하는지도 정리합니다. 바로 가져다 쓸 수 있는 프로덕션급 코드 템플릿도 몇 개 넣었습니다.

1. 구조화 출력이 Agent의 기반인 이유

먼저 제가 겪었던 “형식 드리프트” 문제부터 이야기해 보겠습니다. 이는 개별 사례가 아니라 Agent 개발을 하는 사람이라면 누구나 마주치는 악몽입니다.

형식 드리프트의 세 가지 모습

첫 번째: 필드 누락. LLM에게 name, age, email을 포함한 사용자 정보 객체를 반환하라고 했는데, {"name": "张三"}만 돌려줍니다. 뒤의 두 필드는 사라졌습니다. 매번 빠지는 것은 아니고 가끔 빠집니다. 하지만 프로덕션 환경에서 “가끔”은 곧 “반드시”입니다.

두 번째: 타입 오류. 문서에는 분명히 user_id가 정수라고 적혀 있습니다. 그런데 LLM은 "user_id": "12345"처럼 문자열로 반환합니다. Python의 Pydantic 검증은 바로 오류를 내고, 전체 호출 체인이 끊깁니다.

세 번째: 불필요한 내용. 가장 은밀한 형태입니다. JSON만 반환하라고 했는데 앞에는 “Here is the response:“를 붙이고, 뒤에는 “I hope this helps!”를 붙입니다. JSON 파서는 이런 문장을 보는 순간 그대로 막힙니다.

5-10%
JSON Mode 실패율
<0.1%
Structured Outputs 실패율

OpenAI의 공식 데이터는 문제를 잘 보여 줍니다. JSON Mode, 즉 합법적인 JSON 반환만 보장하는 방식의 실패율은 5-10%입니다. 반면 Structured Outputs, 즉 Schema 준수를 강제하는 방식의 실패율은 0.1% 미만입니다. 두 자릿수 정도의 차이가 납니다.

왜 이 문제가 이렇게 중요한가

이렇게 생각할 수도 있습니다. “파싱 실패일 뿐 아닌가? 재시도를 몇 번 더 넣으면 되지.”

문제는 재시도가 공짜가 아니라는 점입니다.

API 호출 비용. GPT-4 호출 한 번에 몇십 원이 들 수 있고, 5번 재시도하면 몇백 원이 됩니다. Agent가 하루에 10만 건의 요청을 처리하고, 요청마다 평균 2번 재시도한다면 이 비용은 직접 계산해 보면 됩니다.

지연 누적. 호출 한 번이 2초이고 3번 재시도하면 사용자는 6초 이상 기다려야 합니다. 실시간 대화 시나리오에서는 받아들이기 어렵습니다.

사용자 경험 붕괴. 사용자가 날씨를 물었는데 Agent가 멈춰서 10초 동안 로딩만 돌다가 결국 “시스템 오류”를 반환합니다. 다음에는 다시 오지 않을 가능성이 큽니다.

그래서 구조화 출력은 “있으면 좋은 기능”이 아니라 Agent가 안정적으로 동작할 수 있는지의 기반입니다. 이제 이 문제를 어떻게 해결할지 이야기해 보겠습니다. 답은 “프롬프트를 더 잘 쓰는 것”이 아니라 신뢰할 수 있는 아키텍처를 세우는 것입니다.

2. 3계층 신뢰성 보장 아키텍처

이 아키텍처는 제가 여러 시행착오를 거친 뒤 정리한 것입니다. 은탄환은 아니지만, 형식 오류를 만날 확률을 5-10%에서 거의 0에 가깝게 낮출 수 있습니다.

L1: 매개변수 검증 계층, 첫 번째 방어선

이 계층이 하는 일은 간단합니다. Pydantic으로 기대하는 데이터 구조를 정의하고, 타입 변환과 화이트리스트 필터링을 강제합니다.

from pydantic import BaseModel, Field, field_validator
from typing import Optional, List
from datetime import datetime

class ToolCallParams(BaseModel):
    """도구 호출 매개변수 모델"""
    city: str = Field(..., min_length=1, max_length=50, description="도시 이름")
    date: Optional[datetime] = Field(None, description="조회 날짜")
    units: str = Field("metric", pattern="^(metric|imperial)$")

    @field_validator("city")
    @classmethod
    def validate_city(cls, v: str) -> str:
        # 화이트리스트 검증
        allowed_cities = {"北京", "上海", "广州", "深圳", "杭州"}
        if v not in allowed_cities:
            raise ValueError(f"지원하지 않는 도시: {v}, 현재 지원: {allowed_cities}")
        return v

Pydantic은 세 가지 일을 도와줍니다. 타입 강제 변환, 예를 들어 문자열 “123”을 정수 123으로 변환하는 것, 필드 누락 감지, 사용자 정의 검증입니다. 가장 기본적이면서도 가장 중요한 계층입니다.

L2: 실패 재시도 계층, 피드백을 통한 자기 수정

LLM이 반환한 데이터 검증에 실패했을 때 단순히 다시 시도하지 않습니다. 오류 정보를 다시 전달해 스스로 고치게 합니다. Instructor 라이브러리는 이 로직을 잘 감싸 둔 도구입니다.

import instructor
from openai import OpenAI
from pydantic import ValidationError

client = instructor.patch(OpenAI())

def get_weather_with_retry(user_query: str, max_retries: int = 3):
    """오류 피드백이 포함된 재시도 메커니즘"""
    messages = [{"role": "user", "content": user_query}]

    for attempt in range(max_retries):
        try:
            response = client.chat.completions.create(
                model="gpt-4o",
                response_model=ToolCallParams,  # Pydantic 모델
                messages=messages,
                temperature=0.1  # 구조화 출력에는 낮은 온도 사용
            )
            return response  # 자동 검증 통과

        except ValidationError as e:
            # 오류를 LLM에 다시 전달해 수정하게 함
            error_msg = f"매개변수 검증 실패: {str(e)}\n수정한 뒤 올바른 JSON 형식으로 다시 반환하세요."
            messages.append({"role": "assistant", "content": "매개변수 생성 중..."})
            messages.append({"role": "user", "content": error_msg})

            if attempt == max_retries - 1:
                raise Exception(f"{max_retries}번 재시도 후에도 실패: {e}")

# 사용 예시
result = get_weather_with_retry("帮我查一下北京明天的天气")

핵심 아이디어는 이렇습니다. LLM은 무작정 찍는 것이 아니라 어디가 왜 틀렸는지 알 수 있습니다. 피드백을 주면 고칠 수 있습니다. 제가 실제로 테스트했을 때 이 피드백 메커니즘을 추가한 뒤 재시도 성공률은 60%에서 95% 이상으로 올라갔습니다.

L3: 제약 디코딩 계층, 근원에서 오류 차단

앞의 두 계층이 “사후 보정”이라면, L3는 “사전 예방”입니다.

제약 디코딩의 원리는 이렇습니다. LLM이 각 token을 생성할 때 유한 상태 기계(FSM)로 선택 가능한 범위를 제한해, Schema에 맞는 token 시퀀스만 생성하도록 강제합니다. 비유하자면 LLM에 “브레이크”를 달아 둔 셈이라, 마음대로 엉뚱한 출력을 하려 해도 할 수 없습니다.

구현 방식에는 두 가지 주류 선택지가 있습니다.

Outlines(오픈소스 방식, 로컬 모델에 적합):

from outlines import models, generate
import json

# 로컬 모델 로드
model = models.transformers("Qwen/Qwen2.5-7B-Instruct")

# Schema 정의
schema = {
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "age": {"type": "integer"}
    },
    "required": ["name", "age"]
}

# 제약 생성기 생성
generator = generate.json(model, schema)
result = generator("提取用户信息: 张三今年28岁")
# 100% Schema를 준수하므로 재시도가 필요 없음

vLLM의 guided_json(대형 모델 배포에 적합):

from vllm import LLM, SamplingParams

llm = LLM(model="Qwen/Qwen2.5-72B-Instruct")
sampling_params = SamplingParams(
    temperature=0.0,
    guided_decoding_backend="outlines",
    guided_json={  # JSON Schema를 직접 전달
        "type": "object",
        "properties": {
            "tool_name": {"type": "string"},
            "arguments": {"type": "object"}
        }
    }
)

L3의 비용은 추가 컴파일 오버헤드입니다. FSM을 Schema에 따라 미리 구성해야 하기 때문입니다. Schema가 자주 바뀐다면 매번 FSM을 다시 만들 때 지연이 생깁니다. 하지만 대부분의 Agent 애플리케이션에서 Schema는 비교적 안정적이므로 이 비용은 받아들일 만합니다.

세 계층은 어떻게 선택할까

시나리오추천 방식
OpenAI API 호출L1 + L2(Pydantic + Instructor)
Claude API 호출L1 + L2(Claude는 Strict Mode를 지원하지 않음)
로컬 모델 배포L1 + L3(Outlines/vLLM guided_json)
신뢰성 요구가 매우 높음L1 + L2 + L3 모두 적용

3. 벤더별 비교: OpenAI, Claude, Gemini는 어떻게 고를까

이번 장에서는 각 벤더의 구현 차이를 살펴보겠습니다. 솔직히 가로 비교를 하지 않으면 쉽게 함정에 빠집니다. 벤더마다 “구조화 출력”이라는 개념과 구현 방식이 꽤 다르기 때문입니다.

OpenAI: Strict Mode로 강제 준수

OpenAI는 2024년 8월 Structured Outputs 기능을 출시했습니다. 현재 상용 API 중 가장 신뢰할 수 있는 방식입니다.

핵심 메커니즘은 strict: true 매개변수입니다. 이 옵션을 켜면 LLM의 출력이 사용자가 정의한 JSON Schema로 강제 제약되어 100% 준수를 보장합니다. 내부적으로는 Grammar-based Constrained Decoding 기반의 제약 디코딩 기술을 사용하며, 원리는 Outlines와 비슷합니다.

from openai import OpenAI

client = OpenAI()

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "提取用户信息"}],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "user_info",
            "strict": True,  # 핵심 매개변수
            "schema": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "age": {"type": "integer"}
                },
                "required": ["name", "age"]
            }
        }
    }
)
# 출력은 100% Schema를 준수함

OpenAI 공식 데이터에 따르면 Strict Mode의 실패율은 0.1% 미만입니다. 제가 써 봤을 때도 실제로 형식 오류를 만나지 않았습니다. 다만 제한은 있습니다. 재귀 Schema는 지원하지 않고, 일부 복잡한 중첩 구조는 우회 처리가 필요합니다.

Anthropic Claude: Tool Use, 준수는 보장하지 않음

Claude의 구조화 출력은 다른 길을 택합니다. Tool Use, 즉 도구 호출입니다.

도구를 정의하면 Claude가 그것을 호출하면서 매개변수를 전달합니다. 하지만 여기에는 함정이 있습니다. Claude의 strict 매개변수는 설정할 수 있지만, 공식 문서에는 명확히 적혀 있습니다. 이 매개변수는 무시됩니다. Claude는 매개변수가 반드시 사용자가 정의한 Schema를 따른다고 보장하지 않습니다.

Anthropic 공식 문서의 원문은 이렇습니다(2026년 4월 업데이트).

“The strict parameter is currently ignored for tool definitions. Claude will make a best effort to provide valid arguments, but does not guarantee schema compliance.”

풀어 말하면, 최선을 다하겠지만 보장하지는 않는다는 뜻입니다. 그래서 Claude로 도구 호출을 할 때는 반드시 L1(매개변수 검증)과 L2(실패 재시도)를 추가해야 합니다.

import anthropic

client = anthropic.Anthropic()

# Claude의 도구 정의
tools = [{
    "name": "get_weather",
    "input_schema": {
        "type": "object",
        "properties": {
            "city": {"type": "string"}
        },
        "required": ["city"]
    }
}]

response = client.messages.create(
    model="claude-3.5-sonnet",
    max_tokens=1024,
    tools=tools,
    messages=[{"role": "user", "content": "北京天气"}]
)

# 중요: tool_use의 매개변수를 반드시 수동 검증해야 함
for block in response.content:
    if block.type == "tool_use":
        # 여기서 Pydantic 검증 수행
        validated_params = ToolCallParams.model_validate(block.input)

Google Gemini: Controlled Generation

Gemini의 방식은 Controlled Generation이라고 부르며, response_schema 매개변수로 출력 구조를 지정합니다.

import google.generativeai as genai

model = genai.GenerativeModel('gemini-1.5-pro')

response = model.generate_content(
    "提取用户信息",
    generation_config={
        "response_mime_type": "application/json",
        "response_schema": {
            "type": "object",
            "properties": {
                "name": {"type": "string"},
                "age": {"type": "integer"}
            },
            "required": ["name", "age"]
        }
    }
)

Gemini의 신뢰성은 OpenAI와 Claude 사이쯤에 있습니다. 제약은 있지만 OpenAI처럼 “강제 준수”하는 수준은 아닙니다. 실제 테스트에서 실패율은 대략 1-2%였습니다. JSON Mode보다는 좋지만 Strict Mode 수준에는 미치지 못합니다.

오픈소스 모델: Outlines/vLLM에 의존

Qwen, Llama, Mistral 같은 오픈소스 모델 자체는 구조화 출력을 지원하지 않으므로 외부 도구의 도움이 필요합니다. 주류 방식은 앞에서 언급한 Outlines와 vLLM의 guided_json입니다.

재미있는 점은 오픈소스 모델에 Outlines를 붙이면 구조화 출력의 신뢰성이 오히려 일부 상용 API보다 높아질 수 있다는 것입니다. FSM은 하드 제약이기 때문에 “최선을 다하지만 보장하지는 않는다”는 상황이 없습니다.

선택 제안 요약표

요구사항추천 방식이유
순수 API 호출, 안정성 우선OpenAI + Structured Outputs0.1% 실패율, 가장 신뢰 가능
복잡한 추론 + 도구 호출 필요Claude + L1/L2 검증추론 능력은 강하지만 검증이 필요
프라이빗 모델 배포Qwen/Llama + Outlines비용 제어 가능, 신뢰성 높음
형식 요구가 매우 높음(금융, 의료)OpenAI Strict 또는 Outlines둘 다 거의 0에 가까운 실패율 가능
빠른 프로토타입 검증Instructor + 임의 API래핑이 잘 되어 있고 자동 재시도 지원

4. 실전 코드 템플릿

이번 장에는 프로덕션급 코드 템플릿 몇 개를 넣었습니다. 모두 제가 프로덕션 환경에서 검증한 코드라 바로 가져다 써도 됩니다.

템플릿 1: OpenAI Structured Outputs 전체 예시

"""
OpenAI Structured Outputs 전체 예시
적합한 시나리오: 도구 호출, 데이터 추출, 보고서 생성 등
"""
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import List, Optional
import json

# 1. Pydantic 모델 정의
class SearchQuery(BaseModel):
    """검색 쿼리 매개변수"""
    keywords: List[str] = Field(
        ...,
        min_length=1,
        max_length=5,
        description="검색 키워드 목록"
    )
    filters: Optional[dict] = Field(
        default=None,
        description="선택적 필터 조건"
    )
    limit: int = Field(
        default=10,
        ge=1,
        le=100,
        description="반환할 결과 수"
    )

# 2. Pydantic 모델을 JSON Schema로 변환
def model_to_schema(model: type[BaseModel]) -> dict:
    """Pydantic 모델을 JSON Schema로 변환"""
    schema = model.model_json_schema()
    # Pydantic이 추가한 메타데이터 정리
    schema.pop("title", None)
    for prop in schema.get("properties", {}).values():
        prop.pop("title", None)
    return schema

# 3. 구조화 출력 호출
client = OpenAI()

def extract_search_params(user_input: str) -> SearchQuery:
    """사용자 입력에서 검색 매개변수 추출"""
    schema = model_to_schema(SearchQuery)

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {
                "role": "system",
                "content": "당신은 사용자가 검색 매개변수를 추출하도록 돕는 검색 어시스턴트입니다."
            },
            {"role": "user", "content": user_input}
        ],
        response_format={
            "type": "json_schema",
            "json_schema": {
                "name": "search_query",
                "strict": True,
                "schema": schema
            }
        },
        temperature=0.1  # 구조화 출력에는 낮은 온도 사용
    )

    # 4. 파싱 및 2차 검증
    raw_content = response.choices[0].message.content
    data = json.loads(raw_content)
    return SearchQuery.model_validate(data)

# 사용 예시
if __name__ == "__main__":
    query = extract_search_params(
        "我想找一些关于 Python 异步编程的文章,只要最近一个月的,最多 20 条"
    )
    print(query)
    # SearchQuery(keywords=['Python', '异步编程'], filters={'date_range': 'last_month'}, limit=20)

템플릿 2: Instructor 자동 재시도 예시

"""
Instructor 자동 재시도 예시
적합한 시나리오: Claude API, OpenAI JSON Mode(Strict 아님), 장애 허용이 필요한 경우
"""
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field, ValidationError

class AgentAction(BaseModel):
    """Agent 행동 결정"""
    action_type: str = Field(
        ...,
        pattern="^(search|execute|respond|clarify)$"
    )
    parameters: dict = Field(default_factory=dict)
    reasoning: str = Field(..., min_length=10)

# OpenAI client 패치
client = instructor.patch(OpenAI())

def get_agent_decision(
    context: str,
    user_request: str,
    max_retries: int = 3
) -> AgentAction:
    """
    Agent의 행동 결정을 가져오며 자동 재시도를 포함합니다

    Args:
        context: 현재 대화 컨텍스트
        user_request: 사용자 요청
        max_retries: 최대 재시도 횟수

    Returns:
        AgentAction: 검증된 행동 결정
    """
    messages = [
        {"role": "system", "content": "당신은 사용자의 요구를 분석하고 다음 행동을 결정하는 지능형 어시스턴트입니다."},
        {"role": "user", "content": f"컨텍스트: {context}\n\n사용자 요청: {user_request}"}
    ]

    try:
        response = client.chat.completions.create(
            model="gpt-4o",
            response_model=AgentAction,  # Instructor 자동 검증
            messages=messages,
            max_retries=max_retries,  # 내장 재시도
            temperature=0.1
        )
        return response

    except ValidationError as e:
        # Instructor가 이미 max_retries만큼 재시도한 상태
        raise Exception(f"형식 오류를 수정할 수 없습니다. 모델 정의를 확인하세요: {e}")

# 사용 예시
decision = get_agent_decision(
    context="사용자가 날씨 정보를 조회하고 있습니다",
    user_request="帮我查北京明天的天气,要是晴天就推荐户外活动"
)
print(f"행동 유형: {decision.action_type}")
print(f"매개변수: {decision.parameters}")
print(f"추론 과정: {decision.reasoning}")

템플릿 3: Outlines 로컬 모델 구조화 출력

"""
Outlines 로컬 모델 구조화 출력 예시
적합한 시나리오: 프라이빗 배포, 비용 민감, 높은 개인정보 요구
"""
from outlines import models, generate
from pydantic import BaseModel
from typing import List
import json

# 데이터 구조 정의
class ProductInfo(BaseModel):
    """상품 정보"""
    name: str
    price: float
    category: str
    tags: List[str]

# 모델 로드(첫 로드에는 몇 초 지연이 있을 수 있음)
model = models.transformers("Qwen/Qwen2.5-7B-Instruct")

# 구조화 생성기 생성
# 주의: schema는 최초 호출 시 FSM으로 컴파일되며 약 1-2초 오버헤드가 있습니다
schema_str = json.dumps(ProductInfo.model_json_schema())
generator = generate.json(model, schema_str)

def extract_product_info(description: str) -> ProductInfo:
    """
    상품 설명에서 구조화 정보를 추출합니다

    Args:
        description: 상품 설명 텍스트

    Returns:
        ProductInfo: 구조화된 상품 정보
    """
    prompt = f"다음 상품 설명에서 핵심 정보를 추출해 JSON 형식으로 반환하세요:\n{description}"

    # 생성 결과는 100% Schema를 준수함
    result = generator(prompt)

    # Pydantic 모델로 변환(2차 검증으로 더 안전하게)
    return ProductInfo.model_validate(result)

# 사용 예시
description = """
이 블루투스 이어폰은 최신 노이즈 캔슬링 기술을 사용하며 가격은 299위안입니다.
디지털 액세서리 카테고리에 속하고 운동, 출퇴근 등의 상황에 적합합니다.
"""
product = extract_product_info(description)
print(product)
# ProductInfo(name='블루투스 이어폰', price=299.0, category='디지털 액세서리', tags=['운동', '출퇴근'])

템플릿 4: 전체 도구 호출 흐름

"""
전체 도구 호출 매개변수 검증 흐름
포함 내용: Schema 정의 → LLM 호출 → 매개변수 검증 → 실패 재시도 → 도구 실행
"""
from openai import OpenAI
from pydantic import BaseModel, Field, field_validator, ValidationError
from typing import Callable, Dict, Any
import json

# 1. 도구 매개변수 모델 정의
class WeatherQueryParams(BaseModel):
    """날씨 조회 도구 매개변수"""
    city: str = Field(..., min_length=1, max_length=50)
    date_offset: int = Field(default=0, ge=-7, le=7, description="날짜 오프셋, 0은 오늘을 의미")

    @field_validator("city")
    @classmethod
    def validate_city(cls, v: str) -> str:
        allowed = {"北京", "上海", "广州", "深圳", "杭州", "成都", "武汉"}
        if v not in allowed:
            raise ValueError(f"지원하지 않는 도시입니다. 선택 가능: {allowed}")
        return v

# 2. 도구 호출 관리자
class ToolCallManager:
    """도구 호출의 전체 흐름 관리"""

    def __init__(self):
        self.client = OpenAI()
        self.tools: Dict[str, Callable] = {}

    def register_tool(self, name: str, func: Callable, param_model: type[BaseModel]):
        """도구 등록"""
        self.tools[name] = {
            "function": func,
            "param_model": param_model
        }

    def execute_with_retry(
        self,
        tool_name: str,
        user_request: str,
        max_retries: int = 3
    ) -> Any:
        """재시도를 포함해 도구 호출 실행"""

        tool_config = self.tools[tool_name]
        param_model = tool_config["param_model"]
        schema = param_model.model_json_schema()

        messages = [
            {"role": "system", "content": f"도구 '{tool_name}'의 호출 매개변수를 추출하세요"},
            {"role": "user", "content": user_request}
        ]

        for attempt in range(max_retries):
            try:
                # LLM을 호출해 매개변수 가져오기
                response = self.client.chat.completions.create(
                    model="gpt-4o",
                    messages=messages,
                    response_format={
                        "type": "json_schema",
                        "json_schema": {
                            "name": tool_name,
                            "strict": True,
                            "schema": schema
                        }
                    },
                    temperature=0.1
                )

                # 매개변수 검증
                params = param_model.model_validate_json(
                    response.choices[0].message.content
                )

                # 도구 실행
                return tool_config["function"](params)

            except ValidationError as e:
                # 오류를 피드백해 LLM이 수정하도록 함
                messages.append({
                    "role": "user",
                    "content": f"매개변수 검증 실패: {e}\n매개변수 형식을 수정하세요."
                })
                continue

        raise Exception(f"도구 호출 실패: {max_retries}번 재시도 후에도 검증을 통과하지 못했습니다")

# 3. 사용 예시
def get_weather(params: WeatherQueryParams) -> str:
    """날씨 조회 시뮬레이션"""
    # 여기에 실제 API 호출 로직이 들어갑니다
    return f"{params.city} 앞으로 {params.date_offset}일 동안 날씨가 맑습니다"

manager = ToolCallManager()
manager.register_tool("get_weather", get_weather, WeatherQueryParams)

result = manager.execute_with_retry(
    "get_weather",
    "帮我查一下北京明天的天气"
)
print(result)  # 北京 미래 1일 동안 날씨가 맑습니다

이 템플릿들은 가장 흔한 시나리오 몇 가지를 포괄합니다. 실제로 사용할 때는 요구사항에 맞게 조합하고 수정하면 됩니다.

5. 프로덕션 환경 모범 사례

코드는 다 썼더라도 프로덕션 환경에는 주의해야 할 세부 사항이 많습니다. 여기서는 제가 밟았던 몇 가지 함정과 대응 방법을 공유합니다.

Temperature 설정: 높게 잡지 말 것

구조화 출력 시나리오에서는 Temperature를 0.0-0.2 사이로 설정하는 것을 권장합니다. 이 범위는 OpenAI 공식 문서가 권장하는 값이며, 실제 테스트에서도 가장 안정적이었습니다.

온도가 높으면 무엇이 문제일까요? LLM이 더 “발산”하고 출력이 더 무작위적으로 변합니다. 무작위성은 구조화 출력의 적입니다. 필요한 것은 결정성이지 창의성이 아닙니다. 예전에 Temperature를 0.7로 설정했더니 형식 오류율이 15%까지 치솟았습니다. 나중에 0.1로 바꾸자 거의 다시는 문제가 발생하지 않았습니다.

재시도 전략: 모든 오류를 재시도해서는 안 됨

재시도하기 전에 먼저 오류 유형을 판단해야 합니다.

오류 유형재시도 여부이유
매개변수 형식 오류(필드 누락, 타입 오류)재시도 + 오류 피드백LLM이 스스로 수정할 수 있음
API 서비스 오류(429, 500)재시도 + 지연 백오프서버 측 일시 문제
비즈니스 검증 실패(도시가 화이트리스트에 없음)재시도하지 않고 바로 오류 반환사용자 확인 필요
도구 실행 실패(빈 결과 반환)재시도하지 않고 fallback으로 전환도구 자체의 문제

모든 오류를 무한 재시도하는 사람도 봤습니다. 결과적으로 도시명이 화이트리스트에 없었고, LLM은 10번을 추측해도 맞히지 못한 채 결국 타임아웃으로 무너졌습니다. 오류 유형을 구분해야 효율적으로 처리할 수 있습니다.

성능 오버헤드 비교

방식지연 증가비용 증가신뢰성
Prompt 제약(특수 매개변수 없음)+0ms+0%5-10% 실패
JSON Mode(OpenAI만 해당)+50ms+0%2-5% 실패
Structured Outputs(Strict)+100ms+0%<0.1% 실패
Instructor 재시도재시도 1회당 +200-500ms비용 × 재시도 횟수거의 0% 실패
Outlines FSM+1-2s(최초 컴파일)+0%100% 준수

선택할 때는 균형을 봐야 합니다. 극한의 안정성을 원한다면 Structured Outputs 또는 Outlines를 선택하세요. 빠른 프로토타입이 목표라면 Instructor 자동 재시도가 좋습니다. 예산이 제한되어 있다면 JSON Mode + 수동 검증도 그럭저럭 쓸 수 있습니다.

모니터링 지표: 반드시 봐야 할 세 가지

출시 후에는 다음 지표를 반드시 모니터링해야 합니다.

  1. 형식 실패율: 검증 실패 요청의 비율입니다. 1%를 넘으면 조사해야 합니다.
  2. 평균 재시도 횟수: 정상적으로는 0.5-1.5 사이여야 합니다. 2회를 넘으면 모델이나 Schema에 문제가 있다는 신호입니다.
  3. 평균 지연: 구조화 출력은 일반 출력보다 50-200ms 정도 더 걸릴 수 있지만, 허용 가능한 범위 안에 묶어야 합니다.

저는 Prometheus + Grafana로 모니터링하고 매주 한 번 리포트를 확인합니다. 한 번은 재시도 횟수가 갑자기 0.8에서 2.5로 뛰는 것을 발견했습니다. 조사해 보니 Schema를 바꿨는데 코드에는 동기화하지 않았던 문제였습니다. 다행히 모니터링 덕분에 빨리 발견할 수 있었습니다.

정리

길게 이야기했지만 핵심은 하나입니다. 2026년의 구조화 출력은 더 이상 어려운 문제가 아닙니다. 올바른 방법을 쓰기만 하면 됩니다.

3계층 아키텍처, 즉 매개변수 검증 + 실패 재시도 + 제약 디코딩은 “일단 돌아가는” 단계부터 “안정적으로 돌아가는” 단계까지 모든 시나리오를 포괄합니다. 벤더를 고를 때는 이것만 기억하면 됩니다. OpenAI Strict Mode가 가장 안정적이고, Claude는 직접 검증이 필요하며, 오픈소스 모델은 Outlines와 결합하면 오히려 신뢰성이 매우 높습니다.

코드 템플릿은 4장에 모두 넣어 두었습니다. 가져가서 조금만 고치면 바로 쓸 수 있습니다. Agent 개발을 막 시작했다면 Instructor부터 시작하는 것을 권합니다. 래핑이 잘 되어 있고 자동 재시도와 오류 피드백이 내장되어 있습니다. 익숙해진 뒤 100% 강제 준수가 필요한지에 따라 Outlines 도입을 검토하면 됩니다.

궁금한 점이 있다면 댓글을 남기거나 바로 이야기해 주세요. 이번 내용은 조금 깊은 편이지만, 여러분이 몇 가지 함정을 덜 밟는 데 도움이 되길 바랍니다.

OpenAI Structured Outputs 전체 흐름 구현하기

Pydantic 모델 정의부터 구조화 출력 호출까지의 전체 단계

⏱️ Estimated time: 15 min

  1. 1

    Step 1: Pydantic 데이터 모델 정의

    Pydantic 모델 클래스를 만들고 Field로 필드 제약을 정의합니다:

    • `Field(..., min_length=1, max_length=50)`으로 문자열 길이 범위를 정의합니다
    • `Field(default=10, ge=1, le=100)`으로 숫자 범위를 정의합니다
    • `@field_validator`로 사용자 정의 검증 로직을 추가합니다(예: 화이트리스트 필터링)
    • `Optional[T]`로 선택 필드를 정의합니다
  2. 2

    Step 2: Pydantic 모델을 JSON Schema로 변환

    `model.model_json_schema()` 메서드를 사용해 변환합니다:

    ```python
    schema = SearchQuery.model_json_schema()
    schema.pop("title", None) # Pydantic 메타데이터 정리
    ```

    Schema가 OpenAI Structured Outputs 요구사항을 충족하는지 확인합니다.
  3. 3

    Step 3: OpenAI API를 호출하고 Strict Mode 활성화

    API 요청에서 `response_format` 매개변수를 설정합니다:

    • `type: "json_schema"` — 구조화 출력 유형을 지정합니다
    • `strict: True` — 강제 준수 모드를 활성화합니다
    • `json_schema.name` — Schema 이름입니다(사용자 정의)
    • `json_schema.schema` — 이전 단계에서 변환한 JSON Schema입니다
  4. 4

    Step 4: 응답을 파싱하고 2차 검증 수행

    Strict Mode가 100% 준수를 보장하더라도 2차 검증을 권장합니다:

    • `json.loads()`로 응답 문자열을 파싱합니다
    • `model.model_validate(data)`로 Pydantic 검증을 수행합니다
    • `ValidationError` 예외를 잡고 경계 상황을 처리합니다
  5. 5

    Step 5: Temperature 매개변수 설정

    구조화 출력 시나리오에서는 낮은 온도를 설정합니다:

    ```python
    temperature=0.1 # 권장 0.0-0.2
    ```

    높은 온도로 인해 출력 무작위성이 증가하고 형식 안정성이 흔들리는 일을 피합니다.

FAQ

LLM 출력 JSON 형식이 틀리면 어떻게 해야 하나요?
3계층 신뢰성 보장 아키텍처를 사용합니다:

• L1 매개변수 검증 계층: Pydantic으로 데이터 모델을 정의하고 자동 타입 변환과 필드 검증을 수행합니다
• L2 실패 재시도 계층: Instructor 라이브러리로 자동 재시도하고 오류를 LLM에 피드백해 스스로 수정하게 합니다
• L3 제약 디코딩 계층: Outlines 또는 vLLM의 guided_json으로 생성 단계부터 준수를 보장합니다
OpenAI와 Claude의 구조화 출력은 무엇이 다른가요?
OpenAI Structured Outputs의 strict 모드는 100% 형식 준수를 보장하며 실패율은 &lt;0.1%입니다. Claude의 Tool Use는 준수를 보장하지 않고 strict 매개변수도 공식적으로 무시되므로 L1/L2 계층 검증을 직접 추가해야 합니다. 극한의 안정성을 원한다면 OpenAI를 선택하고, 복잡한 추론 능력이 필요하다면 Claude + 직접 검증 조합이 더 나은 선택입니다.
적절한 구조화 출력 방식을 어떻게 선택해야 하나요?
시나리오와 요구사항에 따라 선택합니다:

• **OpenAI API 호출**: Structured Outputs + Strict Mode(가장 안정적)
• **Claude API 호출**: Pydantic 검증 + Instructor 재시도(직접 검증 필요)
• **로컬 모델 배포**: Outlines 또는 vLLM guided_json(비용 제어 가능, 신뢰성 높음)
• **빠른 프로토타입 검증**: Instructor 라이브러리(잘 감싸져 있고 바로 사용 가능)
• **금융/의료 등 고요구 환경**: OpenAI Strict 또는 Outlines(거의 0에 가까운 실패율)
Temperature 매개변수는 어떻게 설정해야 하나요?
구조화 출력 시나리오에서는 Temperature를 0.0-0.2 사이로 설정하는 것을 권장합니다. 이는 OpenAI 공식 권장 범위이며, 실제 테스트에서도 가장 안정적이었습니다. 높은 온도는 출력 무작위성을 높여 형식 오류율을 올립니다. 제가 테스트했을 때 Temperature 0.7에서는 오류율이 15%에 달했지만, 0.1로 바꾸자 형식 문제가 거의 사라졌습니다.
구조화 출력은 성능 오버헤드를 얼마나 늘리나요?
방식마다 오버헤드 차이가 큽니다:

• **Prompt 제약**: 지연 +0ms, 실패율 5-10%
• **JSON Mode**: 지연 +50ms, 실패율 2-5%
• **Structured Outputs**: 지연 +100ms, 실패율 &lt;0.1%
• **Instructor 재시도**: 재시도 1회당 +200-500ms, 실패율 거의 0%
• **Outlines FSM**: 최초 컴파일 +1-2s, 100% 준수

신뢰성 요구와 예산 사이에서 균형을 잡아 선택하세요.
어떤 오류는 재시도해야 하고, 어떤 오류는 재시도하지 말아야 하나요?
재시도 전략은 오류 유형에 따라 구분해야 합니다:

**재시도해야 하는 오류**:
• 매개변수 형식 오류(필드 누락, 타입 오류) — LLM이 스스로 수정할 수 있습니다
• API 서비스 오류(429, 500) — 서버 측 일시 문제일 수 있습니다

**재시도하지 말아야 하는 오류**:
• 비즈니스 검증 실패(도시가 화이트리스트에 없음) — 사용자 확인이 필요합니다
• 도구 실행 실패(빈 결과 반환) — 도구 자체 문제이므로 fallback으로 전환해야 합니다

무한 재시도는 타임아웃을 부릅니다. 오류 유형을 구분해야 효율적으로 처리할 수 있습니다.

6분 읽기 · 게시일: 2026년 5월 6일 · 수정일: 2026년 10월 21일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog