At a glance
Key takeaways
- The problem: every token is a chance to break the format, so an answer of n tokens is valid about of the time. Prompting raises p but never to 1.
- Constrained decoding: before each draw, set the logit of every token that can't lead to a valid answer to minus infinity, then sample as usual. Allow the end token only when the answer is complete. Valid by construction, if the answer is allowed to finish.
- Patterns: compile to a finite-state machine; a token is allowed if the machine can read all its characters; precompute the allowed tokens for every state, so each step is a lookup.
- JSON Schema: nesting needs a stack (a pushdown automaton); the schema decides which keys, types and closers are legal at each character.
- Pitfalls: the mask can force a made-up value or bend the model's preferences; tokens don't align with grammar pieces; nested grammars cost more to check. Retrying is fine when the model is usually right. Always validate business rules afterwards.
Level 2
How it works, from scratch
A language model writes one token at a time, and each token is a draw from a
probability distribution (see primer.ml.inference). Most of the time you
want free text. Sometimes a program is going to read the answer: a tool call's
arguments, a row for a database, a label from a fixed list. Then the answer
must have an exact shape, such as a JSON object with an integer field
called age, and one stray character breaks it.
Structured output is the family of techniques that make the shape certain. The main one, constrained decoding, is surprisingly small: before each token is drawn, find every token that could not possibly continue a valid answer, and forbid it. This lesson builds that from scratch: first for simple patterns, with a finite-state machine, then for nested JSON, with a stack.
Chapter 1
Asking nicely is not enough
Everyday picture You read a form aloud over the phone to a friend and ask them to type it in exactly. They are careful and mostly right. But now and then they add "Sure, here you go!" before the form, or they forget the last bracket, or a finger slips. A person reading the result shrugs it off. A program reading it stops dead: one wrong character and the whole thing is rejected.
Tiny worked example This lesson's toy model, ToyModel, has mostly
learned to answer {"age": 42}. It is a pretend model, a few lines of NumPy,
that copies that answer with some noise on every choice and a habit of
opening with "Sure". Asked 200 times, with nothing but the request to guide
it, it writes the exact answer 133 times (66.5%). The other 67 look like this:
| What it wrote | What went wrong |
|---|---|
Sure{"age": 42} |
a friendly word before the JSON (22 times) |
{"age": 42w |
a slip where the closing brace should be |
{"age": 428 |
an extra digit, then it stopped |
{"age"6 42} |
a digit where the colon belongs |
Sure{"age": 42}EUR |
chatter at both ends |
Nothing here is a big mistake. Each one is a single bad token.
Figure 1 · Diagram
flowchart LR
P["Prompt: reply with JSON"] --> M[Model draws one token]
M --> T[Append it to the answer]
T -->|not finished| M
T -->|finished| J{Parser}
J -->|every token right| OK[Valid JSON]
J -->|one token wrong| ERR[Parse error:<br/>the whole answer is lost]
That is why failures pile up with length. If each token is right with chance , and the chances are roughly independent, the whole answer is right only when every token is:
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| the chance that one token is right, between 0 and 1 | 0.98 | |
| how many tokens the answer has | 10 | |
| multiplied by itself times: the chance that all are right | ||
| the chance that the whole answer parses | 0.817 |
In words: "the chance that a whole answer is valid is the chance that one token is right, multiplied together once for every token."
With the numbers: a model that gets 98% of tokens right, writing a 10-token answer, produces valid output of the time: nearly one answer in five is broken. Make the answer 100 tokens long and it drops to .
Level 3: in Python
# p: chance one token is right; n: tokens in the answer
p, n = 0.98, 10
# p^n: all n tokens right
round(p ** n, 3) # → 0.817
# a ten times longer answer
round(p ** 100, 3) # → 0.133
Figure 2 · Drawn from the lesson's code
Valid-output rate falls as the answer grows: at 98% per token, 10 tokens are valid 82% of the time and 100 tokens only 13%
In code: ToyModel is the pretend model, sample_answer draws one answer token by token, and chance_all_valid is the formula.
Why it matters in practice. A program that calls a model a million times a day and fails 1% of the time fails ten thousand times a day. Long answers, nested objects and small models make it worse. Prompting and examples help, but they only move you to a better curve. To get to 100% you have to change how tokens are chosen.
Chapter 2
Constrained decoding: mask, then sample
Everyday picture Picture a keyboard whose keys lock and unlock as you type. You still decide what to write. But at every keystroke, any key that would make the text break the form is locked for that one keystroke. You cannot make the mistake, because the key isn't there.
Tiny worked example At the very first step the model scores four tokens
(these scores are logits: raw preferences, before softmax turns them
into probabilities; see primer.ml.attention for softmax from zero). Only
{ and [ can start a JSON value, so the other two are locked:
| Token | Logit | Allowed? | Share before the mask | Share after the mask |
|---|---|---|---|---|
Sure |
2.0 | 0 | 0.579 | 0 |
Here |
1.0 | 0 | 0.213 | 0 |
{ |
0.5 | 1 | 0.129 | 0.622 |
[ |
0.0 | 1 | 0.078 | 0.378 |
Before the mask the model put 79% of its belief on chatter. After the mask,
the chatter has a chance of exactly zero, and the two allowed tokens share
everything. They keep their odds against each other: { was 1.65 times as
likely as [ before, and it still is.
Figure 3 · Diagram
flowchart LR
A[Answer so far] --> M[Model: a logit<br/>for every token]
A --> C[Constraint: which tokens<br/>can still lead to a valid answer?]
M --> K[Set forbidden logits to minus infinity]
C --> K
K --> S[Softmax over what is left<br/>then sample]
S --> N{End token?}
N -->|no| A2[Append the token] --> A
N -->|yes| D[Done: valid by construction]
The mask as a formula is softmax with a 0-or-1 switch on every term:
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| the vocabulary: every token the model can write | the 4 tokens in the table | |
| how many tokens that is | 4 | |
| the token whose probability we are computing | 3, the { |
|
| token 's logit | ||
| the mask: 1 if token can continue a valid answer, 0 if not | ||
| raised to the logit: always positive | ||
| add up the following for every token | ||
| the probability that token is drawn | 0.622 |
In words: "a token's probability is its usual softmax share, except that forbidden tokens count as zero on top and bottom, so the allowed tokens share all the probability between them."
With the numbers: .
Level 3: in Python
import math
# Sure, " Here", "{", "["
z = [2.0, 1.0, 0.5, 0.0]
# m_i: 1 if the token may come next
m = [0, 0, 1, 1]
# m_i e^(z_i): forbidden tokens contribute nothing
kept = [m_i * math.exp(z_i) for m_i, z_i in zip(m, z)]
[round(k, 3) for k in kept] # → [0.0, 0.0, 1.649, 1.0]
# Σ_j m_j e^(z_j)
total = sum(kept)
round(total, 3) # → 2.649
[round(k / total, 3) for k in kept] # → [0.0, 0.0, 0.622, 0.378]
# without the mask, most of the belief went to chatter
e = [math.exp(z_i) for z_i in z]
[round(x / sum(e), 3) for x in e] # → [0.579, 0.213, 0.129, 0.078]
Setting a logit to minus infinity does the same thing, because
: it is the trick the causal mask uses in
primer.ml.attention. After masking, temperature, top-k and top-p from
primer.ml.inference work exactly as before, on the tokens that are left.
Figure 4 · Drawn from the lesson's code
At the toy model's first step, 19% of its belief sits on Sure; after the mask, the brace gets all of it
{"age": 42} task, top four tokens only. Grey bars are what the model
wanted; blue bars are what it may choose from after the mask. "Sure" had
nearly a fifth of the belief and drops to zero. The brace, the only legal way
to begin, takes everything.Why 100% by construction. The checker keeps one promise: every answer so far can still be finished validly. It holds at the start (the empty answer can be finished). Each step only allows a token that keeps it. The end token is allowed only when the answer is already complete. So every answer that ends is valid. No luck is involved.
Figure 5 · Drawn from the lesson's code
Unconstrained, the age answer is valid 66.5% of the time and the person object 18%; constrained, every finished answer is valid
In code: masked_softmax applies the mask, sample_answer takes an optional constraint and masks every step, and validity_experiment produces the bars above.
Why it matters in practice. Constrained decoding changes nothing about the model: no retraining, same weights. It only changes which tokens may be drawn, which is why it can be added to any open model at serving time. The hard part is the checker: answering "which of 100,000 tokens could still lead to a valid answer?" fast, at every step. The next two sections build it.
Chapter 3
From a pattern to a state machine
Everyday picture A subway map. You stand at a station. Each line leaving
it is labelled with one character. To write a character, you ride the line
with that label; if no line from your station has it, that character is
impossible here. Some stations are marked "you may stop here". That map is a
finite-state machine: a fixed set of states (stations) and one move per
character. A regular expression, the pattern language behind
[0-9]+ and cat|car|dog, can always be drawn as one.
Tiny worked example The pattern cat|car|dog (one of three words)
becomes this machine:
Figure 6 · Diagram
stateDiagram-v2 [*] --> S0 S0 --> S1: c S0 --> S2: d S1 --> S3: a S2 --> S4: o S3 --> S5: r S3 --> S6: t S4 --> S7: g S5 --> [*] S6 --> [*] S7 --> [*]
Tokens are not characters. A model writes tokens, and one token can hold several characters. The rule: a token is allowed if the machine can swallow all of its characters, one after another, without getting stuck. With a vocabulary of 13 tokens:
| Token | From S0 | From S3 (after "ca") |
|---|---|---|
c, d |
allowed | stuck |
ca, cat, do, dog |
allowed: every character has a line | stuck |
a, at, o, og, g |
stuck at the first character | stuck |
t, r |
stuck | allowed |
cat is allowed at the start even though no single line reads "cat": the
machine rides c, then a, then t. at is part of a real word and still never
allowed at the start, because S0 has no "a" line.
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| a state of the machine | S0 | |
| the transition function: the state you reach from by reading character , or undefined if there is no such line | ||
| a token | cat |
|
| the characters of , in order; is how many | c, a, t; | |
| read every character of in turn, starting from | ||
| the vocabulary | the 13 tokens above | |
| "the set of every token in for which ... holds" | ||
| the allowed tokens at state | c, d, ca, cat, do, dog |
The end token joins only when is an accepting state (S5, S6 or S7 here), because only there is the text complete.
In words: "to see whether a token fits, walk its characters through the machine one at a time; the allowed tokens are the ones that never get stuck."
With the numbers: ,
so cat is in . is undefined, so at is not.
In Python:
# the cat|car|dog machine: state -> {character: next state}
delta = {0: {"c": 1, "d": 2}, 1: {"a": 3}, 2: {"o": 4}, 3: {"r": 5, "t": 6}, 4: {"g": 7}, 5: {}, 6: {}, 7: {}}
accepting = {5, 6, 7}
def walk(s, token):
# δ*: one δ per character; None means stuck
for ch in token:
s = delta[s].get(ch) if s is not None else None
return s
walk(0, "cat") # → 6
walk(0, "at") # → None
V = ["c", "a", "t", "r", "d", "o", "g", "ca", "cat", "do", "dog", "at", "og"]
# A(0): every token the machine can swallow whole from the start
[t for t in V if walk(0, t) is not None] # → ['c', 'd', 'ca', 'cat', 'do', 'dog']
# A(3), after "ca"
[t for t in V if walk(3, t) is not None] # → ['t', 'r']
# the end token only where the text is complete
walk(0, "cat") in accepting # → True
How a pattern becomes a machine. Two classic steps, both in the code:
Figure 7 · Diagram
flowchart LR P["Pattern<br/>cat|car|dog"] --> T[Thompson's construction:<br/>one small machine per piece,<br/>glued with free jumps] T --> N[NFA: may be in<br/>several states at once] N --> D[Subset construction:<br/>each new state is a set<br/>of NFA states] D --> F[DFA: exactly one<br/>state at a time] F --> TB[Table: for every state,<br/>which tokens are allowed]
|, a loop for +. Glued
together they make an NFA (a non-deterministic machine), which can be in
several states at once: after "c" it is both "inside cat" and "inside car".
The subset construction turns each set of NFA states into one state of a
DFA (a deterministic machine), which is always in exactly one state, so
following it is a dictionary lookup. The last box is the payoff: since the
DFA has a fixed number of states, the allowed tokens can be worked out for
every state before generation starts.The pattern -?[0-9]+ (an optional minus sign, then digits) compiles to just
three states: the start, "saw a minus", and "saw at least one digit", the only
accepting one. The lesson's running example, \{"age": [0-9]+\}, compiles to
11.
Figure 8 · Drawn from the lesson's code
Each row is one state of the age machine, each column a token; only a handful of cells are lit, and the digit states allow many tokens at once
"age" or
": . The two digit rows are
where the model has real choice: any digit, a two-digit token such as 42,
or, once one digit is down, the closing brace. The last row allows only the
end token. This whole table is computed once, before the first token.In code: compile_pattern runs both constructions and returns a DFA; DFA.walk follows text through it; allowed_tokens is , and mask_table precomputes it for every state. RegexConstraint plugs the table into sample_answer.
Why it matters in practice. Integers, dates, enums, phone numbers and fixed-key objects are all patterns. Reframing generation as moving between the states of a machine, with the allowed tokens indexed per state, is the idea of Willard and Louf (2023) behind the open-source Outlines library, and it makes each step's mask a single lookup.
Chapter 4
JSON Schema: nesting needs a stack
Everyday picture A stack of plates. Each time you open something, a bracket, a brace or a quote, you put a plate on the stack with a note: "an array is open", "an object is open". To close something, you may only take the top plate: close the most recent thing first. When the stack is empty, you're done.
A subway map can't do this job. JSON can nest as deep as you like: [[[[...]]]].
A machine with, say, 50 states can't tell 50 open brackets from 51, so it
can't know how many closers it still owes. Counting without limit needs
memory without limit. A finite-state machine plus a stack is called a
pushdown automaton, and it is exactly enough for nested formats such as
JSON, SQL and most programming languages.
Tiny worked example Read {"a": [1, {"b": one character at a time.
Figure 9 · Diagram
flowchart LR
S1["after {<br/>stack: object"] --> S2["after [<br/>stack: object, array"]
S2 --> S3["after the inner {<br/>stack: object, array, object"]
S3 --> S4["after 2}<br/>stack: object, array"]
S4 --> S5["after ]}<br/>stack: empty, done"]
} is the only closer allowed, and ] would be refused even though an
array is open further down. The last box is empty: the value is complete, and
only now is the end token allowed.The checker answers one question about any prefix: can it still be finished?
| Prefix | Status | Why |
|---|---|---|
{"a": [1, {"b": |
open | a value for "b" may come next |
{"a": [1} |
dead | the array is on top, so } can't close it |
{"a": [1, {"b": 2}]} |
complete | the stack is empty |
The schema steers every character. A JSON Schema (see
primer.agents.tools) names the fields and their types. This lesson's person
schema allows name (a string), age (an integer) and pets (an array of
"cat" or "dog"); name and age are required and nothing else is allowed.
The checker enforces each rule at the first character that breaks it:
| Prefix | Status | The rule that decides |
|---|---|---|
{"na |
open | "na" can still become "name" |
{"nx |
dead | no field starts with "nx" |
{"name": "Ada"} |
dead | age is required, so the object may not close yet |
{"age": 3. |
dead | an integer has no decimal point |
{"age": 01 |
dead | JSON numbers never start with a zero followed by digits |
{"pets": ["x |
dead | only "cat" and "dog" are listed |
{"name": "Ada", "age": 36} |
complete | every required field is present |
Inside one frame, a small state machine does the work. Here is an object's:
Figure 10 · Diagram
stateDiagram-v2
[*] --> open: {
open --> key: quote, if a field may be added
key --> key: a letter that still spells an unused field
key --> colon: closing quote, if the key is complete
colon --> value: colon, then at most one space
value --> after_value: the value's own frame is pushed and later popped
after_value --> key: comma, space, quote
after_value --> [*]: }, only if every required field is present
open --> [*]: }, only if nothing is required
The leaves of JSON (numbers, true, false, null) are regular patterns,
so the checker reads numbers with the machines from section 3. Real engines
split the work the same way: patterns for the small pieces, a stack for the
nesting.
One rule here is deliberate: at most one space after : and ,. JSON allows
any amount of whitespace, and a constrained model that has lost its way can
pad with spaces until the budget runs out. Real engines limit whitespace for
the same reason.
In code: SchemaChecker.step reads one character and returns the new stack (a tuple of Frame entries), or nothing when the prefix is dead; SchemaChecker.status answers open, dead or complete for a prefix; SchemaChecker.open_containers shows the stack; SchemaConstraint tries every token against the stack at each step.
Why it matters in practice. This is how a schema becomes a guarantee: compile the schema into grammar rules, run them as a pushdown automaton, and mask every token that would kill it. Geng et al. (2023) showed the approach works for structured tasks without any fine-tuning, and PICARD (Scholak et al., 2021) did the same for SQL by parsing incrementally. The stack has a cost: it can grow without limit, so the masks can't all be tabled in advance as they were for a pattern. Section 5 comes back to that.
Chapter 5
Costs and pitfalls
Constrained decoding guarantees the shape. It does not guarantee a good answer, and it isn't free.
The mask changes what the model says
Everyday picture A satellite navigation system that only forbids illegal turns, one junction at a time, and never looks ahead. It happily takes the motorway because the motorway looks fastest right now, and only at the end discovers that the one legal exit is a long detour. A driver who could see the whole map would have taken the side road from the start.
Tiny worked example 1: forced to invent. The model is asked for a person's age, but the text never says it. The model's honest belief for the next token:
| Token | Model's belief | Allowed by "type": "integer"? |
After the mask |
|---|---|---|---|
null |
0.80 | no | 0 |
3 |
0.12 | yes | 0.12 / 0.20 = 0.60 |
5 |
0.08 | yes | 0.08 / 0.20 = 0.40 |
The mask throws away 80% of the model's belief, and the answer is a made-up
age, delivered as confidently as a real one. The toy model does this too:
told to write the person schema while it "wants" to write only a name, it is
forced to add an age, and invents numbers such as 9700. The fix is in the
schema, not the decoder: allow null ("type": ["integer", "null"]), or add
a field for "not stated". A related finding (Tam et al., 2024): forcing a strict
format from the first token can hurt a model's reasoning, so let the model
reason in free text first, or put a reasoning field before the answer field.
Tiny worked example 2: locally tempting, globally wrong. A two-token model. Valid answers must end in "y".
Figure 11 · Diagram
flowchart LR S[start] -->|A 0.9| A[A] S -->|B 0.1| B[B] A -->|x 0.99| AX["Ax: invalid"] A -->|y 0.01| AY["Ay: valid, 0.9 × 0.01 = 0.009"] B -->|x 0.1| BX["Bx: invalid"] B -->|y 0.9| BY["By: valid, 0.1 × 0.9 = 0.09"]
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| one whole answer | Ay | |
| its -th token; is every token before it | y, A | |
| the number of tokens in the answer | 2 | |
| the model's own chance of writing : its token chances multiplied together | ||
| the set of valid answers (the "language" the grammar allows) | {Ay, By} | |
| add up over every valid answer | ||
| "the chance of given that the answer is valid": the model's own odds, among valid answers only | 0.091 | |
| the masked, renormalised chance of token at that step | ||
| multiply together over every step | ||
| the chance that token-by-token masking produces | 0.9 |
In words: "what the model believes, restricted to valid answers, is each valid answer's probability divided by the total of all valid ones; what masking actually produces is the product of the step-by-step masked probabilities, and the two need not agree."
With the numbers: , but : ten times the model's own preference.
Level 3: in Python
first = {"A": 0.9, "B": 0.1}
second = {"A": {"x": 0.99, "y": 0.01}, "B": {"x": 0.1, "y": 0.9}}
# P(y) for each valid answer: the model's token chances multiplied
P = {a + "y": first[a] * second[a]["y"] for a in first}
{k: round(v, 3) for k, v in P.items()} # → {'Ay': 0.009, 'By': 0.09}
# P(y | valid): divide by the total over valid answers
total = sum(P.values())
{k: round(v / total, 3) for k, v in P.items()} # → {'Ay': 0.091, 'By': 0.909}
# P_mask: step 1 keeps both, step 2 renormalises "y" to 1
{a + "y": first[a] * (second[a]["y"] / second[a]["y"]) for a in first} # → {'Ay': 0.9, 'By': 0.1}
Figure 12 · Drawn from the lesson's code
The model's own odds among valid answers favour By 91 to 9; token-by-token masking produces Ay 90% of the time
The toy model shows the everyday version: once noise knocks it off its answer,
the mask keeps it legal but not sensible, and it writes digits until the
closing brace happens to win, as in {"age": 4174209}.
In code: forced_choice renormalises a belief over the allowed tokens and reports the share thrown away; NULLABLE_AGE_SCHEMA is the fix; distortion_example computes both distributions above.
Token boundaries
Everyday picture You can say "forty-two", or spell it "four, two". Both arrive at the same text, but only one is how you would naturally say it.
Tiny worked example The toy vocabulary has a 42 token and single
digits, so "42" can be written two ways: 42, or 4 then 2. The whole
answer {"age": 42} can be spelled 16 ways, and the person answer 1,536
ways. The mask allows every one of them, but a trained model has almost only
ever seen the first, its tokenizer's usual spelling (see
primer.ml.tokenization). When the mask forces it onto an unusual spelling,
it is in unfamiliar territory and its next choices get worse.
Figure 13 · Diagram
flowchart LR S["after the space"] -->|42, the usual token| E[before the brace] S -->|4| M[after the 4] M -->|2| E
Two more boundary effects follow from the same fact. A single token can cross
several structural boundaries at once, such as "} closing a string and an
object together; walking the token character by character, as allowed_tokens
does, handles that. And if the prompt ends in the middle of what would
normally be one token (a prompt ending in {"age": when the model would
usually write ": as one token), the natural token is no longer available.
Some engines back up one token and let the model rewrite it, which is called
token healing.
In code: tokenizations lists every way a vocabulary can spell a text.
The speed of computing masks
Everyday picture Before every keystroke, a proofreader checks every word in the dictionary against the rules: slow. Or: a card for every station, printed once, listing which words fit there: fast, once the cards exist.
Tiny worked example A real vocabulary has around 128,000 tokens. Say they average 4 characters, the answer is 200 tokens long, and the pattern's machine has 50 states. Checking every token at every step walks 200 × 128,000 × 4 = 102.4 million characters for one answer. Building the table walks 50 × 128,000 × 4 = 25.6 million characters once; after that, each step only reads one row of 128,000 yes-or-no entries.
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| work: characters walked or table entries read | ||
| tokens generated | 200 | |
| vocabulary size | 128,000 | |
| the average token length in characters (the bar means "average") | 4 | |
| states in the machine | 50 | |
| multiply |
In words: "the naive way walks every token at every step; the table walks every token once per state, up front, then reads one row per step."
With the numbers: for every answer. for the first answer, and only the second term, 25.6 million cheap reads, for each answer after that.
Level 3: in Python
n, V, L_bar, S = 200, 128_000, 4, 50
# W_naive = n · |V| · L̄
n * V * L_bar # → 102400000
# W_table = S · |V| · L̄ + n · |V|
S * V * L_bar + n * V # → 51200000
# ten answers: the table's build cost is paid once
10 * n * V * L_bar, S * V * L_bar + 10 * n * V # → (1024000000, 281600000)
Figure 14 · Drawn from the lesson's code
Checking every token at every step costs the same for every answer; the table pays once, then grows slowly
For a schema with nesting, the stack can grow without limit, so no table can cover every situation. XGrammar (Dong et al., 2024) splits the vocabulary: most tokens are context-independent (whether they fit depends only on the current grammar position, not on what is deeper in the stack), and those are prechecked into tables; only the few context-dependent ones are checked against the stack at run time.
In code: mask_cost is the formula; mask_table builds the table once and RegexConstraint reads a row per step, while SchemaConstraint walks every token against the stack at every step, the naive way.
When validating and retrying is enough
Everyday picture Instead of a form that can't be filled in wrong, you let people fill in a blank sheet, check it, and hand it back with a note when it is wrong. Fine if most people get it right first time; miserable if most don't.
Tiny worked example The toy model writes a valid age answer 66.5% of the time. Retrying until it succeeds takes 1 / 0.665 = 1.50 attempts on average, and three tries in a row all fail only 0.335³ = 3.8% of the time. For the longer person object, valid 18% of the time, it takes 5.56 attempts on average: slow and expensive.
Figure 15 · Diagram
flowchart LR
A[Ask the model] --> V{Validate}
V -->|valid| D[Use it]
V -->|invalid| E[Send back the<br/>validator's message] --> A
E -.->|too many tries| F[Give up or<br/>fall back]
primer.agents.tools.validate produces) makes the second try
much more likely to succeed. The dotted exit is the budget: a loop needs a
limit.Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| the chance one try is valid | 0.665 | |
| the expected value: the long-run average over many repeats | ||
| the average number of tries to the first success | 1 / 0.665 = 1.50 | |
| a number of tries | 3 | |
| the chance one try fails | 0.335 | |
| the chance that independent tries all fail |
In words: "if each try succeeds with chance p, you need one over p tries on average, and the chance that k tries all fail is the chance of one failure, multiplied together k times."
With the numbers: ; ; for the person object, .
Level 3: in Python
# the toy model's unconstrained rate on the age answer
p = 0.665
# E[attempts] = 1 / p
round(1 / p, 2) # → 1.5
# (1 - p)^k: three tries, all invalid
round((1 - p) ** 3, 4) # → 0.0376
# the longer person answer, valid 18% of the time
round(1 / 0.18, 2) # → 5.56
Figure 16 · Drawn from the lesson's code
Expected attempts are close to 1 for a model that is usually right, and shoot up as the valid rate falls
Validate and retry when: you can't change the decoder (a hosted model
without a structured-output option), the model is already right nearly every
time, or the rule can't be expressed as a grammar. Constrain when answers are
long or nested, the model is small, or latency matters. Either way, keep the
validator: a grammar enforces shape, not rules such as minimum, and never
truth.
In code: expected_attempts and chance_still_failing are the two formulas.
Chapter 6
JSON mode, strict schemas and tool calls
Everyday picture Two paper forms. One only insists that you write in block capitals: whatever you write is readable, but nothing says what goes where. The other has a labelled box for each field, and tick boxes where only certain answers are allowed. The first is JSON mode; the second is a strict schema.
Tiny worked example Give the toy model a reference answer that leaves
out the age, {"name": "Ada"}, and ask 50 times. With JSON mode (the empty
schema {}, which allows any JSON value) every finished answer parses: 44
copies of {"name": "Ada"} with the required age missing, one
{"name": 42628} whose name is a number, and one bare false. All valid
JSON; not one valid person. (The other 3 ran out of budget.) With the person
schema, every one of the 37 answers that finish has an age, because the
closing brace stays locked until one is written. Since the model had no age
to give, it invents one, such as {"name": "Ada","age": 9700,"pets": []}:
section 5's warning in action. The other 13 show a second cost of forcing a
model off its path: it loses its way and wanders, legally, until the token
budget runs out.
A tool call is the same thing with a name attached: the model's arguments are
a JSON object that must fit the tool's input_schema (see
primer.agents.tools and primer.agents.llm).
Figure 17 · Diagram
sequenceDiagram
participant App as Your code
participant API as Model server
participant G as Grammar engine
App->>API: messages + tool with a strict JSON Schema
API->>G: compile the schema (cached for next time)
loop every token
G-->>API: mask of allowed tokens
API->>API: mask, softmax, sample
end
API-->>App: tool_use with arguments that fit the schema
App->>App: business rules: does customer C-999 exist? is the amount above the minimum?
primer.agents.tools makes this concrete: {"amount": 0, "currency": "USD"}
fits the schema's shape exactly, and primer.agents.tools.validate still
rejects it, because "minimum": 0.01 is a rule about the value, not the
shape.In code: SchemaConstraint with the schema {} is JSON mode, and with PERSON_SCHEMA it is a strict schema; primer.agents.tools.ToolRegistry.definitions shows how a strict tool definition is sent.
Why it matters in practice. You now have three tools and know what each buys. JSON mode guarantees something parseable. A strict schema guarantees the shape your code expects, so the parsing and type-checking code disappears. Validation after the fact still catches what only your program knows. Hosted APIs (for example Claude's structured outputs and strict tool use) and open engines (Outlines, llama.cpp grammars, XGrammar) all run the mechanism built in this lesson: a checker that masks the logits before every draw.
Test yourself
9 questions
Answer each one out loud or on paper before you open it. If you can explain it, you know it.
Question 1Why does asking a model for JSON in the prompt fail some of the time, and why do longer outputs fail more?Think it through, then reveal
Each token is a separate draw with some small chance of being wrong, and a single wrong token breaks the parse. The chance that all n tokens are right is about , which shrinks as n grows: 98% per token gives 82% at 10 tokens and 13% at 100.
Question 2What exactly does constrained decoding change in the model?Think it through, then reveal
Nothing in the weights. At each step it sets the logits of forbidden tokens to minus infinity (probability zero) before sampling. The allowed tokens keep their relative odds, and temperature and top-p still apply to them.
Question 3Why is the output valid "by construction", and what can still go wrong with the shape?Think it through, then reveal
Every prefix is kept completable, and the end token is allowed only when the answer is complete, so any answer that ends is valid. It can still be cut off by the token budget, leaving a valid but unfinished prefix.
Question 4How do you decide whether a multi-character token is allowed in a given state?Think it through, then reveal
Walk its characters through the state machine one at a time from the current
state. It is allowed if the walk never gets stuck. cat is allowed at the
start of cat|car|dog; at is not, because the start has no "a" line.
Question 5Why can't a finite-state machine check arbitrary JSON?Think it through, then reveal
JSON nests without limit, and closing correctly requires remembering every open bracket in order. A machine with a fixed number of states can't count without limit. A stack, which a pushdown automaton adds, can.
Question 6Why can masks be precomputed for a pattern but not fully for a JSON Schema?Think it through, then reveal
A pattern's machine has a fixed number of states, so the allowed tokens for each can be tabled once. With nesting the stack can take unboundedly many forms. Engines precompute the tokens whose fate depends only on the current position and check the rest against the stack at run time.
Question 7How can constrained decoding make answers worse?Think it through, then reveal
It forces the model's choices into the allowed set even when the model believed something else: an integer field makes it invent a number when the honest answer was null, and token-by-token masking can commit early to a path the model thought unlikely overall. Allowing null, or letting the model reason before the structured part, helps.
Question 8When is validating and retrying good enough?Think it through, then reveal
When the model is valid almost every time (95% needs about 1.05 calls on average), when you can't change the decoder, or when the rule can't be written as a grammar. It gets expensive fast as the valid rate falls: 1/p calls on average.
Question 9What does a strict schema guarantee about tool arguments, and what doesn't it?Think it through, then reveal
It guarantees the shape: field names, types, required fields, enum values. It does not guarantee the values are true or allowed: a customer ID can be well formed and not exist, and an amount can fit the type while breaking a minimum. Your code still validates business rules.
Primary sources
The papers behind this lesson
Recast constrained generation as moving between the states of a finite-state machine, with the allowed tokens indexed per state in advance, the design behind Outlines.
The paper ↗Showed that constraining decoding with a formal grammar lets an off-the-shelf model produce complex structured outputs reliably, with no task-specific training.
The paper ↗Rejected tokens that an incremental parser could not accept, making generated SQL valid as it was written.
The paper ↗Made grammar-constrained decoding fast by prechecking context-independent tokens and checking only the context-dependent ones against the stack.
The paper ↗Showed that token-by-token masking distorts the model's distribution over valid outputs, and proposed a way to sample closer to the model's own conditional odds.
The paper ↗Measured how strict output formats can lower a model's reasoning performance.
The paper ↗Researcher's shelf
Further reading
- Russ Cox, Regular Expression Matching Can Be Simple And Fast (Thompson's construction, explained): https://swtch.com/~rsc/regexp/regexp1.html
- Understanding JSON Schema: https://json-schema.org/understanding-json-schema
- RFC 8259, The JavaScript Object Notation (JSON) Data Interchange Format: https://www.rfc-editor.org/rfc/rfc8259
- Claude structured outputs (JSON outputs and strict tool use): https://platform.claude.com/docs/en/build-with-claude/structured-outputs
- llama.cpp, GBNF Guide (grammars for local models): https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md
- Outlines, structured generation library: https://github.com/dottxt-ai/outlines
- Willard and Louf (2023): https://arxiv.org/abs/2307.09702
- Dong et al., XGrammar (2024): https://arxiv.org/abs/2411.15100
- Park et al., Grammar-Aligned Decoding (2024): https://arxiv.org/abs/2405.21047
About this lesson. This is the illustrated edition of a lesson from the open-source AI Primer. Its text, figures and numbers are generated from the Primer's source at commit c8d5c21, so the two always agree: the explanation, the code that builds it and the tests that prove it.