LLM을 선언형으로 제어하기: Constrained Decoding과 스키마 기반 출력의 실제 작동 원리
TL;DR
LLM의 선언형 제어는 “어떻게”가 아닌 “무엇을”로 모델 출력을 구속하는 패러다임이며, 내부적으로는 constrained decoding이 토큰 확률 분포를 실시간으로 마스킹하여 구조화된 출력을 강제한다. Chain-of-Thought(CoT)는 절차형 프롬프트의 정점이지만, 출력 형식의 보장은 여전히 취약하다 [추정]. JSON mode, function calling, 그리고 outlines 같은 라이브러리가 이 간극을 메우는 실제 기술이다.
배경: 절차형 프롬프트의 한계
LLM 프롬프트 설계는 오랫동안 절차형 패러다임에 머물렀다. “단계별로 생각하라”, “먼저 X를 하고 그 다음 Y를 하라”는 지시는 CoT(Chain-of-Thought) 기법의 핵심이며, 복잡한 추론 태스크에서 유의미한 성능 향상을 가져왔다.
그러나 절차형 프롬프트는 근본적인 취약점을 가진다. 모델이 지시한 절차를 따르더라도, 최종 출력의 형식이나 구조는 보장되지 않는다. 프로덕션 파이프라인에서 LLM 응답을 파싱하다 json.JSONDecodeError를 마주한 경험은 이 문제를 직접적으로 드러낸다.
선언형 접근은 이 질문을 바꾼다. “어떻게 답하라”가 아니라 “이 스키마를 만족하는 출력을 생성하라”고 명세한다. 이는 단순한 프롬프트 작성 스타일의 변화가 아니라, 모델의 디코딩 과정 자체에 개입하는 기술적 전환이다.
핵심 메커니즘: Constrained Decoding의 작동 원리
LLM의 텍스트 생성은 매 스텝마다 전체 어휘(vocabulary)에 대한 확률 분포를 계산하고, 그 중 하나의 토큰을 샘플링하는 과정이다. Constrained decoding은 이 샘플링 단계에서 유효하지 않은 토큰의 확률을 -inf로 마스킹하여 사실상 선택 불가능하게 만든다.
P_masked(t | context) = softmax(logits + mask)
여기서 mask[i] = 0 if token_i ∈ valid_next_tokens(schema, current_state)
= -inf otherwise
valid_next_tokens는 현재까지 생성된 토큰 시퀀스와 목표 스키마(예: JSON Schema, 정규표현식)를 입력으로 받아, 다음 위치에서 허용되는 토큰 집합을 반환한다. 이를 위해 내부적으로 유한 상태 오토마톤(FSA) 또는 파싱 테이블이 사용된다.
FSA 상태 전이의 구체적 동작
JSON 스키마가 FSA로 변환되는 과정을 단계별로 살펴보자. 다음과 같은 단순 스키마를 예시로 든다.
{
"type": "object",
"properties": {
"label": { "type": "string", "enum": ["positive", "negative"] },
"score": { "type": "number" }
},
"required": ["label", "score"]
}
이 스키마는 먼저 다음과 같은 정규표현식으로 변환된다.
\{"label":"(positive|negative)","score":[0-9]+(\.[0-9]+)?\}
그리고 이 정규표현식이 FSA로 컴파일된다. 생성 과정에서 FSA는 아래와 같이 상태를 전이하며 허용 토큰 집합을 동적으로 결정한다.
[S0] 초기 상태
→ '{' 입력 시 S1 전이 / 허용 토큰: { '{' }
[S1] 객체 시작 후
→ '"' 입력 시 S2 전이 / 허용 토큰: { '"' }
[S2] 키 시작
→ 'label' 입력 시 S3 전이 / 허용 토큰: { 'label' }
[S3] 키 완료
→ '":"' 입력 시 S4 전이 / 허용 토큰: { '":"' }
[S4] 값 대기 (enum 분기)
→ '"positive"' 입력 시 S5 전이
→ '"negative"' 입력 시 S5 전이
/ 허용 토큰: { '"positive"', '"negative"' }
※ 이 시점에서 'neutral', '"maybe"' 등 모든 다른 토큰은 mask = -inf
[S5] label 값 완료
→ ',"score":' 입력 시 S6 전이 / 허용 토큰: { ',"score":' }
[S6] score 값 대기
→ [0-9] 입력 시 S7 전이 / 허용 토큰: { '0'~'9' }
※ 문자, 공백, 따옴표 등은 모두 mask = -inf
[S7] 정수부 진행 중
→ [0-9] 입력 시 S7 유지 (루프)
→ '.' 입력 시 S8 전이
→ '}' 입력 시 S9 (종료) 전이
[S9] 수락 상태 (Accept)
→ EOS 토큰만 허용
valid_next_tokens 함수는 내부적으로 이 FSA의 현재 상태를 조회하여 전이 가능한 엣지의 레이블 집합을 반환한다. 토크나이저 어휘 전체를 순회하며 각 토큰이 현재 상태에서 유효한 전이를 일으키는지 확인하고, 유효하지 않은 토큰의 인덱스에 -inf 마스크를 적용한다.
def valid_next_tokens(fsa_state, vocabulary):
allowed = []
for token_id, token_str in vocabulary.items():
next_state = fsa_state.try_transition(token_str)
if next_state is not None: # 유효한 전이가 존재하면
allowed.append(token_id)
return allowed # 이 집합 외 모든 토큰 → mask = -inf
이 과정이 매 토큰 생성마다 반복되므로, FSA 상태 전이 비용과 어휘 순회 비용이 레이턴시에 직접 영향을 미친다 [추정].
실제 구현: outlines 라이브러리
outlines는 이 원리를 Python에서 직접 사용할 수 있게 해주는 오픈소스 라이브러리다.
import outlines
from pydantic import BaseModel
from typing import Literal
# 출력 스키마 정의
class SentimentResult(BaseModel):
label: Literal["positive", "negative", "neutral"]
confidence: float
reasoning: str
# 모델 로드 (로컬 GGUF 또는 HuggingFace 모델)
model = outlines.models.transformers("mistralai/Mistral-7B-Instruct-v0.2")
# 스키마 기반 생성기 생성
generator = outlines.generate.json(model, SentimentResult)
prompt = """다음 리뷰의 감성을 분석하라.
리뷰: "배송이 늦었지만 제품 품질은 만족스럽다."
"""
# 출력은 항상 SentimentResult 인스턴스로 반환됨
result: SentimentResult = generator(prompt)
print(result.label) # "positive" | "negative" | "neutral" 중 하나가 보장됨
print(result.confidence) # float 타입 보장
OpenAI의 JSON mode나 function calling도 동일한 원리를 서버 사이드에서 구현한 것이다. 차이는 마스킹 로직이 클라이언트에 노출되지 않는다는 점뿐이다.
한국어 LLM 환경에서의 고려사항
※ 이 섹션은 원문(codesolvent 블로그)의 범위를 벗어난 한국어 환경 적용 분석이다. 원문은 한국어 환경을 별도로 다루지 않으며, 아래 내용은 공개된 기술 문서와 토크나이저 특성에 근거한 추가 분석임을 밝힌다.
Constrained decoding의 효율성은 토크나이저 설계와 밀접하게 연관된다. 영어 중심 모델(Llama-2, Mistral)은 한국어를 바이트 단위로 분절하는 경향이 있어, 한국어 문자열 하나가 여러 개의 토큰으로 분해된다. 이는 FSA 상태 전이 횟수를 증가시켜 constrained decoding의 오버헤드를 키운다.
Polyglot-Ko(EleutherAI, 12.8B)와 EXAONE(LG AI Research)은 한국어 전용 어휘를 포함한 토크나이저를 사용하므로, 한국어 출력에 대한 constrained decoding 적용 시 상대적으로 효율적일 것으로 예상된다. 다만 두 모델 간 또는 Llama-2 대비 구체적인 토크나이저 효율 수치는 공식적으로 검증된 비교 벤치마크를 별도로 확인하기를 권장한다(Polyglot-Ko 기술 보고서).
프로덕션 환경에서 한국어 구조화 출력이 필요하다면, 다음 선택 기준을 고려할 수 있다.
| 상황 | 권장 접근 |
|---|---|
| OpenAI API 사용 가능 | response_format={"type": "json_object"} 또는 function calling |
| 온프레미스 / 비용 민감 | outlines + 한국어 어휘 포함 모델(Polyglot-Ko, EXAONE 등) |
| 한국어 도메인 특화 스키마 | 전용 어휘 모델 + 커스텀 FSA 규칙 |
한계와 트레이드오프
선언형 제어가 만능은 아니다.
표현력 손실: 스키마가 엄격할수록 모델이 생성할 수 있는 출력 공간이 좁아진다. 복잡한 추론 과정을 reasoning: str 필드 하나로 압축하면, 모델이 중간 사고 과정을 충분히 전개하지 못해 최종 답의 품질이 저하될 수 있다. CoT와 구조화 출력을 결합하려면 thinking 필드를 별도로 두거나, 두 단계 생성(자유 형식 추론 → 구조화 요약)을 고려해야 한다.
토크나이저 경계 문제: FSA 기반 마스킹은 토큰 경계와 문자 경계가 일치하지 않을 때 복잡해진다. 특히 멀티바이트 문자(한국어, 일본어, 이모지)를 포함한 스키마에서 엣지 케이스가 발생할 수 있으며, outlines 등의 라이브러리도 이 문제를 완전히 해결하지는 못했다.
레이턴시 증가: 매 토큰 생성마다 FSA 상태를 갱신하고 마스크를 계산하는 오버헤드가 존재한다. 스키마 복잡도에 따라 다르지만, 단순 JSON 스키마 기준으로 비제약 생성 대비 10~30%의 추가 지연이 보고된다.
결론
LLM의 선언형 제어는 프롬프트 작성 기술의 문제가 아니라 디코딩 알고리즘 수준의 공학적 선택이다. Constrained decoding은 토큰 확률 분포를 스키마 유효성 조건으로 필터링함으로써, 자연어의 유연성과 구조화 출력의 신뢰성을 동시에 추구한다.
절차형(CoT)과 선언형(schema-constrained)은 배타적이지 않다. 복잡한 추론이 필요한 태스크에서는 두 접근을 파이프라인 단계별로 분리하여 결합하는 것이 현실적인 해법이다. 중요한 것은 “프롬프트를 어떻게 쓸까”가 아니라 “출력 공간을 어떻게 설계할까”라는 질문으로 사고의 축을 전환하는 것이다.
참고 자료
– 원문: Declarative Programming with AI/LLMs
– outlines 라이브러리: https://github.com/outlines-dev/outlines
– Polyglot-Ko 기술 보고서: https://arxiv.org/abs/2306.02254
– OpenAI Structured Outputs: https://platform.openai.com/docs/guides/structured-outputs