- Published on
LLM 구조화된 출력과 Constrained Decoding 실전 가이드: JSON Schema부터 프로덕션 적용까지
- Authors
- Name
- 들어가며
- 구조화된 출력이 필요한 이유
- Constrained Decoding 원리: FSM과 CFG
- 주요 Constrained Decoding 엔진 비교
- OpenAI/Anthropic API 네이티브 방식
- 오픈소스 모델 적용: vLLM과 TGI
- Function Calling 통합
- 프로덕션 적용 전략
- 성능 영향과 트레이드오프
- 트러블슈팅
- 운영 시 주의사항
- 실패 사례와 복구
- 체크리스트
- 참고자료

들어가며
LLM을 프로덕션 파이프라인에 통합할 때 가장 빈번하게 부딪히는 문제는 출력 형식의 불확실성이다. 프롬프트에 "JSON으로 응답해줘"라고 지시하는 것만으로는 스키마 불일치, 필드 누락, 잘못된 타입, 불완전한 JSON 등 다양한 장애가 발생한다. 실제로 프롬프트만으로 JSON 출력을 요청했을 때 약 5~15%의 요청에서 파싱 실패가 발생한다는 보고가 있다.
이 문제를 근본적으로 해결하는 기술이 **Constrained Decoding(제약 디코딩)**이다. 프롬프트 수준의 "부탁"이 아니라, 토큰 생성 과정 자체에 문법적 제약을 부여하여 100% 스키마 준수를 보장한다.
이 글에서는 Constrained Decoding의 핵심 원리(FSM/CFG)부터 시작하여 주요 엔진(Outlines, XGrammar, llguidance)을 비교하고, OpenAI/Anthropic의 네이티브 API, vLLM/TGI 등 오픈소스 서빙 프레임워크에서의 적용법, Function Calling 통합, 그리고 프로덕션 환경에서의 전략과 트러블슈팅까지 실전 코드와 함께 다룬다.
구조화된 출력이 필요한 이유
자유 형식 출력의 한계
LLM의 자유 형식 텍스트 출력을 프로그래밍적으로 처리하려면 정규표현식이나 커스텀 파서를 작성해야 한다. 하지만 이 접근은 본질적으로 취약하다.
- 파싱 실패: JSON이 중간에 잘리거나, 쉼표가 빠지거나, 따옴표가 누락된다
- 스키마 불일치: 필드명이
name이 아닌名前로 반환되거나,rating이 문자열로 온다 - 환각 필드: 요청하지 않은 필드가 추가되거나, 필수 필드가 누락된다
- 포맷 혼합: JSON과 자연어 설명이 뒤섞여 나온다
구조화된 출력이 해결하는 것
구조화된 출력은 다음을 보장한다.
- 형식 보장: 출력이 반드시 유효한 JSON/XML/YAML이다
- 스키마 준수: 지정한 JSON Schema에 100% 부합한다
- 타입 안전성: 숫자 필드에 문자열이 들어오지 않는다
- 재시도 불필요: 파싱 실패로 인한 재시도 비용이 제거된다
이를 달성하는 방법은 크게 두 가지로 나뉜다. API 네이티브 방식(OpenAI Structured Outputs, Anthropic Tool Use)과 Constrained Decoding 엔진(Outlines, XGrammar, llguidance)이다.
Constrained Decoding 원리: FSM과 CFG
토큰 생성 과정에 제약 부여하기
LLM은 매 스텝마다 전체 어휘(vocabulary)에 대한 확률 분포를 생성한다. Constrained Decoding은 이 확률 분포에서 문법적으로 허용되지 않는 토큰의 확률을 0(또는 -inf)으로 마스킹하여 유효한 토큰만 선택되도록 한다.
예를 들어, JSON 객체의 시작 부분에서는 { 토큰만 허용하고, 키 이름이 끝난 후에는 : 토큰만 허용하는 식이다.
FSM(Finite State Machine) 기반 접근
정규표현식이나 단순한 문법은 FSM으로 표현할 수 있다. FSM 기반 Constrained Decoding의 동작 방식은 다음과 같다.
- JSON Schema를 정규표현식으로 변환한다
- 정규표현식을 DFA(Deterministic Finite Automaton)로 컴파일한다
- 매 디코딩 스텝에서 현재 FSM 상태에서 전이 가능한 토큰만 허용한다
- 선택된 토큰에 따라 FSM 상태를 업데이트한다
Outlines 라이브러리가 이 방식의 대표적인 구현체다. 장점은 구현이 비교적 단순하고 상태 추적이 빠르다는 것이지만, 재귀적 구조(중첩 JSON 객체, 가변 길이 배열 등)를 자연스럽게 처리하기 어렵다는 한계가 있다.
CFG(Context-Free Grammar) 기반 접근
JSON의 전체 문법(중첩 객체, 배열, 재귀 구조 포함)을 정확히 표현하려면 CFG가 필요하다. CFG 기반 접근은 다음과 같이 동작한다.
- JSON Schema를 EBNF(Extended Backus-Naur Form) 문법으로 변환한다
- 매 디코딩 스텝에서 PDA(Pushdown Automaton) 또는 파서 상태를 추적한다
- 현재 문법 상태에서 허용되는 토큰만 선택한다
- 스택 기반으로 중첩 깊이를 관리한다
XGrammar와 llguidance가 CFG 기반 접근을 채택한다. 재귀적 구조를 자연스럽게 처리할 수 있지만, FSM 대비 상태 관리 오버헤드가 크다.
토큰 마스킹의 핵심 메커니즘
두 방식 모두 최종적으로는 logit masking을 수행한다. 디코딩 시 logit 벡터에서 허용되지 않는 토큰의 위치에 -inf를 설정하면, softmax 이후 해당 토큰의 확률이 0이 된다.
import torch
import numpy as np
def apply_grammar_mask(logits: torch.Tensor, allowed_token_ids: set, vocab_size: int) -> torch.Tensor:
"""
Constrained Decoding의 핵심: 문법적으로 허용된 토큰만 남기고
나머지 토큰의 logit을 -inf로 마스킹한다.
"""
mask = torch.full((vocab_size,), float('-inf'), dtype=logits.dtype, device=logits.device)
allowed_ids = torch.tensor(list(allowed_token_ids), dtype=torch.long, device=logits.device)
mask[allowed_ids] = 0.0
masked_logits = logits + mask
return masked_logits
# 예시: JSON 객체 시작 직후, 키 문자열만 허용
vocab_size = 32000
logits = torch.randn(vocab_size) # 모델이 출력한 raw logits
# 현재 FSM 상태에서 허용되는 토큰: 쌍따옴표(")로 시작하는 키
allowed_tokens = {345, 678, 1234} # tokenizer에서 '"name', '"age' 등에 해당하는 ID
masked = apply_grammar_mask(logits, allowed_tokens, vocab_size)
# softmax 적용 후 허용된 토큰만 양의 확률을 가짐
probs = torch.softmax(masked, dim=-1)
assert probs[0].item() == 0.0 # 허용되지 않은 토큰의 확률은 0
주요 Constrained Decoding 엔진 비교
비교표
| 항목 | Outlines | XGrammar | llguidance | 네이티브 API (OpenAI) |
|---|---|---|---|---|
| 문법 모델 | FSM (정규표현식 + JSON Schema) | CFG (EBNF) | CFG (EBNF) + 바이트 레벨 | 내부 구현 (비공개) |
| 지원 형식 | JSON, 정규표현식, CFG | JSON, EBNF, 정규표현식 | JSON, EBNF, 정규표현식, Substrs | JSON Schema |
| 프레임워크 통합 | vLLM, TGI, SGLang | vLLM, SGLang, MLC-LLM | Guidance, Azure AI | OpenAI API 전용 |
| 전처리 시간 | 보통 (FSM 인덱스 빌드) | 빠름 (적응적 토큰 마스크) | 빠름 (파서 기반) | 없음 (서버 측) |
| 런타임 오버헤드 | 낮음 (~3% 지연 증가) | 매우 낮음 (~1% 지연 증가) | 낮음 (~2% 지연 증가) | 없음 (투명) |
| 배치 디코딩 지원 | 지원 | 고효율 지원 (비트마스크 재사용) | 지원 | 해당 없음 |
| 중첩 구조 처리 | 제한적 (깊은 재귀 시 성능 저하) | 우수 (푸시다운 오토마타) | 우수 | 우수 |
| 라이선스 | Apache 2.0 | Apache 2.0 | MIT | 상용 API |
| 성숙도 | 높음 (2023~) | 중간 (2024~) | 중간 (2024~) | 높음 |
Outlines
Outlines는 Constrained Decoding 분야의 선구적 라이브러리다. 정규표현식을 FSM으로 변환하고, JSON Schema를 정규표현식으로 변환하는 두 단계 파이프라인을 사용한다. vLLM에서 기본 guided decoding 백엔드로 채택되었으며, 2025년 이후 XGrammar로 기본 백엔드가 전환되는 추세이지만 여전히 널리 사용된다.
XGrammar
XGrammar는 MLC-AI 팀이 개발한 CFG 기반 엔진으로, 2024년 말 공개되었다. 핵심 혁신은 적응적 토큰 마스크 캐싱이다. 문법 상태를 "컨텍스트 독립적" 부분과 "컨텍스트 의존적" 부분으로 분리하여, 전자는 사전 계산된 비트마스크를 재사용하고 후자만 실시간 계산한다. 이를 통해 배치 디코딩에서 특히 높은 효율을 달성한다.
llguidance
llguidance는 Microsoft의 Guidance 프로젝트에서 파생된 Rust 기반 파서 엔진이다. 바이트 레벨에서 동작하여 토크나이저에 독립적이며, 부분 문자열 매칭(Substrs) 등 독특한 제약 타입을 지원한다. Azure AI Inference에서 사용되고 있다.
OpenAI/Anthropic API 네이티브 방식
OpenAI Structured Outputs
OpenAI는 2024년 8월부터 Structured Outputs를 정식 지원한다. 내부적으로 Constrained Decoding을 적용하여 JSON Schema 준수를 100% 보장한다.
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import List, Optional
from enum import Enum
client = OpenAI()
# Pydantic 모델로 출력 스키마 정의
class Severity(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class SecurityFinding(BaseModel):
vulnerability_type: str = Field(description="취약점 유형 (예: SQL Injection, XSS)")
severity: Severity = Field(description="심각도")
affected_component: str = Field(description="영향받는 컴포넌트")
description: str = Field(description="취약점 상세 설명")
remediation: str = Field(description="권장 조치")
cvss_score: Optional[float] = Field(default=None, ge=0.0, le=10.0, description="CVSS 점수")
class SecurityReport(BaseModel):
findings: List[SecurityFinding] = Field(description="발견된 취약점 목록")
overall_risk: Severity = Field(description="전체 위험 수준")
summary: str = Field(description="보안 분석 요약")
scan_timestamp: str = Field(description="스캔 시각 (ISO 8601)")
# Structured Outputs로 호출 - 스키마 100% 보장
response = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{
"role": "system",
"content": "당신은 보안 분석 전문가입니다. 주어진 코드를 분석하여 보안 취약점을 보고하세요."
},
{
"role": "user",
"content": """다음 Flask 코드의 보안 취약점을 분석해주세요:
@app.route('/search')
def search():
query = request.args.get('q')
result = db.execute(f"SELECT * FROM users WHERE name = '{query}'")
return render_template_string(f"<h1>Results for {query}</h1>")
"""
}
],
response_format=SecurityReport
)
report = response.choices[0].message.parsed
print(f"전체 위험 수준: {report.overall_risk.value}")
for finding in report.findings:
print(f" [{finding.severity.value}] {finding.vulnerability_type}: {finding.description}")
Anthropic: Tool Use 기반 구조화된 출력
Anthropic은 별도의 Structured Outputs API 대신 Tool Use(Function Calling) 메커니즘을 활용한다. 도구를 하나만 정의하고 tool_choice를 해당 도구로 강제하면 구조화된 출력을 얻을 수 있다.
import anthropic
import json
client = anthropic.Anthropic()
# Tool Use를 활용한 구조화된 출력
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2048,
tools=[
{
"name": "analyze_code_security",
"description": "코드의 보안 취약점을 구조화된 형식으로 분석합니다",
"input_schema": {
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"vulnerability_type": {"type": "string"},
"severity": {
"type": "string",
"enum": ["low", "medium", "high", "critical"]
},
"affected_component": {"type": "string"},
"description": {"type": "string"},
"remediation": {"type": "string"}
},
"required": ["vulnerability_type", "severity", "description", "remediation"]
}
},
"overall_risk": {
"type": "string",
"enum": ["low", "medium", "high", "critical"]
},
"summary": {"type": "string"}
},
"required": ["findings", "overall_risk", "summary"]
}
}
],
tool_choice={"type": "tool", "name": "analyze_code_security"},
messages=[
{
"role": "user",
"content": "다음 코드의 보안 취약점을 분석해주세요:\n\n@app.route('/login', methods=['POST'])\ndef login():\n username = request.form['username']\n password = request.form['password']\n query = f\"SELECT * FROM users WHERE username='{username}' AND password='{password}'\"\n user = db.execute(query).fetchone()\n if user:\n session['user'] = username\n return redirect('/dashboard')"
}
]
)
# Tool Use 결과에서 구조화된 데이터 추출
tool_use_block = next(block for block in response.content if block.type == "tool_use")
report = tool_use_block.input
print(f"전체 위험: {report['overall_risk']}")
print(f"발견 항목: {len(report['findings'])}건")
오픈소스 모델 적용: vLLM과 TGI
vLLM의 Guided Decoding
vLLM은 v0.5.0부터 guided decoding을 지원하며, 백엔드로 Outlines와 XGrammar를 선택할 수 있다. v0.7.0 이후에는 XGrammar가 기본 백엔드로 설정되어 있다.
from vllm import LLM, SamplingParams
from pydantic import BaseModel, Field
from typing import List, Optional
import json
# Pydantic 모델로 출력 스키마 정의
class ExtractedEntity(BaseModel):
name: str = Field(description="엔티티 이름")
entity_type: str = Field(description="엔티티 유형: PERSON, ORG, LOCATION, DATE, PRODUCT")
confidence: float = Field(ge=0.0, le=1.0, description="신뢰도")
context: Optional[str] = Field(default=None, description="원문에서의 관련 문맥")
class EntityExtractionResult(BaseModel):
entities: List[ExtractedEntity] = Field(description="추출된 엔티티 목록")
source_language: str = Field(description="원문 언어")
# vLLM 엔진 초기화
llm = LLM(
model="meta-llama/Llama-3.1-8B-Instruct",
max_model_len=4096,
gpu_memory_utilization=0.85,
guided_decoding_backend="xgrammar", # 또는 "outlines"
)
# JSON Schema를 가이드로 사용하는 SamplingParams
sampling_params = SamplingParams(
temperature=0.1,
top_p=0.95,
max_tokens=1024,
guided_decoding={
"json_object": EntityExtractionResult.model_json_schema()
}
)
# 배치 추론
texts = [
"삼성전자 이재용 회장이 2026년 3월 서울에서 AI 반도체 전략을 발표했다.",
"Apple CEO Tim Cook announced the new M5 chip at WWDC 2026 in Cupertino.",
"소프트뱅크 손정의 회장이 도쿄에서 ARM 기반 AI 인프라 투자를 발표했다.",
]
prompts = [
f"다음 텍스트에서 엔티티(사람, 조직, 장소, 날짜, 제품)를 추출하세요:\n\n{text}"
for text in texts
]
outputs = llm.generate(prompts, sampling_params)
for output in outputs:
result = json.loads(output.outputs[0].text)
parsed = EntityExtractionResult(**result)
print(f"언어: {parsed.source_language}")
for entity in parsed.entities:
print(f" [{entity.entity_type}] {entity.name} (신뢰도: {entity.confidence})")
print("---")
vLLM OpenAI 호환 서버에서 사용
vLLM의 OpenAI 호환 서버를 사용하면 기존 OpenAI SDK 코드를 거의 수정 없이 오픈소스 모델에 적용할 수 있다.
# vLLM 서버 실행 (guided decoding 활성화)
vllm serve meta-llama/Llama-3.1-8B-Instruct \
--guided-decoding-backend xgrammar \
--max-model-len 4096 \
--port 8000
from openai import OpenAI
# vLLM 서버에 연결
client = OpenAI(base_url="http://localhost:8000/v1", api_key="dummy")
# extra_body로 guided decoding 전달
response = client.chat.completions.create(
model="meta-llama/Llama-3.1-8B-Instruct",
messages=[
{"role": "system", "content": "텍스트에서 엔티티를 추출하세요."},
{"role": "user", "content": "Google의 Sundar Pichai CEO가 Mountain View에서 Gemini 3.0을 공개했다."}
],
extra_body={
"guided_json": {
"type": "object",
"properties": {
"entities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"type": {"type": "string", "enum": ["PERSON", "ORG", "LOCATION", "PRODUCT"]},
"confidence": {"type": "number"}
},
"required": ["name", "type", "confidence"]
}
}
},
"required": ["entities"]
}
}
)
import json
result = json.loads(response.choices[0].message.content)
print(json.dumps(result, indent=2, ensure_ascii=False))
TGI(Text Generation Inference)에서의 적용
Hugging Face TGI도 grammar 기반 guided generation을 지원한다. grammar 파라미터로 JSON Schema를 전달한다.
# TGI 서버 실행
docker run --gpus all -p 8080:80 \
ghcr.io/huggingface/text-generation-inference:latest \
--model-id meta-llama/Llama-3.1-8B-Instruct \
--max-input-tokens 2048 \
--max-total-tokens 4096
import requests
import json
# TGI의 grammar 파라미터로 JSON Schema 전달
response = requests.post(
"http://localhost:8080/generate",
json={
"inputs": "서울의 유명 관광지 3곳을 추천해주세요.",
"parameters": {
"max_new_tokens": 512,
"temperature": 0.7,
"grammar": {
"type": "json",
"value": {
"type": "object",
"properties": {
"places": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"category": {"type": "string"},
"description": {"type": "string"}
},
"required": ["name", "category", "description"]
},
"minItems": 3,
"maxItems": 3
}
},
"required": ["places"]
}
}
}
}
)
result = json.loads(response.json()["generated_text"])
for place in result["places"]:
print(f"{place['name']} ({place['category']}): {place['description']}")
Function Calling 통합
구조화된 출력과 Function Calling의 관계
Function Calling은 본질적으로 구조화된 출력의 특수한 형태다. LLM이 "어떤 함수를 어떤 인자로 호출할지"를 구조화된 JSON으로 출력하는 것이기 때문이다. 두 기능을 결합하면 강력한 에이전트 시스템을 구축할 수 있다.
LangChain with_structured_output
LangChain은 with_structured_output 메서드를 통해 프로바이더에 관계없이 통일된 인터페이스로 구조화된 출력을 얻을 수 있다.
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
from pydantic import BaseModel, Field
from typing import List, Literal
# 출력 스키마 정의
class DatabaseQuery(BaseModel):
"""자연어를 SQL 쿼리로 변환한 결과"""
sql: str = Field(description="생성된 SQL 쿼리")
tables_used: List[str] = Field(description="사용된 테이블 목록")
query_type: Literal["SELECT", "INSERT", "UPDATE", "DELETE"] = Field(description="쿼리 타입")
explanation: str = Field(description="쿼리 설명")
estimated_complexity: Literal["low", "medium", "high"] = Field(description="쿼리 복잡도")
# OpenAI 모델에 구조화된 출력 적용
openai_llm = ChatOpenAI(model="gpt-4o", temperature=0)
openai_structured = openai_llm.with_structured_output(DatabaseQuery)
# Anthropic 모델에도 동일하게 적용
anthropic_llm = ChatAnthropic(model="claude-sonnet-4-20250514", temperature=0)
anthropic_structured = anthropic_llm.with_structured_output(DatabaseQuery)
# 동일한 인터페이스로 호출
question = "지난 30일 동안 가장 매출이 높은 상위 10개 제품과 해당 카테고리를 보여줘"
result_openai = openai_structured.invoke(question)
result_anthropic = anthropic_structured.invoke(question)
print(f"[OpenAI] SQL: {result_openai.sql}")
print(f"[OpenAI] 테이블: {result_openai.tables_used}")
print(f"[OpenAI] 복잡도: {result_openai.estimated_complexity}")
print()
print(f"[Anthropic] SQL: {result_anthropic.sql}")
print(f"[Anthropic] 테이블: {result_anthropic.tables_used}")
print(f"[Anthropic] 복잡도: {result_anthropic.estimated_complexity}")
Function Calling + Structured Output 결합 패턴
에이전트가 도구를 호출하되, 최종 응답은 구조화된 형식으로 반환하는 패턴이다.
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import List, Optional
import json
client = OpenAI()
# 1단계: Function Calling으로 정보 수집
tools = [
{
"type": "function",
"function": {
"name": "search_database",
"description": "데이터베이스에서 제품 정보를 검색합니다",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "검색 쿼리"},
"category": {"type": "string", "enum": ["electronics", "clothing", "food"]},
"limit": {"type": "integer", "default": 10}
},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "get_price_history",
"description": "제품의 가격 이력을 조회합니다",
"parameters": {
"type": "object",
"properties": {
"product_id": {"type": "string"},
"days": {"type": "integer", "default": 30}
},
"required": ["product_id"]
}
}
}
]
# 2단계: 최종 응답은 Structured Output으로 반환
class ProductAnalysis(BaseModel):
product_name: str
current_price: float
price_trend: str # "rising", "falling", "stable"
recommendation: str
confidence: float = Field(ge=0.0, le=1.0)
class AnalysisReport(BaseModel):
analyses: List[ProductAnalysis]
market_summary: str
generated_at: str
# 멀티턴 대화로 Function Calling 후 Structured Output
messages = [
{"role": "system", "content": "제품 시장 분석 전문가입니다. 도구를 사용하여 정보를 수집하고 분석 보고서를 작성하세요."},
{"role": "user", "content": "최신 노트북 시장 동향을 분석해주세요."}
]
# Function Calling 단계
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
tool_choice="auto"
)
# 도구 호출 결과를 처리한 후 최종 Structured Output 요청
# (도구 실행 및 결과 피드백 로직 생략)
# 최종 분석 보고서를 Structured Output으로 생성
final_response = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=messages, # 전체 대화 이력 포함
response_format=AnalysisReport
)
report = final_response.choices[0].message.parsed
print(f"시장 요약: {report.market_summary}")
for analysis in report.analyses:
print(f" {analysis.product_name}: {analysis.price_trend} (신뢰도: {analysis.confidence})")
프로덕션 적용 전략
계층적 폴백 전략
프로덕션 환경에서는 단일 방법에 의존하지 않고 계층적 폴백을 구성해야 한다.
1차: Constrained Decoding (100% 스키마 보장)
↓ 실패 시
2차: JSON Mode + Pydantic 검증
↓ 실패 시
3차: 자유 텍스트 + LLM 기반 재파싱
↓ 실패 시
4차: 기본값 반환 + 알림
스키마 설계 원칙
Constrained Decoding의 효과를 극대화하려면 스키마 설계에 주의가 필요하다.
- 필드 수 최소화: 필드가 많을수록 FSM/CFG 상태 공간이 커지고, 디코딩 속도가 느려진다. 핵심 필드만 포함하자.
- 중첩 깊이 제한: 3단계 이상의 중첩은 성능에 영향을 줄 수 있다. 가능하면 평탄화(flatten)하자.
- enum 적극 활용: 자유 텍스트 필드보다 enum이 훨씬 효율적이다. 허용 값을 미리 정의하면 토큰 마스크가 좁아져 품질이 향상된다.
- Optional 필드 주의: Optional 필드가 많으면 모델이
null을 과도하게 선택할 수 있다. 필수 필드를 우선하자. - description 활용: JSON Schema의
description필드는 모델에 힌트를 제공한다. 명확한 설명을 작성하자.
캐싱 전략
Constrained Decoding 엔진은 스키마를 FSM/CFG로 컴파일하는 전처리 시간이 필요하다. 동일한 스키마를 반복 사용하면 컴파일 결과를 캐싱하여 오버헤드를 줄일 수 있다.
- Outlines:
RegexGuide인덱스를 사전 빌드하여 재사용 - XGrammar:
GrammarCompiler로 컴파일된 문법을 캐싱 - vLLM: 서버 수준에서 guided decoding 캐시를 관리
성능 영향과 트레이드오프
디코딩 속도 영향
Constrained Decoding은 매 토큰 생성 시 추가 연산이 필요하다. 실제 벤치마크를 보면 다음과 같다.
| 시나리오 | 제약 없음 (tok/s) | Outlines (tok/s) | XGrammar (tok/s) | 오버헤드 |
|---|---|---|---|---|
| 단순 JSON (5필드) | 520 | 505 | 515 | 1~3% |
| 복잡 JSON (20필드, 중첩) | 520 | 470 | 500 | 4~10% |
| 정규표현식 (이메일) | 520 | 510 | 518 | 0.5~2% |
| 대규모 enum (100개 값) | 520 | 490 | 510 | 2~6% |
대부분의 경우 오버헤드는 무시할 수 있는 수준이다. 다만 매우 복잡한 스키마(깊은 중첩, 대규모 enum, 긴 정규표현식)에서는 10% 이상의 성능 저하가 발생할 수 있다.
품질 영향
Constrained Decoding은 모델의 자유도를 제한하므로, 의미적 품질에 미미한 영향을 줄 수 있다. 특히 다음 상황에서 주의가 필요하다.
- 매우 제한적인 enum: 모델이 표현하고 싶은 의미와 가장 가까운 enum 값이 없으면 엉뚱한 값이 선택될 수 있다
- 과도한 필드 수: 필드가 30개 이상이면 뒤쪽 필드의 품질이 저하되는 경향이 있다
- 짧은 max_tokens: 구조화된 출력은 자유 텍스트보다 더 많은 토큰을 사용하므로, 충분한 여유를 두어야 한다
트러블슈팅
자주 발생하는 문제와 해결법
1. vLLM에서 guided decoding 시 첫 요청이 느린 경우
FSM/CFG 인덱스 빌드에 시간이 걸린다. 서버 시작 시 워밍업 요청을 보내거나, XGrammar 백엔드로 전환하면 전처리 시간이 줄어든다.
2. 중첩된 배열에서 무한 루프 발생
maxItems를 설정하지 않으면 모델이 배열 원소를 계속 생성할 수 있다. JSON Schema에서 maxItems를 반드시 지정하자.
3. Unicode 문자가 깨지는 경우
일부 Constrained Decoding 엔진은 멀티바이트 Unicode 토큰 처리에 취약하다. llguidance는 바이트 레벨 동작으로 이 문제를 우회한다. Outlines를 사용할 때는 정규표현식에 Unicode 범위를 명시적으로 포함하자.
4. 모델이 스키마 필드에 빈 문자열을 채우는 경우
minLength: 1을 설정하거나, 프롬프트에서 각 필드에 대한 구체적인 지침을 제공하자. Constrained Decoding은 형식만 보장하지 의미적 품질은 보장하지 않는다.
5. OpenAI Structured Outputs에서 refusal이 반환되는 경우
모델이 안전 정책에 의해 응답을 거부하면 response.choices[0].message.refusal이 설정된다. parsed가 None인지 반드시 확인하자.
response = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=messages,
response_format=MySchema
)
if response.choices[0].message.refusal:
print(f"응답 거부: {response.choices[0].message.refusal}")
elif response.choices[0].message.parsed:
result = response.choices[0].message.parsed
# 정상 처리
운영 시 주의사항
JSON Schema 제약사항
OpenAI Structured Outputs는 JSON Schema의 모든 기능을 지원하지 않는다. 주요 제약은 다음과 같다.
additionalProperties를false로 설정해야 한다- 모든 프로퍼티가
required에 포함되어야 한다 (Optional은 타입에null을 union으로 추가) $ref는 지원되지만 재귀 깊이에 제한이 있다patternProperties,if/then/else는 지원되지 않는다- 중첩 깊이 최대 5단계
스키마 버전 관리
프로덕션에서 스키마를 변경할 때는 하위 호환성을 유지해야 한다. 새로운 필드를 추가할 때는 Optional로 시작하고, 기존 필드를 제거할 때는 deprecated 기간을 둬야 한다.
비용 고려사항
구조화된 출력은 자유 텍스트보다 토큰 수가 증가한다. JSON의 구조적 오버헤드({, }, ", :, ,) 때문이다. 평균적으로 15~30%의 토큰 오버헤드가 발생한다. 비용에 민감한 환경에서는 필드 수를 최소화하고, 짧은 키 이름을 사용하는 것이 도움이 된다.
모니터링 지표
프로덕션에서 추적해야 할 핵심 지표는 다음과 같다.
- 파싱 성공률: 구조화된 출력의 JSON 파싱 성공 비율 (목표: 99.9% 이상)
- 스키마 검증 통과율: Pydantic 검증을 통과하는 비율
- 폴백 발동률: 폴백 전략이 작동하는 빈도 (높으면 스키마나 프롬프트 개선 필요)
- 토큰 효율: 유효 정보 토큰 / 전체 출력 토큰 비율
- 지연 시간 분포: P50, P95, P99 지연 시간 추적
실패 사례와 복구
사례 1: 스키마 과도 복잡으로 인한 타임아웃
30개 이상의 필드, 4단계 중첩, 복잡한 정규표현식 패턴을 가진 스키마를 사용했을 때, Outlines 백엔드에서 FSM 인덱스 빌드에 60초 이상 소요되어 타임아웃이 발생했다.
복구: 스키마를 2개의 단순한 스키마로 분리하고, 2단계 호출로 변경했다. 첫 번째 호출에서 핵심 필드를 추출하고, 두 번째 호출에서 상세 정보를 추출하는 방식이다.
사례 2: enum 값과 모델 지식의 충돌
국가 코드를 ISO 3166-1 alpha-2로 제한했는데, 모델이 "대한민국"을 KR 대신 KO(존재하지 않는 코드)로 매핑하려다 가장 가까운 유효한 값인 KP(북한)를 선택하는 문제가 발생했다.
복구: enum 값에 대한 설명을 프롬프트에 포함하고, 자주 혼동되는 매핑을 명시적으로 예시로 제공했다.
사례 3: 배열 크기 미제한으로 인한 메모리 폭증
maxItems를 설정하지 않은 상태에서 "가능한 모든 항목을 나열해주세요"라는 프롬프트를 사용했을 때, 모델이 수백 개의 배열 원소를 생성하며 max_tokens에 도달할 때까지 계속했다.
복구: 모든 배열 필드에 maxItems를 설정하고, 프롬프트에서도 "최대 N개"를 명시했다.
체크리스트
프로덕션에 구조화된 출력을 적용하기 전에 다음을 확인하자.
- JSON Schema에
maxItems,maxLength등 크기 제한이 설정되어 있는가 - 모든 필수 필드가
required에 포함되어 있는가 - enum 값이 모델이 이해할 수 있는 의미적으로 명확한 값인가
- 중첩 깊이가 3단계 이내인가 (가능하면 2단계)
- 폴백 전략이 구현되어 있는가
- 파싱 실패 시 재시도 로직이 있는가 (최대 3회)
max_tokens가 예상 출력 크기의 1.5배 이상으로 설정되어 있는가- 응답 거부(refusal) 처리가 구현되어 있는가
- 스키마 변경에 대한 하위 호환성 정책이 있는가
- 모니터링 대시보드에 파싱 성공률, 폴백률, 지연 시간이 포함되어 있는가
- 부하 테스트에서 guided decoding의 오버헤드가 허용 범위 내인가
- Unicode/다국어 텍스트에서 정상 동작하는지 검증했는가
참고자료
- OpenAI Structured Outputs Guide
- Anthropic Tool Use Documentation
- Outlines - Structured Text Generation
- XGrammar - Flexible and Efficient Grammar-Guided Generation
- XGrammar: Flexible and Efficient Structured Generation Engine (arXiv 2501.10868)
- vLLM Guided Decoding Documentation
- llguidance - Rust-based Parser Engine
- LangChain Structured Output Guide