LLM 비정형 출력의 한계를 분석하고, 구조화된 출력의 3가지 접근 방식과 제약 디코딩의 원리를 살펴봅니다.
**LLM(Large Language Model)**대규모 언어 모델은 자연어 생성에 최적화되어 있습니다. 사람이 읽기에는 자연스러운 텍스트를 생성하지만, 프로그램이 소비하기에는 예측 불가능한 형태를 반환하는 경우가 많습니다.
다음과 같은 프롬프트를 LLM에 전달했다고 가정해 봅시다.
다음 리뷰에서 감성(긍정/부정/중립)과 핵심 키워드를 추출해 주세요.
리뷰: "배송은 빨랐지만 포장 상태가 별로였어요. 제품 자체는 만족합니다."LLM은 다음과 같이 다양한 형태로 응답할 수 있습니다.
감성: 긍정적 (하지만 일부 부정적 요소 포함)
키워드: 배송, 포장, 제품, 만족분석 결과입니다.
- 전체 감성: 긍정
- 세부 감성: 배송(긍정), 포장(부정), 제품(긍정)
- 핵심 키워드: 배송 속도, 포장 상태, 제품 만족도두 응답 모두 내용적으로는 합리적이지만, 프로그래밍 관점에서는 심각한 문제가 있습니다.
1. 파싱 불가능성
자유 형식 텍스트에서 "감성"이라는 필드 값을 정확히 추출하려면 정규표현식이나 문자열 파싱이 필요합니다. 그러나 LLM은 매번 다른 형식으로 응답하므로, 파서가 모든 변형을 처리할 수 없습니다.
2. 다운스트림 시스템 연동 실패
추출된 데이터를 데이터베이스에 저장하거나 API로 전달하려면 정해진 스키마에 맞아야 합니다. 비정형 출력은 스키마 검증을 통과하지 못하고, 파이프라인 전체가 중단됩니다.
3. 일관성 부재
동일한 프롬프트에 대해서도 LLM은 다른 키 이름, 다른 값 형식, 다른 구조로 응답합니다. 이는 자동화 시스템에서 치명적인 결함이 됩니다.
import re
def parse_sentiment(response: str) -> str:
"""비정형 LLM 응답에서 감성을 추출하는 취약한 파서"""
# 이 정규표현식은 특정 응답 형식에만 동작합니다
match = re.search(r"감성[:\s]*(긍정|부정|중립)", response)
if match:
return match.group(1)
# 다른 형식 시도
match = re.search(r"(positive|negative|neutral)", response, re.I)
if match:
return match.group(1).lower()
# 파싱 실패 - 파이프라인 중단
raise ValueError(f"감성을 파싱할 수 없습니다: {response[:100]}")이 코드는 LLM 응답 형식이 조금만 달라져도 실패합니다. 프로덕션 환경에서 이런 취약한 파서에 의존하는 것은 위험합니다.
비정형 출력 문제를 해결하기 위해 업계에서는 크게 세 가지 접근 방식이 발전해 왔습니다.
가장 단순한 방법은 프롬프트에 출력 형식을 명시하는 것입니다.
다음 리뷰를 분석하고 반드시 아래 JSON 형식으로만 응답하세요.
다른 텍스트는 포함하지 마세요.
{
"sentiment": "positive" | "negative" | "neutral",
"keywords": ["키워드1", "키워드2"]
}이 방식은 구현이 간단하지만, LLM이 지시를 무시하고 추가 설명을 붙이거나 잘못된 JSON을 생성할 수 있습니다. 특히 복잡한 스키마에서는 실패율이 높아집니다.
**Function Calling(함수 호출)**은 원래 LLM이 외부 API를 호출하기 위해 설계된 기능이지만, 구조화된 출력을 얻는 데에도 활용됩니다. LLM에게 "함수의 매개변수"라는 형태로 스키마를 전달하면, LLM은 해당 스키마에 맞는 인자를 생성합니다.
tools = [
{
"type": "function",
"function": {
"name": "analyze_review",
"description": "리뷰 감성 분석 결과를 구조화된 형태로 반환",
"parameters": {
"type": "object",
"properties": {
"sentiment": {
"type": "string",
"enum": ["positive", "negative", "neutral"]
},
"keywords": {
"type": "array",
"items": {"type": "string"}
}
},
"required": ["sentiment", "keywords"]
}
}
}
]이 방식은 프롬프트 기반보다 신뢰도가 높지만, 여전히 100% 스키마 준수를 보장하지는 못합니다.
가장 강력한 접근 방식은 LLM 추론 엔진 자체에서 출력을 제약하는 네이티브 Structured Output입니다. OpenAI의 Structured Outputs API가 대표적이며, JSON Schema를 전달하면 100% 스키마에 부합하는 출력을 보장합니다.
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o-2026-02",
messages=[
{"role": "user", "content": "다음 리뷰를 분석하세요: ..."}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "review_analysis",
"strict": True,
"schema": {
"type": "object",
"properties": {
"sentiment": {
"type": "string",
"enum": ["positive", "negative", "neutral"]
},
"keywords": {
"type": "array",
"items": {"type": "string"}
}
},
"required": ["sentiment", "keywords"],
"additionalProperties": False
}
}
}
)| 특성 | 프롬프팅 | 도구 호출 | 네이티브 JSON Schema |
|---|---|---|---|
| 스키마 준수율 | 낮음 (80-95%) | 중간 (90-98%) | 높음 (99.9%+) |
| 구현 복잡도 | 낮음 | 중간 | 낮음 |
| 프로바이더 지원 | 모든 LLM | 주요 LLM | OpenAI, 일부 오픈소스 |
| 추가 지연 시간 | 없음 | 최소 | 최소 |
| 유연성 | 높음 | 중간 | 스키마 제약 있음 |
네이티브 Structured Output이 어떻게 100%에 가까운 스키마 준수를 달성하는지 이해하려면, **Constrained Decoding(제약 디코딩)**의 원리를 알아야 합니다.
LLM은 텍스트를 토큰 단위로 순차 생성합니다. 각 단계에서 모델은 다음 토큰의 확률 분포를 계산하고, 가장 적합한 토큰을 선택합니다. 일반적인 생성에서는 전체 어휘(vocabulary)의 모든 토큰이 후보가 됩니다.
제약 디코딩은 JSON Schema를 **FSM(Finite State Machine)**유한 상태 머신으로 변환합니다. 이 FSM은 현재까지 생성된 출력을 추적하고, 다음에 올 수 있는 유효한 토큰만 허용합니다.
예를 들어, JSON 객체의 여는 중괄호 직후에는 문자열 키가 와야 합니다. 이 시점에서 FSM은 " 토큰만 허용하고, 나머지 모든 토큰의 확률을 0으로 마스킹합니다.
def constrained_decode(model, schema_fsm, input_tokens):
"""제약 디코딩의 개념적 구현"""
output_tokens = []
state = schema_fsm.initial_state
while not schema_fsm.is_final(state):
# 1. 모델이 다음 토큰의 확률 분포를 계산
logits = model.forward(input_tokens + output_tokens)
# 2. FSM에서 현재 상태에서 허용되는 토큰 집합을 조회
allowed_tokens = schema_fsm.get_allowed_tokens(state)
# 3. 허용되지 않는 토큰의 확률을 마이너스 무한대로 설정
for token_id in range(len(logits)):
if token_id not in allowed_tokens:
logits[token_id] = float("-inf")
# 4. 마스킹된 확률 분포에서 토큰 선택
next_token = sample(logits)
output_tokens.append(next_token)
# 5. FSM 상태 전이
state = schema_fsm.transition(state, next_token)
return output_tokens제약 디코딩은 모델의 "창의성"을 제한하는 것이 아닙니다. JSON 구조에 맞는 토큰만 허용하되, 값 내용의 선택은 모델에게 위임합니다. 즉, 형식은 강제하되 내용은 자유롭게 생성합니다.
제약 디코딩은 각 토큰 생성 단계에서 추가 연산이 필요하지만, 실제 지연 시간 증가는 미미합니다. 오히려 불필요한 텍스트(설명, 마크다운 포맷팅 등) 생성을 방지하므로 총 토큰 수가 줄어들어 전체 응답 시간이 단축되는 경우도 있습니다.
| 항목 | 일반 생성 | 제약 디코딩 |
|---|---|---|
| 토큰당 추가 연산 | 없음 | FSM 조회 + 마스킹 |
| 총 생성 토큰 수 | 많음 (설명 포함) | 적음 (순수 데이터) |
| 재시도 필요성 | 높음 | 거의 없음 |
| 실질 응답 시간 | 길어질 수 있음 | 예측 가능 |
Structured Output은 단순한 편의 기능이 아닙니다. LLM을 프로덕션 시스템에 통합하기 위한 필수 인프라입니다.
데이터 파이프라인 관점에서 Structured Output이 중요한 이유는 다음과 같습니다.
이 시리즈에서는 Structured Output의 다양한 구현 방식을 살펴보고, 이를 실제 데이터 파이프라인에 통합하는 방법을 단계적으로 학습합니다. 최종적으로 송장 데이터를 자동 추출하는 엔드투엔드 시스템을 구축합니다.
이번 장에서는 LLM 비정형 출력의 문제점을 확인하고, 이를 해결하는 세 가지 접근 방식을 비교했습니다. 또한 네이티브 Structured Output의 핵심 기술인 제약 디코딩과 유한 상태 머신 기반 토큰 마스킹의 원리를 살펴보았습니다.
핵심 내용을 다시 정리하면 다음과 같습니다.
2장에서는 JSON Schema의 기초 문법을 학습하고, OpenAI, Anthropic, Google 등 주요 프로바이더의 Structured Outputs API를 실습합니다. 스키마 설계 패턴과 중첩 구조, 열거형, 선택적 필드 처리 방법도 함께 다룹니다.
이 글이 도움이 되셨나요?
JSON Schema 기초 문법을 학습하고, OpenAI, Anthropic, Google 주요 프로바이더의 구조화된 출력 API를 실습합니다.
Function Calling의 원리를 이해하고, OpenAI/Anthropic/Google의 도구 호출 인터페이스로 구조화된 출력을 생성하는 방법을 학습합니다.
Pydantic v2로 LLM 출력 스키마를 정의하고, Instructor 라이브러리로 자동 재시도와 스트리밍 구조화 출력을 구현합니다.