The model ran out of room to think, and every diagnostic guessed wrong
Drafted by AI · reviewed & edited by Michael
A model that reasons before it answers spends tokens on the reasoning before it spends any on
the answer. Hand it a short max_tokens and something worth thinking about, and it can spend
the entire allowance thinking and stop there — finish_reason: length, content: "", HTTP 200.
Nothing broke. The request was answered exactly as specified, with exactly the room it was
given, and the room ran out before the answer began.
That one fact has cost me three separate investigations this month, on three different models. Not because the mechanism is subtle — it's a single sentence, and I just wrote it. Because every time I actually hit it, whatever was reading the response had a different, wrong idea of what had gone wrong. A parser bug. An unconstrained grammar. Anything except the plain, boring truth: there was no room left.
The field built to report this was itself broken
I found it first in Muse-Glimmer, a reasoning model I run on a DGX Spark under vLLM. Every
completion came back with reasoning_content: "" and reasoning_tokens: 0 — including turns
where the model had obviously reasoned first. One request I captured spent 105 completion tokens
to answer with the single word "hello." Around a hundred tokens of reasoning had been generated
and quietly thrown away.
The obvious suspect was the parser — it's the piece that's supposed to pull reasoning_content
out of the model's raw output. So I checked it three separate ways: the bare regexes against the
model's own channel-tagged output, the parser's internal classification method, and the fully
composed parser object the server actually builds at request time. All three returned the
reasoning text correctly. The parser works. The API still returns an empty string. Whatever
drops the field sits somewhere between a parser that demonstrably has the text and a response
that demonstrably doesn't, and I never found where — that
investigation is still open.
A second, independent defect lived in the same parser, and this one did name a culprit out loud,
in the server logs, on every boot: a warning telling me to check whether the reasoning parser had
implemented reasoning_start_str and reasoning_end_str. It hadn't. But that isn't a bug in the
parser — Muse-Glimmer's reasoning isn't bounded by a single start/end token, it's
channel-structured (to=self<|message|>…<|eom|>), and the two-string contract the warning wants
doesn't fit that shape. The parser made a reasonable choice for its own architecture. What's
actually missing sits a layer up: the config code that's supposed to enforce a reasoning-token
budget has no fallback for a parser like this one, so
the budget cap it's meant to enforce never engages at all.
Stack those two facts on a model whose chat template reasons by default, and you get exactly the
failure at the top of this post: a short-budget call can spend its entire allowance thinking,
return empty content, and report zero reasoning tokens regardless of how many were actually
spent. Nothing in the response says why. I watched it happen mid-investigation, by accident: a
max_tokens=120 call came back with 120 completion tokens, empty content, and empty reasoning.
No error, no partial output. Just nothing, successfully.
A caution about the exact model, written that same afternoon
Days later I was standing up a different model on a different stack — Qwen3.8-Flash-Next,
architecture name qwen4exp, served through llama.cpp instead of vLLM, on the same box. The
findings I wrote up that afternoon include a line I almost cut as too obvious to state: thinking
is on by default for this model, a bare "ping" comes back with 161 characters of
reasoning_content before it gets to "pong," and a short max_tokens can be spent entirely on
reasoning, returning empty content under HTTP 200.
I want to be precise about what that sentence was. It was a caution, written in advance, about a mechanism I already understood from Muse-Glimmer. It was not a report of something that had happened, and nothing else in that document yet said otherwise.
It happened thirteen minutes later. Same model, same day, different piece of software entirely.
The safety check that named the wrong bug
The Agentic Daily — the newsroom this model was being swapped into — runs a pre-flight check
before trusting any new engine with a real edition. It exists because /v1/models will answer
200 whether or not the engine is actually going to behave, and because it had already been burned
twice by exactly that gap: once by a sampling parameter that silently drew HTTP 400 on every
call, once by a decoding grammar that llama.cpp accepted and then quietly ignored. So the pre-flight
does one more thing before clearing an engine: it sends a real request with a JSON schema
attached and checks whether the schema actually binds, not just whether the server said yes to
it.
On the swap that brought in Qwen3.8-Flash-Next, it declared the engine NOT SAFE, printing the same description it uses for the older, real grammar defect: "accepted but the reply does not match the schema — JSON-mode beats will run unconstrained and log nothing."
The schema bound perfectly. The probe was the problem, and the three-arm test that proved it is the clean version of everything above:
max_tokens | thinking | result |
|---|---|---|
| 40 | ON | finish_reason: "length", content: "", 172 characters of reasoning_content |
| 512 | ON | {"verdict": "yes"} — binds |
| 40 | OFF | {"verdict": "yes"} — binds |
Either lever alone recovers a verdict. Raise the budget with thinking still on: fine. Turn
thinking off and leave the budget at 40: also fine. The only cell that fails is the one where a
model that reasons before it answers was handed a budget too small to finish reasoning and still
say anything. That's a fact about the budget. It says nothing about whether response_format
constrains the output.
The probe's own code didn't ask it that way. It read message.content, called
json.loads(txt), and treated any exception as "accepted but not binding." At max_tokens=40
with reasoning on, content was an empty string, json.loads("") raised, and the exception
handler had no way to distinguish "the model ignored the schema" from "the model never got that
far." finish_reason was sitting right there in the same response, unread.
This is worse than an ordinary false positive, and not only because of what it would have cost — a real engine, held back from a real edition, over nothing. The check's own test file says, in its own module docstring, why the stakes are higher than that: it "runs at exactly the moment the operator is least able to debug it — right after an engine swap, before the first edition — so a broken diagnostic is worse than none." This one didn't just misfire quietly. It reused the exact wording of a real, previous outage to explain a problem that wasn't that outage. A false alarm wearing a real name is the expensive kind: it sends you to re-check something that was never broken, and it spends the trust the same alarm needs the next time the schema genuinely doesn't bind.
The fix, written up in full here,
is two changes. The probe's budget went from 40 to 512 — enough room for the 94 tokens the
identical request needed with thinking on. And separately, an empty completion at
finish_reason: length is now checked for on its own, before anything tries to parse it as JSON,
and reported as its own state: no verdict, budget exhausted, binding unknown rather than
disproved. It still fails the check — an engine you can't get a verdict out of is a real problem
— but it fails it honestly.
The shape underneath
I've written before, on this site's dated point of view, that autonomous systems fail in ways that look like success — a decoding grammar that finishes fast because it skipped reasoning entirely, a green schema check next to an answer that quietly got worse. This is the same claim, a third time, and it's the sharpest version yet, because it's the first one with a controlled experiment behind it instead of a single captured trace. I didn't have to guess which variable mattered. I held thinking constant and moved the budget; I held the budget constant and moved thinking. The failure showed up only where both conditions met, and it cleared completely each time I backed either one off on its own — two independent ways to fix it, both pointing at the same missing check.
What would have actually caught this, in all three places, isn't a smarter parser or a better
grammar. It's naming a fact that was already sitting in the response before anything else got
read: finish_reason: length next to an empty content is a budget fact, not a verdict on
whether a schema binds, a parser works, or an engine is safe. It has to be checked, and named,
before any of those other questions are allowed an answer. In every one of these three cases, the
information needed to say that correctly was already there. Nothing had to be added. It had to be
read in the right order.