Constrained decoding on self-hosted models
On a hosted API “structured output” is a checkbox. On a model you serve yourself it is a feature of the engine, with a backend to choose, a schema subset to respect, a compile step to cache and two ways to quietly lower accuracy. The mechanism is in Function calling and structured output; this note is the operational half.
What the engine does per token
# The idea, stripped of the engineering: before sampling each token,
# zero out every token that cannot continue a valid output.
def allowed(prefix: str, vocab: list[str], pattern) -> set[int]:
return {i for i, tok in enumerate(vocab)
if pattern.partial_match(prefix + tok)} # can it still match?
logits[[i for i in range(V) if i not in allowed(out, vocab, pat)]] = -infDoing that literally is far too slow — 150,000 vocabulary entries per step — so the backends precompute. Regex and JSON Schema become an automaton over the tokenizer’s vocabulary: for every automaton state, the set of tokens that keep it valid is computed once and stored as a bitmask, and each decoding step is a table lookup plus a mask applied to the logits before sampling. The compile happens once per schema and is cached by hash.
Two families of backend:
| Backend | Approach |
|---|---|
| Outlines | regex/JSON → finite-state machine, mask per state |
| XGrammar | context-free grammar, pushdown automaton, cached masks |
| llguidance | Earley parser, lazy token-level masks |
XGrammar is the default in vLLM and SGLang because a pushdown automaton handles recursive schemas (nested objects, arrays of arrays) that a pure FSM cannot, and its mask computation overlaps with the GPU so the per-token overhead is close to zero.
SGLang adds jump-forward decoding: when the grammar allows exactly one continuation — the next key name, a closing brace — those tokens are appended without a forward pass, which makes fixed-key JSON measurably faster than free text.
Asking for it
from openai import OpenAI
client = OpenAI(base_url="http://vllm:8000/v1", api_key="-")
resp = client.chat.completions.create(
model="qwen3-vl-8b",
messages=messages,
response_format={"type": "json_schema",
"json_schema": {"name": "invoice", "schema": SCHEMA}},
# vLLM-specific alternatives, via extra_body:
# {"structured_outputs": {"regex": r"\d{4}-\d{2}-\d{2}"}}
# {"structured_outputs": {"choice": ["approve", "reject"]}}
# {"structured_outputs": {"grammar": EBNF}}
# (older releases spell these guided_json / guided_regex / guided_choice)
)vLLM serves the OpenAI shape — response_format with a JSON Schema — and
its own structured_outputs field for regex, choice and EBNF grammars; the
guided_* names are the pre-2025 spelling and still appear in most blog
posts. SGLang takes json_schema, regex and ebnf in the sampling
parameters. The backend is a server flag; the client does not see it.
With a reasoning model the constraint has to start after the thinking block, or the grammar forbids the model from thinking. Both engines have a reasoning parser for exactly this; enable it or the Thinking variant produces JSON with no reasoning and worse answers than the Instruct one.
The schema subset
Every backend implements a subset of JSON Schema, and the subset differs.
The usual casualties: numeric minimum/maximum, format (dates, emails),
complex patterns, deep $ref recursion, oneOf with overlapping
branches. What is reliably supported: types, required, enum, const,
anyOf, additionalProperties: false, string and array length limits.
Two rules follow. Compile the schema in CI against the pinned engine version — a schema the backend rejects at request time is an outage, not a bug report. And validate with Pydantic after parsing for every rule the grammar cannot express; the grammar guarantees shape, Pydantic checks ranges, formats and the cross-field rules (Structured extraction from documents).
The two ways it lowers accuracy
Forced commitment. The grammar makes the first token {, the second the
first key, and the value right after. A model that would have written “the
total appears twice; the second is the corrected one” before answering
never gets to. On easy pages nothing changes; on hard pages accuracy drops
measurably.
Fixes: order the schema so a free-text analysis field comes first, or use
a Thinking variant with the reasoning kept outside the grammar, or run two
calls — free text, then extraction from the text.
Forced choice. With an enum, the sampler picks the highest-probability
allowed token, and when the model’s intended answer is not in the enum
the pick is whichever option shares a prefix with what it wanted to say —
often the first alphabetically. Add an "other" member and a free-text
field beside it, and treat "other" as a review signal.
Neither is an argument against constraining; unconstrained JSON on an open-weight model fails to parse a few percent of the time, and that failure mode is worse. It is an argument for measuring field accuracy with and without the grammar on your golden set before choosing the schema layout.
Where it breaks in production
- Compile latency on first request. A large schema compiles in seconds on the first call and is cached after; a deploy that rotates pods sees a latency spike per pod. Warm the cache at startup with the schemas you serve.
- Whitespace freedom. Some grammars allow arbitrary whitespace between
tokens, and a model in a bad state emits kilobytes of it. Disable
whitespace flexibility where the backend offers it, and keep
max_tokenstight. - Truncation. The grammar is enforced per token;
max_tokensstill cuts the object in half. Size the cap from the schema’s largest legal output. - Tokenizer mismatch after a model swap. The mask cache is per tokenizer; a fine-tune with added tokens or a different chat template invalidates it, and a stale cache produces impossible outputs.
- Batch slowdown from one hard grammar. In a shared batch, one request with an expensive grammar can hold the step for everyone. Watch per-step latency when adding a new schema, and prefer simpler shapes.
Related
Interview angle 4
- “How does constrained decoding work in vLLM?” - the schema is compiled once into an automaton over the tokenizer’s vocabulary - a pushdown automaton in XGrammar so nesting works - with the allowed-token set per state stored as a bitmask. Each step masks the logits before sampling; the compile is cached by schema hash and the per-token cost is overlapped with the GPU. SGLang adds jump-forward: tokens the grammar fully determines are appended without a forward pass.
- “Does it make the output correct?” - it makes it parse. Shape, not truth: the model can still invent a value, and the grammar can only express a subset of JSON Schema, so Pydantic runs after for ranges, formats and cross-field rules. Follow-up: can it hurt? Yes - forcing the answer before any reasoning lowers accuracy on hard inputs; put a free-text field first or keep reasoning outside the grammar.
- “Outlines or XGrammar?” - XGrammar for JSON Schema in production: CFG handles recursion, near-zero overhead, default in both engines. Outlines when you want the library standalone with plain transformers, or a regex is the natural spec.
- “What goes wrong operationally?” - first-request compile spikes after a
deploy, whitespace loops,
max_tokenstruncating a valid-so-far object, and a stale mask cache after swapping in a fine-tune with a different tokenizer. Warm the cache, tighten the cap, and compile every schema in CI against the pinned engine.