LLM이 돌려준 답을 json.loads에 넣는 코드를 짜고 있는데, 가끔 터지는 파싱 에러 때문에 재시도 루프를 붙일지, API의 스키마 옵션을 켤지, 직접 띄운 모델에 문법 제약을 걸지 고민하고 계신가요? 결론부터 말하면 쓰는 API가 스키마 제약(Structured Outputs)을 지원하면 그걸 켜는 게 비용·지연·안정성 모두에서 가장 싸고, 직접 서빙한다면 vLLM·SGLang의 제약 디코딩을 켜되 새 스키마의 첫 요청 지연과 스키마 지원 범위를 먼저 재 봐야 해요. 프롬프트로 부탁하고 재시도하는 방식은 실패율이 몇 % 안쪽일 때만 버틸 만해요.
제약 디코딩의 원리는 알고 있다고 보고, 이 글은 고를 때 필요한 숫자만 다뤄요. 공식 단가로 재시도 비용을 실패율별로 계산하고, 논문이 공개한 엔진별 지연·스키마 지원률·정답률을 나란히 놓았어요.
LLM JSON 출력 방식 네 가지, 보장하는 범위와 비용
같은 "JSON으로 받기"라도 어디서 형식을 맞추느냐에 따라 실패를 처리하는 비용이 달라요. 실무에서 고르는 선택지는 네 가지예요.
| 방식 | 어디서 형식을 맞추나 | 실패했을 때 | 추가로 드는 것 |
|---|---|---|---|
| 프롬프트 지시 + 재시도 | 모델의 확률에 맡김 | 파싱·검증 실패 시 다시 호출 | 재시도 호출 비용, 꼬리 지연 |
| JSON 모드 | 디코딩 단계(문법만) | 스키마 위반은 직접 검사 후 재시도 | 스키마 위반분 재시도 |
| Structured Outputs (스키마 + strict) | 디코딩 단계(스키마까지) | 길이 초과·거절만 따로 처리 | 새 스키마 첫 요청 지연, 스키마 문법 제한 |
| 자체 서빙 제약 디코딩 (vLLM·SGLang·llama.cpp) | 내 서버의 디코딩 단계 | 엔진이 못 받는 스키마는 에러 또는 무시 | 문법 컴파일 시간, 토큰당 마스크 계산 |
OpenAI Structured Outputs 가이드는 JSON 모드를 스키마는 따르지 않는 이전 단계로 설명하고, JSON 모드에서 메시지에 "JSON"이라는 지시가 없으면 모델이 공백만 찍다가 토큰 한도까지 갈 수 있어 API가 에러를 낸다고 적어요.
자체 서빙 쪽은 vLLM Structured Outputs 문서 기준으로 choice·regex·json·grammar·structural_tag 제약을 걸 수 있고, 기본 백엔드 auto가 xgrammar나 guidance를 골라요. 예전 guided_* 필드는 v0.12.0에서 제거됐어요.
재시도 비용 계산 — 실패율별 100만 건 비용
프롬프트+재시도 방식의 비용은 실패율 하나로 정해져요. 계산 조건은 이렇게 잡았어요.
- 작업: 문서 하나에서 필드 몇 개를 뽑는 추출. 입력 1,500토큰, 출력 300토큰.
- 모델: gpt-4.1-mini. OpenAI 공식 가격표 Standard 기준 입력 100만 토큰당 0.40달러, 출력 1.60달러(2026-10-25 확인).
- 재시도: 실패하면 앞의 잘못된 출력(300토큰)과 에러 메시지(60토큰)를 대화에 붙여 다시 요청. 최대 3번 시도(재시도 2회).
- 각 시도의 실패는 서로 독립이라고 가정.
한 번 호출 비용은 1,500 × 0.40 + 300 × 1.60 = 1,080달러(100만 건당)예요. n번째 시도는 입력이 (n−1) × 360토큰씩 늘어요. n번째 시도까지 갈 확률은 실패율 p의 (n−1)제곱이라서, 기대 비용은 세 시도 비용에 1, p, p²를 곱해 더한 값이에요. 끝내 실패하는 비율은 p³이에요.
실패율 값은 근거 있는 숫자를 쓰려고 JSONSchemaBench 논문(Geng 외, 2025) 표 4의 "제약 없이 생성(LM only)" 준수율을 가져왔어요. Llama-3.2-1B가 제약 없이 스키마를 지킨 비율이 GlaiveAI 데이터셋 0.90, GitHub Easy 0.65, GitHub Medium 0.38이었어요. 즉 실패율 10%·35%·62%예요. 작은 모델 기준이라 큰 상용 모델은 이보다 낮을 수 있어서, 2%를 예시 값으로 하나 더 넣었어요.
| 실패율 p | 평균 호출 수 | 100만 건 기대 비용 | 기본 대비 | 3번 다 실패하는 비율 |
|---|---|---|---|---|
| 2% (예시) | 1.02회 | 약 1,105달러 | +2.3% | 0.0008% |
| 10% (GlaiveAI 수준) | 1.11회 | 약 1,216달러 | +12.6% | 0.1% |
| 35% (GitHub Easy 수준) | 1.47회 | 약 1,676달러 | +55.2% | 4.3% |
| 62% (GitHub Medium 수준) | 2.00회 | 약 2,365달러 | +119% | 23.8% |
실패율 2~10%에서는 돈보다 끝내 실패하는 건수가 문제예요. 10%여도 100만 건 중 1,000건은 깨진 채 남아요. 같은 입력은 같은 이유로 또 실패하는 경우가 많아서, 독립 가정으로 낸 "3번 다 실패" 비율은 하한으로 보는 편이 안전해요.
스키마 쪽 토큰은 확실하지 않아요. OpenAI 함수 호출(Function calling) 가이드는 함수 정의가 입력 토큰으로 청구된다고 적지만, response_format 스키마의 계산 방식은 가이드에 명시가 없어요. 큰 스키마라면 켜기 전후의 usage 입력 토큰을 비교해 보세요.
지연 비교 — 재시도는 꼬리를, 제약 디코딩은 첫 요청을 늘린다
한 번 호출이 2초라면 재시도 포함 평균 지연은 실패율 10%에서 2.22초, 35%에서 2.94초로 작아 보여요. 문제는 꼬리 지연이에요. 실패율이 5%만 넘어도 상위 5% 요청은 최소 두 번 호출되니 p95가 4초로 두 배가 되고, 실패율 35%면 세 번째 시도까지 가는 요청이 12.25%라서 p95가 6초가 돼요. 재시도는 평균보다 꼬리를 망가뜨려요.
Structured Outputs는 반대로 꼬리가 아니라 첫 요청에 비용을 몰아요. OpenAI 가이드는 어떤 스키마든 첫 요청은 API가 스키마를 처리하느라 지연이 추가되고, 같은 스키마로 보내는 이후 요청에는 추가 지연이 없다고 적어요. 요청마다 스키마를 조금씩 바꿔 만들면 매번 첫 요청이 되니, 스키마는 상수로 고정하고 배포 직후 한 번 미리 호출해 두세요.
자체 서빙에서는 엔진 차이가 훨씬 커요. JSONSchemaBench 표 2는 Llama-3.1-8B-Instruct, A100 80GB 한 장, 배치 1, llama.cpp 추론 엔진 위에서 제약 엔진별 문법 컴파일 시간(GCT), 첫 토큰까지 시간(TTFT), 토큰당 시간(TPOT)의 중앙값을 쟀어요. GlaiveAI 데이터셋 값으로 300토큰 응답 하나 만드는 시간을 TTFT + 300 × TPOT로 계산했어요.
| 제약 엔진 (llama.cpp 위) | 컴파일 | TTFT | TPOT | 300토큰 응답 시간 |
|---|---|---|---|---|
| 제약 없음 | 없음 | 0.10초 | 15.40ms | 약 4.72초 |
| Guidance | 0.00초 | 0.24초 | 6.37ms | 약 2.15초 |
| llama.cpp 문법 | 0.05초 | 0.20초 | 29.98ms | 약 9.19초 |
| Outlines | 3.48초 | 3.65초 | 30.33ms | 약 12.75초 |
- 제약이 꼭 느린 건 아니에요. 스키마가 키 이름·괄호·따옴표를 정해 두면 그 토큰은 모델에게 묻지 않고 바로 붙일 수 있어요. Guidance는 이 덕분에 TPOT가 제약 없을 때의 절반 아래였어요.
- 컴파일 비용은 스키마가 복잡할수록 커져요. Outlines는 GitHub Medium 데이터셋에서 컴파일이 8.05초, TTFT가 8.38초였어요. 스키마를 캐시해 재사용하지 못하면 매 요청이 이 값을 내요.
- XGrammar는 붙인 엔진에 따라 달라요. 이 논문의 Transformers 위 측정(표 3)에서는 GlaiveAI 기준 TPOT 66.78ms로 Guidance(36.92ms)보다 느렸지만, XGrammar 논문(Dong 외, 2024) 표 2의 MLC-LLM 측정에서는 JSON 스키마를 켜도 TPOT가 배치 1에서 6.2ms → 6.3ms로 거의 그대로였어요. 내 서빙 스택에서 직접 잰 숫자를 믿어야 하는 이유예요.
스키마 지원 범위 — 엔진마다 받아 주는 스키마가 다르다
지연보다 먼저 볼 것은 엔진이 내 스키마를 받아 주기는 하는지예요. 지원하지 않는 키워드를 만나면 엔진은 에러를 내거나 조용히 무시해요. llama.cpp 문법 안내는 JSON 스키마의 일부만 GBNF로 바꾸고 지원하지 않는 기능은 조용히 건너뛴다고 적어요. 에러 없이 제약이 빠지는 쪽이 더 위험해요.
JSONSchemaBench 표 4의 "실제 커버리지"는 스키마를 받아 주고, 그 스키마에 맞는 출력까지 낸 비율이에요. 실제 GitHub 저장소에서 모은 중간 난이도 스키마(GitHub Medium) 값이에요.
OpenAI는 받아 준 스키마에 대해서는 준수율이 데이터셋별로 0.92~1.00이었어요. 받아 주는 스키마 자체가 적은 거예요. 가이드 규칙을 벗어난 스키마는 strict: true에서 에러로 돌아오는데, 기존 스키마를 넣을 때 자주 걸리는 규칙은 이래요.
- 모든 필드를
required에 넣고, 선택 필드는"type": ["string", "null"]처럼null과의 유니언으로 표현해요. - 모든 객체에
additionalProperties: false를 넣어요. 루트는 객체여야 하고 루트에anyOf를 쓸 수 없어요. allOf·not·if/then/else같은 조합 키워드는 지원하지 않아요.- 크기 제한도 있어요. 속성 이름·enum·const 값 등 문자열 합계 120,000자, 값이 250개 넘는 문자열 enum 하나는 15,000자까지예요.
OpenAPI 명세에서 뽑은 스키마를 그대로 넣으면 이 규칙에 걸리기 쉬워요. LLM용 스키마는 따로 작게 만들고, 쉼표·따옴표 실수는 JSON Validator로 먼저 확인하세요. pattern을 걸 계획이면 vLLM 문서상 xgrammar·guidance·outlines가 러스트 스타일 정규식을 쓰니, Regex Lab에서 시험할 때 전방탐색 같은 기능은 빼고 만드는 편이 안전해요.
품질 — 형식을 묶으면 답이 나빠지나
여기는 연구 결과가 엇갈려요. Let Me Speak Freely? 논문(Tam 외, 2024)은 형식 제한을 걸면 추론 능력이 눈에 띄게 떨어지고, 제약이 엄격할수록 하락이 크다고 보고했어요. 반면 JSONSchemaBench 표 8은 Llama-3.1-8B-Instruct에서 제약 디코딩이 오히려 정답률을 올렸다고 보고해요.
| 방식 | Last Letters | Shuffle Objects | GSM8K |
|---|---|---|---|
| 제약 없음 | 50.7% | 52.6% | 80.1% |
| XGrammar | 51.2% | 52.7% | 83.7% |
| llama.cpp 문법 | 52.0% | 52.6% | 82.4% |
| Outlines | 53.3% | 53.0% | 81.6% |
| Guidance | 54.0% | 55.9% | 83.8% |
실무 규칙으로 바꾸면 이래요.
- 추론이 필요한 작업이면 근거를 쓸 자리를 스키마 안에 만들어요. 출력 키는 스키마 순서대로 나오니,
reasoning같은 문자열 필드를 답 필드보다 앞에 두면 모델이 먼저 풀이를 쓰고 답을 정해요. - 도구 호출·고정 형식 생성은 제약이 확실히 이득이에요. XGrammar 논문 표 4에서 Llama-3.1 8B의 함수 호출 정확도는 제약 없이 62%, 제약을 켜고 100%였어요.
- 내 데이터로 재요. 두 논문은 작업·모델·프롬프트가 달라요. 기존 평가 세트를 제약 켠 것과 끈 것으로 돌려 필드별 정답률을 비교하세요.
상황별 추천 — 무엇을 켤까
| 상황 | 추천 | 이유 |
|---|---|---|
| 문서에서 필드 추출, 상용 API | Structured Outputs (strict) | 재시도 비용과 끝내 실패하는 건수가 거의 사라져요 |
| 에이전트·도구 호출 | 도구 정의에 strict: true | 인자 형식 오류로 도구가 터지는 경우를 막아요 |
| 분류(긍정/부정 등 몇 개 중 하나) | enum 또는 vLLM choice | 출력 토큰이 몇 개라 제약 비용도 거의 없어요 |
| 직접 서빙, 스키마가 몇 개로 고정 | vLLM·SGLang 기본 백엔드(auto) | 컴파일은 스키마당 한 번이라 금방 상쇄돼요 |
| 직접 서빙, 요청마다 스키마가 바뀜 | 컴파일이 짧은 엔진을 실측으로 고르기 | 컴파일 시간이 매 요청 TTFT에 그대로 더해져요 |
| 복잡한 기존 스키마(조합 키워드 많음) | LLM용 축약 스키마 + 앱 쪽 전체 검증 | 엔진이 못 받거나 무시하는 부분을 피해요 |
vLLM을 OpenAI 호환 서버로 띄웠다면 JSON 스키마는 response_format에 "type": "json_schema"로, 선택지·정규식·문법은 extra_body={"structured_outputs": {...}}로 넘겨요. 문서는 백엔드를 바꾸면 허용 토큰과 출력이 달라질 수 있다고 경고하고, lm-format-enforcer 백엔드는 v0.35.0에서 제거될 예정이라고 적어요. 추론(reasoning) 모델과 함께 쓸 때는 문서 예시처럼 --reasoning-parser deepseek_r1 같은 추론 파서를 지정해 서버를 띄워요.
어느 칸을 고르든 응답을 받는 코드는 비슷한 모양이 돼요. 아래는 형식 실패와 내용 실패를 나눠 처리하는 뼈대예요. 길이 초과는 재시도해도 같은 한도에 또 걸리니 따로 분기해요.
from pydantic import BaseModel, ValidationError
class Invoice(BaseModel):
reasoning: str # 근거를 먼저 쓰게 앞에 둔다
vendor: str | None # 원문에 없으면 null
total: int | None
def parse_invoice(call_llm, prompt, max_attempts=3):
for attempt in range(1, max_attempts + 1):
text, finish = call_llm(prompt)
if finish == "length":
raise RuntimeError("토큰 한도 초과 — 재시도 말고 한도·입력 길이 조정")
try:
return Invoice.model_validate_json(text)
except ValidationError as e:
if attempt == max_attempts:
raise
prompt += f"\n\n이전 출력이 형식에 맞지 않았어요: {e.errors()[:3]}"
자주 보는 파싱 에러 메시지가 무엇을 뜻하는지는 JSON 파싱 에러 원인 6가지에 정리해 두었어요.
도입 체크리스트
- 지금 실패율부터 재요. 실제 트래픽 몇천 건으로 파싱 실패율과 스키마 위반율을 따로 기록하면 위 비용표의 어느 줄인지 정해져요.
- 스키마를 LLM용으로 작게 만들어요. 필드 수를 줄이고, 분류 값은 enum으로, 배열은
maxItems로 상한을 둬요. 상한 없는 배열은 출력 토큰 한도에 걸려 잘리기 쉬워요. - 스키마는 상수로 고정해요. 스키마 문자열에 날짜·사용자 이름 같은 값을 넣지 마세요.
- 프롬프트에도 필드 의미를 적어요. llama.cpp 문서는 JSON 스키마가 프롬프트에 주입되지 않는다고 명시해요.
- 세 가지 예외를 분기해요. 토큰 한도로 잘린 응답(OpenAI 가이드 예제는 상태
incomplete·사유max_output_tokens를 확인), 모델이 거절한 응답(refusal필드), 콘텐츠 필터로 끊긴 응답이에요. 이 셋은 스키마 제약을 켜도 스키마에 맞지 않을 수 있어요. - 앱 쪽 검증은 남겨 둬요. 제약 엔진이 조용히 무시한 키워드, 백엔드 교체로 바뀐 동작을 마지막에 잡아 줘요.
- 운영 지표를 정해요. 실패율·재시도율, 첫 요청 지연, TTFT·TPOT p99, 필드별 정답률,
null비율을 모델·엔진을 바꿀 때마다 다시 봐요.
직접 서빙하면서 지연을 더 줄이려면 투기적 디코딩 방식별 속도·비용 비교도 보세요. 제약 디코딩과 함께 켤 수 있는지는 엔진·버전마다 달라요.
자주 묻는 질문
JSON 모드만 켜도 되지 않나요?
JSON 모드는 파싱되는 JSON까지만 보장해요. 키 이름 오타, 빠진 필수 필드, 잘못된 타입은 그대로 나올 수 있어서 스키마 검증과 재시도가 여전히 필요해요. 스키마 제약을 지원하는 모델이라면 Structured Outputs가 같은 일을 재시도 없이 해요.
Structured Outputs를 켜면 비용이 더 드나요?
가격표에 Structured Outputs 전용 요금은 따로 없어요. 함수 호출의 도구 정의는 입력 토큰으로 청구된다고 공식 가이드에 적혀 있고, response_format 스키마의 토큰 계산 방식은 가이드에 명시가 없어요. 큰 스키마라면 켜기 전후의 usage 입력 토큰을 비교해 보세요.
재시도는 몇 번이 적당한가요?
실패가 독립이라고 가정해도 실패율 10%에서 재시도 2회 뒤 남는 실패가 0.1%이고, 3회째부터는 줄어드는 폭이 작아요. 같은 입력은 같은 이유로 반복 실패하는 경우가 많아서, 2회를 넘기기보다 스키마를 단순화하거나 제약 디코딩으로 바꾸는 편이 효과가 커요.
제약 디코딩을 쓰면 환각이 줄어드나요?
형식 오류는 사라지지만 내용의 정확성은 그대로예요. 필수 필드에 넣을 정보가 원문에 없으면 오히려 그럴듯한 값을 지어낼 수 있어요. 모를 수 있는 필드는 null을 허용하고, 필드별 정답률을 따로 측정해야 해요.
정리
- 상용 API에서 스키마 제약을 지원하면 Structured Outputs가 기본 선택이에요. gpt-4.1-mini 계산에서 실패율 35%짜리 프롬프트+재시도는 비용이 55% 늘고도 4.3%가 끝내 실패했어요.
- 재시도는 평균 지연보다 p95·p99를 두 배, 세 배로 늘려요. 스키마 제약은 새 스키마 첫 요청에 지연이 몰리니 스키마를 고정해 재사용해요.
- 직접 서빙하면 엔진에 따라 300토큰 응답이 2.15초에서 12.75초까지, 스키마 커버리지가 29%에서 69%까지 벌어졌어요. 내 스키마로 직접 재서 정해요.
- 어느 방식이든 보장되는 건 형식뿐이에요. 근거 필드를 앞에,
null허용, 길이 초과·거절 분기, 앱 쪽 검증을 챙기고, 스키마 JSON은 JSON Validator로 먼저 점검하세요.