지난 글까지 스키마를 어떻게 적는지를 다뤘다면, 이번에는 그 스키마가 실제로 무슨 일을 하는지를 본다. "스키마를 넘기면 형태가 보장된다"는 문장은 결과만 말한 것이고, 그 보장이 어디에서 오는지를 알면 왜 어떤 스키마는 느린지, 왜 제약을 걸었더니 답이 더 나빠지는 경우가 있는지가 같이 설명된다.
마스킹은 확률을 낮추는 게 아니다
모델은 매 스텝마다 어휘 전체에 대한 점수, 즉 로짓 벡터를 낸다. 샘플링은 이 벡터를 확률로 바꿔 하나를 고르는 일이다. 제약 디코딩은 그 사이에 한 단계를 끼워 넣는다. 지금 문법상 올 수 없는 토큰의 로짓을 음의 무한대로 바꾼다.
소프트맥스를 거치면 $-\infty$ 자리는 정확히 0이 된다. 온도를 아무리 올려도, top-p를 아무리 넓혀도 그 토큰은 안 뽑힌다. "웬만하면 안 나온다"가 아니라 "나올 수 없다"인 이유가 이것이다.
허용 집합을 정하는 것은 파서다. 지금까지 생성된 문자열을 스키마로 만든 상태 기계에 넣으면 현재 상태가 나오고, 그 상태에서 나갈 수 있는 간선이 곧 허용되는 다음 문자다. JSON은 괄호가 짝을 이뤄야 하므로 순수한 유한 상태 기계로는 부족하고, 중첩 깊이를 담을 스택이 붙는다. 배열 안의 객체 안의 배열을 처리하려면 그 스택이 있어야 한다.
어려운 부분은 토크나이저다
원리만 보면 간단한데 구현이 까다로운 이유는 모델이 문자 단위로 생성하지 않기 때문이다. 어휘는 "ity, ": ", P1"처럼 여러 문자를 묶은 조각이고, 그 조각들은 JSON 문법의 경계와 전혀 맞춰져 있지 않다.
그래서 "이 상태에서 허용되는 문자"를 아는 것만으로는 마스크를 못 만든다. 어휘의 토큰 하나하나에 대해 "이 토큰을 통째로 붙이면 문법을 어기지 않고 갈 수 있는가"를 물어야 한다. 어휘가 15만 개면 상태마다 15만 번을 묻는 셈이다. 이걸 매 스텝에 하면 생성이 눈에 띄게 느려진다.
실무 구현은 이 계산을 미리 해 두는 쪽으로 푼다. 상태별 허용 토큰 집합을 만들어 인덱스로 들고, 생성 중에는 조회만 한다.
| 단계 | 언제 | 대략의 비용 |
|---|---|---|
| 스키마를 오토마톤으로 컴파일 | 스키마당 1회 | 수 ms ~ 수백 ms |
| 상태별 허용 토큰 인덱스 구축 | 스키마당 1회 | 스키마 복잡도에 비례 |
| 스텝마다 마스크 조회·적용 | 토큰마다 | 어휘 크기의 비트마스크 한 번 |
| 상태 전이 | 토큰마다 | 상수 시간 |
토큰마다 드는 비용은 GPU에서 로짓 벡터를 다루는 시간에 비하면 무시할 만하다. 체감 지연은 거의 전부 위의 두 줄, 즉 한 번만 하면 되는 일에서 나온다. 그래서 실무에서 제약 디코딩이 느리다고 느껴지는 상황은 대개 하나다 — 매 요청마다 스키마를 새로 만들어 캐시가 안 맞는 것이다.
# 요청마다 새 dict가 만들어져 캐시 키가 매번 달라진다
def handle(req):
schema = {"type": "object", "properties": build_props(req.fields), ...}
return call_model(req.text, schema)
# 스키마 종류만큼만 만들어 두고 재사용한다
SCHEMAS = {
"ticket": TICKET_SCHEMA,
"invoice": INVOICE_SCHEMA,
}
def handle(req):
return call_model(req.text, SCHEMAS[req.kind])
동적으로 스키마를 조립해야 한다면 최소한 조립 결과를 키로 캐시한다. 종류가 수십 개를 넘지 않는다면 이 한 줄로 첫 토큰 지연이 정리된다.
직접 만들면 이런 모양이다
로컬 모델을 직접 돌린다면 로짓 프로세서로 같은 일을 할 수 있다. 원리를 보기에는 이쪽이 명확하다.
import torch
class SchemaMask:
"""오토마톤 상태를 들고 다니며 매 스텝 마스크를 만든다."""
def __init__(self, automaton, vocab_size: int):
self.automaton = automaton
self.state = automaton.start
self.vocab_size = vocab_size
def __call__(self, input_ids, scores):
allowed = self.automaton.allowed_tokens(self.state) # 미리 만든 인덱스
mask = torch.full((self.vocab_size,), float("-inf"), device=scores.device)
mask[allowed] = 0.0
return scores + mask
def accept(self, token_id: int) -> None:
self.state = self.automaton.step(self.state, token_id)
allowed_tokens가 빈 집합을 돌려주는 순간이 이 구조의 유일한 치명적 실패다. 모든 토큰이 막히면 샘플링할 것이 없다. 스키마와 어휘가 어긋나 있을 때 — 예를 들어 pattern이 어떤 토큰 조합으로도 만들 수 없는 문자열을 요구할 때 — 실제로 발생한다. 구현체는 보통 이 경우 예외를 던지거나 종료 토큰을 강제한다. 스키마를 바꿨는데 갑자기 빈 응답이 오기 시작하면 이 자리를 의심한다.
제약이 품질을 떨어뜨리는 경우
여기가 이 글에서 가장 실용적인 부분이다. 제약 디코딩은 형태를 보장하지만, 모델이 원래 가려던 경로를 막는 부작용이 있다.
모델이 어떤 입력에 대해 "이 문서에는 해당 정보가 없습니다"라고 답하려 했다고 하자. 스키마가 {"amount": {"type": "number"}}를 필수로 요구하면 그 경로는 통째로 막힌다. 남은 후보는 숫자뿐이고, 모델은 숫자를 하나 고른다. 이때 나오는 값은 근거가 아니라 그저 확률이 가장 높았던 숫자다. 겉보기에는 자신 있게 채워진 필드이고, 파싱도 검증도 다 통과한다.
즉 제약은 모델의 불확실성을 지워 버린다. 모르는 상태 그대로 출력될 자리가 없어지고, 대신 그럴듯한 값이 들어간다. 스키마에 널 자리를 만들어 두라는 이야기의 기계 수준 근거가 이것이다.
# 모델에게 "모른다"고 답할 경로를 남긴다
{
"amount": {"type": ["number", "null"]},
"amount_source": {
"type": "string",
"enum": ["문서에 명시", "계산", "찾지 못함"],
},
}
amount_source처럼 확신의 근거를 함께 적게 하면 뒷단에서 걸러 낼 수 있는 신호가 생긴다. "찾지 못함"인데 amount가 채워져 있으면 그 응답은 버린다. 모델이 스스로 모순을 적는 일은 드물다.
두 번째 부작용은 조금 더 미묘하다. 제약은 국소적으로 작동한다. 매 스텝에서 문법을 어기지 않는 토큰만 고를 뿐, 그 선택이 나중에 막다른 골목으로 이어질지는 보지 않는다. 그래서 앞부분에서 문법적으로는 멀쩡하지만 뒤가 궁색해지는 경로로 들어가는 일이 생긴다. 긴 문자열 필드에 상한 길이가 걸려 있을 때 문장이 어색하게 끊기는 것이 대표적이다. 이 문제를 근본적으로 없애려면 앞을 내다보는 탐색이 필요한데 비용이 크므로, 실무에서는 상한을 넉넉히 두는 쪽으로 피한다.
스트리밍과 부분 파싱
제약 디코딩이 주는 덤이 하나 있다. 생성 도중의 부분 문자열이 항상 유효한 접두사라는 점이다. 그래서 스트리밍 중에 부분 파싱을 해도 안전하다. {"title": "제안서 초까지 받았을 때, 이 뒤에 무엇이 오든 title이 문자열이라는 사실은 변하지 않는다.
def stream_fields(chunks):
buf = ""
for chunk in chunks:
buf += chunk
# 제약이 걸려 있으므로 buf는 언제나 유효한 JSON 접두사다
partial = parse_partial(buf)
if "title" in partial:
yield ("title", partial["title"])
제약 없이 이걸 하면 위험하다. 접두사가 유효하다는 보장이 없으므로 부분 파서가 엉뚱한 구조를 추정하고, 뒤에 오는 토큰이 그 추정을 뒤집는다. UI에 필드를 하나씩 채워 보여 주는 화면은 제약 디코딩이 있어야 제대로 만들어진다.
정리
제약 디코딩은 세 문장으로 요약된다. 허용되지 않는 토큰의 로짓을 $-\infty$로 만든다. 무엇이 허용되는지는 스키마를 컴파일한 상태 기계가 정한다. 무거운 계산은 스키마당 한 번만 하고 스텝마다는 조회만 한다.
그리고 대가가 있다. 형태를 보장받는 대신 모델이 "모르겠다"고 말할 자리를 잃는다. 그 자리를 스키마 안에 명시적으로 만들어 두는 것이 제약 디코딩을 제대로 쓰는 방법이다.
다음 글에서는 JSON을 넘어 임의의 문법으로 제약을 거는 쪽 — 문법 기반 생성을 본다.
지난 글: JSON Schema로 출력 형태를 못 박기
다음 글: 문법으로 출력을 제약하기
읽어주셔서 감사합니다. 😊

