제6부 응용: 프롬프트·RAG·에이전트
6.9 구조화 출력·제약 디코딩
고객 문의에서 긴급도(1~5)와 카테고리를 뽑아 티켓 시스템에 넣고 싶다고 하자. 프롬프트로 부탁하면 모델은 이런 것을 낸다.
네! 분석 결과를 알려드릴게요. 이 문의는 결제 오류에 관한 것으로,
긴급도는 4 정도로 보입니다. 카테고리는 '결제'입니다. 도움이 되었길 바랍니다!
사람에겐 완벽하다. 그런데 이 출력을 받는 것은 사람이 아니라 다음 단계의 코드다 — 함수 인자로 파싱되고, DB 컬럼에 입력되고, 다음 에이전트의 상태가 된다. 그 코드 입장에서 위 문단은 치명적 문제다. 긴급도를 뽑으려면 정규식으로 긴급도는 (\d)를 긁고, 카테고리는 카테고리는 '(.+?)'를 찾아야 하는데, 이 정규식은 다음 응답에서 반드시 깨진다. 모델이 "긴급도: 4/5"라고 쓰거나, "높음(4)"이라 쓰거나, 사족을 뺀 채 답하거나, 카테고리를 영어 billing으로 쓰는 순간. 그리고 이런 변형은 온도가 0이 아닌 한, 심지어 온도가 0이어도 프롬프트가 조금만 달라지면 언제든 나온다.
이 장의 주제는 그 치명적 문제를 없애는 것이다. 모델을 "글 잘 쓰는 도구"가 아니라 "깨지지 않는 API"로 만드는 여러 방법이 있고, 그 방법들은 느슨한 것부터 강한 것까지 스펙트럼을 이룬다. 스펙트럼의 끝에는 이 장의 핵심인 제약 디코딩(constrained decoding) — 생성의 매 스텝에서 문법상 불가능한 토큰을 후보에서 원천 제거해, 틀린 형식을 애초에 뽑을 수 없게 만드는 기법 — 이 있다. 이것은 1.7에서 본 "로짓을 후처리해 고른다"를 "문법이 매 스텝 자동으로 후처리한다"로 확장한 것이다. 다만 미리 명시해 둘 것이 하나 있다. 제약 디코딩이 보장하는 것은 형식뿐이다. 유효한 JSON 안에 확신에 찬 거짓이 담기는 것은 이 기법이 막지 못한다 — 이 구분이 장 전체를 관통한다.
6.9.1 프로그램이 소비하는 텍스트
자유텍스트 파싱의 근본 문제는 출력 형식이 모델의 변덕에 달려 있다는 것이다. 우리가 원하는 것은 계약인데, 모델은 매번 다른 문서를 준다. 위 예에서 우리가 정말 받고 싶은 것은 이것이다.
{"urgency": 4, "category": "billing"}
이러면 json.loads() 한 줄로 끝난다. 파싱이 결정적이고, 필드가 고정이고, 타입이 명확하다. 정규식으로 자연어를 긁는 것과 스키마로 계약을 세우는 것의 차이는 단순한 편의 차원이 아니다. 정규식 파서는 모델 출력의 표면 형태에 묵시적으로 의존한다 — 모델이 "긴급도는"이라는 정확한 어구를 계속 쓴다는, 어디에도 적히지 않은 가정 위에 서 있다. 그 가정은 모델 업그레이드 한 번, 프롬프트 수정 한 줄, 온도 변경 하나에 조용히 붕괴하고, 붕괴한 것을 파이프라인이 실패하고 나서야 안다. 스키마는 그 가정을 명시적 계약으로 격상한다. 계약을 어기면 어긴 그 자리에서 즉시 검증에 걸리지, 하류 어딘가에서 이상한 값으로 출력되지 않는다.
스키마가 실제로 규정하는 것은 세 층위다. 형식(syntax) — 유효한 JSON인가, 따옴표·중괄호·쉼표가 맞아 파서가 실패하지 않는가. 구조(shape) — 어떤 필드가 있고, 어떤 타입이며, 무엇이 필수인가. urgency는 정수, category는 정해진 열거값 중 하나. 그리고 하류 계약(downstream contract) — 이 출력을 받는 함수·DB·다음 에이전트 단계가 무엇을 기대하는가. 이 세 층위 중 제약 디코딩이 문법 단에서 확실히 보장할 수 있는 것은 앞의 둘, 형식과 구조뿐이다. 세 번째 — "값이 진짜로 맞는가", 긴급도가 정말 4가 맞는가 — 는 스키마가 아니라 모델의 능력·컨텍스트의 문제다.
여기서 흔한 통념 하나가 반박된다. "구조화 출력을 쓰면 LLM이 신뢰할 수 있어진다"는 절반만 맞다. 신뢰할 수 있어지는 것은 파싱이지 값이 아니다. {"urgency": 5, "category": "billing"}은 완벽하게 유효한 JSON이고 스키마를 만족하지만, 실제 긴급도가 2였다면 단순히 틀린 값이다. 유효한 JSON 껍데기 안에 환각(6.4)이 그대로 담긴다. 구조화는 파이프라인의 취약함을 없애지, 모델의 오류를 없애지 않는다. 이 둘을 혼동하면 "우리는 구조화 출력을 쓰니 안전하다"는 잘못된 안심에 빠진다 — 파싱이 실패하지 않게 됐을 뿐, 틀린 값은 이제 더 매끄러운 형태로 하류에 흘러든다.
6.9.2 부탁에서 강제까지 — 접근법 스펙트럼
구조화 출력을 얻는 방법은 하나가 아니라, 강제력과 이식성·유연성 사이의 스펙트럼에 놓인 여러 층위다. 강제력이 약할수록 어떤 모델·API에서도 되고, 강할수록 형식이 확실히 지켜지는 대신 모델 내부(로짓)에 손을 넣어야 한다.
6.9.2.1 프롬프트로 부탁하고 실패하면 재시도한다
가장 단순하고 이식성이 높은 방식이다. "오직 JSON만, 다음 스키마로 출력하라"고 프롬프트에 명시하고, 예시를 한두 개 붙이고(few-shot이 형식 준수에 크게 도움 된다 — 6.1), 출력을 파싱해 본다. 파싱 예외나 스키마 검증 실패가 나면 오류 메시지를 붙여 다시 부탁한다.
# 프롬프트 + 검증 + 재시도 — 라이브러리·모델 무관
import json
from pydantic import BaseModel, ValidationError
class Ticket(BaseModel):
urgency: int # 1..5
category: str
def extract(text, max_retries=2):
prompt = f'문의를 분석해 JSON만 출력하라. 스키마: {{"urgency": int(1-5), "category": str}}\n문의: {text}'
for attempt in range(max_retries + 1):
raw = call_llm(prompt)
try:
return Ticket(**json.loads(strip_code_fence(raw))) # 파싱 + 검증
except (json.JSONDecodeError, ValidationError) as e:
prompt += f'\n방금 출력이 잘못됐다({e}). 순수 JSON만 다시 출력하라.'
raise RuntimeError("구조화 출력 실패")
이 방식의 강점은 어떤 모델·API에서도 된다는 것이다 — 로짓에 접근할 필요가 없다. 약점은 형식이 보장되지 않는다는 것이다. 재시도는 성공률을 높일 뿐 100%로 만들지 못하고, 한 번 실패할 때마다 지연·비용을 곱으로 늘린다(7.5). 강한 모델일수록, 스키마가 단순할수록 첫 시도 성공률이 높아 재시도가 드물게 걸리지만, 약한 모델이나 깊게 중첩된 스키마에서는 실패가 잦아진다. 그리고 strip_code_fence 같은 방어 코드가 늘 따라붙는다 — 모델은 순수 JSON만 내라는 지시를 무시하고 마크다운 코드펜스로 출력을 감싸는 습관이 있다.
6.9.2.2 JSON 모드 — 유효성은 보장, 스키마는 별개
여러 벤더 API가 "JSON 모드"를 제공한다. 켜면 출력이 문법적으로 유효한 JSON임을 대체로 보장한다 — 파싱이 실패하지 않는다. 여기서 두 가지를 혼동하지 말아야 한다. 첫째, 유효한 JSON은 원하는 스키마가 아니다. 기본 JSON 모드는 "파싱 가능한 JSON이면 됨"이라, 필드 이름·타입·필수 여부까지 강제하지 않는 경우가 많다. 여전히 프롬프트로 스키마를 지시하고 사후 검증을 해야 할 수 있다. 프롬프트에서 무슨 필드를 원하는지 아예 안 알려주면, JSON 모드가 텅 빈 {}나 잘못된 구조를 유효하게 출력하기도 한다.
둘째, 스키마 강제 모드는 JSON 모드와 다른 기능이다. 일부 벤더는 한 걸음 더 나아가, JSON Schema를 받으면 그 스키마를 엄격히 따르게 하는 기능(흔히 "structured outputs"로 불린다)을 제공한다. 이것은 내부적으로 이 장의 ④ 제약 디코딩에 가깝다. 다만 어떤 스키마 기능까지 지원하는지 — 재귀, 정규식 pattern, minimum/maximum, additionalProperties — 는 벤더·모델마다 다르므로, "스키마에 썼으니 강제되겠지"라고 가정하지 말고 무엇이 실제로 문법 단에서 강제되는지를 공식 문서에서 확인해야 한다. Anthropic·OpenAI 등의 정확한 지원 범위는 시점에 따라 바뀌므로 각 공식 문서가 단일 출처다.
6.9.2.3 도구 호출은 사실 구조화 출력이다
6.5에서 다룬 함수·도구 호출은 구조화 출력의 특수한 형태다. 도구의 인자 스키마(JSON Schema)를 주면, 모델은 자유텍스트 대신 "이 도구를 이런 인자로 부르겠다"는 구조화된 호출을 낸다. 많은 벤더 구현이 이 인자 생성에 스키마 제약을 걸어, 인자가 스키마를 따르도록 보장한다. 그래서 실제로 함수를 실행할 생각이 전혀 없어도, 값 하나를 구조화해서 받고 싶을 때 더미 도구를 하나 정의하고 그 인자로 답을 받는 패턴이 흔하다 — 흔히 "output tool" 트릭이라 부른다. 예컨대 record_ticket(urgency, category)라는 도구를 정의하고 그것을 부르게 하면, 벤더가 인자를 스키마대로 강제해 주니 사실상 스키마 강제 구조화 출력을 추가 비용 없이 얻는다. 병렬 호출·인자 검증의 세부는 6.5이 다루고, 여기서는 "도구 호출 = 스키마가 붙은 구조화 출력"이라는 연결만 정리해 둔다.
6.9.2.4 제약 디코딩 — 틀린 형식을 뽑을 수 없게
가장 강한 보장이다. 위 세 방식이 "모델에게 부탁하고 사후에 확인"이라면, 제약 디코딩은 생성 과정 자체를 문법에 가둔다. 매 스텝, 문법상 불가능한 토큰을 후보에서 지워 버린다. 그래서 형식·구조가 정의상(by construction) 보장된다 — 확률적으로 유도하는 것이 아니라, 틀린 토큰의 확률이 정확히 0이라 나올 수가 없다. 대가는 모델의 로짓에 접근해야 한다는 것이다. 자체 서빙(vLLM·SGLang·llama.cpp 등, 5.4)을 하거나, 이 기능을 노출한 API가 필요하다. 이 장의 나머지 대부분이 이 ④를 다룬다.
네 접근을 한 표로 정리하면 이렇다.
| 접근 | 형식 보장 | 스키마 강제 | 로짓 접근 필요 | 이식성 | 지연 오버헤드 | 언제 |
|---|---|---|---|---|---|---|
| ① 프롬프트+재시도 | 아니오(확률적) | 사후 검증 | 불필요 | 높음 | 재시도 시 배수 | 어떤 API든, 스키마 단순, 강한 모델 |
| ② JSON 모드 | 유효 JSON까지 | 부분(모드 의존) | 불필요 | 중간 | 낮음 | 벤더 API로 파싱 안전만 원할 때 |
| ③ 도구 호출 | 대개 예 | 예(인자 스키마) | 불필요 | 중간 | 낮음 | 실제 도구 연동·에이전트(6.5) |
| ④ 제약 디코딩 | 예(정의상) | 예 | 필요 | 낮음(자체 서빙) | 컴파일+스텝당 마스킹 | 형식이 절대 깨지면 안 될 때, 로컬·자체 서빙 |
내 견해로는, 대부분의 API 기반 제품에서 실용적 최적점은 ③ 도구 호출이나 ② 스키마 강제 구조화 출력이다 — 벤더가 내부적으로 제약 디코딩을 대신 해 주므로 문법을 직접 만질 이유가 없다. ④를 직접 다뤄야 하는 경우는 자체 서빙을 하거나, 벤더가 지원하지 않는 복잡한 문법(정규식·도메인 특화 언어)을 강제해야 할 때다. 그리고 ①은 어느 방식을 쓰든 폴백으로 늘 남겨 둔다 — 사후 검증·재시도는 안전망으로 유효하다.
6.9.3 로짓 마스킹 — 제약 디코딩의 핵심
1.7에서 본 생성 루프를 떠올리자. 매 스텝 모델이 로짓 $z \in \mathbb{R}^{|V|}$ 를 내고, 후처리(반복 패널티·logit bias) → 온도·softmax → 절단(top-k/p) → 샘플의 순서로 토큰 하나를 뽑는다. 제약 디코딩은 이 파이프라인의 로짓 후처리 단계에 "문법 마스크" 한 겹을 더 추가하는 것이다. logit bias를 직접 몇 개 거는 대신, 문법이 매 스텝 자동으로 거는 셈이다.
이것을 가능하게 하는 부품이 오토마톤(automaton)이다. 오토마톤은 "지금까지 생성한 문자열이 문법의 어디쯤 와 있는가"를 상태 $s$ 로 추적하고, "이 상태에서 다음에 올 수 있는 토큰"의 집합 $\mathcal{A}(s) \subseteq V$ 를 내놓는다. 디코딩 루프는 오토마톤을 옆에 하나 두고 매 스텝 이렇게 실행된다.
- 모델이 로짓 $z$ 를 낸다(평소대로, 1.4).
- 현재 오토마톤 상태 $s$ 에서 허용 토큰 집합 $\mathcal{A}(s)$ 를 조회한다.
- $\mathcal{A}(s)$ 에 없는 토큰의 로짓을 $-\infty$ 로 마스킹한다.
- 남은 것으로 평소의 샘플링(온도·top-p·argmax — 1.7)을 해 토큰 하나를 뽑는다.
- 뽑은 토큰으로 오토마톤을 다음 상태로 전이시킨다($s \to s'$).
- 종료 상태에 도달하고 EOS가 허용되면 끝, 아니면 1로.
마스킹 자체는 로짓 위의 아주 단순한 연산이다. 상태 $s$ 에서 마스킹된 로짓 $\tilde{z}$ 는
$$\tilde{z}_i = \begin{cases} z_i & i \in \mathcal{A}(s) \quad(\text{문법상 허용}) \\[2mm] -\infty & i \notin \mathcal{A}(s) \quad(\text{문법 위반 → 차단}) \end{cases}$$
이 $\tilde{z}$ 에 softmax를 취하면 허용되지 않은 토큰의 확률이 정확히 0이 된다 — $e^{-\infty}=0$ 이기 때문이다. 이것은 1.4의 인과 마스크와 완전히 같은 원리다(미래 자리에 $-\infty$ 를 더해 가중치를 0으로 만들던 그것).
$$p_i = \frac{\exp(\tilde{z}_i / T)}{\sum_{j} \exp(\tilde{z}_j / T)} = \begin{cases} \dfrac{\exp(z_i/T)}{\sum_{j \in \mathcal{A}(s)} \exp(z_j/T)} & i \in \mathcal{A}(s) \\[3mm] 0 & \text{그 외} \end{cases}$$
즉 확률 질량이 허용 토큰들 사이에서만 재정규화된다. 여기서 중요한 것은 온도·top-p·top-k 같은 1.7의 조정 변수가 이 마스킹 위에 그대로 추가된다는 점이다. 마스크가 먼저 후보를 문법으로 자르고, 그다음 평소 샘플링이 남은 후보 사이에서 고른다. 두 가지가 직교한다 — 문법은 "무엇이 가능한가"를, 온도·절단은 "가능한 것 중 어떻게 고르나"를 정한다. 그래서 제약을 걸어도 창의성·다양성 조정 변수를 그대로 쓸 수 있고, 반대로 온도를 아무리 높여도 문법을 벗어난 토큰은 나오지 않는다.
그림에서 주목할 것은 점선이다. 모델은 사실 null이나 hello에 높은 로짓을 줬을 수 있다(점선이 원래 높이). 하지만 문법 상태가 "boolean 값"을 기대하므로 그것들은 $-\infty$ 로 억제되고, true/false/공백 사이에서만 고른다. 이것이 제약 디코딩이 형식을 "확률적으로 유도"가 아니라 "정의상 보장"하는 이유이자, 동시에 뒤에서 볼 품질 트레이드오프의 단초다. 모델이 정말 원한 토큰을 막으면, 남은 것 중 최선이 원래 최선보다 나쁠 수 있다.
6.9.4 문법을 오토마톤으로 — FSM과 CFG
마스킹의 핵심은 "상태 $s$ 에서 허용 토큰 $\mathcal{A}(s)$ 를 어떻게 아는가"다. 답은 문법을 오토마톤으로 컴파일하는 것이고, 문법의 복잡도에 따라 필요한 오토마톤의 힘이 다르다. 이 힘의 위계는 형식언어 이론의 촘스키 계층 그대로다 — 정규 언어는 유한상태기계로, 문맥자유 언어는 스택을 더한 푸시다운 오토마톤으로.
정규식으로 표현되는 형식 — 전화번호, 날짜, 열거값, 고정 키워드 — 은 유한상태기계(Finite State Machine, FSM)로 컴파일된다. FSM은 유한한 상태와 "이 입력을 보면 저 상태로"라는 전이로 이루어진다. "true 또는 false만"이라는 문법의 FSM은 몇 개의 상태로 t→r→u→e와 f→a→l→s→e 경로를 추적한다. 각 상태에서 다음에 올 수 있는 문자가 정해지고, 그것을 토큰 단위 허용 집합으로 바꾸면 마스크가 나온다. FSM 기반은 전이가 상수 시간 $O(1)$ 조회라 빠르다 — Outlines의 접근이 이 계열이다.
JSON Schema는 실무에서 가장 흔한 제약 언어인데, 이것도 컴파일러가 정규식·문법으로 번역해 오토마톤을 만든다. 예컨대 {"type":"object","properties":{"urgency":{"type":"integer"},"category":{"type":"string","enum":["billing","technical","other"]}},"required":["urgency","category"]} 같은 스키마는 대략 이렇게 풀린다 — 객체는 { → 키 → : → 값 → (, 또는 })의 상태 흐름이고, category의 enum은 세 문자열 중 하나만 허용하는 분기이며, integer는 -?[0-9]+ 같은 숫자 정규식이 된다. 아래는 이 티켓 예제를 극단적으로 단순화한 스키마 {"answer": <boolean>}의 오토마톤을 상태도로 그린 것이다. 각 화살표의 라벨이 그 전이를 일으키는 입력이고, 각 상태에서 나가는 화살표들의 라벨만이 그 상태의 허용 토큰이다.
각 상태에서 무엇이 허용되는지가 명확하다. S0에서는 오직 {, S3에서는 공백이나 true/false, S5에서는 오직 종료다. 이 상태→허용집합 매핑이 바로 앞 절의 $\mathcal{A}(s)$ 다.
FSM으로는 표현할 수 없는 것이 하나 있다 — 재귀와 중첩이다. 임의 깊이로 중첩된 JSON([[[...]]]), 짝이 맞는 괄호, 프로그래밍 언어 구문 같은 것은 유한한 상태로는 셀 수 없다. "여는 괄호를 몇 개 봤고 아직 몇 개를 닫아야 하는가"는 잠재적으로 무한한 정보라, 유한 상태 기계의 유한한 상태 안에 담기지 않는다. 이것을 다루려면 문맥자유문법(Context-Free Grammar, CFG)이 필요하다. CFG는 FSM에 스택을 더한 것 — 여는 괄호를 스택에 쌓고 닫을 때 꺼내 짝을 맞춘다. llama.cpp의 GBNF(GGML BNF)가 대표적인 실무 CFG 표기다. BNF(배커스-나우르 형식)를 따른 규칙으로 문법을 쓴다.
root ::= "{" ws "\"answer\"" ws ":" ws bool ws "}"
bool ::= "true" | "false"
ws ::= [ \t\n]*
root가 시작 규칙, bool은 두 리터럴 중 하나, ws는 공백 반복이다. 재귀는 규칙이 자기 자신을 (직접 또는 간접으로) 참조하게 쓰면 된다. 임의 깊이로 중첩 가능한 JSON 값 전체를 쓰면 이렇게 된다.
# 임의 깊이 중첩이 가능한 값 — 재귀
value ::= object | array | string | number | "true" | "false" | "null"
array ::= "[" ws (value (ws "," ws value)*)? ws "]"
object ::= "{" ws (string ws ":" ws value (ws "," ws value)*)? ws "}"
value가 array를 포함하고 array가 다시 value를 포함하니, 이 문법은 [[[[...]]]]를 임의 깊이까지 받는다. 이런 재귀는 FSM으로는 원리적으로 불가능하고 스택(CFG)이 필수다. XGrammar·llama.cpp·최신 Outlines 등이 CFG를 지원한다. 실무에서 주의할 것은, 스키마가 재귀적이면 무한히 깊어질 수 있어 max_tokens나 깊이 제한을 함께 걸지 않으면 종료가 보장되지 않는다는 점이다(6.9.11).
6.9.5 토큰과 문자는 같지 않다
여기까지는 전부 "문자 단위" 이야기였다. 그런데 모델은 문자가 아니라 토큰을 뽑는다(1.2). 이 불일치가 제약 디코딩 구현의 실제 난제이고, 왜 이것이 라이브러리 한 편의 핵심 기술이 되는지의 이유다.
문법은 문자열 true를 원한다. 그런데 모델의 어휘에서 true가 어떻게 토큰화돼 있는지는 어휘마다 다르다 — 전체가 한 토큰일 수도, tr+ue로 분할될 수도, t+rue일 수도 있다. 더 고약한 것은 한 토큰이 문법 경계를 걸쳐 있는 경우다. 예컨대 BPE 어휘에는 e}나 true} 같은 토큰이 흔히 존재하는데, e}는 true의 마지막 e와 객체를 닫는 }를 한 번에 담는다. 문자 오토마톤 관점에서 이 한 토큰은 상태 $S_4$(값의 끝)에서 $S_5$(닫힘)를 지나 종료까지 여러 전이를 한꺼번에 밟는다. 그래서 "이 상태에서 허용되는 토큰"을 구하려면, 문법을 한 문자씩 보는 것으로는 부족하고 어휘의 모든 토큰 각각에 대해 "이 토큰의 문자열이 현재 상태에서 문법을 끝까지 따라갈 수 있는가"를 판정해야 한다.
이것이 왜 비용이 큰가. 어휘가 128,000개인데 매 디코딩 스텝마다 12만 8천 개 토큰을 전부 오토마톤에 통과시켜 보는 것은 순진하게 하면 감당이 안 된다. 게다가 이 판정은 정확해야 한다 — 잘못 판정하면 (a) 유효한 토큰을 막아 모델이 정당한 출력을 못 내게 하거나, (b) 무효한 토큰을 허용해 문법이 깨진 출력을 내게 한다. 그래서 각 도구가 이 문제를 서로 다른 방식으로 공략한다.
- 미리 계산(precompute): Outlines는 문법을 컴파일할 때, 각 FSM 상태에서 허용되는 토큰 집합을 어휘 전체에 대해 미리 인덱싱해 둔다. 그러면 추론 시엔 상태로 조회만 하면 돼 스텝당 오버헤드가 작다. 대가는 컴파일 비용과 인덱스 메모리인데, 이것은 다음 절에서 숫자로 따진다. 같은 스키마를 반복 쓰면 이 인덱스를 캐시해 상각한다.
- 토큰 트라이·마스크 캐시: XGrammar 등은 어휘를 트라이(trie)로 두고, 문맥에 무관하게 항상 허용/차단되는 토큰을 미리 걸러 매 스텝 실제로 검사할 토큰 수를 줄인다. CFG처럼 상태가 잠재적으로 무한한 경우에도 스텝당 비용을 실용적 수준으로 낮추려는 최적화다(구체적 성능 수치는 벤더 벤치마크에 의존하므로 워크로드로 직접 재는 것이 정직하다).
- 토큰 힐링(token healing): 프롬프트가 토큰 중간에서 끝나는 경우를 보정하는 기법도 있다. 예컨대 프롬프트가
{"na로 끝나면, 다음 토큰이me"가 되어야 하는데 어휘에 그런 토큰이 정확히 있으리란 보장이 없다. 힐링은 마지막 토큰을 되돌려 문자 경계에서 다시 시작하게 해 이 어긋남을 푼다.
여기서 알아야 할 핵심은 특정 알고리즘이 아니라 개념이다. 제약 디코딩은 문자 문법을 토큰 어휘로 번역해야 하고, 그 번역은 두 축 — 정확성(유효한 것을 막지도, 무효한 것을 허용하지도 않는가)과 오버헤드(매 스텝, 큰 어휘에서 얼마나 빠른가) — 에서 평가된다. 도구를 고를 때 보는 것이 이 두 축이다.
6.9.6 수계산 — 토큰 마스킹의 진행
앞 절의 스키마 {"answer": <boolean>}에 대해 토큰 마스킹이 스텝마다 어떻게 진행되는지 끝까지 따라가 보자(모두 설명용 개략이다). 토이 어휘를 정렬이 명료하도록 골라 쓴다.
$$V = \{\ \texttt{\{},\ \ \texttt{\}},\ \ \texttt{"answer"},\ \ \texttt{:},\ \ \texttt{␣}(\text{공백}),\ \ \texttt{true},\ \ \texttt{false},\ \ \texttt{null},\ \ \texttt{42},\ \ \texttt{hello},\ \ \texttt{[},\ \ \texttt{,},\ \ \text{EOS}\ \}$$
오토마톤은 6.9.4의 상태 $S_0 \dots S_5$ 를 쓴다. 매 스텝: 모델 로짓 → 상태의 허용 토큰만 남기고 마스킹 → 샘플. 표에서 "차단된 것"은 그 스텝에 마스크로 억제된 대표적 토큰이다.
| 스텝 | 지금까지 생성 | 상태 | 허용 토큰 $\mathcal{A}(s)$ | 차단된 것(예) | 선택 |
|---|---|---|---|---|---|
| 1 | (없음) | $S_0$ | { | hello·[·42·null | { |
| 2 | { | $S_1$ | "answer" | true·{·42·hello | "answer" |
| 3 | {"answer" | $S_2$ | : | 그 외 전부 | : |
| 4 | {"answer": | $S_3$ | ␣, true, false | null·42·hello·} | ␣ |
| 5 | {"answer": | $S_3$ | true, false | null·42·{·, | true |
| 6 | {"answer": true | $S_4$ | ␣, } | false·,·null·42 | } |
| 7 | {"answer": true} | $S_5$ | EOS | 그 외 전부 | EOS |
세 가지를 확인하자. 스텝 4가 핵심이다 — 여기서 모델이 만약 null을 원해도(높은 로짓을 줬다 치자), 상태 $S_3$ 의 허용 집합에 null이 없으므로 $-\infty$ 로 억제된다. 모델은 true/false(또는 공백) 중에서만 고를 수 있어 잘못된 값 타입이 원천 차단된다. 그런데 분기는 살아 있다 — 스텝 5에서 true와 false 둘 다 허용된다. 제약은 형식을 강제하되 내용의 선택지는 열어 둔다. 모델의 로짓이 true에 쏠렸으면 true, false에 쏠렸으면 false가 나온다. 여기가 "모델의 판단"이 반영되는 자리다. 마지막으로 종료도 문법이 정한다 — 스텝 7에서 오직 EOS만 허용되므로 모델이 "도움이 되었길 바랍니다!" 같은 사족을 붙일 여지가 없다.
한 스텝의 마스킹을 로짓으로 구체화해 보자(스텝 4, 상태 $S_3$). 모델 로짓이
$$z(\texttt{true})=3.1,\quad z(\texttt{false})=2.4,\quad z(\texttt{␣})=1.0,\quad z(\texttt{null})=4.5,\quad z(\texttt{42})=2.0$$
이었다고 하자 — 모델은 null을 가장 원한다. 마스킹이 없었다면 이 다섯의 softmax(온도 1)는 지수 $e^{4.5}=90.0,\ e^{3.1}=22.2,\ e^{2.4}=11.0,\ e^{2.0}=7.39,\ e^{1.0}=2.72$, 합 $133.3$ 이라 $p(\texttt{null})=90.0/133.3\approx 0.675$ 다. 모델은 67.5% 확률로 null을 뽑았을 것이다. 이제 문법이 $S_3$ 에서 null·42를 차단하면
$$\tilde{z}(\texttt{null}) = -\infty,\quad \tilde{z}(\texttt{42}) = -\infty,\quad \tilde{z}(\texttt{true})=3.1,\quad \tilde{z}(\texttt{false})=2.4,\quad \tilde{z}(\texttt{␣})=1.0$$
남은 셋의 지수만으로 재정규화한다. $e^{3.1}+e^{2.4}+e^{1.0}=22.2+11.0+2.72=35.9$ 이므로
$$p(\texttt{true})=\frac{22.2}{35.9}\approx 0.62,\quad p(\texttt{false})=\frac{11.0}{35.9}\approx 0.31,\quad p(\texttt{␣})=\frac{2.72}{35.9}\approx 0.08$$
이고 null·42는 0이다. 모델이 67.5% 확률로 가장 원했던 null이 확률 0으로 사라졌다 — 이것이 형식 보장의 힘이자 대가다. 형식은 완벽하게 지켜지지만, 만약 이 태스크에서 정답이 정말로 "boolean이 아닌 무언가"였다면(예: 답을 모른다는 뜻으로 null이 옳았다면), 제약이 그 정답을 막아 버린 것이다. 스키마 설계가 모델의 자연스러운 표현과 어긋날수록 이 손실이 커진다. 6.9.9와 6.9.11에서 이 문제를 정면으로 다룬다.
6.9.7 제약의 비용을 숫자로
제약 디코딩은 무비용이 아니다. 비용은 세 군데에서 온다 — 문법을 오토마톤으로 컴파일하는 1회 비용, 허용 집합을 저장하는 메모리, 그리고 매 스텝 로짓에 마스크를 적용하는 비용. 어느 쪽이 실제로 문제가 되는지는 숫자를 넣어 보면 분명해진다. 어휘 $|V|=128{,}000$, 로짓을 bf16(2바이트)으로 두고, 비교 기준으로 5.1의 8B 모델 디코드 스텝(가중치 16 GB, 대역폭 3.3 TB/s, 스텝당 약 4.8 ms)을 쓴다.
먼저 스텝당 마스크 적용이다. 마스킹은 로짓 벡터의 차단 자리에 $-\infty$ 를 쓰는 한 번의 elementwise 연산이다. 로짓 벡터 하나는 $128{,}000 \times 2 = 256$ KB이고, 마스크를 적용하려면 이것을 한 번 읽고 한 번 쓴다 — 약 512 KB의 메모리 트래픽이다. 3.3 TB/s로 나누면
$$\frac{512\ \text{KB}}{3.3\ \text{TB/s}} \approx 0.16\ \mu\text{s}$$
로, 스텝 시간 4.8 ms의 0.003%에 불과하다. 즉 "로짓에 마스크를 씌우는" 행위 자체는 사실상 무비용이다 — 디코드 스텝을 지배하는 것은 어차피 16 GB 가중치를 읽는 대역폭이고, 그 옆에서 512 KB를 더 만지는 것은 반올림 오차 수준이다.
그렇다면 비용이 큰 것은 마스크를 적용하는 것이 아니라 무엇을 마스킹할지 알아내는 것, 즉 $\mathcal{A}(s)$ 를 구하는 것이다. 이것을 매 스텝 어휘 12만 8천 개를 다 훑어 계산하면 비용이 커지므로, Outlines류는 컴파일 시점에 각 FSM 상태의 허용 집합을 미리 인덱싱한다. 상태 하나의 허용 집합을 어휘 크기 비트마스크로 저장하면 상태당 $128{,}000 / 8 = 16$ KB다. 스키마가 만들어 내는 FSM이 상태 500개라면 전체 인덱스는
$$500 \times 16\ \text{KB} = 8\ \text{MB}$$
로, 16 GB 가중치 옆에서 무시할 만하다. 대신 이 인덱스를 만드는 컴파일 비용이 든다 — 각 상태에서 어휘의 어떤 토큰이 오토마톤을 전진시키는지 판정해야 하니, 순진하게는 (상태 수) × (어휘 크기) 번의 토큰-전진 검사, 500 × 128,000 ≈ 6,400만 회다. 이것은 요청당 지연에 추가되면 부담이 되지만, 스키마가 같으면 한 번만 하고 캐시한다. 그래서 실제 함정은 요청마다 스키마를 새 객체로 만들어 캐시 키가 매번 달라지는 것이다 — 그러면 6,400만 회 컴파일을 매 요청 다시 문다. 스키마를 상수로 두거나 컴파일 결과를 키로 캐시하면 이 비용이 첫 요청에만 걸린다.
정리하면, 제약 디코딩의 지연 프로파일은 직관과 반대다. "매 토큰 로짓을 만지니 느리겠지"가 아니라, 마스크 적용은 무비용에 가깝고 인덱스 메모리도 작으며, 실질 비용은 1회 컴파일에 몰려 있어 캐싱으로 상각된다. 여기에 연속 배칭(5.3)과 결합할 때의 상태 관리 비용이 더해지는데 — 요청마다 다른 문법·상태를 추적해야 하므로 — 이것은 서빙 엔진 구현의 성숙도에 달려 있고, 순 효과는 워크로드로 재야 한다. 한 가지 반직관적 이득도 있다. 문법이 후보를 강하게 자를수록, 어떤 자리는 허용 토큰이 유일해져(예: 스텝 3의 :) 그 자리는 모델을 신뢰할 필요조차 없이 결정적으로 채울 수 있다 — 극단적으로는 구조 토큰을 아예 모델을 건너뛰고 코드가 출력하는 "채우기형"(6.9.8)으로 흘러 오히려 빨라지기도 한다.
6.9.8 도구와 서빙 엔진
제약 디코딩을 직접 구현할 일은 드물다. 여러 라이브러리와 서빙 엔진이 이를 제공한다. 아래는 개관이며, 정확한 기능·성능·API는 버전에 따라 바뀌므로 각 도구의 공식 문서가 단일 출처다.
| 도구 | 제약 표현 | 접근 방식 | 특징·위치 |
|---|---|---|---|
| Outlines | 정규식·JSON Schema·CFG·Pydantic | FSM 상태별 허용 토큰 미리 인덱싱 | 파이썬 라이브러리, vLLM 등에 백엔드로 통합 |
| XGrammar | CFG(문맥자유) 중심 | 토큰 트라이·마스크 캐시로 스텝당 저오버헤드 지향 | 서빙 엔진 통합, 고효율 지향 |
| llama.cpp GBNF | GBNF(BNF 계열 CFG) | 문법 파일을 파서로 컴파일, 스텝마다 허용 문자→토큰 | 로컬·엣지 추론(8.4), --grammar 옵션 |
| jsonformer | JSON Schema(제한적) | 값 슬롯만 모델이 생성, 구조는 코드가 채움 | 단순·견고하나 복잡 스키마엔 제한 |
| Guidance / LMQL | 템플릿+제약 DSL | 프로그램으로 생성 흐름·제약을 짬 | 생성 오케스트레이션까지 포함 |
| lm-format-enforcer | JSON Schema·정규식 | 토큰 필터를 여러 백엔드에 끼움 | HF·vLLM 등과 결합 |
이 도구들을 두 설계 철학으로 나누면 이해가 쉽다. 마스킹형은 Outlines·XGrammar·GBNF처럼 모델이 매 스텝 후보를 자유롭게 생성하되 문법으로 마스킹한다 — 임의 문법·재귀·분기를 다룰 수 있어 일반적이고, 이 장의 원리 그대로다. 채우기형은 jsonformer처럼 구조는 코드가 고정으로 출력하고 모델은 값 자리만 채운다 — "{"urgency": 까지는 코드가 쓰고, 숫자만 모델에게 생성시킨 뒤 , "category": 를 코드가 잇는" 식이다. 채우기형은 단순하고 견고하지만 스키마가 정적이어야 하고, 가변 길이 배열·깊은 중첩·선택 필드가 까다로워 표현력이 낮다. 반대로 마스킹형은 표현력이 높은 대신 앞 절에서 본 토큰-문자 정렬 문제를 정면으로 풀어야 한다.
실무에서는 이 도구들을 직접 쓰기보다 서빙 프레임워크의 옵션으로 만나는 경우가 많다(5.4). vLLM은 guided decoding(guided_json·guided_regex·guided_grammar 계열)을 제공하며 백엔드로 Outlines·XGrammar 등을 고르게 한다. SGLang은 구조화 출력·정규식 제약을 자체 지원하며 프리픽스 캐시(5.6)와 결합한다. TGI·TensorRT-LLM도 각자의 형태로 문법·JSON 제약을 노출하고, llama.cpp는 --grammar/--json-schema로 GBNF·스키마 제약을 로컬·온디바이스에서 건다(8.4). 파라미터 이름과 지원 범위는 엔진·버전마다 다르므로, 아래 호출은 개념 예시로 읽어야 한다.
# 서빙 엔진에 JSON Schema 제약을 거는 개념적 호출 (엔진마다 키가 다름 — 공식 문서 확인)
response = client.chat.completions.create(
model="local-model",
messages=[{"role": "user", "content": "이 문의를 분류하라: 결제가 두 번 청구됐어요"}],
extra_body={
"guided_json": { # 엔진에 따라 response_format / grammar 등
"type": "object",
"properties": {
"urgency": {"type": "integer", "minimum": 1, "maximum": 5},
"category": {"type": "string", "enum": ["billing", "technical", "other"]}
},
"required": ["urgency", "category"],
"additionalProperties": False
}
},
)
이 코드에서 반드시 확인할 것은 스키마의 어떤 키워드가 문법 단에서 강제되고 어떤 것이 무시되는가다. 어떤 백엔드는 enum은 오토마톤으로 강제하지만 minimum/maximum 같은 값 범위 제약은 문법으로 표현하기 어려워 무시하고 사후 검증에 맡긴다 — urgency를 정수로는 강제하되 그 정수가 1~5인지는 검사하지 않는 것이다. additionalProperties: false나 복잡한 pattern의 지원 여부도 백엔드마다 갈린다. "스키마에 썼으니 강제되겠지"라는 가정이 여기서 조용히 배신당한다. 무엇이 강제되고 무엇이 안 되는지는 문서와 실험으로 명시해 두고, 강제 안 되는 제약은 사후 검증으로 넘겨야 한다.
6.9.9 형식은 강제해도 품질은 아니다
제약 디코딩을 둘러싼 가장 큰 오해이자 가장 중요한 대목이다. 형식을 강제하면 내용 품질이 떨어질 수 있다. 왜 그런지는 6.9.6의 수계산에 이미 단초가 있었다 — 모델이 67.5% 확률로 원했던 토큰을 문법이 0으로 억제했다. 이 메커니즘이 세 갈래로 품질을 잠식한다.
첫째, 선호 토큰 차단이다. 모델이 가장 원한 토큰이 문법상 막히면 남은 것 중 최선을 뽑는데, 그것이 원래 최선보다 나쁠 수 있다. 스키마가 모델의 "자연스러운" 표현과 어긋날수록 심하다. 둘째, 그리고 실무에서 가장 크게 무는 것은 추론 여지 제거다. JSON을 즉시 강제하면, 모델이 답 앞에 사고사슬(chain-of-thought)을 펼칠 자리가 없어진다. 첫 토큰부터 {를 강제당하면 "생각하고 답하기"를 못 한다. 추론이 필요한 작업 — 수학, 다단계 분류, 근거를 따져야 하는 판단 — 에서 이 여지를 막으면 정확도가 떨어진다(3.7·6.1가 왜 사고사슬이 성능을 높이는지 다룬다). 그래서 실무 완화책은 스키마에 reasoning 필드를 먼저 두거나({"reasoning": "...", "answer": ...} — 모델이 먼저 자유롭게 추론한 뒤 답을 채우게 한다), 아예 자유 추론 → 그다음 구조화의 2단계로 나누는 것이다. 셋째, 분포 왜곡이다. 제약은 모델의 확률분포를 문법 안으로 밀어 넣는데, 이 재정규화가 모델이 학습한 자연스러운 언어 분포를 비틀 수 있다.
이 효과의 크기와 방향은 연구 주장이 엇갈린다. 한쪽에서는 형식·스키마 제약이 추론 과제 성능을 저하시킨다는 관찰을 보고했다 — 형식 제약이 강할수록 추론 정확도가 하락한다는 계열의 연구다. 반대쪽, 특히 구조화 생성 도구 진영에서는 제대로만 하면 떨어지지 않고 오히려 파싱 실패가 줄어 실질 성능이 오른다고 반박한다. 두 주장이 실제로는 서로 다른 것을 재고 있어서 이렇게 갈린다 — 무엇을 "제약"으로 삼았는지(즉시 JSON 강제인지, reasoning 필드를 둔 설계인지), 어떤 과제인지(추론이 필요한지 단순 추출인지), 비교 대상이 무엇인지(제약 없는 자유 생성인지, 재시도 붙은 파싱인지)에 따라 결론이 뒤집힌다. 정직한 결론은 조건 의존이라는 것이다 — 모델·과제·스키마 설계·추론 필드 유무에 따라 다르고, 일반화된 정답은 없다. "제약을 걸면 항상 안전하고 손해 없다"는 마케팅이고, "제약은 항상 멍청하게 만든다"는 과장이다. 유일하게 미덥게 아는 길은 자기 데이터로 오프라인 평가(4.2)와 A/B(8.2)로 재는 것이다.
6.9.10 형식이 맞아도 깨지는 것들
"제약 디코딩 = 100% 안전"이라는 인상이 가장 위험한 오해다. 형식만 보장된다는 것을 엣지케이스로 명시해 두자.
가장 큰 함정은 이미 여러 번 다룬 형식 유효 ≠ 의미 정확이다. {"urgency": 5, "category": "billing"}은 완벽하게 유효한 JSON이고 스키마를 만족하지만, 실제 긴급도가 2였다면 단순히 틀린 값이다. 제약은 "숫자 자리에 숫자를 넣는 것"을 보장하지 "그 숫자가 맞는 것"을 보장하지 않는다. enum도 마찬가지다 — 카테고리를 셋 중 하나로 강제해도 모델은 틀린 하나를 고를 수 있다. 의미 정확성은 여전히 모델 능력·프롬프트·컨텍스트(RAG, 6.3)의 문제다.
max_tokens 절단에 의한 부분 출력도 흔하다. 제약 디코딩은 "문법을 따라가되"이지 "끝까지 완성"을 보장하지 않는다. max_tokens(1.7)에 걸려 중간에 잘리면 오토마톤이 종료 상태에 못 간 채 멈춘다 — 결과는 {"urgency": 5, "cate 같은, 문법상 유효한 접두사이지만 미완성인 JSON이다. 파싱하면 실패한다. 대응은 max_tokens를 스키마가 요구하는 최대 길이보다 충분히 크게 설정하고, finish_reason이 length인지 확인해 절단을 감지해 재시도·확장하는 것이다. 스트리밍(7.1)에서는 완결 전까지 파싱을 미루거나 부분 파서를 쓴다.
무한 생성은 문법이 배열·반복을 허용할 때 나온다. value ("," value)* 같은 규칙은 종료를 허용할 뿐 강제하지 않으므로, 모델이 종료 토큰을 안 내고 "a", "b", "c", ...를 무한히 출력할 수 있다. 반복 성향(1.7)과 겹치면 심해진다. 문법에 최대 반복 횟수(maxItems)를 넣거나 max_tokens·반복 패널티를 안전판으로 둔다.
미묘하지만 중요한 것이 거부와 제약의 충돌이다. 모델이 답하기를 거부해야 하는 상황 — 안전·정책 위반 요청(7.3) — 에서 스키마가 {"answer": string}을 강제하면, 모델은 거부할 자리가 없다. 문법이 답을 억지로 만들어 낸다. 즉 제약이 안전 거부를 무력화하거나, 거부 문구를 억지로 스키마에 우겨넣어 이상한 출력을 낸다. 대응은 스키마에 거부·불확실 경로를 명시적으로 두는 것이다 — 예컨대 {"status": "answered" | "refused" | "unknown", "answer": string | null}처럼. 제약을 쓸 때는 모델이 정직하게 "못 하겠다/모르겠다"고 말할 출구를 스키마에 설계해 둬야 한다. 이것은 안전만의 문제가 아니라 품질의 문제이기도 하다 — 출구가 없으면 모델은 모르는 것도 아는 척 채워 넣는다.
이 모든 엣지케이스 때문에, 제약 디코딩을 쓰더라도 사후 검증은 남긴다. 견고한 파이프라인은 층으로 방어한다.
각 층이 다른 실패를 검출한다. 파싱은 json.loads가 되는가(제약 디코딩이면 대개 통과하되 절단은 예외). 스키마 검증은 필드·타입·범위·enum을 Pydantic이나 JSON Schema validator로 재확인하는데, 여기서 문법이 강제하지 못한 제약 — 앞서 본 minimum/maximum 같은 — 을 검출한다. 의미·업무 규칙은 "긴급도 5인데 카테고리가 'other'면 재확인" 같은 상호 일관성과 값 범위, 거부 여부를 본다. 그리고 재시도가 소진되면 무한 루프에 빠지지 말고 기본값·사람 에스컬레이션·명시적 에러로 안전하게 실패한다. 재시도 예산 없이 "성공할 때까지" 루프를 실행하면 근본적으로 불가능한 요청에서 비용·지연이 폭주하므로(7.5), 항상 상한을 두고 반복 실패는 로그로 남겨 스키마·프롬프트·모델을 고칠 신호로 삼는다(7.6·8.1).
6.9.11 언제 무엇을 쓰나
정답은 상황이 정한다. 세 갈래로 출발점을 펼친다.
벤더 API를 쓰면서 실제 도구를 부르거나 에이전트를 구현한다면, 도구 호출(6.5)이나 벤더의 스키마 강제 구조화 출력이 자연스럽다. 벤더가 제약 디코딩을 내부에서 해 주니 직접 문법을 만질 필요가 없고, 사후 검증만 안전망으로 남기면 된다. 벤더 API인데 파싱 안전만 원하고 스키마가 단순하다면, JSON 모드에 Pydantic 검증과 재시도를 붙이는 것으로 충분한 경우가 많다 — 부하가 작고 이식성이 높으며, 강한 모델에서는 첫 시도 실패율이 낮아 재시도가 드물게 걸린다. 자체 서빙(vLLM·SGLang·llama.cpp, 5.4)을 하거나 벤더가 지원 못 하는 복잡한 문법(정규식·DSL·재귀 구조)을 강제해야 한다면, 제약 디코딩(Outlines·XGrammar·GBNF)이 강수다. 형식이 절대 깨지면 안 되고 로짓 접근이 가능할 때 쓰되, 컴파일·오버헤드·추론 여지 축소를 감수하고 A/B로 검증한다.
어느 쪽을 고르든 가로지르는 원칙이 몇 개 있다. 추론이 필요하면 구조를 늦춰라 — 즉시 JSON을 강제하지 말고 reasoning 필드를 먼저 두거나 2단계로 나눈다. 거부·불확실 출구를 스키마에 넣어 모델이 정직하게 "모름/못 함"을 표현할 자리를 준다. 제약을 쓰든 안 쓰든 사후 검증은 유지한다 — 형식 보장은 의미 보장이 아니기 때문이다. 그리고 제약이 품질·지연에 주는 순 효과는 워크로드마다 다르니, 추측이 아니라 평가와 A/B로 정한다.
한 문장으로 남길 것은 이것이다. 구조화 출력은 모델과 코드 사이의 계약을 세워 파이프라인의 취약함을 없애고, 제약 디코딩은 그 계약의 형식을 정의상 보장한다 — 그러나 계약의 형식을 지킨다는 것과 계약의 내용이 참이라는 것은 다른 문제이며, 후자는 여전히 모델의 몫으로 남는다. 이 경계를 정확히 아는 것이 이 장의 전부다. 제6부의 응용 — 프롬프트·검색·도구·에이전트·멀티모달, 그리고 이 장의 구조화 출력 — 은 여기서 매듭지어진다. 다음 부는 이 능력들을 실제 사용자에게 서비스로 내보내는 문제로 넘어가고, 그 첫 장인 챗 제품 설계(7.1)에서 대화 UX·스트리밍·세션을 다룬다. 이 장에서 세운 구조화 출력·제약 디코딩은 거기서 제품의 신뢰할 수 있는 API 경계를 만드는 도구로 다시 등장한다.