Split View: LLM 구조화된 출력 안정화 — 파싱 실패를 없애는 네 겹의 방어
LLM 구조화된 출력 안정화 — 파싱 실패를 없애는 네 겹의 방어
들어가며 — 파싱은 99퍼센트만 성공해도 실패입니다
LLM 출력을 파이프라인의 중간 단계로 쓰기 시작하면, 자연어 품질보다 파싱 성공률이 먼저 문제가 됩니다. 요약이 조금 밋밋한 것은 견딜 수 있지만, 하루 10만 건 중 1퍼센트가 파싱에 실패하면 그건 1천 건의 예외 처리입니다.
이 문제가 까다로운 이유는 실패가 매번 다른 모습으로 온다는 데 있습니다. 어제는 마크다운 펜스로 감싸서 왔고, 오늘은 뒤에 친절한 설명 문장이 붙었고, 내일은 중간에서 잘립니다. 그때마다 문자열 처리를 하나씩 덧붙이다 보면 정규식 스무 줄짜리 파서가 생기고, 그것도 결국 새는 곳이 생깁니다.
해결책은 방어를 겹으로 쌓되 각 겹이 무엇을 막고 무엇을 못 막는지 명확히 아는 것입니다. 이 글은 실패 유형을 먼저 분류하고, 그다음 네 겹의 방어를 순서대로 배치합니다.
실패 유형을 먼저 분류합니다
로그를 열어 파싱 실패 케이스를 100건만 모아 분류하면, 대응 우선순위가 바로 정해집니다. 대체로 여섯 가지로 나뉩니다.
| 실패 유형 | 증상 | 원인 | 가장 값싼 방어 |
|---|---|---|---|
| 코드 펜스 포장 | 백틱 세 개와 json 표기로 감싸서 옴 | 학습 데이터의 마크다운 습관 | 파싱 전 펜스 제거 |
| 앞뒤 설명 문장 | "요청하신 결과입니다" 같은 서문 | 대화형 정렬의 부작용 | 사전 채움, 정지 시퀀스 |
| 잘림 | 객체가 닫히지 않고 끝남 | 토큰 상한 도달 | 종료 사유 확인, 상한 재산정 |
| 잘못된 이스케이프 | 문자열 안 따옴표와 개행 처리 오류 | 긴 자유 텍스트 필드 | 필드 분리, 제약 디코딩 |
| 환각 필드 | 스키마에 없는 키가 추가됨 | 스키마 제약 부재 | 엄격 스키마, 알 수 없는 키 거부 |
| 타입 불일치 | 숫자를 문자열로, 날짜 형식 제각각 | 타입 명시 부족 | 필드 이름과 설명에 형식 명시 |
이 표에서 중요한 건 마지막 열이 전부 다르다는 점입니다. 하나의 대책으로 전부 막으려다 보면 과잉 설계가 됩니다. 실제 분포를 보고 상위 두세 개만 처리해도 실패율이 대개 한 자릿수 아래로 떨어집니다.
앞의 두 유형은 특히 값싸게 막힙니다.
import json, re
FENCE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)
def extract_json(text):
"""1) 펜스 제거 2) 그래도 안 되면 첫 객체 구간만 잘라 시도"""
cleaned = FENCE.sub("", text).strip()
try:
return json.loads(cleaned)
except json.JSONDecodeError:
pass
start, end = cleaned.find("{"), cleaned.rfind("}")
if start != -1 and end > start:
return json.loads(cleaned[start:end + 1])
raise ValueError("JSON을 찾을 수 없음")
이건 임시 처방입니다. 근본 대책은 아래 계층에서 다루지만, 이 열 줄이 오늘 밤의 알림을 멈춰 주는 것도 사실입니다.
방어의 네 겹 — 지시, 예시, 스키마, 제약
방어 수단을 강도 순으로 놓으면 이렇습니다. 위로 갈수록 강하고, 아래일수록 싸고 어디서나 됩니다.
첫째 겹은 프롬프트 지시입니다. "JSON만 출력하고 다른 텍스트는 붙이지 마십시오"는 놀라울 만큼 잘 듣습니다. 여기에 두 가지를 더하면 효과가 커집니다. 정지 시퀀스를 지정해 객체가 닫힌 뒤 이어지는 설명을 잘라 내고, 어시스턴트 응답을 여는 중괄호로 미리 시작해 두는 사전 채움을 씁니다. 사전 채움은 서문을 물리적으로 불가능하게 만듭니다.
둘째 겹은 예시입니다. 원하는 형태를 한두 개 보여 주는 것이 형식 준수에 미치는 효과는 지시문 몇 줄보다 큽니다. 특히 열거값, 날짜 형식, 빈 값의 표현 방식처럼 말로 설명하면 장황해지는 규칙에서 그렇습니다.
셋째 겹은 함수 호출 또는 도구 스키마입니다. 자유 텍스트로 JSON을 요청하는 대신, 인자 스키마를 가진 도구를 정의하고 그 도구를 호출하게 만듭니다. 제공자 쪽에서 스키마를 강제해 주므로 앞의 실패 유형 대부분이 사라집니다. API를 쓰신다면 이 계층부터 시작하는 것이 맞습니다.
넷째 겹은 제약 디코딩입니다. 스키마를 유한 상태 기계나 문법으로 컴파일하고, 매 스텝마다 그 상태에서 허용되지 않는 토큰의 확률을 0으로 만듭니다. 출력이 문법을 위반하는 것이 원리적으로 불가능해집니다.
# 오픈 모델을 직접 서빙한다면 제약 디코딩은 서버 옵션으로 켭니다
vllm serve <모델경로> --guided-decoding-backend xgrammar
# 요청 측에서 스키마를 넘깁니다 (예: OpenAI 호환 엔드포인트)
# "guided_json": { ...JSON Schema... }
# "guided_choice": ["긍정", "부정", "중립"]
# "guided_regex": "\\d{4}-\\d{2}-\\d{2}"
분류처럼 출력이 몇 개의 라벨 중 하나인 작업에서는 guided_choice가 특히 강력합니다. 출력 공간이 유한하므로 파싱이라는 개념 자체가 사라집니다.
제약 디코딩이 보장하는 것과 보장하지 않는 것
여기서 가장 중요한 구분을 하겠습니다. 제약 디코딩은 문법적 유효성을 보장합니다. 의미적 정확성은 보장하지 않습니다.
스키마가 price 필드를 정수로 요구하면 정수가 나옵니다. 그 정수가 문서에 적힌 가격인지는 아무도 보장하지 않습니다. 오히려 상황이 미묘하게 나빠질 수 있습니다. 제약이 없을 때 모델은 "문서에서 가격을 찾을 수 없습니다"라고 형식을 어기며 알려 주기라도 하는데, 제약이 걸리면 그 탈출구가 막혀 어떤 숫자든 내놓아야 합니다. 형식 오류가 값 오류로 바뀐 것이고, 값 오류는 감지하기 훨씬 어렵습니다.
그래서 제약 디코딩을 켤 때 반드시 함께 해야 하는 일이 있습니다. 스키마에 "모름"을 표현할 자리를 만드는 것입니다.
# 나쁨: 값이 없어도 반드시 숫자를 내야 한다
{"price": {"type": "integer"}}
# 좋음: 없음을 표현할 수 있다
{"price": {"type": ["integer", "null"]},
"price_found": {"type": "boolean"}}
두 번째 유의점은 문법이 모델의 자연스러운 생성 경로와 충돌할 때입니다. 제약은 확률 분포를 자르는 것이지 모델의 이해를 바꾸는 것이 아니므로, 모델이 가고 싶어 하는 경로가 전부 막히면 남은 것 중 가장 덜 나쁜 토큰이 선택됩니다. 스키마가 복잡하고 부자연스러울수록 이 현상이 커집니다. 제약 디코딩을 켰더니 형식 오류는 사라졌는데 정확도가 떨어졌다면, 스키마를 단순하게 만드는 것이 먼저입니다.
세 번째는 성능입니다. 스텝마다 허용 토큰 마스크를 계산해야 하므로 오버헤드가 있습니다. 최근 구현들은 문법을 미리 컴파일하고 캐시해서 이 비용을 대부분 상쇄하지만, 스키마가 매 요청 달라지면 컴파일 비용이 그대로 드러납니다. 스키마 수가 유한하다면 재사용되도록 만드십시오.
스키마 설계가 곧 프롬프트입니다
모델은 스키마를 제약으로만 받는 것이 아니라 텍스트로 읽습니다. 그래서 필드 이름과 설명은 지시문과 같은 무게를 가집니다. 여기서 얻는 정확도 개선이 프롬프트를 다듬어 얻는 것보다 큰 경우가 많습니다.
from pydantic import BaseModel, Field
from typing import Literal, Optional
# 나쁜 스키마
class BadTicket(BaseModel):
type: str # 무엇이든 들어온다
date: str # 형식 제각각
info: dict # 구조가 없다
# 좋은 스키마
class Ticket(BaseModel):
reasoning: str = Field(description="분류 근거를 두 문장 이내로")
category: Literal["결제", "배송", "환불", "계정", "기타"]
urgency: Literal["낮음", "보통", "높음"]
order_id: Optional[str] = Field(None, description="주문번호. 본문에 없으면 null")
due_date_iso: Optional[str] = Field(None, description="YYYY-MM-DD 형식")
네 가지가 달라졌습니다.
자유 문자열을 열거형으로 바꿨습니다. 이건 정확도와 파싱 안정성을 동시에 올립니다. 다운스트림에서 "결제"와 "결제 문의"를 같은 것으로 매핑하는 코드도 사라집니다.
필드 이름에 형식을 넣었습니다. due_date_iso는 date보다 훨씬 강한 지시입니다. 이름만으로 형식이 전달되면 설명문에 쓸 토큰이 절약됩니다.
없을 수 있는 값을 널 허용으로 만들었습니다. 필수 문자열로 두면 모델은 빈 문자열이나 그럴듯한 가짜 주문번호를 만들어 냅니다.
그리고 가장 효과가 큰 변경은 reasoning을 맨 앞에 둔 것입니다. 생성은 왼쪽에서 오른쪽으로 진행되므로, 앞에 놓인 필드가 뒤 필드의 조건이 됩니다. 근거를 먼저 쓰게 하면 분류 결과가 그 근거에 조건부로 생성됩니다. 같은 필드를 맨 뒤에 놓으면 이미 정해진 답에 대한 사후 설명이 되어 정확도 개선 효과가 사라집니다. JSON 객체의 키 순서는 의미상 무관하지만, 생성 순서는 전혀 무관하지 않습니다.
한 가지 더. 중첩은 얕게 유지하십시오. 3단계 이상 중첩된 객체는 형식 오류율과 값 오류율이 함께 올라가고, 제약 디코딩의 문법도 커집니다. 중첩이 필요해 보이면 대체로 호출을 두 번으로 나누는 편이 낫습니다.
검증-재시도 루프를 제대로 구현하기
방어를 다 쌓아도 실패는 남습니다. 남은 실패를 처리하는 방식이 성공률을 크게 가릅니다.
핵심은 재시도할 때 무엇을 바꾸느냐입니다. 같은 프롬프트로 그냥 다시 부르는 것은 온도가 0이 아닐 때만 의미가 있고, 그마저도 같은 실패를 반복할 확률이 높습니다. 오류 메시지를 대화에 되돌려 주면 성공률이 확연히 달라집니다.
import json
from pydantic import ValidationError
def generate_structured(client, messages, model_cls, max_attempts=3, max_tokens=1024):
history = list(messages)
last_error = None
for attempt in range(max_attempts):
resp = client.create(messages=history, max_tokens=max_tokens)
text = resp.content
finish = resp.finish_reason
if finish == "length":
# 잘림은 재생성으로 안 풀린다. 상한을 올리거나 작업을 쪼갠다.
max_tokens = min(max_tokens * 2, 8192)
history.append({"role": "user",
"content": "출력이 잘렸습니다. 더 짧게, 설명 없이 다시 출력하십시오."})
continue
try:
return model_cls.model_validate(extract_json(text))
except (ValueError, json.JSONDecodeError, ValidationError) as e:
last_error = e
# 실패한 출력과 구체적 오류를 함께 되돌려 준다
history.append({"role": "assistant", "content": text})
history.append({"role": "user", "content":
f"위 출력이 스키마 검증에 실패했습니다.\n오류: {e}\n"
f"오류만 수정한 JSON을 다른 텍스트 없이 출력하십시오."})
raise RuntimeError(f"{max_attempts}회 시도 후 실패: {last_error}")
세 가지를 짚겠습니다.
오류 메시지는 구체적이어야 합니다. "검증 실패"가 아니라 "urgency 필드의 값 '매우 높음'은 허용된 값이 아닙니다"까지 전달해야 모델이 고칠 수 있습니다. pydantic이나 jsonschema의 오류 문자열을 그대로 넘기면 대체로 충분합니다.
실패한 출력 자체를 대화에 넣어야 합니다. 모델이 자기가 뭘 썼는지 봐야 수정이 됩니다.
잘림은 다른 실패입니다. 같은 방식으로 재시도하면 같은 지점에서 또 잘립니다. 종료 사유를 확인해 분기해야 합니다.
재시도의 비용도 계산해 두면 좋습니다.
def retry_math(p_fail, max_attempts):
expected_calls = sum(p_fail ** i for i in range(max_attempts))
residual = p_fail ** max_attempts
return expected_calls, residual
for p in (0.04, 0.15):
calls, res = retry_math(p, 3)
print(f"1회 실패율 {p:.0%} → 평균 호출 {calls:.3f}회 (비용 +{calls - 1:.1%}), 최종 실패율 {res:.4%}")
# 1회 실패율 4% → 평균 호출 1.042회 (비용 +4.2%), 최종 실패율 0.0064%
# 1회 실패율 15% → 평균 호출 1.172회 (비용 +17.2%), 최종 실패율 0.3375%
여기서 독립 시행을 가정했다는 점은 짚고 넘어가야 합니다. 실제 실패는 상관되어 있습니다. 특정 입력이 어려워서 실패한 것이라면 두 번째 시도도 같은 이유로 실패할 확률이 높습니다. 그래서 위 최종 실패율은 낙관적인 추정치이고, 실제 값은 로그에서 재야 합니다. 재시도 횟수를 3회 이상으로 늘리는 것이 거의 도움이 안 되는 이유도 이 상관성 때문입니다.
출력 상한과 잘림 대비
잘림은 조용한 실패입니다. 파싱 오류로 드러나면 그나마 다행이고, 배열의 마지막 항목만 사라진 채로 유효한 JSON이 되는 경우가 최악입니다. 검증도 통과하고 알림도 안 울리는데 데이터가 빠져 있습니다.
def estimate_output_tokens(schema_fields, avg_value_tokens, list_len=1):
"""구조 오버헤드 + 값. 실제 값은 토크나이저로 재는 것이 정확하다."""
per_item = sum(len(name) // 3 + 4 + avg_value_tokens for name in schema_fields)
return int(per_item * list_len * 1.25) # 25% 여유
fields = ["reasoning", "category", "urgency", "order_id", "due_date_iso"]
print(estimate_output_tokens(fields, avg_value_tokens=12, list_len=1)) # 116
print(estimate_output_tokens(fields, avg_value_tokens=12, list_len=20)) # 2325
목록을 뽑는 작업에서 항목 수가 예측 불가능하면 상한 산정이 근본적으로 불가능합니다. 이럴 때 쓸 수 있는 방법이 셋 있습니다.
스키마에 항목 수 상한을 명시하고 초과분은 다음 호출로 넘기는 페이지네이션을 만듭니다. 아니면 JSON 배열 대신 한 줄에 객체 하나씩 출력하는 방식으로 바꿔, 잘려도 마지막 줄만 버리면 되게 합니다. 마지막으로 배열 길이를 세는 필드를 앞쪽에 두고 실제 배열 길이와 비교해 잘림을 사후 검증합니다.
어느 쪽이든 종료 사유 확인은 필수입니다. 이 값을 확인하지 않는 파이프라인은 잘림을 절대 감지하지 못합니다.
마치며 — 형식은 강제하고 의미는 검증하십시오
정리하면 한 문장입니다. 형식은 생성 단계에서 강제하고, 의미는 생성 이후에 검증하십시오.
형식 강제는 도구 스키마와 제약 디코딩으로 원리적으로 해결됩니다. 여기에 시간을 오래 쓸 이유가 없습니다. 반면 값이 맞는지, 없는 값을 지어내지 않았는지, 열거값이 실제 상황과 대응하는지는 스키마가 절대 잡아 주지 않습니다. 제약 디코딩을 도입한 뒤 파싱 실패율이 0이 되는 순간이 위험한 이유가 여기 있습니다. 대시보드의 빨간불이 꺼졌을 뿐, 틀린 값은 그대로 흘러갑니다.
그러니 파싱 성공률 옆에 값 정확도 지표를 같이 놓으십시오. 그리고 스키마를 손볼 때는 필드 이름과 순서부터 보십시오. 프롬프트를 열 번 고치는 것보다 필드 하나를 앞으로 옮기는 편이 나은 경우가 자주 있습니다.
Stabilizing LLM Structured Output — Four Layers of Defense Against Parse Failures
Introduction — parsing that succeeds only 99 percent of the time is a failure
Once you start using LLM output as an intermediate stage in a pipeline, parse success rate becomes a problem before natural-language quality does. A summary that reads a bit flat is survivable, but 1 percent of 100,000 daily requests failing to parse is a thousand exceptions to handle.
What makes this hard is that the failure arrives in a different shape every time. Yesterday it came wrapped in a markdown fence, today it had a helpful explanatory sentence appended, tomorrow it gets cut off midway. Bolt on one string fix at a time and you end up with a twenty-line regex parser that still leaks somewhere.
The solution is to stack the defenses in layers while knowing exactly what each layer does and does not block. This post classifies the failure types first, then lays out four layers of defense in order.
Classify the failure types first
Open your logs, collect just 100 parse failure cases and classify them, and your response priorities settle immediately. They generally split into six kinds.
| Failure type | Symptom | Cause | Cheapest defense |
|---|---|---|---|
| Code fence wrapping | Arrives wrapped in three backticks with a json tag | Markdown habits from training data | Strip the fence before parsing |
| Explanation before or after | A preamble like "here is the result you requested" | Side effect of conversational alignment | Prefill, stop sequences |
| Truncation | Ends without closing the object | Token ceiling reached | Check the finish reason, recompute the ceiling |
| Bad escaping | Mishandled quotes and newlines inside strings | Long free-text fields | Split the field, constrained decoding |
| Hallucinated fields | A key not in the schema is added | No schema constraint | Strict schema, reject unknown keys |
| Type mismatch | Numbers as strings, dates in assorted formats | Types not stated | State the format in field names and descriptions |
What matters in this table is that the last column is different in every row. Trying to block all of them with a single countermeasure leads to overdesign. Look at the actual distribution and handling just the top two or three usually drops the failure rate below one percent.
The first two types are especially cheap to block.
import json, re
FENCE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)
def extract_json(text):
"""1) strip the fence 2) if that still fails, cut out the first object span and try that"""
cleaned = FENCE.sub("", text).strip()
try:
return json.loads(cleaned)
except json.JSONDecodeError:
pass
start, end = cleaned.find("{"), cleaned.rfind("}")
if start != -1 and end > start:
return json.loads(cleaned[start:end + 1])
raise ValueError("JSON을 찾을 수 없음")
This is a stopgap. The real fix lives in the layers below, but it is also true that these ten lines will stop tonight's alerts.
Four layers of defense — instruction, example, schema, constraint
Ordered by strength, the defenses look like this. The higher you go the stronger it gets; the lower you go the cheaper it is and the more places it works.
The first layer is the prompt instruction. "Output only JSON and do not attach any other text" works surprisingly well. Adding two things makes it more effective: specify a stop sequence to cut off the explanation that follows the closing brace, and use prefill by starting the assistant response with an opening brace. Prefill makes a preamble physically impossible.
The second layer is examples. Showing one or two instances of the shape you want does more for format compliance than several lines of instruction, especially for rules that get verbose when described in words — enum values, date formats, how to represent an empty value.
The third layer is function calling, or tool schemas. Instead of requesting JSON as free text, define a tool with an argument schema and make the model call it. The provider enforces the schema, so most of the failure types above disappear. If you are on an API, this is the layer to start from.
The fourth layer is constrained decoding. The schema is compiled into a finite state machine or grammar, and at every step the probability of any token not permitted in that state is set to zero. It becomes impossible in principle for the output to violate the grammar.
# if you serve an open model yourself, turn constrained decoding on as a server option
vllm serve <모델경로> --guided-decoding-backend xgrammar
# pass the schema from the request side (e.g. an OpenAI-compatible endpoint)
# "guided_json": { ...JSON Schema... }
# "guided_choice": ["positive", "negative", "neutral"]
# "guided_regex": "\\d{4}-\\d{2}-\\d{2}"
For tasks like classification, where the output is one of a handful of labels, guided_choice is especially powerful. The output space is finite, so the concept of parsing disappears entirely.
What constrained decoding guarantees and what it does not
Here is the most important distinction in this post. Constrained decoding guarantees syntactic validity. It does not guarantee semantic correctness.
If the schema requires the price field to be an integer, an integer comes out. Whether that integer is the price written in the document is guaranteed by nobody. In fact, things can get subtly worse. Without the constraint, the model at least has the option of breaking format to tell you "I cannot find a price in the document"; with the constraint, that escape hatch is closed and it has to produce some number. A format error has turned into a value error, and value errors are far harder to detect.
So there is something you must do whenever you turn constrained decoding on: make room in the schema to express "I do not know."
# bad: a number must be produced even when there is no value
{"price": {"type": "integer"}}
# good: absence can be expressed
{"price": {"type": ["integer", "null"]},
"price_found": {"type": "boolean"}}
The second thing to watch for is when the grammar collides with the model's natural generation path. A constraint clips the probability distribution; it does not change the model's understanding. So when every path the model wants to take is blocked, the least bad of the remaining tokens gets chosen. The more complex and unnatural the schema, the stronger this effect. If turning on constrained decoding made format errors vanish while accuracy dropped, simplifying the schema comes first.
The third is performance. Computing the allowed-token mask at every step carries overhead. Recent implementations precompile and cache the grammar and offset most of that cost, but if the schema differs on every request, the compilation cost is exposed directly. If the number of schemas is finite, make them reusable.
Schema design is prompt design
The model does not receive the schema only as a constraint; it reads it as text. Field names and descriptions therefore carry the same weight as instructions. The accuracy improvement available here is often larger than what you get from polishing the prompt.
from pydantic import BaseModel, Field
from typing import Literal, Optional
# bad schema
class BadTicket(BaseModel):
type: str # anything can arrive
date: str # assorted formats
info: dict # no structure
# good schema
class Ticket(BaseModel):
reasoning: str = Field(description="분류 근거를 두 문장 이내로")
category: Literal["결제", "배송", "환불", "계정", "기타"]
urgency: Literal["낮음", "보통", "높음"]
order_id: Optional[str] = Field(None, description="주문번호. 본문에 없으면 null")
due_date_iso: Optional[str] = Field(None, description="YYYY-MM-DD 형식")
Four things changed.
Free strings became enums. That raises accuracy and parse stability at the same time. The downstream code that maps two different spellings of the same label onto one value also disappears.
The format went into the field name. due_date_iso is a far stronger instruction than date. When the name alone conveys the format, you save the tokens you would have spent on a description.
Values that may be absent were made nullable. Leave them as required strings and the model produces an empty string or a plausible fake order number.
And the change with the largest effect is putting reasoning first. Generation runs left to right, so a field placed earlier becomes a condition on the fields after it. Making the model write its justification first means the classification is generated conditioned on that justification. Put the same field last and it becomes a post-hoc explanation of an answer already decided, and the accuracy benefit disappears. The key order of a JSON object is semantically irrelevant, but the generation order is not irrelevant at all.
One more thing. Keep nesting shallow. Objects nested three or more levels deep raise both the format error rate and the value error rate, and they inflate the grammar for constrained decoding. When nesting starts to look necessary, splitting into two calls is usually better.
Implementing the validation-retry loop properly
Even with every defense stacked, some failures remain. How you handle the remainder makes a large difference to the success rate.
The key is what you change on retry. Simply calling again with the same prompt only means anything when the temperature is not zero, and even then it has a high chance of repeating the same failure. Feed the error message back into the conversation and the success rate changes markedly.
import json
from pydantic import ValidationError
def generate_structured(client, messages, model_cls, max_attempts=3, max_tokens=1024):
history = list(messages)
last_error = None
for attempt in range(max_attempts):
resp = client.create(messages=history, max_tokens=max_tokens)
text = resp.content
finish = resp.finish_reason
if finish == "length":
# truncation is not solved by regenerating. Raise the ceiling or split the task.
max_tokens = min(max_tokens * 2, 8192)
history.append({"role": "user",
"content": "출력이 잘렸습니다. 더 짧게, 설명 없이 다시 출력하십시오."})
continue
try:
return model_cls.model_validate(extract_json(text))
except (ValueError, json.JSONDecodeError, ValidationError) as e:
last_error = e
# hand back the failed output together with the specific error
history.append({"role": "assistant", "content": text})
history.append({"role": "user", "content":
f"위 출력이 스키마 검증에 실패했습니다.\n오류: {e}\n"
f"오류만 수정한 JSON을 다른 텍스트 없이 출력하십시오."})
raise RuntimeError(f"{max_attempts}회 시도 후 실패: {last_error}")
Three points.
The error message has to be specific. Not "validation failed" but something like "the value given for the urgency field is not one of the allowed values" — only then can the model fix it. Passing the pydantic or jsonschema error string through verbatim is usually enough.
The failed output itself has to go into the conversation. The model has to see what it wrote in order to correct it.
Truncation is a different failure. Retrying the same way truncates at the same point again. You have to check the finish reason and branch.
It also helps to work out what retries cost.
def retry_math(p_fail, max_attempts):
expected_calls = sum(p_fail ** i for i in range(max_attempts))
residual = p_fail ** max_attempts
return expected_calls, residual
for p in (0.04, 0.15):
calls, res = retry_math(p, 3)
print(f"1회 실패율 {p:.0%} → 평균 호출 {calls:.3f}회 (비용 +{calls - 1:.1%}), 최종 실패율 {res:.4%}")
# 1회 실패율 4% → 평균 호출 1.042회 (비용 +4.2%), 최종 실패율 0.0064%
# 1회 실패율 15% → 평균 호출 1.172회 (비용 +17.2%), 최종 실패율 0.3375%
It has to be said that this assumes independent trials. Real failures are correlated. If a particular input failed because it is hard, the second attempt has a high chance of failing for the same reason. So the residual failure rate above is an optimistic estimate and the real value has to be measured from your logs. That correlation is also why raising the retry count past three helps almost not at all.
Output ceilings and preparing for truncation
Truncation is a silent failure. Surfacing as a parse error is the lucky case; the worst case is valid JSON with only the last item of an array missing. Validation passes, no alert fires, and data is gone.
def estimate_output_tokens(schema_fields, avg_value_tokens, list_len=1):
"""structural overhead + values. Measuring the real value with a tokenizer is more accurate."""
per_item = sum(len(name) // 3 + 4 + avg_value_tokens for name in schema_fields)
return int(per_item * list_len * 1.25) # 25% headroom
fields = ["reasoning", "category", "urgency", "order_id", "due_date_iso"]
print(estimate_output_tokens(fields, avg_value_tokens=12, list_len=1)) # 116
print(estimate_output_tokens(fields, avg_value_tokens=12, list_len=20)) # 2325
For extraction tasks where the item count is unpredictable, setting a ceiling is fundamentally impossible. There are three approaches available.
Put an item-count ceiling in the schema and build pagination that pushes the overflow into the next call. Or switch from a JSON array to one object per line, so that a truncation only costs you the last line. Finally, put a field counting the array length near the front and compare it against the actual array length to validate truncation after the fact.
Either way, checking the finish reason is mandatory. A pipeline that does not check this value will never detect truncation.
Closing — enforce the format, verify the meaning
It reduces to one sentence. Enforce the format at generation time and verify the meaning after generation.
Format enforcement is solved in principle by tool schemas and constrained decoding. There is no reason to spend long on it. Whether the values are right, whether the model invented values that were not there, whether the enum matches the actual situation — none of that is ever caught by a schema. That is exactly why the moment your parse failure rate hits zero after adopting constrained decoding is a dangerous one. The red light on the dashboard went out; the wrong values keep flowing.
So put a value accuracy metric next to the parse success rate. And when you touch the schema, look at field names and ordering first. Moving one field forward is frequently worth more than ten prompt revisions.