사내에 vLLM이나 Ollama로 모델을 띄워놓고 거기에 데이터 추출이나 함수호출을 맡기다 보면, 결국 같은 자리에서 막힌다. 모델이 뱉은 텍스트를 json.loads()에 넣는 순간이다. 열에 한두 번은 앞뒤에 "Here is the JSON:" 같은 군더더기가 붙거나, 마지막 쉼표 하나 때문에 파싱이 터진다. 프롬프트에 "JSON만 출력해"라고 아무리 적어도 완전히는 안 막힌다.
결론부터 말하자면, 프롬프트로 부탁하지 말고 디코딩 단계에서 스키마를 강제하면 된다. vLLM은 structured_outputs(옛 이름 guided decoding), Ollama는 format 파라미터로 JSON Schema를 받아 생성 토큰을 제약한다. 모델이 스키마를 벗어나는 토큰을 아예 고를 수 없게 만들기 때문에, 출력은 스키마에 맞는 JSON으로 나온다.
1. guided/structured decoding이 무엇인가
일반적인 생성에서 모델은 매 스텝마다 어휘(vocabulary) 전체를 놓고 다음 토큰을 확률로 고른다. 여기에는 스키마를 깨뜨리는 토큰도 다 섞여 있다. structured decoding(구조화 디코딩, guided decoding이라고도 부른다)은 이 선택지를 매 스텝 걸러낸다. 지금 문맥에서 JSON Schema가 허용하는 토큰에만 확률을 남기고 나머지는 마스킹한다.
즉 사후 검증이 아니라 사전 제약이다. 생성이 끝난 뒤 JSON이 맞는지 보는 게 아니라, 애초에 틀린 토큰이 나올 수 없게 샘플링을 막는다. 그래서 "다시 생성해달라"는 재시도 루프 없이 한 번에 유효한 구조가 나온다.
vLLM에서 이 일을 하는 엔진을 백엔드라고 부른다. 기본값은 auto이고, 요청 형태를 보고 xgrammar나 guidance 중에서 고른다. 제약 종류는 다섯 가지다. 값 목록 중 하나를 고르게 하는 choice, 정규식에 맞추는 regex, JSON Schema를 강제하는 json, EBNF 문법을 적용하는 grammar, 지정한 태그 안에서만 JSON을 쓰게 하는 structural_tag. 아래에서는 실무에서 제일 많이 쓰는 JSON Schema 강제를 vLLM과 Ollama 양쪽에서 차례로 살펴본다.
2. vLLM에서 JSON Schema 강제하기
vLLM은 OpenAI 호환 서버를 띄우고 OpenAI SDK로 붙는 방식이 제일 편하다. 먼저 서버를 띄운다.
$ vllm serve Qwen/Qwen2.5-7B-Instruct \
--structured-outputs-config.backend auto
INFO ... Started server process
INFO ... Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
백엔드를 명시하고 싶으면 --structured-outputs-config.backend xgrammar처럼 바꾼다. 특별한 이유가 없으면 auto로 둔다.
response_format으로 스키마 넘기기
권장하는 방식은 OpenAI의 response_format에 json_schema를 실어 보내는 것이다. Pydantic 모델을 정의하고 model_json_schema()로 스키마를 뽑아 넘기면 중복이 없다.
from openai import OpenAI
from pydantic import BaseModel
client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
class Ticket(BaseModel):
title: str
priority: str
tags: list[str]
completion = client.chat.completions.create(
model="Qwen/Qwen2.5-7B-Instruct",
messages=[{"role": "user",
"content": "결제가 두 번 청구됨. 환불 요청. 긴급."}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "ticket",
"schema": Ticket.model_json_schema(),
},
},
)
print(completion.choices[0].message.content)
{"title": "결제 이중 청구 환불 요청", "priority": "high", "tags": ["billing", "refund"]}
앞에 설명 문장 없이 바로 JSON이 떨어진다. json.loads()에 그대로 넣어도 터지지 않는다.
choice로 분류만 시키기
감정 분류나 라벨링처럼 정해진 값 중 하나만 받고 싶을 때는 스키마까지 갈 것 없이 choice가 간단하다. OpenAI SDK에서는 extra_body에 실어 보낸다.
completion = client.chat.completions.create(
model="Qwen/Qwen2.5-7B-Instruct",
messages=[{"role": "user", "content": "이 리뷰 감정은? '배송 빠르고 좋아요'"}],
extra_body={"structured_outputs": {"choice": ["positive", "negative", "neutral"]}},
)
print(completion.choices[0].message.content) # positive
출력이 세 값 중 하나임이 보장되므로, 받는 쪽에서 문자열 매칭을 다시 할 필요가 없다.
오프라인 추론에서는 StructuredOutputsParams
서버 없이 파이썬 안에서 바로 LLM을 돌릴 때는 SamplingParams에 StructuredOutputsParams를 넣는다.
from vllm import LLM, SamplingParams
from vllm.sampling_params import StructuredOutputsParams
llm = LLM(model="Qwen/Qwen2.5-7B-Instruct")
params = SamplingParams(
structured_outputs=StructuredOutputsParams(choice=["Positive", "Negative"])
)
out = llm.generate("분류: vLLM 좋다", params)
버전 주의: guided_json은 이제 옛 이름이다
예전 예제를 보면 guided_json, guided_choice, guided_decoding_backend 같은 이름이 나온다. 이 필드들은 v0.12.0부터 structured_outputs로 통합되면서 더 이상 권장되지 않는다. 설치한 vLLM이 그보다 낮은 버전이면 아직 옛 이름을 써야 하니, 먼저 버전부터 확인한다.
$ vllm --version INFO ... vLLM version 0.12.0
기억과 문서가 다르면 설치된 버전 기준으로 맞춘다. 버전 번호는 설치 상태에 따라 다를 수 있다.
3. Ollama에서 JSON Schema 강제하기
Ollama는 더 단순하다. /api/chat과 /api/generate 요청에 format 필드로 JSON Schema 객체를 그대로 넘기면 된다. 먼저 curl로 확인한다.
$ curl -s http://localhost:11434/api/chat -d '{
"model": "llama3.1",
"messages": [{"role": "user", "content": "캐나다에 대해 알려줘"}],
"stream": false,
"format": {
"type": "object",
"properties": {
"name": {"type": "string"},
"capital": {"type": "string"},
"languages": {"type": "array", "items": {"type": "string"}}
},
"required": ["name", "capital", "languages"]
}
}'
{"message":{"role":"assistant","content":"{\"name\":\"Canada\",\"capital\":\"Ottawa\",\"languages\":[\"English\",\"French\"]}"}, ...}
content 안의 문자열이 스키마에 맞는 JSON이다. stream은 false로 둬야 통째로 한 번에 받기 편하다.
파이썬에서는 공식 ollama 라이브러리와 Pydantic을 같이 쓴다.
from ollama import chat
from pydantic import BaseModel
class Country(BaseModel):
name: str
capital: str
languages: list[str]
response = chat(
model="llama3.1",
messages=[{"role": "user", "content": "캐나다에 대해 알려줘"}],
format=Country.model_json_schema(),
)
country = Country.model_validate_json(response.message.content)
print(country.capital) # Ottawa
model_json_schema()로 스키마를 넘기고, 받은 쪽에서 같은 모델의 model_validate_json()으로 되돌린다. 스키마 정의와 파싱이 한 모델로 묶여서 어긋날 일이 없다. 제약 디코딩으로 유효한 문자열이 보장되어도 받는 쪽에서 한 번 더 검증하는 게 좋다.
4. 함정과 권장
제약 디코딩을 걸어도 완전히 자유로운 건 아니다. 직접 돌려보면 몇 군데서 걸린다.
max_tokens에 잘려서 JSON이 중간에 끊긴다
제약은 토큰 선택만 막을 뿐, 생성 길이를 늘려주지는 않는다. 스키마가 요구하는 필드가 많은데 max_tokens가 작으면, 닫는 중괄호가 나오기 전에 생성이 끝나 잘린 JSON이 나올 수 있다. 이러면 다시 json.loads()가 터진다. 배열이나 긴 문자열 필드가 있는 스키마는 max_tokens를 넉넉히 준다.
스키마가 엄격할수록 생성이 느려질 수 있다
매 스텝 어휘를 마스킹하는 비용이 있다. 복잡한 정규식이나 깊게 중첩된 스키마는 제약을 컴파일하고 적용하는 오버헤드가 커질 수 있다. 체감 속도는 모델, 백엔드, 스키마 복잡도에 따라 다르니 실제로 재본다.
$ curl -s http://localhost:11434/api/chat -d '{...}' | jq '.eval_count, .eval_duration'
Ollama 응답의 eval_count(생성 토큰 수)와 eval_duration(나노초)으로 초당 토큰을 대략 계산할 수 있다. 제약을 걸기 전후로 재서 비교한다.
스키마만으로 의미까지 보장되지는 않는다
디코딩 제약은 구조를 맞춰줄 뿐, 값이 맞는지는 모른다. priority가 문자열이라는 건 보장해도, 그 값이 "high"인지 아무 단어인지는 모델 몫이다. 값의 범위까지 좁히고 싶으면 enum을 스키마에 박거나(JSON Schema의 enum), 아예 choice로 제약을 건다.
어느 쪽을 쓰나
GPU가 있고 처리량이 중요한 서비스라면 vLLM을 쓴다. OpenAI 호환 API라 기존 코드를 거의 그대로 붙일 수 있고, structured_outputs가 다섯 가지 제약을 다 받는다. 노트북이나 단일 서버에서 가볍게 돌리고 CPU로도 버텨야 하면 Ollama가 설치와 운영이 단순하다. 둘 다 Pydantic 모델에서 model_json_schema()로 스키마를 뽑는 패턴은 똑같으니, 모델 정의만 공유하면 백엔드는 바꿔 끼울 수 있다.