Constrained decoding: making the model return valid JSON
Stop asking the model nicely. Make the invalid output unrepresentable.
Download structured_output_from_scratch.py
— everything below runs from it. NumPy and plain Python. There is no
language model anywhere in it: a model contributes exactly one thing to
this idea — a vector of logits over the vocabulary — so I generate that
with numpy and spend the file on the part that does the work.
I'm an AI Engineer. I've shipped models, done the MLOps, and for two years my answer to "the JSON came back malformed" was a better prompt and a retry loop. It worked. That is the worst thing that can be said about it. It worked in dev, it worked in staging, it worked for the demo, and then it failed in production at a rate nobody could put a number on, and the retry loop hid it until the invoice arrived.
Nobody told me there was a version of this problem with an actual guarantee, and that the guarantee is one line long.
It's the causal mask, pointed somewhere else
You already know the retry shape. You've written it for flaky HTTP:
for attempt in range(5):
text = model(prompt)
try:
return json.loads(text) # hope
except ValueError:
continue # hope again, with feeling
raise # and now what?
That is validation. It runs after the fact, it can fail, and its cost is a random variable. Now the other version. At every step the model hands you one vector of logits over the whole vocabulary. A parser knows which of those tokens could possibly continue a valid document. So:
state = parser.start
while not parser.done(state):
logits = model(prefix) # unchanged. the model is innocent
logits[not_allowed(state)] = -np.inf # <-- the entire paper
tok = sample(logits)
state = parser.step(state, tok)
That loop is the sampling loop from the GPT page with
one line inserted into it. If you read the
attention page you have seen
that middle line before. It is the causal mask — set the forbidden entries
to −∞ before the softmax so they come out at exactly 0. The
only difference is what it runs over. The causal mask runs over
positions and is driven by time. This one runs over the
vocabulary and is driven by a parser.
Validation after the fact is a test. Constrained decoding is a type.
| Term | Means | Programmer's analogy |
|---|---|---|
| Grammar | The set of strings you will accept | The schema, before anything is written |
| Parser state | Where in the document you are | The cursor in a state machine |
| Allowed set | Token ids that keep the document valid | The whitelist for this one step |
| Logit mask | −∞ on everything else | A read-lock on the rest of the vocabulary |
| Index | state → allowed set, precomputed | A dict you build at boot instead of a scan per request |
Why the retry loop is worse than it looks
Say one attempt returns parseable JSON with probability p. Attempts
until success is a geometric distribution: mean 1/p, and the
quantiles are a log away. My script prices it.
Don't take my word for it. Here's the actual output:
A request costs 400 tokens. You retry until the JSON parses.
p = probability one attempt returns valid JSON.
p E[attempts] p95 attempts p99.9 tokens/1k reqs worst case
0.70 1.43 3 6 571,428 unbounded
0.90 1.11 2 3 444,444 unbounded
0.95 1.05 1 3 421,052 unbounded
0.99 1.01 1 2 404,040 unbounded
simulated 20,000 requests at p=0.95: mean attempts 1.053, max 4
Look at the last column, not the second. 95% costs you about 5% extra tokens, which is fine, and it has no worst case — there is no n at which you can tell your on-call "this will have succeeded". At p=0.99 you still can't. You have bought yourself a tail you haven't measured yet, and a latency budget you can't write down.
The one formula
ℓ is the logit vector the model gave you. s is the parser state. A(s) is the set of tokens allowed from that state. That's it — everything else in both papers is how you compute A(s) fast enough to run per token. Read it as pipeline stages:
| Step | Shape | What it does |
|---|---|---|
ℓ | (V,) | One logit per vocabulary entry. The model's whole contribution |
A(s) | a set | The paper. Which token ids keep the document parseable |
+ ms | (V,) | 0 on the allowed ones, −∞ on the rest. Before the softmax |
softmax | (V,) | Renormalises over what's left. Still sums to 1 |
sample | one id | Ordinary sampling. Temperature, top-p, all unchanged |
parser.step | new s | Advance the state machine by the token you actually emitted |
One step, before and after
Here is a single concrete step from my toy 45-token vocabulary, at the very start of the document, where the only legal thing is an opening brace. The model wanted a newline. It does not get a newline.
parser state 0: the document is empty, only '{' can start it
allowed tokens: ['{', '{"']
token logit p before p after
'\n' 2.718 0.2016 0.0000 <- forbidden
'","' 2.680 0.1942 0.0000 <- forbidden
'0}' 2.122 0.1111 0.0000 <- forbidden
'null' 1.769 0.0780 0.0000 <- forbidden
'g' 1.391 0.0535 0.0000 <- forbidden
'name' 0.980 0.0355 0.0000 <- forbidden
'age' 0.714 0.0272 0.0000 <- forbidden
'}' 0.597 0.0242 0.0000 <- forbidden
'{' 0.002 0.0133 0.8793 <- the only survivors
'{"' -1.983 0.0018 0.1207 <- the only survivors
total probability on invalid tokens before masking: 0.9848
total probability on invalid tokens after masking: 0.0000
probabilities still sum to 1: 1.000000
same mask written with 0.0 instead of -inf: 0.9742 of the
probability mass is STILL on invalid tokens.
Two things worth staring at. The 98.5% of probability mass that the model
put on tokens which cannot appear here — that is the fraction of the retry
loop's failures you are pre-emptively deleting. And the last line: write the
mask as 0.0 instead of −∞ and 97% of the mass is
still on garbage. 0 is not "off". 0 is a
perfectly ordinary logit. Exactly the bug the attention page's mask table
warns about, in a different file.
A real state machine, and what "by construction" means
My grammar is one fixed schema, no whitespace:
{"name":"<letters>","age":<digits>}. That language is
regular, so a finite-state machine is enough — 20 states,
15 of which are just single steps inside the fixed literals. Then the loop
runs, and at each step the mask is whatever that state allows.
20 states in all, 15 of them single
steps inside a fixed literal. The five that mean something:
state 0 start 1 chars '{' -> 2/45 tokens
state 1 in name string 28 chars ' "abcdefghij...' -> 18/45 tokens
state 2 need a digit 9 chars '123456789' -> 13/45 tokens
state 3 in age 11 chars '0123456789}' -> 16/45 tokens
state 4 done 0 chars '' -> 0/45 tokens
one generation, traced:
state 0 -> allowed 2/45 -> emitted '{'
state 5 -> allowed 1/45 -> emitted '"'
state 6 -> allowed 2/45 -> emitted 'name'
state 10 -> allowed 3/45 -> emitted '":"'
state 1 -> allowed 18/45 -> emitted 'ada'
state 1 -> allowed 18/45 -> emitted 'nam'
state 1 -> allowed 18/45 -> emitted 'ada'
state 1 -> allowed 18/45 -> emitted '","'
state 15 -> allowed 2/45 -> emitted 'age'
state 18 -> allowed 2/45 -> emitted '":'
state 2 -> allowed 13/45 -> emitted '36}'
emitted: {"name":"adanamada","age":36}
final state 4, accepting: True
json.loads says: {'name': 'adanamada', 'age': 36}
500 constrained generations: 500 parsed, 0 failed, 0 retries
lengths 19-173 chars, all schema-conformant, 0 cut off by the step cap
500 for 500 is not a good hit rate. It is not a hit rate at all. The parser
cannot leave the set of valid prefixes, because the tokens that would take
it out of that set were never sampleable. The logits are random noise from
rng.normal — a genuinely terrible model — and the output still
parses every time. That's the claim: the guarantee comes from the mask, not
from the model having been persuaded.
The hard part: grammars speak characters, models emit tokens
This is where most explanations stop, and it's the part that actually costs you. Your grammar is defined over characters. The model emits tokens, and a token is a multi-character string that some compression algorithm picked years ago without consulting your schema — see the tokenisation page. So a single token can straddle a grammar boundary.
Sitting inside the name string, 28 characters are legal here. Watch what that does and does not tell you about tokens:
token first char ok? whole token ok? why
'"}' True False walks off the DFA
'","' True True ends in state 15
'lovelace' True True ends in state 1
'ace' True True ends in state 1
'}' False False walks off the DFA
'age' True True ends in state 1
'":"' True False walks off the DFA
The same token flips legality with the state. Token '0}':
from state 0 (start ): None
from state 1 (in name string ): None
from state 2 (need a digit ): None
from state 3 (in age ): 4
from state 4 (done ): None
'"}' is the one to hold on to. Its first character is legal —
the quote closes the name string. The } welded to it is not,
because the schema still owes us an age field. A character-level check on
the first character says yes; the token as a unit is a no; and you cannot
emit half a token. That is why you run the whole token through the
automaton and keep only tokens that survive end to end.
'0}' shows the other direction: the same token is illegal in
"need a digit" (JSON forbids a leading zero) and legal one state later.
Legality is a property of the pair, not of the token. So the answer is a
table:
So the answer is an index -- parser state -> allowed token ids:
state 0 (start ) 2 tokens ['{', '{"']
state 1 (in name string ) 18 tokens [' ', '"', '","', 'a', 'ace', 'ada', '...']
state 2 (need a digit ) 13 tokens ['1', '180', '2', '3', '36', '36}', '...']
state 3 (in age ) 16 tokens ['0', '0}', '1', '180', '2', '3', '...']
state 4 (done ) 0 tokens []
... and the other 15 literal states, 1 or 2 tokens each
That table is Willard and Louf's contribution. Not the masking — people were masking already, and Geng et al. had wired a grammar to a decoder the same year. The Outlines claim is about when you compute the allowed set: once per (grammar, tokeniser) pair, at load time, instead of once per generated token.
What the index costs, and what it saves
I count DFA character-steps rather than milliseconds, so the script prints the same numbers on your laptop as on mine. It's also the more honest unit: it's the work, not my machine's mood.
vocabulary 45 tokens, grammar 20 states
parser state naive scan index lookup
start 46 steps 1 lookup
in name string 82 steps 1 lookup
need a digit 51 steps 1 lookup
in age 52 steps 1 lookup
done 45 steps 1 lookup
index built once, at load time: 986 character-steps, 68 entries
and the scan grows with the vocabulary while the lookup does not:
vocab 45: naive 82 steps per generated token, index 1
vocab 180: naive 487 steps per generated token, index 1
vocab 720: naive 2107 steps per generated token, index 1
vocab 2880: naive 8587 steps per generated token, index 1
The whole index cost 986 character-steps to build — under a dozen naive scans — and after that every step is a dict lookup. Multiply the left column by a real vocabulary (50k–200k entries), a real grammar, and every token of every request, and you have the difference between a technique and a product. That is the word "Efficient" in the title, and all it claims.
What this does not buy you
Six outputs from the constrained decoder. All six parse. All six match the schema. Zero of them are true.
ground truth: {'name': 'ada lovelace', 'age': 36}
output parses schema correct
{"name":"age","age":36} yes yes NO
{"name":"","age":479} yes yes NO
{"name":"","age":30} yes yes NO
{"name":"","age":8} yes yes NO
{"name":"","age":93594505170} yes yes NO
{"name":"null","age":390} yes yes NO
6 distinct outputs, all valid JSON, all matching the
schema. The number that were right about Ada Lovelace: 0
Schema validity is a property of the output's syntax. It says nothing about the world. My "model" here is random noise, which makes the point loudly, but the same thing is true of a good model: constraining moves the failures from "unparseable" to "parseable and wrong", and parseable and wrong is the one that gets into your database. Whether the age is 36 is an eval question — see the evals page.
One honest cost, while we're here. Masking renormalises over a subset, so the constrained distribution is not the model's distribution conditioned on validity — it's a greedy, per-step approximation of it, and people have measured quality shifts from it in both directions. Don't sell constrained decoding as free. Sell it as a guarantee you're buying with a known, small distortion.
நான் ஒரு AI Engineer. Models ship செய்திருக்கிறேன், MLOps செய்திருக்கிறேன். இரண்டு வருடங்களாக "JSON malformed-ஆக வந்துவிட்டது" என்பதற்கு என் பதில் ஒரு நல்ல prompt-ம் ஒரு retry loop-ம் மட்டும்தான். அது வேலை செய்தது. அதைப் பற்றி சொல்லக்கூடிய மிக மோசமான விஷயமே அதுதான். Dev-ல் வேலை செய்தது, staging-ல் வேலை செய்தது, demo-வுக்கு வேலை செய்தது, பிறகு production-ல் யாராலும் எண்ணிக்கையாகச் சொல்ல முடியாத ஒரு விகிதத்தில் தோற்றது — invoice வரும் வரை அந்த retry loop அதை மறைத்து வைத்திருந்தது.
இந்தப் பிரச்சனைக்கு உண்மையான ஒரு guarantee கொடுக்கும் ஒரு வடிவம் இருக்கிறது என்றும், அந்த guarantee ஒரே ஒரு வரி நீளம்தான் என்றும் யாரும் எனக்குச் சொல்லவில்லை.
இது causal mask-தான், வேறு திசையில் திருப்பப்பட்டது
Retry-யின் வடிவம் உங்களுக்குத் தெரியும். Flaky HTTP-க்கு இதை எழுதியிருக்கிறீர்கள்:
for attempt in range(5):
text = model(prompt)
try:
return json.loads(text) # நம்பிக்கை
except ValueError:
continue # மீண்டும் நம்பிக்கை, உணர்ச்சியுடன்
raise # இப்போது என்ன செய்வது?
அது validation. நடந்து முடிந்த பிறகு ஓடுகிறது, தோற்கலாம், அதன் செலவு ஒரு random variable. இப்போது இன்னொரு வடிவம். ஒவ்வொரு step-லும் model உங்களுக்கு முழு vocabulary மீதான ஒரு logits vector-ஐத் தருகிறது. ஒரு parser-க்கு, அந்த tokens-ல் எவை ஒரு valid document-ஐத் தொடர முடியும் என்று தெரியும். அப்படியானால்:
state = parser.start
while not parser.done(state):
logits = model(prefix) # மாற்றமில்லை. model குற்றமற்றது
logits[not_allowed(state)] = -np.inf # <-- paper முழுவதும் இதுதான்
tok = sample(logits)
state = parser.step(state, tok)
அந்த loop, GPT page-ல் உள்ள sampling loop-தான் — ஒரே
ஒரு வரி உள்ளே சேர்க்கப்பட்டிருக்கிறது. நீங்கள்
attention page-ஐப் படித்திருந்தால், அந்த நடுவரி
உங்களுக்கு ஏற்கனவே பரிச்சயம். அது causal mask — தடைசெய்யப்பட்ட entries-ஐ
softmax-க்கு முன் −∞ ஆக வைத்தால், அவை வெளியே சரியாக 0-ஆக
வருகின்றன. வித்தியாசம் என்னவென்றால், அது எதன் மீது ஓடுகிறது என்பதுதான்.
Causal mask positions மீது ஓடுகிறது, காலம் அதை இயக்குகிறது. இது
vocabulary மீது ஓடுகிறது, ஒரு parser அதை இயக்குகிறது.
Ithu causal mask dhaan, aana positions-ku pathila vocabulary mela. Forbidden tokens-ku −∞, softmax-ku munnadi. Oru line, adhu dhaan paper.
பின்னால் செய்யும் validation ஒரு test. Constrained decoding ஒரு type.
| சொல் | அர்த்தம் | Programmer-க்கான உதாரணம் |
|---|---|---|
| Grammar | நீங்கள் ஏற்கும் strings-ன் தொகுப்பு | எதுவும் எழுதப்படுவதற்கு முன்பே இருக்கும் schema |
| Parser state | Document-ல் நீங்கள் எங்கே இருக்கிறீர்கள் | ஒரு state machine-ன் cursor |
| Allowed set | Document-ஐ valid-ஆக வைத்திருக்கும் token ids | இந்த ஒரு step-க்கான whitelist |
| Logit mask | மற்ற எல்லாவற்றின் மீதும் −∞ | மீதி vocabulary மீது ஒரு read-lock |
| Index | state → allowed set, முன்கூட்டியே கணக்கிடப்பட்டது | ஒவ்வொரு request-க்கும் scan செய்வதற்குப் பதிலாக boot-ல் கட்டும் dict |
Retry loop தெரிவதைவிட ஏன் மோசம்
ஒரு attempt parse ஆகக்கூடிய JSON-ஐத் தரும் நிகழ்தகவு p என்று
வையுங்கள். வெற்றி வரும் வரையிலான attempts ஒரு geometric distribution:
சராசரி 1/p, quantiles ஒரு log தூரத்தில். என்
script அதற்கு விலை போடுகிறது.
என்னை நம்ப வேண்டாம். இதோ உண்மையான output:
A request costs 400 tokens. You retry until the JSON parses.
p = probability one attempt returns valid JSON.
p E[attempts] p95 attempts p99.9 tokens/1k reqs worst case
0.70 1.43 3 6 571,428 unbounded
0.90 1.11 2 3 444,444 unbounded
0.95 1.05 1 3 421,052 unbounded
0.99 1.01 1 2 404,040 unbounded
simulated 20,000 requests at p=0.95: mean attempts 1.053, max 4
இரண்டாவது column-ஐ அல்ல, கடைசி column-ஐப் பாருங்கள். 95% உங்களுக்கு சுமார் 5% கூடுதல் tokens செலவு — அது பரவாயில்லை — ஆனால் அதற்கு worst case இல்லை: "இதற்குள் நிச்சயம் வெற்றி பெற்றிருக்கும்" என்று உங்கள் on-call-க்குச் சொல்லக்கூடிய ஒரு n கிடையாது. p=0.99-லும் சொல்ல முடியாது. நீங்கள் இன்னும் அளக்காத ஒரு tail-ஐயும், எழுதிக் காட்ட முடியாத ஒரு latency budget-ஐயும் வாங்கியிருக்கிறீர்கள்.
அந்த ஒரு சூத்திரம்
ℓ என்பது model தந்த logit vector. s என்பது parser state. A(s) என்பது அந்த state-லிருந்து அனுமதிக்கப்பட்ட tokens-ன் தொகுப்பு. அவ்வளவுதான் — இரண்டு papers-லும் மீதி எல்லாமே, A(s)-ஐ ஒவ்வொரு token-க்கும் ஓடும் அளவுக்கு வேகமாக எப்படிக் கணக்கிடுவது என்பதுதான். இதை pipeline stages-ஆகப் படியுங்கள்:
| Step | Shape | என்ன செய்கிறது |
|---|---|---|
ℓ | (V,) | Vocabulary-ல் ஒவ்வொரு entry-க்கும் ஒரு logit. Model-ன் முழுப் பங்களிப்பு |
A(s) | ஒரு set | Paper இதுதான். எந்த token ids document-ஐ parse ஆகும்படி வைக்கின்றன |
+ ms | (V,) | அனுமதிக்கப்பட்டவற்றின் மீது 0, மீதியின் மீது −∞. Softmax-க்கு முன் |
softmax | (V,) | மீதி இருப்பவற்றின் மீது மறு-normalise. இன்னும் கூட்டுத்தொகை 1 |
sample | ஒரு id | சாதாரண sampling. Temperature, top-p எல்லாம் மாற்றமில்லை |
parser.step | புதிய s | நீங்கள் உண்மையில் emit செய்த token-ஆல் state machine-ஐ முன்னகர்த்து |
ஒரு step, முன்பும் பின்பும்
என் toy 45-token vocabulary-லிருந்து ஒரு உறுதியான step, document-ன் ஆரம்பத்தில், அங்கு சட்டப்படி வரக்கூடியது ஒரு opening brace மட்டுமே. Model ஒரு newline விரும்பியது. அதற்கு newline கிடைக்கவில்லை.
parser state 0: the document is empty, only '{' can start it
allowed tokens: ['{', '{"']
token logit p before p after
'\n' 2.718 0.2016 0.0000 <- forbidden
'","' 2.680 0.1942 0.0000 <- forbidden
'0}' 2.122 0.1111 0.0000 <- forbidden
'null' 1.769 0.0780 0.0000 <- forbidden
'g' 1.391 0.0535 0.0000 <- forbidden
'name' 0.980 0.0355 0.0000 <- forbidden
'age' 0.714 0.0272 0.0000 <- forbidden
'}' 0.597 0.0242 0.0000 <- forbidden
'{' 0.002 0.0133 0.8793 <- the only survivors
'{"' -1.983 0.0018 0.1207 <- the only survivors
total probability on invalid tokens before masking: 0.9848
total probability on invalid tokens after masking: 0.0000
probabilities still sum to 1: 1.000000
same mask written with 0.0 instead of -inf: 0.9742 of the
probability mass is STILL on invalid tokens.
உற்றுப் பார்க்க வேண்டிய இரண்டு விஷயங்கள். இங்கே வரவே முடியாத tokens மீது
model வைத்திருந்த 98.5% probability mass — அதுதான் retry loop-ன்
தோல்விகளில் நீங்கள் முன்கூட்டியே அழித்துவிடும் பங்கு. பிறகு கடைசி வரி:
mask-ஐ −∞-க்குப் பதிலாக 0.0 என்று எழுதினால் 97%
mass இன்னும் குப்பையின் மீதே இருக்கிறது. 0 என்பது "off"
அல்ல. 0 ஒரு முற்றிலும் சாதாரண logit. Attention page-ன் mask
table எச்சரிக்கும் அதே bug-தான், வேறொரு file-ல்.
Mask-ku 0.0 podadheenga — 0 oru normal logit dhaan, softmax adhukku probability koduthudum. −∞ dhaan off. Attention page-la sonna same bug.
ஒரு உண்மையான state machine, "by construction" என்றால் என்ன
என் grammar ஒரே ஒரு நிலையான schema, whitespace இல்லை:
{"name":"<letters>","age":<digits>}. அந்த மொழி
regular, அதனால் ஒரு finite-state machine போதும் — 20
states, அவற்றில் 15 வெறும் நிலையான literals-க்குள் ஒற்றை steps. பிறகு loop
ஓடுகிறது, ஒவ்வொரு step-லும் mask என்பது அந்த state அனுமதிப்பதுதான்.
20 states in all, 15 of them single
steps inside a fixed literal. The five that mean something:
state 0 start 1 chars '{' -> 2/45 tokens
state 1 in name string 28 chars ' "abcdefghij...' -> 18/45 tokens
state 2 need a digit 9 chars '123456789' -> 13/45 tokens
state 3 in age 11 chars '0123456789}' -> 16/45 tokens
state 4 done 0 chars '' -> 0/45 tokens
one generation, traced:
state 0 -> allowed 2/45 -> emitted '{'
state 5 -> allowed 1/45 -> emitted '"'
state 6 -> allowed 2/45 -> emitted 'name'
state 10 -> allowed 3/45 -> emitted '":"'
state 1 -> allowed 18/45 -> emitted 'ada'
state 1 -> allowed 18/45 -> emitted 'nam'
state 1 -> allowed 18/45 -> emitted 'ada'
state 1 -> allowed 18/45 -> emitted '","'
state 15 -> allowed 2/45 -> emitted 'age'
state 18 -> allowed 2/45 -> emitted '":'
state 2 -> allowed 13/45 -> emitted '36}'
emitted: {"name":"adanamada","age":36}
final state 4, accepting: True
json.loads says: {'name': 'adanamada', 'age': 36}
500 constrained generations: 500 parsed, 0 failed, 0 retries
lengths 19-173 chars, all schema-conformant, 0 cut off by the step cap
500-க்கு 500 என்பது ஒரு நல்ல hit rate அல்ல. அது hit rate-ஏ அல்ல. Parser
valid prefixes-ன் தொகுப்பிலிருந்து வெளியேற முடியாது, ஏனெனில் அதை வெளியே
கொண்டுசெல்லும் tokens ஒருபோதும் sample செய்யக்கூடியவையாக இருக்கவில்லை.
Logits என்பவை rng.normal-லிருந்து வரும் random noise — உண்மையிலேயே
மோசமான ஒரு model — இருந்தும் output ஒவ்வொரு முறையும் parse ஆகிறது.
கூற்று இதுதான்: guarantee வருவது mask-லிருந்து, model சம்மதிக்க
வைக்கப்பட்டதிலிருந்து அல்ல.
கடினமான பகுதி: grammars characters பேசுகின்றன, models tokens தருகின்றன
பெரும்பாலான விளக்கங்கள் இங்கே நின்றுவிடுகின்றன, ஆனால் உங்களுக்கு உண்மையில் செலவாகும் பகுதி இதுதான். உங்கள் grammar characters மீது வரையறுக்கப்பட்டது. Model tokens-ஐ emit செய்கிறது, ஒரு token என்பது உங்கள் schema-ஐக் கேட்காமல் வருடங்களுக்கு முன்பு ஒரு compression algorithm தேர்ந்தெடுத்த பல-character string — tokenisation page-ஐப் பாருங்கள். அதனால் ஒரே token ஒரு grammar எல்லையைத் தாண்டி நிற்கலாம்.
Name string-க்குள் இருக்கும்போது, இங்கே 28 characters சட்டப்படி அனுமதிக்கப்படுகின்றன. அது tokens பற்றி என்ன சொல்கிறது, என்ன சொல்லவில்லை என்று பாருங்கள்:
token first char ok? whole token ok? why
'"}' True False walks off the DFA
'","' True True ends in state 15
'lovelace' True True ends in state 1
'ace' True True ends in state 1
'}' False False walks off the DFA
'age' True True ends in state 1
'":"' True False walks off the DFA
The same token flips legality with the state. Token '0}':
from state 0 (start ): None
from state 1 (in name string ): None
from state 2 (need a digit ): None
from state 3 (in age ): 4
from state 4 (done ): None
'"}'-ஐத்தான் மனதில் வைத்துக்கொள்ள வேண்டும். அதன் முதல் character
சட்டப்படி சரி — அந்த quote name string-ஐ மூடுகிறது. அதனுடன் ஒட்டியிருக்கும்
} சரியல்ல, ஏனெனில் schema இன்னும் ஒரு age field-ஐக் கடன்பட்டிருக்கிறது.
முதல் character மீதான character-level சோதனை ஆம் என்கிறது; token ஒரு
முழு அலகாகப் பார்த்தால் இல்லை; ஒரு token-ல் பாதியை emit செய்ய முடியாது.
அதனால்தான் முழு token-ஐயும் automaton வழியாக ஓட்டி, முழுவதும்
தப்பிப்பிழைக்கும் tokens-ஐ மட்டும் வைத்துக்கொள்ள வேண்டும்.
'0}' மறுதிசையைக் காட்டுகிறது: அதே token "need a digit"-ல்
சட்டவிரோதம் (JSON முன்னால் ஒரு zero வருவதைத் தடுக்கிறது), ஒரு state
கழித்து சட்டப்படி சரி. சட்டப்படி சரியா என்பது அந்த ஜோடியின் பண்பு, token-ன்
பண்பு அல்ல. அதனால் விடை ஒரு table:
So the answer is an index -- parser state -> allowed token ids:
state 0 (start ) 2 tokens ['{', '{"']
state 1 (in name string ) 18 tokens [' ', '"', '","', 'a', 'ace', 'ada', '...']
state 2 (need a digit ) 13 tokens ['1', '180', '2', '3', '36', '36}', '...']
state 3 (in age ) 16 tokens ['0', '0}', '1', '180', '2', '3', '...']
state 4 (done ) 0 tokens []
... and the other 15 literal states, 1 or 2 tokens each
Grammar characters-la pesudhu, model tokens-a kudukkudhu. Full token-a automaton-la ottunga; first character mattum paathaa '"}' nuzhainjidum.
அந்த table-தான் Willard-ம் Louf-ம் தந்த பங்களிப்பு. Masking அல்ல — மக்கள் ஏற்கனவே mask செய்துகொண்டிருந்தார்கள், அதே வருடத்தில் Geng et al. ஒரு grammar-ஐ ஒரு decoder-உடன் இணைத்திருந்தார்கள். Outlines-ன் கூற்று allowed set-ஐ எப்போது கணக்கிடுகிறீர்கள் என்பது பற்றியது: ஒவ்வொரு உருவாக்கப்பட்ட token-க்கும் ஒரு முறை அல்ல, ஒவ்வொரு (grammar, tokeniser) ஜோடிக்கும் ஒரு முறை, load time-ல்.
Index என்ன செலவாகிறது, என்ன மிச்சப்படுத்துகிறது
நான் milliseconds-க்குப் பதிலாக DFA character-steps-ஐ எண்ணுகிறேன், அதனால் script உங்கள் laptop-லும் என்னுடையதிலும் ஒரே எண்களை அச்சிடும். அதுவே நேர்மையான அலகும் கூட: அது வேலை, என் machine-ன் மனநிலை அல்ல.
vocabulary 45 tokens, grammar 20 states
parser state naive scan index lookup
start 46 steps 1 lookup
in name string 82 steps 1 lookup
need a digit 51 steps 1 lookup
in age 52 steps 1 lookup
done 45 steps 1 lookup
index built once, at load time: 986 character-steps, 68 entries
and the scan grows with the vocabulary while the lookup does not:
vocab 45: naive 82 steps per generated token, index 1
vocab 180: naive 487 steps per generated token, index 1
vocab 720: naive 2107 steps per generated token, index 1
vocab 2880: naive 8587 steps per generated token, index 1
முழு index-ஐக் கட்ட 986 character-steps ஆனது — ஒரு டஜன் naive scans-க்கும் குறைவு — அதற்குப் பிறகு ஒவ்வொரு step-ம் ஒரு dict lookup. இடது column-ஐ ஒரு உண்மையான vocabulary-ஆல் (50k–200k entries), ஒரு உண்மையான grammar-ஆல், ஒவ்வொரு request-ன் ஒவ்வொரு token-ஆல் பெருக்குங்கள் — ஒரு technique-க்கும் ஒரு product-க்கும் உள்ள வித்தியாசம் அதுதான். தலைப்பில் உள்ள "Efficient" என்ற வார்த்தையின் அர்த்தம் அதுவே, அது இவ்வளவுதான் கூறுகிறது.
இது உங்களுக்கு வாங்கித் தராதது என்ன
Constrained decoder-லிருந்து ஆறு outputs. ஆறும் parse ஆகின்றன. ஆறும் schema-வுடன் பொருந்துகின்றன. அவற்றில் பூஜ்ஜியம் உண்மை.
ground truth: {'name': 'ada lovelace', 'age': 36}
output parses schema correct
{"name":"age","age":36} yes yes NO
{"name":"","age":479} yes yes NO
{"name":"","age":30} yes yes NO
{"name":"","age":8} yes yes NO
{"name":"","age":93594505170} yes yes NO
{"name":"null","age":390} yes yes NO
6 distinct outputs, all valid JSON, all matching the
schema. The number that were right about Ada Lovelace: 0
Schema validity என்பது output-ன் syntax-ன் பண்பு. உலகத்தைப் பற்றி அது எதுவும் சொல்வதில்லை. இங்கே என் "model" random noise, அது இந்தக் கருத்தைச் சத்தமாகச் சொல்கிறது — ஆனால் ஒரு நல்ல model-க்கும் இதே உண்மை: constraining தோல்விகளை "parse ஆகாதவை"-லிருந்து "parse ஆகும், ஆனால் தவறு" என்பதற்கு நகர்த்துகிறது — உங்கள் database-க்குள் நுழைவது இரண்டாவதுதான். Age 36-ஆ என்பது ஒரு eval கேள்வி — evals page-ஐப் பாருங்கள்.
இங்கேயே ஒரு நேர்மையான செலவும் சொல்லிவிடுகிறேன். Masking ஒரு subset மீது மறு-normalise செய்கிறது, அதனால் constrained distribution என்பது validity-ஐ வைத்து condition செய்யப்பட்ட model-ன் distribution அல்ல — அது ஒரு greedy, per-step approximation, அதனால் வரும் quality shifts-ஐ மக்கள் இரு திசைகளிலும் அளந்திருக்கிறார்கள். Constrained decoding-ஐ இலவசம் என்று விற்காதீர்கள். தெரிந்த, சிறிய ஒரு distortion கொடுத்து வாங்கும் ஒரு guarantee என்று விற்குங்கள்.
मैं एक AI Engineer हूँ। मैंने models ship किए हैं, MLOps किया है, और दो साल तक "JSON malformed आया" का मेरा जवाब एक बेहतर prompt और एक retry loop ही था। वह चलता था। इसके बारे में कही जा सकने वाली सबसे बुरी बात यही है। dev में चला, staging में चला, demo में चला, और फिर production में ऐसी दर पर fail हुआ जिसे कोई संख्या में नहीं बता सकता था — और retry loop उसे तब तक छिपाए रखता रहा जब तक invoice नहीं आ गया।
किसी ने मुझे नहीं बताया कि इस समस्या का एक ऐसा रूप भी है जिसमें असली guarantee मिलती है, और वह guarantee एक लाइन लंबी है।
यह causal mask ही है, बस दूसरी दिशा में
retry का आकार आप जानते हैं। आपने इसे flaky HTTP के लिए लिखा है:
for attempt in range(5):
text = model(prompt)
try:
return json.loads(text) # उम्मीद
except ValueError:
continue # फिर उम्मीद, पूरे मन से
raise # और अब क्या?
यह validation है। यह बाद में चलता है, fail हो सकता है, और इसकी लागत एक random variable है। अब दूसरा रूप। हर step पर model आपको पूरे vocabulary पर एक logits vector देता है। एक parser जानता है कि उनमें से कौन-से tokens एक valid document को आगे बढ़ा सकते हैं। तो:
state = parser.start
while not parser.done(state):
logits = model(prefix) # वही का वही. model निर्दोष है
logits[not_allowed(state)] = -np.inf # <-- पूरा paper यही है
tok = sample(logits)
state = parser.step(state, tok)
वह loop GPT page वाला sampling loop ही है, बस उसमें
एक लाइन डाल दी गई है। अगर आपने
attention page पढ़ा है तो वह बीच वाली लाइन आप
पहले देख चुके हैं। यह causal mask है — softmax से पहले मना किए गए entries
को −∞ कर दो ताकि वे बाहर ठीक 0 आएँ। फ़र्क़ सिर्फ़ इतना है कि
वह किस चीज़ पर चलता है। causal mask positions पर चलता है और उसे
समय चलाता है। यह वाला vocabulary पर चलता है और उसे एक parser
चलाता है।
बाद में की गई validation एक test है। Constrained decoding एक type है।
| शब्द | मतलब | Programmer की उपमा |
|---|---|---|
| Grammar | वे strings जिन्हें आप स्वीकार करेंगे | schema, कुछ लिखे जाने से पहले ही |
| Parser state | आप document में कहाँ हैं | state machine का cursor |
| Allowed set | वे token ids जो document को valid रखते हैं | इस एक step का whitelist |
| Logit mask | बाक़ी सब पर −∞ | बची हुई vocabulary पर एक read-lock |
| Index | state → allowed set, पहले से गणना किया हुआ | हर request पर scan के बजाय boot पर बना dict |
retry loop दिखने से ज़्यादा बुरा क्यों है
मान लीजिए एक attempt में parse होने लायक़ JSON मिलने की probability
p है। सफलता तक के attempts एक geometric distribution हैं: mean
1/p, और quantiles एक log दूर। मेरी script इसकी
क़ीमत लगाती है।
मेरी बात मत मानिए। यह रहा असली output:
A request costs 400 tokens. You retry until the JSON parses.
p = probability one attempt returns valid JSON.
p E[attempts] p95 attempts p99.9 tokens/1k reqs worst case
0.70 1.43 3 6 571,428 unbounded
0.90 1.11 2 3 444,444 unbounded
0.95 1.05 1 3 421,052 unbounded
0.99 1.01 1 2 404,040 unbounded
simulated 20,000 requests at p=0.95: mean attempts 1.053, max 4
दूसरे column को नहीं, आख़िरी column को देखिए। 95% पर क़रीब 5% ज़्यादा tokens लगते हैं, जो ठीक है, लेकिन उसका कोई worst case नहीं — कोई ऐसा n नहीं है जिस पर आप अपने on-call से कह सकें "इतने में तो सफल हो ही जाएगा"। p=0.99 पर भी नहीं कह सकते। आपने अपने लिए एक ऐसी tail ख़रीद ली है जिसे आपने अभी नापा नहीं, और एक latency budget जिसे आप लिख नहीं सकते।
वह एक सूत्र
ℓ वह logit vector है जो model ने दिया। s parser state है। A(s) उस state से allowed tokens का set है। बस इतना ही — दोनों papers में बाक़ी सब यही है कि A(s) को इतनी तेज़ी से कैसे निकाला जाए कि वह हर token पर चल सके। इसे pipeline stages की तरह पढ़िए:
| Step | Shape | यह क्या करता है |
|---|---|---|
ℓ | (V,) | vocabulary की हर entry पर एक logit. model का पूरा योगदान |
A(s) | एक set | यही paper है। कौन-से token ids document को parse होने लायक़ रखते हैं |
+ ms | (V,) | allowed पर 0, बाक़ी पर −∞. softmax से पहले |
softmax | (V,) | जो बचा है उस पर फिर से normalise. जोड़ अब भी 1 |
sample | एक id | सामान्य sampling. temperature, top-p सब वैसे के वैसे |
parser.step | नया s | जो token आपने सचमुच emit किया, उससे state machine आगे बढ़ाओ |
एक step, पहले और बाद में
यह मेरी toy 45-token vocabulary से एक ठोस step है, document की बिलकुल शुरुआत में, जहाँ केवल एक opening brace ही वैध है। model को newline चाहिए था। उसे newline नहीं मिलता।
parser state 0: the document is empty, only '{' can start it
allowed tokens: ['{', '{"']
token logit p before p after
'\n' 2.718 0.2016 0.0000 <- forbidden
'","' 2.680 0.1942 0.0000 <- forbidden
'0}' 2.122 0.1111 0.0000 <- forbidden
'null' 1.769 0.0780 0.0000 <- forbidden
'g' 1.391 0.0535 0.0000 <- forbidden
'name' 0.980 0.0355 0.0000 <- forbidden
'age' 0.714 0.0272 0.0000 <- forbidden
'}' 0.597 0.0242 0.0000 <- forbidden
'{' 0.002 0.0133 0.8793 <- the only survivors
'{"' -1.983 0.0018 0.1207 <- the only survivors
total probability on invalid tokens before masking: 0.9848
total probability on invalid tokens after masking: 0.0000
probabilities still sum to 1: 1.000000
same mask written with 0.0 instead of -inf: 0.9742 of the
probability mass is STILL on invalid tokens.
दो चीज़ें ग़ौर से देखने लायक़ हैं। 98.5% probability mass जो model ने उन
tokens पर रखा जो यहाँ आ ही नहीं सकते — retry loop की जिन failures को आप
पहले ही मिटा दे रहे हैं, वह उतना ही हिस्सा है। और आख़िरी लाइन: mask को
−∞ के बजाय 0.0 लिखिए और 97% mass अब भी कूड़े पर
है। 0 का मतलब "off" नहीं है। 0 एक बिलकुल
सामान्य logit है। ठीक वही bug जिससे attention page की mask table आगाह
करती है, बस दूसरी file में।
एक असली state machine, और "by construction" का मतलब
मेरा grammar एक तय schema है, whitespace नहीं:
{"name":"<letters>","age":<digits>}. वह भाषा
regular है, इसलिए एक finite-state machine काफ़ी है — कुल
20 states, जिनमें से 15 तय literals के भीतर बस एक-एक क़दम हैं। फिर loop
चलता है, और हर step पर mask वही है जो वह state allow करता है।
20 states in all, 15 of them single
steps inside a fixed literal. The five that mean something:
state 0 start 1 chars '{' -> 2/45 tokens
state 1 in name string 28 chars ' "abcdefghij...' -> 18/45 tokens
state 2 need a digit 9 chars '123456789' -> 13/45 tokens
state 3 in age 11 chars '0123456789}' -> 16/45 tokens
state 4 done 0 chars '' -> 0/45 tokens
one generation, traced:
state 0 -> allowed 2/45 -> emitted '{'
state 5 -> allowed 1/45 -> emitted '"'
state 6 -> allowed 2/45 -> emitted 'name'
state 10 -> allowed 3/45 -> emitted '":"'
state 1 -> allowed 18/45 -> emitted 'ada'
state 1 -> allowed 18/45 -> emitted 'nam'
state 1 -> allowed 18/45 -> emitted 'ada'
state 1 -> allowed 18/45 -> emitted '","'
state 15 -> allowed 2/45 -> emitted 'age'
state 18 -> allowed 2/45 -> emitted '":'
state 2 -> allowed 13/45 -> emitted '36}'
emitted: {"name":"adanamada","age":36}
final state 4, accepting: True
json.loads says: {'name': 'adanamada', 'age': 36}
500 constrained generations: 500 parsed, 0 failed, 0 retries
lengths 19-173 chars, all schema-conformant, 0 cut off by the step cap
500 में 500 कोई अच्छा hit rate नहीं है। यह hit rate है ही नहीं। parser
valid prefixes के set से बाहर जा ही नहीं सकता, क्योंकि जो tokens उसे बाहर
ले जाते, वे कभी sample किए जा ही नहीं सकते थे। logits
rng.normal से आया random noise हैं — सचमुच एक भयानक model —
और फिर भी output हर बार parse होता है। दावा यही है: guarantee mask से आती
है, model को मना लेने से नहीं।
मुश्किल हिस्सा: grammar characters बोलता है, model tokens देता है
ज़्यादातर explanations यहीं रुक जाती हैं, और असल क़ीमत यही हिस्सा वसूलता है। आपका grammar characters पर परिभाषित है। model tokens emit करता है, और एक token कई characters की वह string है जिसे किसी compression algorithm ने सालों पहले आपके schema से पूछे बिना चुना था — देखिए tokenisation page. तो एक ही token grammar की सीमा के आर-पार बैठ सकता है।
name string के भीतर होने पर यहाँ 28 characters वैध हैं। देखिए यह tokens के बारे में क्या बताता है और क्या नहीं:
token first char ok? whole token ok? why
'"}' True False walks off the DFA
'","' True True ends in state 15
'lovelace' True True ends in state 1
'ace' True True ends in state 1
'}' False False walks off the DFA
'age' True True ends in state 1
'":"' True False walks off the DFA
The same token flips legality with the state. Token '0}':
from state 0 (start ): None
from state 1 (in name string ): None
from state 2 (need a digit ): None
from state 3 (in age ): 4
from state 4 (done ): None
'"}' को पकड़े रखिए। इसका पहला character वैध है — वह quote
name string को बंद करता है। उससे जुड़ा } वैध नहीं, क्योंकि
schema अभी हम पर एक age field उधार है। पहले character पर किया गया
character-level check हाँ कहता है; पूरा token एक इकाई के तौर पर ना है; और
आप आधा token emit नहीं कर सकते। इसीलिए पूरे token को automaton
से गुज़ारा जाता है और सिर्फ़ वही tokens रखे जाते हैं जो शुरू से आख़िर तक
बच जाएँ।
'0}' दूसरी दिशा दिखाता है: वही token "need a digit" में
अवैध है (JSON शुरू में zero मना करता है) और एक state बाद वैध। वैधता उस
जोड़ी का गुण है, token का नहीं। तो जवाब एक table है:
So the answer is an index -- parser state -> allowed token ids:
state 0 (start ) 2 tokens ['{', '{"']
state 1 (in name string ) 18 tokens [' ', '"', '","', 'a', 'ace', 'ada', '...']
state 2 (need a digit ) 13 tokens ['1', '180', '2', '3', '36', '36}', '...']
state 3 (in age ) 16 tokens ['0', '0}', '1', '180', '2', '3', '...']
state 4 (done ) 0 tokens []
... and the other 15 literal states, 1 or 2 tokens each
वह table ही Willard और Louf का योगदान है। masking नहीं — लोग पहले से mask कर रहे थे, और उसी साल Geng et al. एक grammar को decoder से जोड़ चुके थे। Outlines का दावा इस बारे में है कि allowed set आप कब निकालते हैं: हर generate हुए token पर एक बार नहीं, बल्कि हर (grammar, tokeniser) जोड़ी पर एक बार, load time पर।
index की क़ीमत, और उसकी बचत
मैं milliseconds के बजाय DFA character-steps गिनता हूँ, ताकि script आपके laptop पर वही संख्याएँ छापे जो मेरे पर। यह ज़्यादा ईमानदार इकाई भी है: यह काम है, मेरी machine का मूड नहीं।
vocabulary 45 tokens, grammar 20 states
parser state naive scan index lookup
start 46 steps 1 lookup
in name string 82 steps 1 lookup
need a digit 51 steps 1 lookup
in age 52 steps 1 lookup
done 45 steps 1 lookup
index built once, at load time: 986 character-steps, 68 entries
and the scan grows with the vocabulary while the lookup does not:
vocab 45: naive 82 steps per generated token, index 1
vocab 180: naive 487 steps per generated token, index 1
vocab 720: naive 2107 steps per generated token, index 1
vocab 2880: naive 8587 steps per generated token, index 1
पूरा index बनाने में 986 character-steps लगे — एक दर्जन naive scans से भी कम — और उसके बाद हर step एक dict lookup है। बाएँ column को एक असली vocabulary (50k–200k entries), एक असली grammar, और हर request के हर token से गुणा कीजिए, और आपको एक technique और एक product का फ़र्क़ मिल जाएगा। title में "Efficient" शब्द का यही मतलब है, और इससे ज़्यादा वह कुछ दावा नहीं करता।
यह आपको क्या नहीं देता
constrained decoder से छह outputs। छहों parse होते हैं। छहों schema से मेल खाते हैं। इनमें से शून्य सही हैं।
ground truth: {'name': 'ada lovelace', 'age': 36}
output parses schema correct
{"name":"age","age":36} yes yes NO
{"name":"","age":479} yes yes NO
{"name":"","age":30} yes yes NO
{"name":"","age":8} yes yes NO
{"name":"","age":93594505170} yes yes NO
{"name":"null","age":390} yes yes NO
6 distinct outputs, all valid JSON, all matching the
schema. The number that were right about Ada Lovelace: 0
Schema validity output के syntax का गुण है। दुनिया के बारे में वह कुछ नहीं कहती। यहाँ मेरा "model" random noise है, जो बात को ज़ोर से कहता है, पर अच्छे model के लिए भी यही सच है: constraining failures को "parse नहीं होता" से हटाकर "parse होता है और ग़लत है" पर ले आता है, और आपके database में वही दूसरा वाला घुसता है। age 36 है या नहीं, यह एक eval सवाल है — देखिए evals page.
एक ईमानदार क़ीमत यहीं बता दूँ। masking एक subset पर फिर से normalise करता है, इसलिए constrained distribution model की वह distribution नहीं है जो validity पर condition की गई हो — वह उसका एक greedy, per-step approximation है, और लोगों ने उससे होने वाले quality shifts दोनों दिशाओं में नापे हैं। constrained decoding को मुफ़्त मत बेचिए। उसे एक ऐसी guarantee की तरह बेचिए जो एक ज्ञात, छोटी distortion देकर ख़रीदी जाती है।
The code
Plain Python and NumPy. No torch, no model, no training — the automaton, the mask and the index, so you can watch the allowed set shrink. Every function under 20 lines.
Download the full script — nine short functions plus seven runnable demos. Then:
python3 structured_output_from_scratch.py
def step_char(state, ch):
"""One character. None means 'this character cannot continue the doc'."""
return DELTA.get(state, {}).get(ch)
def step_token(state, token):
"""Feed a whole token through, character by character.
Returns the state after the token, or None if ANY character inside it
fell off the automaton. A token is allowed or it is not; there is no
partial credit, because you cannot emit half a token."""
for ch in token:
state = step_char(state, ch)
if state is None:
return None
return state
def allowed_tokens(state):
"""Naive version: walk every token in the vocabulary through the DFA.
Cost is O(vocab x token length) PER GENERATED TOKEN. This is the thing
the Outlines paper removes."""
out = {}
for i, tok in enumerate(VOCAB):
nxt = step_token(state, tok)
if nxt is not None:
out[i] = nxt
return out
def apply_mask(logits, allowed):
"""Set every disallowed logit to -inf. Not 0 -- 0 is a perfectly ordinary
logit that softmax will happily hand probability to."""
masked = np.full_like(logits, -np.inf)
idx = np.fromiter(allowed, dtype=int, count=len(allowed))
masked[idx] = logits[idx]
return masked
def build_index():
"""state -> {token id: state after that token}. Built once, reused forever."""
return {s: allowed_tokens(s) for s in DELTA}
build_json_dfa (which chains one state per character of each
fixed literal), generate (the loop from the top of this page,
plus tracing), softmax, sample and the vocabulary
list are all in the script.
Now break it
This is the part that actually makes it stick. Reading won't do it. You debug for a living — reverse-engineering from broken behaviour is your native mode, and every row here is a bug someone has shipped.
| Break this | Watch for |
|---|---|
Pass mask_with_zero=True to generate — the mask writes 0.0 instead of -np.inf | Demo 7 prints invalid outputs: 200/200 against 0/200 for −∞. At step one, 0.9742 of the probability mass is still on tokens that cannot legally appear. |
In allowed_tokens, replace step_token with step_char(state, tok[0]) — check the grammar one character at a time | Token '"}' passes the check from inside the name string and closes the document early: {"name":"ada"}. Valid JSON, wrong schema, no age field. Demo 7(b) prints the disagreement. |
| Sample from the raw logits and reject illegal tokens afterwards, instead of masking first | Demo 7(c): mean 23.6 samples, max 142, to get one legal first token. Masking gets it in 1, every time, from the same distribution. |
Delete the retry cap: while True: around the parse in demo 1's simulation | At p=0.90 over 100,000 requests the longest run takes 6 attempts — 2,400 tokens and a timeout for one user. The mean stays a comfortable 1.109 the whole time. |
Trust the values: assert only json.loads(text) succeeds in demo 6 | Every assertion passes. {"name":"","age":479} is well-formed, schema-conformant and completely wrong about Ada Lovelace. 0 of 6 correct. |
Drop max_steps to 12 in generate | Demo 3's truncated count goes from 0 to 472 of 500. A length cap can slice a valid prefix in half — the grammar guarantees the prefix stays valid, not that you gave it room to finish. |
On the maths anxiety
There is almost none here, and that surprised me. The retry section is one
distribution — the geometric — and everything I printed from it is 1/p and a
log to turn "how many attempts covers 95% of requests" into a
number. You could derive that column in a spreadsheet.
The mechanism is a comparison and an assignment: is this token in a set, and
if not, write −∞. The only "maths" is knowing why −∞
and not 0, and that's just knowing what exp(−∞) = 0
does inside a softmax. Same fact as the causal mask.
The genuinely useful theory is computer science, not maths, and it is the first-year kind: regular languages and finite-state machines, context-free grammars and pushdown automata, and the fact that a fixed schema lives in the first box while nested JSON lives in the second. If you can say which of those two your schema needs, you understand this paper. What you actually need on the job is different again: knowing your tokeniser, knowing your library (Outlines, guidance, llama.cpp's grammars, the structured-output modes the APIs now ship), and an eval set — because the failure mode you've just bought is confidently well-formed nonsense.
Where to go next
This page gave you the mask and the index. These give you the compiler, the competing designs, and the production ergonomics.
-
Efficient Guided Generation for Large Language Models
Read it for the construction I hand-wrote: regex and schema compiled to a finite-state machine, and the indexing argument that makes the allowed-set lookup cheap enough to run per token. Short, and much easier once you have the state table above in your head.
-
Grammar-Constrained Decoding for Structured NLP Tasks without Finetuning
The half I skipped entirely: what constraining does to task accuracy on real benchmarks with real models, versus fine-tuning for the same output shape. Note the arXiv title says "without Finetuning" — the paper is often cited under an earlier wording.
-
Outlines documentation
What this looks like when someone else has done the work: a Pydantic model or a regex in, guaranteed-shaped output out. Read the JSON-schema and regex pages to see which parts of a schema actually compile to a machine.
-
Coalescence: making LLM inference 5× faster
The follow-up idea I left out: when a state's allowed set has exactly one member you can skip the model call altogether, which is why constrained decoding can be faster than unconstrained. It also shows the distribution shift that trick causes, honestly.