Skip to content
AI360XpertBeta

Structured Outputs

Structured outputs force a model to return data in an exact, predictable shape a program can trust, instead of prose you have to parse and hope is right.

Schema-constrained decoding masks illegal tokens at each step, so the order record with quantity 2 always parses while a plain JSON request can still fail.
Schema-constrained decoding masks illegal tokens at each step, so the order record with quantity 2 always parses while a plain JSON request can still fail.

Why Does This Exist?

Say you are building a system that reads customer emails and turns them into orders. One real email looks like this: "Hey, could you get me two of the standing desk converters by next Friday if possible? Thanks, Dana." You read that in two seconds. Your order system can't. It wants a record with an exact shape: customer_name as a string, item as a string, quantity as an integer, requested_delivery_date as a date. For years that gap was bridged with regex and keyword rules, and they broke every time a customer phrased things a little differently.

A language model is good at the reading part. It knows "two" means 2 and "next Friday" is a date. The trouble starts one step later, when the answer has to come back in a form code can trust. Ask the model to "reply in JSON" and you will get something that looks right most runs. Most runs is not a guarantee. You will see a markdown fence around the object, a friendly sentence before the first brace, deliveryDate where your code expects requested_delivery_date, or the string "two" where the integer 2 belongs. Any one of those makes json.loads throw. Worse, a string can slip where an integer belongs and surface three steps down the pipeline.

This page is about the guarantee: how a schema becomes a per-step rule that keeps output parseable by construction, what it costs, and what it still does not promise. The masking math underneath lives in constrained decoding. The main consumer, models filling argument shapes for real code, lives in tool use and function calling. Checking whether the values are true rather than just well shaped lives in guardrails.

The one idea you need first: a model writes one token at a time, and at each step it emits logits, raw scores with one entry per token in its vocabulary. Sampling turns those scores into probabilities that sum to 1. Everything below edits the scores before sampling runs. The full loop is in next-token prediction, and the picking rules are in decoding strategies.

Think of It Like This

A printed intake form versus a blank sheet of paper

Your supplier takes orders two ways. One customer gets a blank sheet that says "write down your order." Back comes a letter: full sentences, a comment about the weather, the quantity written as "a couple." Another customer gets a printed form with four boxes, Name, Item, Quantity, Delivery date, each exactly one line wide. The quantity box is too narrow for a sentence. It cannot hold "a couple" plus a story. The paper's shape does the enforcing, not your politeness.

Prompting "please reply in JSON" is the blank sheet with better handwriting. A schema constraint is the printed form: each field admits only what fits before a single character gets written.

Where it stops: a real form cannot check that "2" is what the customer meant, only that "2" fits the box. Same here. Shape is guaranteed. Truth is not.

How It Actually Works

You hand the system a real schema, a JSON Schema document or the equivalent model in code, naming each field and its type. The engine compiles it into a tracker that always knows where generation stands: inside the quantity value, after the digits, before the closing brace. At step tt that tracker outputs one thing: AtA_t, the allowed set, the list of tokens that may legally come next. VV is the vocabulary, every token the model can emit. zz in R∣V∣\mathbb{R}^{|V|} holds the logits, one raw score per token.

From schema to allowed tokens

Say the schema defines an order with quantity as an integer. While generation sits inside that value, the tracker allows digit tokens and blocks the rest. One step later, after the digits, it allows a comma or a closing brace instead. The model never sees your schema directly. It feels the effect: some tokens are available this step and the rest are gone.

Mask, then sample

The engine builds masked logits z′z'. In plain words, zi′z'_i equals ziz_i when token ii sits in AtA_t, and equals −∞-\infty when it does not. Then it applies softmax:

