Structured Output and JSON Mode
Asking politely for JSON works most of the time, and “most of the time” is a synonym for “breaks in production”. By the end of this lesson you will be able to make a local model return an object that matches a schema on every request, explain the difference between constraining the sampler and validating the reply, and name the failure modes that neither of those two fixes.
Three places to enforce a shape
Section titled “Three places to enforce a shape”There are exactly three points at which output structure can be imposed, and they are not alternatives. A serious pipeline uses all three.
The three layers, and what each one can and cannot do
- The promptAsk for JSON, show an example. Costs nothing, enforces nothing: the model may still add prose, a code fence, or an apology.
- The samplerA schema or grammar sent with the request masks every token that would break the structure. The reply is structurally valid by construction.
- The validatorParse the reply into a typed object. Catches truncation, wrong types the grammar allowed, and values that are the right shape and the wrong answer.
- The repair loopOn a validation failure, show the model its own output and the error, and ask again. Bounded, and it fails loudly when the bound is reached.
A schema in the request
Section titled “A schema in the request”The OpenAI-compatible chat endpoint carries structure in a response_format field, and
local servers implement it. The llama-server README documents support for “both plain JSON
output (e.g. {"type": "json_object"}) and schema-constrained JSON”, the latter through a
json_schema object. Its native /completion endpoint takes the same thing under two
parameter names: json_schema to “Set a JSON schema for grammar-based sampling”, and
grammar to “Set grammar for grammar-based sampling”.
The distinction between the two response_format types matters. json_object says only
“emit syntactically valid JSON”; the keys and types are still whatever the model felt like.
json_schema says “emit JSON that matches this shape”. Use the second one. The first is
worth knowing about only because it is what you get when a server does not support the
second, and because a reply that parses but has none of your fields is the symptom.
vLLM exposes the same capability with more knobs. Its structured-outputs page documents
constraining output to one of a set of choices, to a regular expression, to a JSON schema,
to a context-free grammar, or to a JSON schema “within a set of specified tags”. The
OpenAI-compatible response_format with "type": "json_schema" works there too, which is
why the same client code runs against both engines and against the gateway from Part 9.
What the constraint actually does
Section titled “What the constraint actually does”Underneath, both engines do the same thing: at every decoding step they compute which tokens could continue a valid document and set the probability of all the others to zero. This is worth internalising because it explains both the power and the limits.
llama.cpp’s format for expressing those rules is GBNF, described in its guide as “a format
for defining formal grammars to constrain model outputs in llama.cpp”. It is Backus-Naur
Form with regex-like conveniences: a root rule as the entry point, terminals written as
literal characters or ranges such as [1-9], negation with [^\n], and the repetition
operators *, +, ?, {m} and {m,n}. You rarely write one by hand for JSON, because
the project ships json_schema_to_grammar.py to convert a schema ahead of time, and the
server accepts a schema directly. You do write one by hand when the output is not JSON: a
chess move, a SQL identifier, a date, a single word from a fixed list.
RunnableAll tracks
llama-server \ --model ~/models/qwen3-4b/Qwen3-4B-Q4_K_M.gguf \ --alias qwen3-4b \ --jinja \ --grammar-file ~/grammars/verdict.gbnf \ --host 127.0.0.1 \ --port 8080vLLM’s page names its backends: xgrammar, guidance (also called llguidance), outlines
and lm-format-enforcer, selected with --structured-outputs-config.backend and defaulting
to auto, which “will try to choose an appropriate backend based on the details of the
request”. You will meet these names in issue threads; they are implementations of the same
idea, differing in which schema features they support and how much time they spend compiling
the constraint before the first token.
One class, two jobs
Section titled “One class, two jobs”Pydantic is the piece that keeps the schema and the check from drifting apart. It is described in its own documentation as “the most widely used data validation library for Python”, where “schema validation and serialization are controlled by type annotations”. Define the shape once as a class, and it gives you both the JSON Schema to send with the request and the validator to run on the reply.
Fragment — not complete on its own
class ModelRecord(BaseModel): name: str publisher: Optional[str] = None total_params_b: Optional[float] = None licence: Optional[str] = None quantisations: List[str] = Field(default_factory=list)
# the same class, used twiceschema = ModelRecord.model_json_schema() # goes in the requestrecord = ModelRecord.model_validate_json(reply) # checks what came backWhen validation fails, Pydantic “will raise an error with a breakdown of what was wrong”, listing for each problem the error type, the field location, the message and the offending input. That breakdown is not just for your log: it is the most useful thing you can put in a repair prompt, because it tells the model precisely which field to fix.
The failure modes that survive constrained decoding
Section titled “The failure modes that survive constrained decoding”A constrained sampler makes the shape correct. It does nothing about the four failures below, which is why the validator and the repair loop exist.
Truncation. The grammar decides which tokens are allowed; the token budget decides when
generation stops. Hit max_tokens in the middle of an object and you get a valid prefix of
an invalid document. The symptom is a JSON decode error on long extractions, and the fix is
a larger budget or a smaller schema, not a better prompt.
Right shape, wrong values. "total_params_b": 70.0 for an 8B model satisfies every
constraint you wrote. Schemas cannot check facts. Narrow the type where you can, with
enumerations, ranges and patterns, and accept that the rest is an evaluation problem, which
is what this part’s lab is for.
Silent nulls and empty lists. A model that finds nothing will often return an empty array rather than say so, and an empty array is valid. If “found nothing” is a meaningful outcome, give it a field of its own and make it required.
Invented fields filled in confidently. With additionalProperties false the model
cannot add fields, so instead it puts something plausible in the fields you did give it.
This is the same behaviour that makes ungrounded question answering dangerous, and it is
handled the same way: ask for the evidence alongside the value, and check that the evidence
is really in the input.
Schemas that survive contact with a model
Section titled “Schemas that survive contact with a model”A schema that is valid JSON Schema is not automatically a schema a model handles well. Five rules, learned the same way everybody learns them.
Flat beats nested. Every level of nesting is another structure the model has to hold open while it writes, and another place a repair loop has to describe when something goes wrong. Two flat objects and a join in your own code are usually better than one deeply nested object.
Enumerate wherever you can. A field typed as a string can hold anything; a field typed as an enumeration of five values can hold five things. Constrained decoding turns that from a preference into an impossibility, which is the strongest form of validation available and costs one line.
Name fields the way a person would. The field name is in the prompt, in effect: a model
fills total_params_billions more reliably than tpb. Short names save tokens that you were
not short of.
Make “not found” expressible. If the model cannot honestly fill a field, it has three options: leave it null, invent something, or fail. Only the first is useful, and it is available only if you made the field nullable and said so in its description.
Put the evidence next to the value. For extraction, a field holding the span of input the value came from turns an unverifiable claim into a checkable one: your code can confirm that the quoted span really appears in the input, and reject the record when it does not. This is the same move the retrieval lesson makes with citations, applied one level down.
The script
Section titled “The script”This is the whole pattern in one file: a schema, a request, a validation, and a bounded
repair loop that shows the model the validator’s own complaint. It runs against any
OpenAI-compatible endpoint, so llama-server from Part 6 and the gateway from Part 9 are
both fine.
RunnableAll tracks
#!/usr/bin/env python3"""Get a validated JSON object out of a local model, with a schema and a repair loop.
Purpose: demonstrate the three layers that make structured output reliable on a local server: a JSON Schema sent with the request so the sampler is constrained, Pydantic validation of what comes back, and a bounded repair loop that shows the model its own error. Two tasks are included, extraction and classification, plus a --free-form mode that sends no schema so the difference is visible rather than asserted.Platform: all (pure Python over HTTP; the server may be on any track or on another machine)Minimum memory: 8 GB on the machine running the model; this script needs almost noneAssumes: Python 3.9 or later, pydantic 2.x installed in the active environment, and an OpenAI-compatible endpoint (llama-server from Part 6, or the gateway from Part 9) reachable at --base-url.
Usage: python3 structured-extract.py --base-url http://127.0.0.1:8080/v1 --model qwen3-8b --demo python3 structured-extract.py --base-url http://127.0.0.1:8080/v1 --model qwen3-8b \ --task classify --input message.txt --labbook labbook.md python3 structured-extract.py --base-url http://127.0.0.1:8080/v1 --model qwen3-8b \ --demo --free-form # no schema: see how often it still parses"""
from __future__ import annotations
import argparseimport jsonimport sysimport timeimport urllib.errorimport urllib.requestfrom pathlib import Pathfrom typing import List, Optional
try: from pydantic import BaseModel, Field, ValidationErrorexcept ImportError: # pragma: no cover - environment check, not logic sys.exit("pydantic is not installed. Run: uv pip install 'pydantic>=2'")
# --------------------------------------------------------------------------------------# The two schemas. These are the contract: the same class produces the JSON Schema that# constrains the sampler and the validator that checks the reply, so the two cannot drift.# --------------------------------------------------------------------------------------
class ModelRecord(BaseModel): """One open-weight model as described in a paragraph of release notes."""
name: str = Field(description="The model name exactly as written in the text") publisher: Optional[str] = Field(default=None, description="Who released it, or null") total_params_b: Optional[float] = Field( default=None, description="Total parameters in billions, or null if not stated" ) licence: Optional[str] = Field(default=None, description="Licence name, or null if not stated") quantisations: List[str] = Field( default_factory=list, description="Quantisation formats named in the text" )
class Extraction(BaseModel): """The top-level object for the extraction task."""
models: List[ModelRecord]
class Classification(BaseModel): """A single-label classification with a short justification."""
label: str = Field(description="One of: bug, question, feature-request, documentation, other") confidence: float = Field(ge=0.0, le=1.0, description="0 to 1") rationale: str = Field(description="One sentence, at most 25 words")
LABELS = ["bug", "question", "feature-request", "documentation", "other"]
TASKS = { "extract": { "schema_model": Extraction, "system": ( "You extract structured records from release notes. " "Report only what the text states. Use null for anything it does not state." ), "demo_input": ( "This week's roundup. Alibaba published Qwen3-8B, a dense 8.2B model under " "Apache-2.0, with community GGUF builds at Q4_K_M and Q8_0. OpenAI released " "gpt-oss-20b, a mixture-of-experts model with 21B total parameters shipped with " "native MXFP4 weights, also Apache-2.0. A third release was mentioned on a forum " "but the licence was not given." ), }, "classify": { "schema_model": Classification, "system": ( "You classify an incoming message into exactly one of these labels: " + ", ".join(LABELS) + ". Choose 'other' when none of the rest fits." ), "demo_input": ( "After I upgrade the server my long conversations start returning empty replies " "once they pass about eight thousand tokens. Nothing in the log looks wrong." ), },}
def post_chat(base_url: str, api_key: Optional[str], payload: dict, timeout: int) -> dict: """One POST to /chat/completions. Raises RuntimeError with a readable message.""" url = base_url.rstrip("/") + "/chat/completions" body = json.dumps(payload).encode("utf-8") headers = {"Content-Type": "application/json"} if api_key: headers["Authorization"] = f"Bearer {api_key}" request = urllib.request.Request(url, data=body, headers=headers, method="POST") try: with urllib.request.urlopen(request, timeout=timeout) as response: return json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", "replace")[:500] raise RuntimeError(f"{url} returned HTTP {exc.code}: {detail}") from exc except urllib.error.URLError as exc: raise RuntimeError(f"cannot reach {url}: {exc.reason}") from exc
def build_payload(args, messages: list, schema_model) -> dict: payload = { "model": args.model, "messages": messages, "temperature": args.temperature, "max_tokens": args.max_tokens, } if args.seed is not None: payload["seed"] = args.seed if not args.free_form: payload["response_format"] = { "type": "json_schema", "json_schema": { "name": schema_model.__name__.lower(), "schema": schema_model.model_json_schema(), "strict": True, }, } return payload
def run(args) -> dict: task = TASKS[args.task] schema_model = task["schema_model"] text = task["demo_input"] if args.demo else Path(args.input).read_text(encoding="utf-8")
messages = [ {"role": "system", "content": task["system"]}, {"role": "user", "content": text}, ]
started = time.time() last_error = None for attempt in range(1, args.retries + 2): payload = build_payload(args, messages, schema_model) reply = post_chat(args.base_url, args.api_key, payload, args.timeout) content = reply["choices"][0]["message"]["content"] or "" try: parsed = schema_model.model_validate_json(content) except ValidationError as exc: last_error = f"validation failed: {exc.error_count()} error(s)" complaint = json.dumps(exc.errors(include_url=False)[:5], default=str) except json.JSONDecodeError as exc: last_error = f"not JSON at all: {exc}" complaint = str(exc) else: elapsed = time.time() - started print(json.dumps(parsed.model_dump(), indent=2)) print(f"\nvalid on attempt {attempt} of {args.retries + 1} ({elapsed:.1f} s)") return { "ok": True, "attempts": attempt, "seconds": round(elapsed, 2), "result": parsed.model_dump(), }
# The repair turn: the model is shown its own output and the validator's complaint. print(f"attempt {attempt}: {last_error}", file=sys.stderr) messages = messages + [ {"role": "assistant", "content": content}, { "role": "user", "content": ( "That reply did not validate against the schema. The validator reported: " f"{complaint}. Return the corrected JSON object and nothing else." ), }, ]
elapsed = time.time() - started print(f"gave up after {args.retries + 1} attempt(s): {last_error}", file=sys.stderr) return {"ok": False, "attempts": args.retries + 1, "seconds": round(elapsed, 2), "error": last_error}
def main() -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--base-url", default="http://127.0.0.1:8080/v1", help="OpenAI-compatible base URL: llama-server, or the Part 9 gateway") parser.add_argument("--api-key", default=None, help="sent as a bearer token when set") parser.add_argument("--model", required=True, help="model name or alias the server answers to") parser.add_argument("--task", choices=sorted(TASKS), default="extract") parser.add_argument("--input", default=None, help="file to read; omit with --demo") parser.add_argument("--demo", action="store_true", help="use the built-in sample text") parser.add_argument("--free-form", action="store_true", help="send no schema, so the reply is unconstrained (teaching mode)") parser.add_argument("--retries", type=int, default=2, help="repair attempts after the first") parser.add_argument("--temperature", type=float, default=0.0) parser.add_argument("--max-tokens", type=int, default=1024) parser.add_argument("--seed", type=int, default=None) parser.add_argument("--timeout", type=int, default=180) parser.add_argument("--labbook", default=None, help="append one JSON line per run to this file") args = parser.parse_args()
if not args.demo and not args.input: parser.error("give --input FILE or --demo") if args.input and not Path(args.input).is_file(): parser.error(f"--input {args.input} does not exist")
outcome = run(args)
if args.labbook: record = { "lab": "part-10/structured-extract", "task": args.task, "model": args.model, "base_url": args.base_url, "constrained": not args.free_form, "temperature": args.temperature, "seed": args.seed, "ok": outcome["ok"], "attempts": outcome["attempts"], "seconds": outcome["seconds"], "date": time.strftime("%Y-%m-%d"), } with Path(args.labbook).open("a", encoding="utf-8") as handle: handle.write(json.dumps(record) + "\n") print(f"recorded in {args.labbook}")
sys.exit(0 if outcome["ok"] else 1)
if __name__ == "__main__": main()RunnableAll tracks
python3 structured-extract.py \ --base-url http://127.0.0.1:8080/v1 \ --model qwen3-8b \ --demo \ --labbook labbook.mdThen run it again with --free-form, which sends no response_format at all, and again
with --task classify. Three runs, three lessons: how often an unconstrained model returns
something a parser will accept, how a constrained one behaves on the same input, and what a
single-label decision looks like when the label set is part of the schema.
Classification is a special case worth treating specially
Section titled “Classification is a special case worth treating specially”Single-label classification does not need a JSON object at all. The cleanest formulation is a constraint that permits only the label strings, which vLLM exposes directly as a choice constraint and which is four lines of GBNF in llama.cpp:
Pseudocode — not a real command
root ::= "bug" | "question" | "feature-request" | "documentation" | "other"That is one or two decoded tokens per classification instead of an object with a rationale, which on a batch of ten thousand messages is a different program entirely. Add the rationale field back only when a person is going to read it. The demo script includes the rationale because a person is: you, checking whether the labels mean anything.
When not to force a schema
Section titled “When not to force a schema”Structured output is a hammer, and some jobs are not nails. Forcing a summary into
{"summary": "..."} buys you nothing over returning text and costs you the tokens spent on
punctuation. Forcing a long chain of reasoning into nested objects tends to produce worse
reasoning, because the model is spending its decoding budget satisfying a shape. The rule
that has held up: constrain the output when a program is the consumer, and leave it free
when a person is.
A valid object can still represent the wrong answer
Section titled “A valid object can still represent the wrong answer”Suppose an invoice extractor returns a valid object with a numeric total and a currency string. The total might belong to a different invoice, omit tax or be copied from a subtotal. Schema validation establishes types and allowed structure; domain validation establishes relationships such as subtotal plus tax equalling total, within the application’s rounding rules.
Make unavailable information representable. A nullable field with an explicit missing-evidence status is preferable to forcing a number that the document does not contain. Reject or route conflicts for review according to the business rule. Do not repair a malformed response by silently inserting values that were never present in the source.
Evaluate parsing success, schema validity and semantic correctness separately. Include truncated outputs, refusals, empty evidence and contradictory passages in the test set. Even a constrained sampler can hit an output limit before completing the object. The application must inspect termination and validation results before consuming fields. A bounded retry can fix some formatting failures; repeated retries cannot manufacture absent source evidence.
Structure is enforced in three places and they compose: the prompt asks, the sampler makes
structurally invalid output impossible, and the validator rejects what is structurally valid
and semantically wrong. response_format with a JSON schema is the portable way to reach the
sampler on both llama-server and vLLM, and GBNF is what llama.cpp compiles that schema
into, with documented limits including features that are skipped without a warning. Pydantic
gives you the schema and the validator from one class definition, and its error breakdown is
the best content for a repair turn. Constrained decoding does not prevent truncation, wrong
values, empty results or confident invention, so the validator stays and the retry loop is
bounded and loud. For single-label decisions, constrain to the labels rather than to an
object.
Check your understanding
Sources for this lesson
4 verified · checked 2026-09-08
- 01llama.cpp — llama-server README§ response_format; json_schema and grammar parameters on /completiongithub.com/ggml-org/llama.cpp/blob/master/tools/server/README.md2026-09-08
- 02llama.cpp — GBNF grammar guide§ Background; syntax; JSON schema conversion; limitationsgithub.com/ggml-org/llama.cpp/blob/master/grammars/README.md2026-09-08
- 03vLLM — Structured Outputs§ Parameters; backends; OpenAI-compatible response_formatdocs.vllm.ai/en/latest/features/structured_outputs.html2026-09-08
- 04Pydantic — Getting started§ BaseModel; validation errors; JSON Schemapydantic.dev/docs/validation/latest/get-started2026-09-08
Every technical claim on this page was checked against the official documentation of the tool, vendor or model publisher on the date shown, at the version pinned for the course. Where the course disagrees with folklore, the source is how you can tell which one to trust.