The Reasoning Knob That Silently Did Nothing
A local voice assistant started returning silence. Not an error, not a timeout —
the request completed, the server returned HTTP 200, and the content field of
the response was an empty string. The user asked a question and nothing was
spoken back.
The cause turned out to be reasoning, and the fix turned out to be a different setting than the obvious one. The obvious one was measured, produced numbers, and those numbers showed it doing nothing at all.
Why a thinking model can answer with nothing
A reasoning model spends tokens thinking before it spends tokens answering. On
a llama.cpp server, both come out of the same max_tokens budget for the
request.
That has a failure mode that does not exist for non-thinking models: if the
reasoning pass is long enough, it consumes the entire budget, generation stops,
and the answer never starts. The response is well-formed. The reasoning content
is populated. content is empty.
Nothing in that response looks like an error, which is exactly why it reached the interface as silence rather than as a stack trace.
The obvious fix, which did nothing
The apparent solution is to cap how much the model is allowed to think. The API
accepted a per-request reasoning_budget field, so the first attempt set it
low.
Same question, same context, budget of 64 against the default:
| Setting | Reasoning produced | Time | Answer tokens |
|---|---|---|---|
reasoning_budget: 64 |
4,663 characters | ~14.4s | 0 |
| default | 4,608 characters | ~14.4s | 0 |
A budget of 64 produced more reasoning than the default. The difference is noise. The field was accepted, returned no error, and changed nothing — it was not plumbed through in that build.
This is a particularly easy trap because a server-level flag with almost the
same name does work. Starting the server with --reasoning-budget and a
companion message to inject when the budget is exceeded behaves as documented.
Seeing the flag work at the process level makes it reasonable to assume the
per-request field maps onto it. It does not, and nothing tells you so.
Two things made this cost more time than it should have:
- A silently ignored field looks like a field with a subtle effect. The first instinct on seeing no change is to try a different value, not to suspect the parameter is inert.
- The measurement that settled it was cheap. Two requests, identical except for one field, comparing reasoning length. That should have been the first step, not the fourth.
This is the same discipline that locates a throughput cliff on a GPU: hold every variable fixed, move one, and compare a number you can actually see. It works identically on a flag that might be inert and on a context size that might be sitting just past a hardware edge.
The setting that worked
Thinking, on this build, is not a dial. It is a switch:
{
"chat_template_kwargs": { "enable_thinking": false }
}
Same question, same context, thinking disabled: 710ms, and a correct answer. Against ~14.4 seconds and no answer at all.
That is a 20x latency difference between two configurations that differ by one boolean, where the slower one also returns nothing usable.
The mechanism is worth understanding rather than memorising: enable_thinking
is consumed by the model's chat template when the prompt is assembled, so it
changes the prompt itself. reasoning_budget would have to be interpreted by
the sampling loop at generation time. The first is template plumbing that
already existed; the second is runtime plumbing that did not.
Three knobs, and only some of them exist
The confusing part is that "control the reasoning" is not one feature. On a given build and model, expect to find some subset of:
| Knob | Where it acts | In this case |
|---|---|---|
--reasoning-budget (server flag) |
Process startup | Works |
reasoning_budget (request field) |
Would be generation-time | Silently ignored |
enable_thinking (template kwarg) |
Prompt assembly | Works, binary only |
reasoning_effort (request field) |
Prompt assembly | Works, where the model defines the levels |
The last one matters. On a model that defines reasoning levels, a per-request
reasoning_effort of low / medium / high is honoured, which gives back
the graduated control that reasoning_budget failed to provide. So the summary
is not "per-request reasoning control does not work" — it is that one specific
field did not, while a different one did.
Do not infer any of this from documentation. Infer it from two requests that differ by one parameter.
Keep the guard
Independent of which knob you use, the empty-answer case should be handled rather than prevented, because you will not prevent it in every case.
The wrapper now retries once with thinking disabled when content comes back
empty. It is a few lines, it converts a silent failure into a slightly slower
correct answer, and it is the difference between a system that is occasionally
mute and one that is not.
That guard is easy to remove later during a cleanup pass, because from the code alone it looks defensive rather than necessary. It is worth a comment saying what it caught.
The cost of just turning thinking off
Disabling reasoning is not free, and a post that recommended it without saying so would be marketing.
On a 20B reasoning model, tasks were split cleanly by whether reasoning was required:
- Counting words in a passage: correct with reasoning at a low setting.
- Producing words that exclude a given letter: failed consistently at a medium reasoning setting, and only became reliable at a high one.
Exclusion constraints need the model to check its own candidate output before committing to it, which is precisely what the reasoning pass does. Turn it off globally and that class of task quietly gets worse — quietly, again, because wrong answers arrive fully formed and confident.
The workable arrangement was routing rather than a global setting: classify the request cheaply, then pick the reasoning level for that turn. A broad lookup that does not need deliberation went from 25 seconds to 3.3 seconds by dropping to the lowest level, while compound and generative requests kept the higher one.
The classifier is a few regular expressions, not a model call. It has been worth more than any single flag.
The method, compressed
- An empty
contentfrom a thinking model is a budget-exhaustion symptom, not a crash. - Before tuning a parameter, prove it does anything: two requests, one field different, compare a number you can see.
- A server flag and a request field sharing a name does not mean they share an implementation.
- Prefer the knob that acts on prompt assembly. It is the one that is usually actually wired up.
- Handle the empty answer anyway, and leave a comment saying why.
- Route reasoning per request. A global on/off trades one failure mode for another.