XGrammar-2: 구조화 출력을 80배 빠르게 만든 설계 원리
LLM이 툴을 호출하거나 JSON을 반환할 때, 사실 꽤 무거운 작업이 뒤에서 돌아가고 있습니다. 모델이 토큰을 하나 뱉을 때마다 "이 토큰이 현재 문법 상태에서 유효한가?"를 실시간으로 판단해야 하고, 툴이 수백 개로 늘어나면 요청이 들어올 때마다 문법을 새로 컴파일하는 비용이 조용히 쌓이기 시작합니다. AI 에이전트를 직접 서빙해봤든 아니든, 이 문제는 LLM 기반 서비스를 운영하다 보면 피하기 어려운 병목이 됩니다.
저도 DeepSeek 계열 모델에 함수 호출을 붙이다가 <think> 블록이 끝난 뒤 JSON 파싱이 뒤틀리는 상황을 몇 번 겪었는데, 당시엔 그냥 파서를 직접 짜서 우겨넣었습니다. 그게 얼마나 야만적인 방식이었는지는 XGrammar-2를 보고 나서야 실감했습니다.
2026년 5월, CMU 연구팀과 MLC 프로젝트가 공개한 XGrammar-2는 XGrammar 1세대 대비 최대 80배 효율 향상, 토큰당 처리 속도 6–10배, 컴파일 시간 100배 이상 단축을 달성했습니다. 이 글은 논문과 공식 블로그를 분석하고 코드 예시 일부를 직접 검증한 내용을 담고 있습니다. 읽고 나면 어떤 조건에서 XGrammar-2 전환이 실질적인 효과를 가져오는지, 그리고 vLLM이나 SGLang에서 코드 한 줄을 어떻게 바꾸면 되는지 판단할 수 있습니다.
핵심 개념
XGrammar-2는 단순한 성능 업그레이드가 아닙니다. 에이전트 환경에서 기존 엔진들이 근본적으로 가정을 잘못 세웠다는 것을 지적하고, 그 두 가지 잘못된 전제를 각각 다른 메커니즘으로 해결합니다. 구체적으로 무엇이 문제였는지부터 살펴보겠습니다.
구조화 출력이 왜 에이전트 환경에서 특히 까다로운가
구조화 출력(Structured Generation): LLM이 JSON, 함수 호출 스키마, 특정 프로토콜처럼 미리 정의된 형식에 정확히 맞는 텍스트만 생성하도록 토큰 단위로 제약을 거는 기술. 토큰이 생성될 때마다 토큰 마스크(token mask) — 현재 문법 상태에서 다음에 올 수 있는 토큰을 제한하는 비트 배열 — 를 계산해서 적용합니다.
기존 구조화 생성 엔진들은 크게 두 가지 전제 위에서 설계되었습니다. 첫째, 스키마는 요청 전에 미리 컴파일해둘 수 있다. 둘째, 한 요청 안에서 출력 구조는 바뀌지 않는다. 에이전트 환경은 이 두 전제를 모두 깨버립니다.
요청 간 동적성(Inter-request dynamism): 에이전트마다 쓰는 툴이 다르고, 500개짜리 툴 레지스트리에서 요청마다 다른 서브셋을 골라 스키마를 새로 컴파일해야 합니다. 미리 캐싱해둘 조합의 수가 사실상 무한대에 가까워집니다.
요청 내 동적성(Intra-request dynamism): DeepSeek R1이나 QwQ 같은 chain-of-thought 모델은 한 요청 안에서 <think>...</think> 자유형식 추론 → 툴 콜 JSON → 응답 텍스트 순서로 구조를 전환합니다. 단일 문법으로는 이 흐름을 커버할 수 없습니다.
에이전트 개발 경험이 없더라도 이게 왜 문제인지 직관적으로 이해할 수 있습니다. 전통적인 웹 API는 요청 하나에 스키마 하나가 고정되어 있지만, LLM 에이전트는 "지금 이 순간 모델이 무엇을 출력하고 있느냐"에 따라 허용되는 토큰 집합이 실시간으로 바뀝니다. 정적으로 설계된 파서로는 이 동적성을 효율적으로 다룰 수 없습니다.
XGrammar-2는 이 두 가지 동적성을 각각 다른 메커니즘으로 해결합니다.
TagDispatch: 요청 내 구조 전환을 자동으로 처리
TagDispatch는 출력 스트림에서 특정 태그를 감지하면 활성 문법을 동적으로 교체하는 디스패치 레이어입니다. <tool_call> 태그가 나타나면 해당 툴의 JSON 스키마 문법으로, <think> 태그가 나타나면 무제약 자유형식 문법으로 자동 전환됩니다.
# TagDispatch 개념 구조 (의사 코드)
grammar_map = {
"<think>": FreeFormGrammar(),
"<tool_call>": JSONSchemaGrammar(tool_schema),
"<response>": ResponseFormatGrammar(),
}
dispatcher = TagDispatch(grammar_map)
for token in token_stream:
active_grammar = dispatcher.dispatch(token)
# 현재 문법의 상태 기계(state machine) 기반으로 토큰 마스크 계산
mask = active_grammar.get_token_mask(current_state)저도 처음엔 "그냥 파서 레벨에서 구간을 쪼개면 안 되나?" 싶었는데, 문제는 토큰 마스크 계산이 문법 상태 기계의 현재 상태에 의존한다는 점입니다. 문법이 바뀌면 상태 기계 자체가 바뀌어야 하고, 이걸 런타임에 오버헤드 없이 전환하는 게 핵심 난제입니다. TagDispatch는 이 상태 기계 전환을 태그 감지 시점에 원자적으로(atomically) 처리합니다.
Cross-Grammar Cache: 서로 다른 스키마 간 공유 구조 재사용
Cross-Grammar Cache: 서로 다른 문법(툴 스키마) 사이에서 공통으로 등장하는 서브구조의 토큰 마스크를 한 번만 계산하고 재사용하는 캐싱 레이어. 중요한 건 이 캐시가 스키마 레벨이 아닌 문법 상태 기계의 상태 단위에서 동작한다는 점입니다.
500개의 툴이 있다고 해도, 모든 툴 스키마는 결국 JSON 오브젝트 구조를 따릅니다. {, "key":, 숫자 파싱, 배열 닫기 같은 패턴은 대부분의 스키마에서 반복됩니다. 기존 방식은 스키마마다 이 계산을 독립적으로 수행했지만, Cross-Grammar Cache는 공유 서브구조를 문법 상태 레벨에서 식별하고 한 번 계산된 토큰 마스크를 여러 문법에서 재사용합니다.
직관적으로 설명하면 이렇습니다. 툴 A의 JSON 스키마와 툴 B의 JSON 스키마가 내용은 달라도, 두 문법이 "JSON 오브젝트를 열고 첫 번째 키를 기다리는 상태"에서는 동일한 상태 기계 구조를 공유합니다. 이 공통 상태에서의 토큰 마스크는 한 번만 계산하면 두 문법 모두에서 재사용할 수 있습니다.
그 외 핵심 메커니즘
Earley 파서: 문맥 자유 문법(CFG)을 파싱하는 알고리즘으로, JSON 스키마처럼 재귀적 구조를 가진 문법을 정확하게 처리할 수 있습니다. 일반적인 입력에서는 O(N²) 이하로 동작하지만, 고도로 모호한 문법에서는 O(N³)까지 올라갈 수 있습니다.
| 기술 | 해결하는 문제 | 핵심 원리 |
|---|---|---|
| JIT 컴파일 | 불필요한 사전 컴파일 비용 | 실제 요청이 들어왔을 때만 해당 문법을 컴파일. 500개 툴이 있어도 실제 요청에 쓰이는 서브셋만 처리 |
| 반복 상태 압축 | 배열·리스트 패턴의 메모리 낭비 | [item, item, ...] 같은 반복 구조를 상태 공간에서 압축해 메모리와 연산량 절감 |
| Earley 적응형 마스크 | 복잡한 재귀 문법 처리 | Earley 파서 기반으로 문맥 의존 문법에서도 효율적인 토큰 마스크 계산 지원 |
| TagDispatch | 요청 내 구조 전환 | 태그 감지 시점에 문법 상태 기계를 원자적으로 교체 (위에서 상세 설명) |
| Cross-Grammar Cache | 요청 간 스키마 중복 계산 | 공통 상태 서브구조의 토큰 마스크를 상태 단위로 재사용 (위에서 상세 설명) |
Structural Tag: 에이전트 출력 형식의 추상화
에이전트 출력 형식은 모델마다, 프레임워크마다 제각각입니다. OpenAI 형식, Anthropic 형식, 커스텀 형식이 혼재하는 상황에서 XGrammar-2는 Structural Tag라는 추상화 레이어를 도입합니다.
솔직히 이 부분은 논문보다 실제 운영 코드에서 어떻게 표현되는지가 아직 충분히 공개되지 않아 체감하기 어려운 부분이 있습니다. 기본적인 아이디어는 다음과 같습니다.
{
"type": "tool_call",
"name": "search",
"arguments": {"query": "서울 날씨"},
"reasoning": "<think> ... </think>"
}TagDispatch가 태그 감지 레이어라면, Structural Tag는 그 위에서 에이전트 출력을 일관된 JSON 구조로 표현하는 프로토콜입니다. OpenAI의 응답 형식을 둘러싼 표준화 논의가 진행 중인 가운데, 다양한 형식을 하나의 추상화로 통일하려는 시도입니다.
실전 적용
예시 1: SGLang에서 대규모 툴 콜링 에이전트 서빙
환경: SGLang 최신 버전 (XGrammar-2 통합 여부는 버전별 릴리스 노트에서 확인을 권장합니다), Python 3.10+
SGLang은 XGrammar-2를 기본 구조화 출력 백엔드로 통합했습니다. guided_json 파라미터로 스키마를 넘기면 Cross-Grammar Cache가 자동으로 작동합니다.
import sglang as sgl
from pydantic import BaseModel, ConfigDict
from typing import Literal
class ToolCallResponse(BaseModel):
model_config = ConfigDict(extra="forbid") # 추가 필드 차단
tool_name: Literal["search", "calculate", "fetch_data"]
arguments: dict
confidence: float
@sgl.function
def tool_call_agent(s, user_query: str):
s += sgl.system("You are a helpful assistant with access to tools.")
s += sgl.user(user_query)
s += sgl.assistant(
sgl.gen(
"response",
guided_json=ToolCallResponse.model_json_schema(),
max_tokens=512,
)
)
# XGrammar-2 백엔드로 런타임 초기화
runtime = sgl.Runtime(model_path="deepseek-ai/DeepSeek-V3", backend="xgrammar")
result = tool_call_agent.run(
user_query="현재 서울 날씨를 검색해서 섭씨로 알려줘",
)
print(result["response"])| 코드 포인트 | 설명 |
|---|---|
guided_json=ToolCallResponse.model_json_schema() |
Pydantic 모델의 JSON 스키마를 그대로 넘깁니다. XGrammar-2가 이 스키마를 JIT 컴파일하여 토큰 마스크를 생성 |
backend="xgrammar" |
SGLang 런타임에서 XGrammar-2 백엔드를 명시적으로 활성화 |
ConfigDict(extra="forbid") |
스키마에 없는 추가 필드를 차단합니다. 이게 없으면 모델이 예상 외의 키를 넣는 상황이 생길 수 있습니다 |
예시 2: 추론 채널이 있는 모델에서 혼합 출력 처리
DeepSeek R1, QwQ처럼 <think> 블록을 먼저 출력하고 이후 구조화된 응답을 내놓는 모델을 다룰 때가 솔직히 가장 골치 아픈 케이스입니다. TagDispatch가 이 전환을 자동으로 처리하는 방식을 보면 꽤 깔끔합니다.
주의: 아래 코드는 XGrammar-2 공식 문서 기반의 의사 코드입니다. 실제 클래스명·메서드명은 라이브러리 버전에 따라 다를 수 있으므로, 실행 전 공식 GitHub의 API 문서를 확인해보시는 것을 권장합니다.
# pip install xgrammar
# 아래는 XGrammar-2 개념 기반 의사 코드입니다 (실제 API명과 다를 수 있습니다)
from xgrammar import GrammarCompiler, TagDispatchGrammar
# GrammarCompiler 인스턴스는 재사용해야 Cross-Grammar Cache 효과를 누릴 수 있습니다
compiler = GrammarCompiler()
tag_dispatch_grammar = TagDispatchGrammar(
tag_grammar_map={
# <think> 블록: 자유형식 (토큰 마스크 제약 없음)
"think": compiler.compile_free_form(),
# <tool_call> 블록: 엄격한 JSON 스키마
"tool_call": compiler.compile_json_schema({
"type": "object",
"properties": {
"name": {"type": "string"},
"arguments": {"type": "object"},
},
"required": ["name", "arguments"],
"additionalProperties": False,
}),
# <response> 블록: 자유형식 응답
"response": compiler.compile_free_form(),
},
default_grammar=compiler.compile_free_form(),
)| 처리 단계 | 활성 문법 | 토큰 마스크 |
|---|---|---|
<think> 시작 ~ </think> |
자유형식 | 전체 어휘 허용 |
<tool_call> 시작 이후 |
JSON 스키마 문법 | 스키마에 맞는 토큰만 허용 |
</tool_call> 이후 |
기본 문법 | 태그 감지 대기 |
GrammarCompiler 인스턴스를 재사용하는 것이 핵심입니다. 이 컴파일러 안에 Cross-Grammar Cache가 살아있어서, 여러 요청에 걸쳐 공통 서브구조의 토큰 마스크를 누적해서 재사용합니다. 요청마다 새 컴파일러를 만들면 캐시가 초기화되어 성능 이점이 사라집니다.
예시 3: vLLM에서 기존 코드에 XGrammar-2 통합
환경: vLLM v0.4+ (XGrammar-2 지원 여부는 버전별 릴리스 노트에서 확인을 권장합니다), Python 3.10+
vLLM을 이미 쓰고 있다면 guided_decoding_backend 설정 하나로 전환할 수 있습니다. 기존 코드는 그대로 두고 백엔드만 바꾸는 방식이라 마이그레이션 리스크가 거의 없습니다.
from vllm import LLM, SamplingParams
llm = LLM(
model="Qwen/Qwen2.5-72B-Instruct",
guided_decoding_backend="xgrammar", # 이 한 줄만 추가
)
response_schema = {
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["success", "error", "partial"]},
"results": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"content": {"type": "string"},
"score": {"type": "number"},
},
"additionalProperties": False,
},
},
"metadata": {"type": "object"},
},
"additionalProperties": False,
}
params = SamplingParams(
temperature=0.0,
guided_json=response_schema,
max_tokens=1024,
)
outputs = llm.generate(["검색 결과를 JSON으로 정리해줘"], params)툴이 고정적이고 10개 이하인 환경에서는 기존 엔진으로도 충분할 수 있습니다. 반면 요청마다 다른 툴 서브셋을 동적으로 선택하거나, 추론 채널이 있는 모델을 서빙하는 경우라면 전환 효과가 체감될 가능성이 높습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 극적인 성능 향상 | XGrammar 1세대 대비 최대 80× 효율, 6–10× 토큰당 처리 속도, 100× 이상 컴파일 시간 단축 |
| 동적 에이전트 최적화 | 요청 간·요청 내 구조 변화를 모두 효율적으로 처리하는 유일한 엔진 |
| 범용 프레임워크 호환 | vLLM, SGLang, TensorRT-LLM, MLC-LLM 모두 통합 완료 |
| 추론 모델 네이티브 지원 | DeepSeek, Qwen 계열의 <think> 패턴을 TagDispatch로 자연스럽게 처리 |
| 오픈소스 MIT | GitHub 공개, 커스터마이징 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 복잡도 증가 | TagDispatch, Cross-Grammar Cache 등 새로운 추상화 레이어로 디버깅 난이도 상승 | 로그 레벨을 높여 문법 전환 시점을 추적하는 것을 권장합니다 |
| 단순 고정 스키마엔 이점 적음 | 스키마가 항상 동일한 경우 XGrammar 1세대 대비 실질 이점이 크지 않을 수 있음 | 툴이 10개 이하·고정 스키마라면 기존 엔진으로도 충분합니다 |
| Earley 파서 복잡도 | 최악의 경우 O(N³) — 매우 복잡한 재귀 문법에서는 병목 가능 | 실제 스키마로 벤치마크 후 도입 여부를 결정하는 것을 권장합니다 |
| 신규 기술 | 2026년 5월 발표, 프로덕션 피드백 누적 기간이 짧음 | 초기엔 스테이징 환경에서 충분히 검증 후 전환하는 것을 권장합니다 |
실무에서 가장 흔한 실수
-
스키마에
additionalProperties: false생략: XGrammar-2가 추가 필드를 허용해버려 모델이 예상 외의 키를 마음대로 넣는 상황이 발생합니다. Pydantic v2 모델을 쓴다면model_config = ConfigDict(extra="forbid")를 함께 설정하는 것을 권장합니다. -
TagDispatch 태그 이름을 모델 프롬프트와 불일치하게 설정: 시스템 프롬프트에서
<tool_call>이라고 안내했는데 TagDispatch에는<function_call>로 등록하면 디스패치가 아예 동작하지 않습니다. 태그 이름을 시스템 프롬프트와 TagDispatch 설정에서 정확히 일치시켜야 합니다. -
단일 프로세스에서
GrammarCompiler를 요청마다 새로 생성: Cross-Grammar Cache의 혜택을 받으려면GrammarCompiler인스턴스를 재사용해야 합니다. 요청마다 새 컴파일러를 만들면 캐시가 초기화되어 성능 이점이 사라집니다.
마치며
XGrammar-2는 에이전트 LLM 서빙에서 구조화 출력이 더 이상 성능 병목이 되지 않아도 된다는 걸 보여준 엔진입니다. 특히 동적 툴 레지스트리를 운영하거나, DeepSeek R1·QwQ처럼 추론 채널이 있는 모델을 서빙하고 있다면 전환 효과를 체감할 가능성이 높습니다. 반대로 고정된 단일 스키마로만 운영 중이라면 지금 당장 교체 우선순위가 높지는 않습니다.
지금 바로 시작해볼 수 있는 3단계:
-
패키지 설치 및 간단한 벤치마크 확인:
pip install xgrammar또는pip install "vllm[xgrammar]"으로 설치한 뒤, 현재 사용 중인 스키마를GrammarCompiler로 컴파일해볼 수 있습니다. 툴 수가 50개를 넘는 환경이라면 컴파일 시간 차이를 바로 체감할 수 있습니다. -
기존 vLLM 또는 SGLang 서빙 코드에 백엔드 옵션 한 줄 추가:
guided_decoding_backend="xgrammar"파라미터 하나만 추가하면 기존 코드 변경 없이 전환이 가능합니다. 스테이징 환경에서 먼저 구조화 출력 관련 레이턴시 지표를 확인해볼 수 있습니다. 구조화 출력 오버헤드가 전체 응답 시간의 5% 이상을 차지하고 있다면 전환을 검토하기에 충분한 신호입니다. -
추론 모델 사용 중이라면 TagDispatch 설정 도입 검토: DeepSeek R1, QwQ, Qwen 계열처럼
<think>블록을 출력하는 모델에서 JSON 파싱이 불안정했던 경험이 있다면, 공식 문서의 TagDispatch 예시를 참고해서 설정해볼 수 있습니다. 이 경우 단순 백엔드 전환보다 더 큰 안정성 개선을 기대할 수 있습니다.
참고 자료
- XGrammar-2: Fast and Customizable Structured Generation for Tool Calling and Agents | MLC Blog
- XGrammar-2: Efficient Dynamic Structured Generation Engine for Agentic LLMs | arXiv
- XGrammar: Achieving Efficient, Flexible, Portable Structured Generation | MLC Blog
- XGrammar (원본 논문) | arXiv
- mlc-ai/xgrammar | GitHub
- xgrammar | PyPI
- XGrammar-2 리뷰 | TheMoonlight