Skip to main content

Reasoning Models and the Token Budget That Eats Your Answer

Alex Chen9/15/20269 min read
llm-engineeringreasoning-modelsdeepseektoken-budgetai-translation

Your provider swapped in a reasoning model and your pipeline still returns 200 OK. Nothing crashes. The logs look calm. And every answer is quietly empty.

We hit this twice in one system, three weeks apart, in two places that had nothing to do with each other. This post is what the symptom looks like, how to confirm it in one API call, and what the fix actually is — including the part almost nobody checks: the fallback that turns an empty answer into a plausible number.

What a reasoning model does to your budget

A reasoning model thinks before it answers. That thinking is tokens, and — this is the part that bites — those tokens come out of the same max_tokens you set for the answer.

Set max_tokens=4096, and the model doesn't get 4096 tokens for its reply. It gets 4096 tokens for thinking plus replying. If the thinking runs long, the budget is gone before a single character of the answer is emitted. The API returns finish_reason: "length" and a message body that is empty.

Not truncated. Not malformed. Empty.

Here is what we measured directly against the API on deepseek-v4-flash, which the provider registry described as a "Fast general purpose model" and which is, in fact, a reasoning model:

Promptmax_tokensResult
Short, 6 segments1500stop, 1272 reasoning tokens, valid JSON
Full batch prompt, 6 segments4096length, reasoning 4095/4095, 0 text
Full batch prompt, 6 segments16384length, reasoning 16384, 0 text
Production batch, 10 segments10000length, reasoning 10000, 0 text

Read the second and third rows together. We quadrupled the budget and got the same result: the model expanded its thinking to fill whatever it was given and never reached the answer. Raising max_tokens is not the fix. It is the most natural thing to try and it buys you nothing but a bigger bill.

Why nobody notices

An empty response is easy to mishandle, because "the model returned nothing" and "the model found nothing" look identical to code that isn't looking carefully.

We found the same defect in two subsystems, and in each one it wore a different disguise.

Disguise one: the retry that looks like resilience

Our batch translator sends numbered segments and expects a numbered array back. When the response is empty, it can't match indices, so it does the sensible-looking thing: it retries each missing segment individually.

That worked. Every segment came back translated. The document was fine.

It was also 38 out of 38 segments falling back to individual calls on every document — a batch path that had silently stopped being a batch path. The retry was so effective at hiding the failure that the only visible symptom was the bill and the clock. One Chinese customs declaration: MT stage 133.6 seconds, whole document 359 seconds, $0.0354.

After the fix: MT stage 5.1 seconds, document 47 seconds, $0.00098. Same model, same document, same output quality — numeric integrity 9 of 9 both times. A 36× cost difference, entirely from tokens spent on thinking that never produced an answer.

Across the whole system the spend history tells the story more bluntly. The same model averaged 34,234 output tokens per call on one day and 697 the next — 49× — with the fix as the only thing that changed.

Disguise two: the fallback that lies

The second occurrence is the one worth stealing as a lesson.

Our quality estimation runs GEMBA-MQM: it asks a model to score machine-translated segments against an error typology. It called the same API without the reasoning switch, with a ceiling of max(4096, n * 150) — which is exactly 4096 for any batch under 27 segments. So every call returned empty.

And every call fell through to _create_fallback_score, which assigns a neutral 75.

Quality estimation appeared to work for weeks. It produced scores. The scores were plausible. They were on every segment. Nobody looked twice at a 75, because 75 is exactly what a mediocre machine translation should score.

The tell, once we looked, was that it was always exactly 75. Never 74, never 78. A real evaluator produces a distribution; a constant is a fingerprint of a fallback.

After the fix, the same 14 segments took 6.1 seconds and scored thirteen 100s and one 96 — with a real style/awkward finding attached to the 96.

A fallback that returns a plausible constant is worse than a crash. A crash gets fixed on Tuesday. A plausible constant ships to production and becomes a number people make decisions with.

How to confirm it in one call

You don't need instrumentation. Send your real prompt at your real max_tokens and print three things:

response = await client.chat.completions.create(
    model="deepseek-v4-flash",
    messages=[{"role": "user", "content": your_real_prompt}],
    max_tokens=4096,
)

choice = response.choices[0]
usage = response.usage

print("finish_reason:", choice.finish_reason)
print("content:", repr(choice.message.content))
print("reasoning tokens:", getattr(usage, "reasoning_tokens", None)
      or getattr(usage.completion_tokens_details, "reasoning_tokens", None))

If finish_reason is "length" while content is None or "", and the reasoning-token count is sitting on your ceiling, you have found it. It takes one call and about half a cent.

