notes.ludex-gg.com

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:

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:

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

  1. An empty content from a thinking model is a budget-exhaustion symptom, not a crash.
  2. Before tuning a parameter, prove it does anything: two requests, one field different, compare a number you can see.
  3. A server flag and a request field sharing a name does not mean they share an implementation.
  4. Prefer the knob that acts on prompt assembly. It is the one that is usually actually wired up.
  5. Handle the empty answer anyway, and leave a comment saying why.
  6. Route reasoning per request. A global on/off trades one failure mode for another.