6 min left
6 min read

The model ran out of room to think, and every diagnostic guessed wrong

Michael Hospedales, software engineer in Miami, FL
Michael Hospedales

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_tokensthinkingresult
40ONfinish_reason: "length", content: "", 172 characters of reasoning_content
512ON{"verdict": "yes"}binds
40OFF{"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.