Use your real prompt. Ours behaved perfectly on a short test prompt at 1500 tokens — the first row of that table — and failed on the full one. A reasoning model's thinking scales with how hard the task looks, so a toy prompt proves nothing about the prompt you ship.

The fix

Two parts, and you need both.

Turn the thinking off where it isn't buying anything. For OpenAI-compatible providers the parameter is reasoning_effort: "none":

response = await client.chat.completions.create(
    model=model,
    messages=messages,
    max_tokens=max_tokens,
    reasoning_effort="none",
)

Some providers also accept extra_body={"thinking": {"type": "disabled"}}. Be warned that the parameter space is full of near-misses that do nothing or the opposite. In our testing reasoning_effort: "minimal"increased reasoning; enable_thinking=False and reasoning.enabled=False had no effect at all. Test the switch, don't assume it.

Make the budget scale with the work. A fixed 4096 is a bug waiting for a bigger batch. Ours now grows with segment count, so a 40-segment batch isn't given the same ceiling as a 4-segment one.

For a provider that has never heard of the parameter, drop it rather than fail: send it, catch the 400 for an unknown field, and retry without it. Claude and Gemini ignore ours entirely, which is the correct behavior.

Where to turn thinking off — and where not to

The switch is not free quality. Turn it off deliberately, per call site.

Turn it off for mechanical work. Translation, extraction, transcription, format conversion — anything where the model transforms input rather than deciding something. There is nothing to reason about in "render this table cell in Russian," and we measured no quality loss: numeric integrity was 9 of 9 with reasoning on and 9 of 9 with it off, and the output actually got cleaner (residual source-script characters dropped from 31 to 11, because a model that isn't thinking itself into a corner is likelier to just finish the job).

Leave it on for judgment. Multi-step analysis, ambiguous classification, anything where you want the model to weigh alternatives. Just give it a budget that fits both the thinking and the answer.

The trap is that mechanical work is usually the high-volume path, so it is exactly where an invisible 36× cost multiplier does the most damage.

Key takeaways

  • Reasoning tokens and answer tokens share one budget. When thinking exhausts it, you get finish_reason: "length" with an empty body — not an error, not a truncation.
  • Raising max_tokens does not help. We went 4096 → 16384 and got the same empty answer; the thinking expands to fill whatever it is given.
  • Treat an empty response body as a budget failure, not as "no result." Log it distinctly. Ours logs [LLM_EMPTY] for an empty body and [LLM_TRUNCATED] for cut-off JSON.
  • Audit every LLM call site, not just the one where you saw the symptom. We found the same defect twice, three weeks apart, in unrelated subsystems. Grep for calls that don't pass the reasoning flag.
  • Distrust fallbacks that return a plausible constant. Ours returned a neutral 75 on every segment and made a broken quality estimator look like a working one.

FAQ

How do I know if my model is a reasoning model?

Don't trust the description. Ours was documented as a "fast general purpose model" in our own registry. Send one real request and check whether the usage object reports reasoning tokens — that is the only answer that counts.

Why does the model return nothing instead of a partial answer?

Because the reasoning happens first. The model exhausts the budget before it begins emitting the reply, so there is no partial answer to return — the message body is genuinely empty rather than cut short.

Will disabling reasoning hurt translation quality?

In our measurements, no. Numeric integrity was identical with and without it, and residual source-script characters actually dropped. Translation is transformation, not deliberation. For genuinely analytical tasks, keep reasoning on and raise the budget instead.

What should I log to catch this in production?

Log finish_reason and reasoning-token usage on every call, and alert when finish_reason == "length" coincides with an empty body. Also alert on suspiciously constant outputs from any scorer — a metric that never varies is usually a fallback, not a measurement.

Does this affect Claude or GPT models too?

The shared-budget behavior applies to reasoning models generally, but the control differs by provider and some ignore the parameter entirely. The diagnostic is the same everywhere: real prompt, real ceiling, check finish_reason against the message body.

Conclusion

This defect is dangerous precisely because it doesn't look like one. There is no exception, no 500, no red line in a dashboard. There is a slow pipeline, a bill that grew, and — if you are unlucky — a metric that reports a comfortable number it never actually measured.

The diagnosis costs one API call. The fix is one parameter plus a budget that scales. The discipline is the hard part: when you find it in one place, audit every other call site the same day, and look hard at any fallback that returns something plausible instead of failing loudly.

KTTC translates official documents against templates, where a dropped code or a fabricated date is not a style issue but a broken document. That is why we measure our own pipeline this closely — and why we publish what the measurements say. Try KTTC and see the numbers on your own documents.

We use cookies to improve your experience. Learn more in our Cookie Policy.