Field Log · Entry
Structured Generation: JSON Schema·FSM·CFG로 출력 강제하기 (6/10)
오늘의 결론
- “JSON으로 답해”라는 prompt는 형식을 요청할 뿐 보장하지 않습니다. Constrained decoding은 매 생성 단계에서 문법상 불가능한 토큰의 확률을 제거해 출력 언어를 제한합니다.
- 정규식과 FSM은 평평한 패턴에, CFG와 parser stack은 중첩된 JSON·코드·SQL에 적합합니다. JSON Schema는 문법으로 바꿀 수 있는 부분과 실행 후 검사해야 하는 의미 제약으로 나눠야 합니다.
- 문법적으로 유효한 JSON이 곧 올바른 tool call은 아닙니다. 타입·범위·상호 필드 조건·리소스 존재 여부·권한·부작용은 별도의 validator와 policy gate가 책임집니다.
- 구조화 생성 엔진은 valid rate만으로 고르지 않습니다. schema compile time, time to first token, inter-token latency, cache hit, semantic success, 빈 출력과 dead end 비율을 함께 측정합니다.
- 제약은 모델의 지식을 늘리지 않습니다. 잘못된 값을 유효한 형식으로 확신 있게 출력할 수도 있으므로 evidence grounding과 uncertainty routing은 그대로 필요합니다.
앞 글에서는 답을 atomic claim으로 나누고 evidence와 citation을 검사했습니다. 이제 판정 결과나 tool argument를 다음 시스템이 안전하게 소비할 수 있도록 출력의 구조 자체를 보장해 봅니다.
prompt only
→ generate any token
→ parse
→ fail
→ repair prompt
→ generate again
constrained decoding
schema → grammar → parser state → allowed-token mask
↓
sample only valid token
→ syntactically valid output → semantic validation → policy gate
이 글에서 답하는 질문
- Prompt, parser, retry와 constrained decoding은 무엇이 다른가?
- FSM·DFA와 CFG·stack은 각각 어떤 제약을 표현하는가?
- JSON Schema를 그대로 token mask로 바꿀 수 없는 이유는 무엇인가?
- Subword tokenizer와 UTF-8 경계에서 왜 구현이 까다로운가?
- RAG Agent의 tool call에서 문법 검증과 권한 검증을 어떻게 분리하는가?
- 구조 유효성, 의미 정확도, 지연을 어떤 실험으로 비교하는가?
1. Structured generation은 출력 공간을 줄이는 일이다
언어 모델은 현재 prefix를 조건으로 다음 token의 확률 분포를 계산합니다.
$$ p_\theta(y_t \mid x, y_{<t}) $$
일반 decoding은 vocabulary $V$ 전체에서 token을 고릅니다. 문법 $G$와 현재 parser state $s_t$가 허용하는 token 집합을 $A_G(s_t)$라고 하면 constrained decoding은 다음처럼 logits를 mask합니다.
$$ \tilde{z}{t,v} = \begin{cases} z{t,v} & v \in A_G(s_t) \ -\infty & v \notin A_G(s_t) \end{cases} $$
그리고 $\operatorname{softmax}(\tilde{z}_t)$에서 다음 token을 선택합니다. 핵심은 생성이 끝난 뒤 잘못된 문자열을 발견하는 것이 아니라, 잘못된 prefix가 시작되지 못하게 하는 것입니다.
Prompt와 parser는 없어지는가
아닙니다. 세 층의 역할이 다릅니다.
| 층 | 보장하는 것 | 보장하지 못하는 것 |
|---|---|---|
| Prompt·few-shot | 어떤 내용을 왜 채울지 유도 | 문법 유효성의 절대 보장 |
| Constrained decoder | 허용된 언어의 문법·일부 값 집합 | 사실성·권한·비즈니스 의미 |
| Parser·validator | 완성 결과의 타입·관계·도메인 규칙 | 모델이 올바른 행동을 선택했다는 사실 |
Prompt는 여전히 field의 의미와 선택 기준을 알려 줍니다. Parser와 validator도 defense in depth로 남깁니다. 다만 끝없는 “JSON을 고쳐서 다시 출력해” loop를 기본 제어 방식으로 쓰지 않습니다.
2. 정규 언어와 FSM: 상태만으로 다음 문자를 결정한다
Finite State Machine, 특히 deterministic finite automaton(DFA)은 현재 상태 하나와 입력 symbol로 다음 상태를 결정합니다.
예를 들어 priority가 low|medium|high 중 하나여야 한다면 다음 정규식으로 표현할 수 있습니다.
"priority"\s*:\s*"(low|medium|high)"
이 패턴은 유한한 상태 전이로 처리할 수 있습니다.
START → "priority" → : → " → l|m|h → ... → " → ACCEPT
FSM이 잘 맞는 사례는 다음과 같습니다.
- 날짜·ID·전화번호처럼 길이와 alphabet이 제한된 문자열
- enum, boolean, 고정된 command keyword
- 간단한 line protocol과 delimiter 패턴
- 최대 깊이가 미리 작게 제한된 구조
하지만 임의 깊이의 괄호 짝처럼 “열린 항목이 몇 개인지” 기억해야 하는 구조는 유한한 상태만으로 자연스럽게 표현하기 어렵습니다. 이때 stack을 사용하는 CFG parser가 필요합니다.
3. CFG와 parser stack: 중첩 구조를 기억한다
Context-Free Grammar는 nonterminal을 production rule로 전개합니다. 아주 단순화한 JSON grammar는 다음과 같습니다.
value ::= object | array | string | number | "true" | "false" | "null"
object ::= "{" members? "}"
members ::= pair ("," pair)*
pair ::= string ":" value
array ::= "[" elements? "]"
elements ::= value ("," value)*
{를 읽으면 parser는 앞으로 }가 필요하다는 상태를 stack에 쌓습니다. 배열 안에 객체가 있고 그 안에 배열이 있더라도 stack으로 중첩을 추적합니다.
CFG가 필요한 대표 사례는 다음과 같습니다.
- 중첩 JSON과 함수 호출 argument
- SQL·Python·DSL처럼 괄호와 production rule이 있는 언어
- AST나 query plan처럼 재귀적인 구조
- 입력마다 선택 가능한 entity나 column이 달라지는 input-dependent grammar
Grammar-Constrained Decoding은 formal grammar를 정보 추출·entity disambiguation·parsing 같은 구조화 NLP 전반에 적용하고, 입력에 따라 달라지는 grammar를 제안했습니다. PICARD는 text-to-SQL에서 각 partial output을 점진적으로 parsing해 유효하지 않은 token을 거부하는 초기의 대표 접근입니다.
4. JSON Schema는 문법과 의미의 경계에 있다
다음 tool schema를 생각해 봅시다.
{
"type": "object",
"properties": {
"document_id": { "type": "string", "minLength": 1 },
"top_k": { "type": "integer", "minimum": 1, "maximum": 20 },
"mode": { "enum": ["lexical", "dense", "hybrid"] },
"filters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"field": { "type": "string" },
"value": { "type": "string" }
},
"required": ["field", "value"],
"additionalProperties": false
}
}
},
"required": ["document_id", "top_k", "mode"],
"additionalProperties": false
}
여기에는 서로 다른 종류의 제약이 섞여 있습니다.
Decoding 중 강제하기 쉬운 제약
{,},:,,와 quote 위치- field name과 enum의 허용 집합
- required field의 등장과 additional property 금지
- string·number·boolean·array·object의 기본 타입
- 일부 길이·숫자 형식·중첩 깊이 제한
완성 후 검사하는 편이 안전한 제약
top_k <= tenant.plan.max_top_k같은 runtime 관계document_id가 실제 존재하고 요청자에게 허용되는지mode=hybrid일 때만 필요한 조건부 field- 두 날짜의 선후 관계와 업무 달력 규칙
- vector DB의 실제 metadata field 목록
- 외부 API의 현재 quota와 side-effect 정책
Schema keyword 지원 범위는 엔진마다 다릅니다. $ref, recursion, oneOf, anyOf, patternProperties, Unicode pattern, 큰 numeric range가 모두 같은 방식으로 지원된다고 가정하면 안 됩니다. JSONSchemaBench는 structured-output framework를 schema coverage, efficiency, quality 관점에서 비교하기 위한 benchmark를 제안했습니다. “JSON mode 지원”이라는 한 문장보다 내 schema corpus에서 어떤 keyword가 compile되고 실제 valid output을 만드는지 측정해야 합니다.
5. Schema에서 token mask까지의 전체 경로
Production 파이프라인은 다섯 단계로 나눠 볼 수 있습니다.
5.1 Normalize
Schema를 canonical form으로 바꿉니다.
$ref를 안전하게 resolve하되 cycle과 최대 깊이를 제한합니다.- object key 순서 정책과 optional field 정책을 정합니다.
- unsupported keyword를 조용히 무시하지 않고 compile error로 반환합니다.
- schema hash에 engine version과 tokenizer version을 포함합니다.
cache_key = sha256(
canonical_schema
+ grammar_engine_version
+ tokenizer_name
+ tokenizer_revision
)
Tokenizer가 바뀌면 같은 grammar라도 token별 허용 mask가 달라질 수 있으므로 cache key에서 빠지면 안 됩니다.
5.2 Compile
Schema 또는 regex를 automaton·CFG·parser program으로 변환합니다. 이 과정은 요청마다 반복하면 time to first token을 크게 늘릴 수 있으므로 자주 쓰는 schema는 배포 시 미리 compile하고 cache합니다.
5.3 Step
현재 생성 prefix와 parser state를 이용해 다음에 가능한 terminal을 구합니다. CFG라면 여러 stack state가 동시에 살아 있을 수 있습니다.
5.4 Map terminal to tokens
허용된 문자 또는 byte prefix를 vocabulary token 집합으로 바꿉니다. Efficient Guided Generation은 FSM 전이와 vocabulary index를 결합하는 방식을 설명합니다. SynCode는 offline DFA mask store를 사용해 CFG 기반 token filtering을 효율화합니다.
5.5 Mask before sampling
허용 token만 남긴 뒤 temperature, top-p 같은 sampling을 적용합니다. 순서를 뒤집으면 top-p 단계에서 유효 token이 모두 잘리거나, invalid token을 고른 뒤 사후 거부하는 비효율이 생길 수 있습니다.
def constrained_step(logits, parser_state, token_index):
allowed = token_index.allowed_tokens(parser_state)
if not allowed:
raise GrammarDeadEnd(parser_state)
masked = logits.new_full(logits.shape, float("-inf"))
masked[allowed] = logits[allowed]
token_id = sample(masked)
next_state = parser_state.consume(token_id)
return token_id, next_state
실제 구현은 token 하나가 여러 byte를 포함할 수 있어 consume(token_id)가 단순 문자 한 개 전이보다 훨씬 복잡합니다.
6. Tokenizer와 UTF-8 경계가 어려운 이유
문법은 보통 문자 또는 byte 단위로 정의되지만 모델은 subword token을 생성합니다. 한 token이 다음을 한꺼번에 포함할 수 있습니다.
token A = '"mode": "hy'
token B = 'brid", "top_k": '
token C = '20}'
현재 문법이 "hybrid"의 hy까지 허용한다고 해서 hy로 시작하는 모든 token을 허용할 수는 없습니다. token의 전체 byte sequence를 parser에 흘려보냈을 때 살아남는 상태가 있어야 합니다.
또한 UTF-8 문자는 여러 byte로 구성됩니다. Token boundary가 Unicode code point 경계와 일치한다고 가정하면 한글·emoji·escape sequence에서 잘못된 mask를 만들 수 있습니다.
안전한 엔진은 다음 조건을 다뤄야 합니다.
- partial UTF-8 sequence와 byte fallback
- token 안의 여러 terminal 소비
- escape된 quote, backslash,
\uXXXX - whitespace와 number prefix의 모호성
- EOS가 허용되는 정확한 accepting state
- 여러 parser stack state가 같은 prefix를 받아들이는 경우
XGrammar은 vocabulary를 context-independent token과 runtime 해석이 필요한 context-dependent token으로 나누고 persistent stack을 사용해 CFG 실행 비용을 줄이는 방향을 제안했습니다. 이 연구는 MLSys 2025에 발표됐지만, 특정 수치를 모든 모델과 schema에 일반화해서는 안 됩니다. 실제 tokenizer·batch·schema로 재측정해야 합니다.
7. 문법 유효성과 의미 유효성을 분리한다
다음 결과는 완벽한 JSON이지만 실행해서는 안 될 수 있습니다.
{
"document_id": "finance-board-confidential",
"top_k": 20,
"mode": "hybrid",
"filters": []
}
문법 엔진은 이 값이 schema를 따른다는 것만 압니다. 요청자에게 해당 문서 권한이 있는지, top_k=20이 현재 plan에서 허용되는지 알지 못합니다.
따라서 tool 실행 경계는 다음처럼 구성합니다.
LLM output
→ syntax parser
→ schema validator
→ semantic validator
→ authorization policy
→ confirmation / approval for side effects
→ idempotent executor
→ typed result
반드시 모델 밖에 둘 것
- tenant·user ACL과 row-level authorization
- 결제·삭제·메일 발송 같은 side-effect 승인
- rate limit, budget, quota
- 허용된 host·path·SQL operation
- idempotency key와 retry 정책
- 감사 로그와 secret redaction
Constrained decoding은 security boundary가 아니라 interface reliability mechanism입니다.
8. RAG Agent에서는 세 종류의 구조화 출력이 필요하다
8.1 Retrieval plan
{
"queries": ["..."],
"strategy": "hybrid",
"top_k": 8,
"filters": [{"field": "product", "value": "etcher-a"}]
}
여기서는 query 개수와 strategy enum은 decoding에서 제한할 수 있습니다. 그러나 filter field가 현재 index schema에 존재하는지는 semantic validator가 확인합니다.
8.2 Tool call
Tool 이름과 argument schema를 grammar에 넣어 invalid function name과 누락된 required field를 줄입니다. 다만 tool이 여러 개인 경우 전체 schema를 한 번에 넣으면 grammar와 prompt가 커집니다. 먼저 router가 tool 후보를 좁힌 뒤 선택된 tool schema만 compile하는 방식도 비교할 가치가 있습니다.
8.3 Evidence-bound answer contract
{
"answer": "...",
"claims": [
{"text": "...", "evidence_ids": ["doc7:p3:s2"]}
],
"confidence": "medium",
"action": "answer"
}
구조를 강제하면 claim과 evidence ID를 안정적으로 파싱할 수 있습니다. 하지만 존재하지 않는 evidence ID를 모델이 생성할 수 있습니다. 가능한 ID를 input-dependent enum으로 제한하거나 생성 후 retrieved set과 대조해야 합니다.
9. Prompt+retry와 constrained decoding을 비교하는 실험
한두 예제로 “잘 된다”고 결론 내리면 안 됩니다. 실제 schema corpus와 traffic slice로 비교합니다.
실험군
- Prompt only
- Prompt + parse + 최대 2회 repair retry
- JSON mode
- JSON Schema constrained decoding
- CFG constrained decoding
데이터 slice
- 평평한 object와 깊은 nested object
- enum이 작은 schema와 수천 개 ID를 가진 schema
- optional field가 많은 schema
oneOf·recursive$ref·Unicode pattern- 한국어 자유 서술 field가 긴 schema
- parallel tool call과 streaming response
- 짧은 context와 긴 RAG context
지표
| 지표 | 질문 |
|---|---|
| Syntax valid rate | Parser가 한 번에 읽을 수 있는가? |
| Schema valid rate | required·type·enum·additional property를 만족하는가? |
| Semantic success | 실제 업무 규칙과 tool precondition을 만족하는가? |
| Task success | 실행 결과가 사용자 목표를 달성했는가? |
| Compile latency | schema를 grammar로 바꾸는 데 얼마나 걸리는가? |
| TTFT | compile·cache lookup 후 첫 token까지 얼마나 걸리는가? |
| Inter-token latency | mask 계산이 decode step을 얼마나 늘리는가? |
| End-to-end latency | retry까지 포함한 p50·p95·p99는 얼마인가? |
| Output tokens | whitespace·key order·강제 표현이 token 수를 늘리는가? |
| Dead-end rate | 허용 token이 사라지거나 EOS에 도달하지 못하는가? |
| Empty-valid rate | {}나 빈 배열 같은 쓸모없는 유효 출력이 늘었는가? |
Retry 방식은 첫 시도 지연이 작아 보여도 실패 요청에서 모델 호출이 한 번 더 발생합니다. Constrained decoding은 step overhead가 있지만 retry를 줄일 수 있습니다. 따라서 단일 token benchmark와 end-to-end task benchmark를 둘 다 봐야 합니다.
10. 흔한 실패 패턴
실패 1. Valid JSON 비율만 보고 성공이라 판단한다
모든 결과가 JSON이어도 action="approve"가 잘못 선택되면 제품은 실패합니다.
대응: syntax, schema, semantic, task success를 분리하고 단계별 failure count를 남깁니다.
실패 2. 지원하지 않는 schema keyword를 묵살한다
엔진이 patternProperties를 무시하면 사용자는 제약이 적용됐다고 오해합니다.
대응: compile report에 supported, lowered, post-validated, rejected keyword를 명시합니다.
실패 3. 거대한 dynamic enum을 매 요청 compile한다
수만 개 document ID를 schema enum으로 넣으면 compile time과 memory가 커질 수 있습니다.
대응: stable prefix grammar와 runtime trie를 분리하거나, opaque ID를 생성한 뒤 retrieved candidate set에서 membership을 검사하는 두 방식을 benchmark합니다.
실패 4. Grammar dead end를 무한 retry한다
Parser bug, tokenizer mismatch, contradictory schema라면 같은 요청을 반복해도 낫지 않습니다.
대응: compile-time satisfiability test, bounded retry, fallback policy, exact parser state trace를 둡니다.
실패 5. Streaming 중간 JSON을 downstream이 실행한다
아직 닫히지 않은 object를 partial parser가 읽었다고 바로 tool을 실행하면 argument가 뒤에서 바뀔 수 있습니다.
대응: accepting state와 semantic validation이 완료된 뒤 commit합니다. UI preview와 side-effect 실행을 분리합니다.
실패 6. 구조 제약을 권한 제약으로 착각한다
user_id가 string이라는 사실은 그 user를 조회할 권한이 있다는 뜻이 아닙니다.
대응: authorization은 trusted runtime identity와 resource policy로 판정합니다.
11. 운영 trace를 어떻게 남길까
다음 필드는 문제 재현에 유용합니다.
{
"schema_hash": "sha256:...",
"schema_version": "search-tool@7",
"grammar_engine": "engine@version",
"tokenizer_revision": "model-tokenizer@sha",
"compile_cache_hit": true,
"compile_ms": 0.4,
"mask_ms_total": 18.7,
"generated_tokens": 96,
"syntax_valid": true,
"schema_valid": true,
"semantic_valid": false,
"semantic_errors": ["document_not_authorized"],
"tool_executed": false,
"fallback": "ask_user"
}
Raw prompt와 민감한 argument를 그대로 기록하지 말고 ID·hash·redacted diagnostic을 사용합니다. Schema와 tokenizer revision이 있어야 같은 코드에서도 왜 mask 결과가 달라졌는지 추적할 수 있습니다.
12. 최소 구현 순서
처음부터 모든 JSON Schema 기능을 지원하려 하지 말고 다음 순서로 범위를 넓힙니다.
- 실제 tool call 3개를 고르고 schema를 작게 유지합니다.
- object, required, string, integer, boolean, enum, array 지원 범위를 선언합니다.
- Unsupported keyword는 hard error로 처리합니다.
- Schema compile cache와 tokenizer revision key를 만듭니다.
- Syntax parser와 독립적인 semantic validator를 둡니다.
- Authorization과 side-effect approval을 모델 밖에 둡니다.
- Prompt+retry baseline과 end-to-end latency·task success를 비교합니다.
- Unicode, escape, nested, empty, max-length, cancellation test를 추가합니다.
- Production trace에서 dead end와 semantic failure를 따로 집계합니다.
- Schema 변경 때 replay corpus를 실행하고 canary로 배포합니다.
13. 실전 체크리스트
- Structured output의 소비자와 실패 비용을 먼저 정의했다.
- Prompt, grammar, schema validator, semantic validator의 책임이 분리됐다.
- 지원하지 않는 JSON Schema keyword를 compile error로 드러낸다.
- Tokenizer와 grammar engine version이 cache key와 trace에 포함된다.
- Mask가 sampling 전에 적용되고 accepting state에서만 EOS를 허용한다.
- UTF-8·escape·nested object·recursive reference 경계를 테스트했다.
- 빈 object·빈 배열처럼 형식만 유효한 답을 별도 측정한다.
- Schema compile latency와 per-token mask overhead를 측정한다.
- ACL·quota·side effect 승인을 trusted runtime에서 검사한다.
- Prompt+retry baseline과 semantic·task success까지 비교했다.
- Schema drift 시 replay와 canary rollback 경로가 있다.
- Raw sensitive arguments 없이 재현 가능한 trace를 남긴다.
스스로 확인하기
Q1. JSON Schema constrained decoding을 쓰면 argument 값도 사실이 되는가?
아닙니다. 문법과 일부 type·enum 제약을 만족할 뿐입니다. RAG evidence, resource existence, cross-field relation, 권한은 별도로 확인해야 합니다.
Q2. 왜 모든 JSON을 정규식 하나로 처리하지 않는가?
임의 깊이의 중첩과 괄호 짝은 stack이 필요한 구조입니다. 깊이를 작게 고정하면 정규식으로 펼칠 수 있지만 일반적인 recursive JSON은 CFG parser가 자연스럽습니다.
Q3. 출력이 항상 parse되면 parser를 제거해도 되는가?
제거하지 않는 편이 안전합니다. Engine bug, version mismatch, transport truncation을 방어하고 typed object를 만들기 위해 final parse와 validation은 유지합니다.
Q4. Constrained decoding이 항상 더 빠른가?
아닙니다. Compile과 per-token mask 비용이 있습니다. 대신 repair retry를 줄일 수 있으므로 schema별 cache를 포함한 end-to-end p95와 비용으로 판단합니다.
Q5. 가장 위험한 오해는 무엇인가?
문법적으로 유효한 tool call을 승인된 tool call로 착각하는 것입니다. 구조화 생성과 authorization은 서로 다른 보안 경계입니다.
마무리
Structured generation의 본질은 모델에게 예쁜 JSON을 부탁하는 것이 아니라 현재 parser state에서 불가능한 미래를 제거하는 것입니다. FSM은 평평한 패턴을, CFG와 stack은 중첩 구조를 다루고, tokenizer-aware index가 문법의 terminal과 실제 model token을 연결합니다.
그러나 문법은 시작점일 뿐입니다. Production RAG Agent는 syntax → schema → semantics → authorization → execution이라는 계약 사슬을 가져야 합니다. 이 사슬을 분리해 두면 형식 오류, 모델 판단 오류, 정책 위반을 같은 “JSON 실패”로 뭉개지 않고 올바른 계층에서 고칠 수 있습니다.
다음 글에서는 teacher model이 만든 instruction·rationale·response를 filtering과 rejection sampling으로 정제해 student model을 학습하는 방법, 그리고 synthetic recursion과 model collapse 위험을 다룹니다.
참고문헌
- Scholak et al., PICARD: Parsing Incrementally for Constrained Auto-Regressive Decoding from Language Models, 2021.
- Geng et al., Grammar-Constrained Decoding for Structured NLP Tasks without Finetuning, EMNLP 2023.
- Willard and Louf, Efficient Guided Generation for Large Language Models, 2023.
- Ugare et al., SynCode: LLM Generation with Grammar Augmentation, 2024.
- Dong et al., XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models, MLSys 2025.
- Geng et al., JSONSchemaBench: A Rigorous Benchmark of Structured Outputs for Language Models, 2025.
검증 메모 — 문헌 링크와 서지 정보는 2026년 7월 16일 확인했습니다. Engine별 JSON Schema keyword 지원 범위와 성능은 빠르게 바뀌므로 실제 사용 버전의 문서와 schema corpus에서 다시 검증하세요.