Skip to content

필사 모드: Stabilizing LLM Structured Output — Four Layers of Defense Against Parse Failures

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

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 typeSymptomCauseCheapest defense
Code fence wrappingArrives wrapped in three backticks with a json tagMarkdown habits from training dataStrip the fence before parsing
Explanation before or afterA preamble like "here is the result you requested"Side effect of conversational alignmentPrefill, stop sequences
TruncationEnds without closing the objectToken ceiling reachedCheck the finish reason, recompute the ceiling
Bad escapingMishandled quotes and newlines inside stringsLong free-text fieldsSplit the field, constrained decoding
Hallucinated fieldsA key not in the schema is addedNo schema constraintStrict schema, reject unknown keys
Type mismatchNumbers as strings, dates in assorted formatsTypes not statedState 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.

현재 단락 (1/112)

Once you start using LLM output as an intermediate stage in a pipeline, parse success rate becomes a...

작성 글자: 0원문 글자: 11,860작성 단락: 0/112