pi′=exp⁡(zi′)∑jexp⁡(zj′)p'_i = \frac{\exp(z'_i)}{\sum_j \exp(z'_j)}

Here p′p' is the new probability distribution, exp⁡\exp is the exponential function, and the sum runs over the whole vocabulary. Because exp⁡(−∞)\exp(-\infty) equals 00, banned tokens land on exactly 00 and the rest renormalises to 11 over the allowed tokens. Only then does sampling run. Greedy choice, temperature, and top-p all operate on p′p', never on the raw scores. That order matters: the mask runs before the randomness, so no temperature can revive a banned token. The full treatment of that step is in constrained decoding.

Worked example: the quantity step

Four tokens are live at the quantity value: "2", "3", "two", and "}", with logits z=[2.0,0.5,2.5,1.0]z = [2.0, 0.5, 2.5, 1.0]. You are inside quantity, so AtA_t holds the two digits only.

First the unconstrained picture. Subtract m=2.5m = 2.5, the maximum, for stability, and exponentiate to [exp⁡(−0.5),exp⁡(−2.0),exp⁡(0),exp⁡(−1.5)][\exp(-0.5), \exp(-2.0), \exp(0), \exp(-1.5)], about [0.607,0.135,1.0,0.223][0.607, 0.135, 1.0, 0.223]. The sum is about 1.9651.965. Divide and you get about [0.309,0.069,0.509,0.114][0.309, 0.069, 0.509, 0.114]. Left alone, greedy decoding picks "two" with 0.5090.509. Fluent, and illegal.

Now mask. Set the last two entries to −∞-\infty to get z′=[2.0,0.5,−∞,−∞]z' = [2.0, 0.5, -\infty, -\infty]. Relative to 2.02.0 that is [1.0,0.223,0.0,0.0][1.0, 0.223, 0.0, 0.0]. The sum is about 1.2231.223. Divide and you get about [0.818,0.182,0.0,0.0][0.818, 0.182, 0.0, 0.0]. The 0.5090.509 on "two" is gone. Greedy decoding now picks "2", and a random draw would split about 82 to 18 between the two legal digits. Those are the numbers drawn in the diagram above.

The trade is direct. You buy output that parses on the first try, and you pay schema build time plus a per-step legality check, plus the odd detour where the model spells something in more tokens than it would have chosen. That price is worth it for APIs, tool use and function calling, and form filling.

Do not use it for open-ended writing. Brainstorming, chat, and drafts have no shape worth enforcing, and a constraint there narrows voice for no gain. Skip it too when the real requirement cannot be written as a grammar: tone, helpfulness, or factual truth. A light post-check plus retry is cheaper there.

The method rests on one assumption: the grammar means what you meant and the character rules line up with your model's tokens. Tokenisers split text in ways a character regex cannot see, so a rule that looks right can strand generation or cut words at odd joints. When constrained output looks oddly clipped, suspect that mapping before you blame the model.

Code

The point of this snippet is the whole trick in four tokens: same logits, then a mask, then new probabilities. Standard library only.

import math

def softmax(logits: list[float]) -> list[float]:    m = max(logits)    exps = [math.exp(x - m) for x in logits]    total = sum(exps)    return [e / total for e in exps]

logits = [2.0, 0.5, 2.5, 1.0]  # tokens: ["2", "3", "two", "}"]allowed = [True, True, False, False]  # inside quantity: digits only
full = softmax(logits)masked = softmax([v if ok else float("-inf") for v, ok in zip(logits, allowed)])
print([round(p, 3) for p in full])  # -> [0.309, 0.069, 0.509, 0.114]print([round(p, 3) for p in masked])  # -> [0.818, 0.182, 0.0, 0.0]

Unconstrained, index 2 ("two") wins with about 0.509. After the mask, index 0 ("2") wins with about 0.818 against 0.182. Same numbers as the hand computation above.

Watch Out For

A required field invents an answer when the email never gives one

The symptom is quiet success. Every reply parses, so nobody looks twice, yet the order ships with quantity 2 when the email never stated a number. The cause is mechanical: the schema demanded an integer and left no room for "unclear," so the model filled the hole with a plausible digit instead of admitting the gap.

Make uncertain fields nullable and validate against the source. Allow an explicit unknown, check each value against the input text before saving, and route unknowns to a person or a retry. A schema that cannot say "missing" will say something wrong.

Constraining the thinking, not just the answer

The symptom is worse answers under a strict schema: short stalled drafts, or reasoning that reads cramped. The cause is placement. You put the schema over the whole draft including the scratch thinking, so the model cannot use the loose words it needs to work through the email before it commits to fields.

Never put the schema over the scratchpad. Let the model reason unconstrained in one call or a hidden scratch field, then constrain only the final emit. Think first, format last.

The Quick Version

  • Structured outputs are a decoding constraint compiled from a real schema, not a sentence in the prompt asking for JSON.
  • At each step the engine masks illegal tokens to zero before sampling, so the reply parses by construction.
  • With logits [2.0, 0.5, 2.5, 1.0] and digits only allowed, "two" at 0.509 vanishes and 0.818 plus 0.182 stay on "2" and "3".
  • A guaranteed shape is not a guaranteed truth: a valid record can still carry an invented quantity.
  • Make uncertain fields nullable, validate values against the source, and keep schemas off open-ended writing and off the model's scratch thinking.