"My prompt is about ten lines, but response.text comes back empty — and when I check finish_reason, it says MAX_TOKENS. I never asked for a long answer." If you have started using the Gemini 2.5 or 3 thinking models, you may have tilted your head at exactly this.
I ran into it myself. In the app business I have run since 2014, I was writing a batch that classifies App Store and Google Play reviews with gemini-2.5-flash. Trying to optimize cost, I dialed maxOutputTokens down to 256 — and the visible text came back empty, costing me half a day to track down. The short version: the thing manufacturing those "empty" responses is the thinking tokens.
The symptom — you ask for a short answer and it stops at MAX_TOKENS
First, how to reproduce it. With a thinking model (gemini-2.5-flash, gemini-2.5-pro, the gemini-3-* family), set maxOutputTokens to something small (tens to a few hundred) and even a request that should produce a one-word answer returns this:
from google import genai
from google.genai import types
client = genai.Client(api_key="YOUR_API_KEY")
resp = client.models.generate_content(
model="gemini-2.5-flash",
contents="Answer the sentiment of this review in one word, positive or negative: 'Runs smoothly, very happy'",
config=types.GenerateContentConfig(max_output_tokens=256),
)
print(repr(resp.text)) # '' or None
print(resp.candidates[0].finish_reason) # FinishReason.MAX_TOKENSA request that returns a single word, stopped by MAX_TOKENS. It feels backwards, and that is where the thinking-model trap hides.
Why it happens — thinking tokens spend the output budget first
Since 2.5, Gemini's thinking models reason internally before producing a final answer. That reasoning consumes real tokens, and crucially, maxOutputTokens caps the sum of thinking tokens plus the visible answer tokens.
So with maxOutputTokens=256, if the model burns all 256 on thinking, there is zero budget left for the visible answer. The result: not a single text part lands in parts, the response is cut off with MAX_TOKENS, and response.text is empty. The response is not broken — it is a budget-allocation problem.
usage_metadata makes this obvious:
um = resp.usage_metadata
print("prompt:", um.prompt_token_count)
print("thoughts:", um.thoughts_token_count) # pinned near maxOutputTokens
print("candidates:", um.candidates_token_count) # 0 or tinythoughts_token_count pinned at maxOutputTokens while candidates_token_count is 0 is the smoking gun that thinking ate the whole budget. The older non-thinking models (gemini-1.5-* and friends) never did this, so it catches a lot of people during migration.
Fix 1 — size maxOutputTokens to include the thinking
The most direct fix is to give the output cap enough room to cover the thinking tokens as well. On a thinking model you must not estimate maxOutputTokens from the answer length alone.
resp = client.models.generate_content(
model="gemini-2.5-flash",
contents="Answer the sentiment of this review in one word, positive or negative: 'Runs smoothly, very happy'",
config=types.GenerateContentConfig(max_output_tokens=2048), # room for thinking
)
print(resp.text) # 'positive'In my experience, if you use a thinking model even for short classification or extraction, keeping maxOutputTokens at 1024 minimum — 2048 to be safe — makes empty MAX_TOKENS responses all but disappear. Treat the output cap as a safety valve against runaway generation, not as a tight fit around your expected answer length.
Fix 2 — control the thinking itself with thinkingBudget
If the answer is reliably short, capping the thinking directly is better for cost too. thinking_config.thinking_budget sets an upper bound on thinking tokens.
resp = client.models.generate_content(
model="gemini-2.5-flash",
contents="Answer the sentiment of this review in one word, positive or negative: 'Runs smoothly, very happy'",
config=types.GenerateContentConfig(
max_output_tokens=256,
thinking_config=types.ThinkingConfig(thinking_budget=0), # disable thinking
),
)
print(resp.text) # 'positive'One caveat about model differences. gemini-2.5-flash and flash-lite let you fully turn off thinking with thinking_budget=0, but gemini-2.5-pro cannot disable thinking entirely and keeps a minimum budget (you generally need to specify 128 or more). Passing zero on Pro yields INVALID_ARGUMENT, so for Pro, prefer Fix 1 — widen the output cap — instead of trying to zero it out. Passing thinking_budget=-1 enables a dynamic mode where the model adjusts the thinking amount to the task.
Detecting empty responses and auto-recovering in production
In batches and long-running services, adding a layer that detects rather than swallows empty responses prevents nightly jobs from silently losing data. In my review-classification pipeline, this runs every night across apps totaling 50 million downloads, so robustness here ties directly to revenue stability.
def generate_with_guard(client, model, contents, max_tokens=2048):
resp = client.models.generate_content(
model=model,
contents=contents,
config=types.GenerateContentConfig(max_output_tokens=max_tokens),
)
fr = resp.candidates[0].finish_reason
text = resp.text or ""
if not text and str(fr) == "FinishReason.MAX_TOKENS":
# assume thinking exhausted the budget; double the cap and retry once
resp = client.models.generate_content(
model=model,
contents=contents,
config=types.GenerateContentConfig(max_output_tokens=max_tokens * 2),
)
text = resp.text or ""
if not text:
raise RuntimeError(f"empty response persists: finish_reason={fr}")
return textSimply enforcing the order "read finish_reason before you touch the text" prevents most empty-response trouble. The idea is the same in the Node SDK (@google/genai): check response.candidates?.[0]?.finishReason and observe thinking spend via response.usageMetadata?.thoughtsTokenCount.
After migrating to a thinking model, the first thing to revisit is maxOutputTokens — start there. I hope this saves someone the half day it cost me.