LLM으로 분류 결과나 추출 데이터를 받아 파이프라인에 넘기는 서비스를 운영하다 보면, 모델이 가끔 JSON 앞에 "물론이죠, 아래가 결과입니다" 같은 말을 붙이거나 마지막 중괄호를 빼먹는다. 프롬프트에 "JSON만 출력"이라고 아무리 못 박아도 100건에 몇 건은 깨진다. 재시도 로직으로 틀어막아 왔는데, 재시도는 지연과 토큰 비용을 그대로 두 배로 만든다.
vLLM의 guided decoding(구조화 출력)은 이 문제를 디코딩 단계에서 끝낸다. 모델이 토큰을 하나 고를 때마다 문법에 맞지 않는 토큰의 확률을 0으로 마스킹하기 때문에, 애초에 스키마를 벗어난 토큰이 나올 수 없다. JSON Schema, 정규식, EBNF 문법, 선택지를 요청마다 넘기면 출력이 반드시 그 제약을 만족하고, 파싱 실패율은 사실상 0으로 떨어진다. 아래에서 백엔드 선택부터 API 사용법, 함정, 오버헤드 측정까지 차례로 정리한다.
1. 프롬프트로 JSON을 부탁하는 방식의 한계
프롬프트 기반 방식은 모델에게 형식을 '요청'할 뿐이다. 디코딩은 여전히 전체 어휘(vocabulary)에서 다음 토큰을 자유롭게 고른다. 그래서 온도(temperature)를 낮춰도, 예시를 많이 넣어도 확률적으로 형식을 벗어나는 출력이 남는다.
guided decoding은 접근이 다르다. 제약을 유한 상태 기계(finite state machine)나 문법으로 컴파일해 두고, 각 디코딩 스텝에서 현재 상태에서 허용되는 토큰만 남긴 마스크를 로짓(logit)에 적용한다. 허용되지 않는 토큰은 선택 자체가 불가능하다. 그 결과 출력 문자열은 정의상 문법을 만족한다. 재시도 로직을 걷어낼 수 있다는 뜻이다.
2. xgrammar와 auto 백엔드
vLLM은 구조화 출력을 여러 백엔드로 처리한다. xgrammar는 문법을 네이티브 코드로 컴파일해 토큰 마스크를 빠르게 계산하는 엔진이고, 그 밖에 guidance, outlines가 있다. 과거에 쓰이던 lm-format-enforcer는 폐기 예정이다.
per-request로 백엔드를 지정하지 않으면 기본값은 auto다. auto는 요청에 담긴 스키마를 보고 xgrammar로 컴파일할 수 있는지 먼저 검증한 뒤, 가능하면 xgrammar를 쓰고 그렇지 않으면 guidance 같은 다른 백엔드로 넘긴다. 즉 xgrammar가 '기본 백엔드'인 것이 아니라, auto가 xgrammar를 우선 시도하는 구조다. 대부분의 JSON Schema와 정규식은 xgrammar가 처리하므로 특별한 이유가 없으면 auto로 두면 된다.
3. 서버 띄우고 백엔드 고르기
백엔드는 서버 기동 시 --structured-outputs-config.backend로 고정할 수 있다. 값을 주지 않으면 auto로 동작한다.
vllm serve Qwen/Qwen2.5-7B-Instruct \ --structured-outputs-config.backend auto # xgrammar로 고정하고 싶을 때 vllm serve Qwen/Qwen2.5-7B-Instruct \ --structured-outputs-config.backend xgrammar
여기서 한 번 걸리기 쉽다. v0.12.0 이전에 쓰던 --guided-decoding-backend 플래그와 요청 쪽 guided_json, guided_regex, guided_choice, guided_grammar 같은 필드는 v0.12.0에서 전부 제거됐다. 예전 설정 파일을 그대로 올리면 플래그를 인식하지 못하고 기동이 실패하거나 요청이 400으로 거부된다. 새 이름인 structured_outputs 체계로 바꿔야 한다.
4. JSON Schema, 정규식, 문법, 선택지 넘기기
OpenAI 호환 API에서 JSON Schema는 표준 response_format으로 넘긴다. 아래는 도시명과 섭씨 온도를 담은 객체를 강제하는 예다.
curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "Qwen/Qwen2.5-7B-Instruct",
"messages": [{"role":"user","content":"서울 날씨를 JSON으로"}],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "weather",
"schema": {
"type": "object",
"properties": {
"city": {"type": "string"},
"temp_c": {"type": "number"}
},
"required": ["city", "temp_c"]
}
}
}
}'
정규식, 선택지, EBNF 문법은 표준 필드가 없으므로 extra_body의 structured_outputs 안에 넣는다. Python 클라이언트로 정규식을 강제하는 예다.
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
resp = client.chat.completions.create(
model="Qwen/Qwen2.5-7B-Instruct",
messages=[{"role": "user", "content": "휴대폰 번호 하나 만들어줘"}],
extra_body={"structured_outputs": {"regex": r"010-\d{4}-\d{4}"}},
)
print(resp.choices[0].message.content)
# 010-3842-9915
감성 분류처럼 정해진 라벨만 받아야 하면 choice가 가장 간단하다. 모델이 셋 중 하나만 뱉게 된다.
extra_body={"structured_outputs": {"choice": ["긍정", "부정", "중립"]}}
SQL이나 특정 DSL처럼 문맥 자유 문법이 필요한 경우 grammar에 EBNF를 넘긴다. json 키로 JSON Schema를 extra_body에 직접 넣는 방식도 있고, structural_tag로 지정한 태그 안쪽만 스키마를 적용할 수도 있다.
5. 오프라인 추론에서 거는 법
서버 없이 배치로 돌릴 때는 SamplingParams에 StructuredOutputsParams를 실어 준다. API와 같은 제약 종류(json, regex, choice, grammar)를 받는다.
from vllm import LLM, SamplingParams
from vllm.sampling_params import StructuredOutputsParams
schema = {
"type": "object",
"properties": {"city": {"type": "string"}, "temp_c": {"type": "number"}},
"required": ["city", "temp_c"],
}
llm = LLM(model="Qwen/Qwen2.5-7B-Instruct")
so = StructuredOutputsParams(json=schema)
sp = SamplingParams(temperature=0.0, max_tokens=128, structured_outputs=so)
out = llm.generate(["서울 날씨를 JSON으로"], sp)
print(out[0].outputs[0].text)
이전 버전에서 쓰던 GuidedDecodingParams가 StructuredOutputsParams로 바뀐 것이다. 마이그레이션할 때 import 경로와 클래스명을 같이 고쳐야 한다.
6. auto가 다른 백엔드로 넘어갈 때
xgrammar가 모든 JSON Schema 기능을 지원하지는 않는다. 복잡한 정규식 패턴 속성, 일부 oneOf 조합, 재귀가 깊은 스키마는 xgrammar가 컴파일하지 못할 수 있다. auto를 쓰면 이 경우 조용히 guidance 같은 백엔드로 넘어가므로 출력은 여전히 유효하지만, 백엔드를 xgrammar로 고정해 두었다면 요청이 에러로 떨어진다. 스키마가 복잡하면 백엔드를 auto로 두는 편을 권한다.
JSON에서 공백이 과하게 늘어지는 현상도 겪을 수 있다. 모델이 들여쓰기와 개행을 길게 뽑으면 토큰만 낭비된다. structured_outputs의 whitespace_pattern으로 허용 공백을 제한하면 출력이 조밀해진다.
또 하나, guided decoding은 문법 적합성만 보장한다. 값의 '내용'이 사실인지, 의미가 맞는지는 책임지지 않는다. temp_c 자리에 숫자가 들어가는 것은 보장해도 그 숫자가 실제 기온인지는 모델의 몫이다. 형식 검증과 값 검증을 분리해서 생각해야 한다.
7. 얼마나 느려지는지 재 보기
문법 컴파일과 토큰 마스킹은 공짜가 아니다. 첫 요청에서 스키마를 컴파일하는 비용이 한 번 들고, 이후에는 캐시되어 스텝마다 마스크 적용 비용만 남는다. xgrammar는 이 마스킹을 네이티브로 처리해 토큰당 추가 지연이 작은 편이지만, 복잡한 문법일수록 커진다.
실제 영향은 /metrics 엔드포인트에서 토큰당 생성 시간을 켜고 끈 상태로 비교하면 된다. 현행 vLLM에서 쓸 지표는 요청별 토큰당 시간인 vllm:request_time_per_output_token_seconds와 토큰 간 지연인 vllm:inter_token_latency_seconds다.
curl -s http://localhost:8000/metrics \ | grep -E "request_time_per_output_token_seconds|inter_token_latency_seconds"
예전 v0.6~0.10에 있던 vllm:time_per_output_token_seconds는 V1 엔진에서 제거되거나 이름이 바뀌었다. 구버전 문서나 블로그를 보고 그 이름으로 조회하면 지표가 잡히지 않으니 위 두 이름을 쓴다. 오버헤드는 스키마 복잡도와 하드웨어에 따라 다르지만, 재시도를 없애서 아끼는 비용이 보통 이 추가 지연보다 크다.
정리하면, 분류나 추출처럼 출력 형식이 고정된 작업이라면 프롬프트에 형식을 부탁하고 재시도로 때우는 대신 guided decoding을 기본으로 켜 두는 쪽이 맞다. 다음 단계로는 프로덕션 스키마를 xgrammar로 컴파일했을 때 fallback이 일어나는지부터 로그에서 확인해 보기를 권한다.