DEV Community

jidonglab
jidonglab

Posted on

Claude API max_tokens Cut Off 143 of 1,600 Summaries Mid-Sentence

The release notes went out on a Friday. Line 14 read: "Fixes a race condition in the session refresh path that caused users on slow networks to be logged out when the".

That was it. Nothing after "the". No period, no ellipsis, no error in the logs. My nightly pipeline had been summarizing GitHub issues with the Claude API for three weeks, and the Claude API max_tokens limit had been quietly chopping summaries in half the whole time. When I finally counted, it was 143 out of 1,600.

The bug was one line I copied from a quickstart. The interesting part is which summaries it ate.

TL;DR

  • max_tokens in the Claude Messages API is a hard ceiling. When the model hits it, generation just stops and the response still comes back as a normal success.
  • The only signal is stop_reason == "max_tokens" on the response. If you only read content[0].text, you will never see it.
  • In my run, 143 of 1,600 summaries (8.9%) were truncated at max_tokens=512, but the rate was 42.5% for my longest inputs, which were also the most important issues.
  • You are billed for tokens actually generated, not for the ceiling, so raising max_tokens from 512 to 2,048 raised my total output tokens by only 7.4%.
  • Fix: check stop_reason on every call, retry with a bigger limit, and fail loudly if the biggest limit still truncates.

What was the pipeline doing?

Every night a Python script pulled closed issues from a handful of repos, sent each one (title, body, the last few comments) to Claude with a system prompt asking for a two-to-four sentence plain-English summary, and stitched the results into a draft changelog.

The core of it looked like this:

resp = client.messages.create(
    model=MODEL,
    max_tokens=512,
    system=SUMMARY_PROMPT,
    messages=[{"role": "user", "content": issue_text}],
)
summary = resp.content[0].text.strip()
Enter fullscreen mode Exit fullscreen mode

max_tokens=512 came straight from an example I had pasted months earlier. 512 tokens is roughly 350 to 400 English words. My prompt asked for four sentences. What could go wrong?

Why does Claude stop mid-sentence without an error?

Claude stops mid-sentence because max_tokens is a budget, not a suggestion. The model does not see the limit and plan around it; generation runs until the model decides it is done or until the token count hits the ceiling, whichever comes first. Hitting the ceiling is not an error, so the API returns HTTP 200 with whatever text was produced so far.

The response tells you what happened, but only if you ask:

print(resp.stop_reason)   # "end_turn" or "max_tokens"
print(resp.usage.output_tokens)  # 512 exactly, every time it got cut
Enter fullscreen mode Exit fullscreen mode

end_turn means the model finished naturally. max_tokens means you cut it off. There are other values (stop_sequence, tool_use, refusal and a couple more), but for a plain summarization job those two are the whole story.

My code never looked at stop_reason. It took the text, stripped whitespace, and moved on.

How I found all 143 truncated summaries

My first instinct was a dumb heuristic: flag any summary that does not end in ., !, ? or a closing quote. That found 151.

Luckily I had been dumping every raw response to disk as JSON (the one good decision in this story), so I could check the real signal:

truncated = [r for r in responses if r["stop_reason"] == "max_tokens"]
print(len(truncated))  # 143
Enter fullscreen mode Exit fullscreen mode

The punctuation heuristic had 8 false positives. Those were summaries that legitimately ended with a function name or a version string like v2.3.1, which my regex treated as unfinished. It also would have missed any truncation that happened to land right after a period. Lesson one: do not reverse-engineer a signal the API already gives you.

Which inputs got truncated the most?

Long inputs got truncated far more often, and that is the part that actually hurt. I bucketed the 1,600 issues by input token count:

Input size Issues Truncated Rate
Under 2k tokens 1,050 13 1.2%
2k to 8k tokens 430 79 18.4%
Over 8k tokens 120 51 42.5%
Total 1,600 143 8.9%

Why would a "write 2-4 sentences" prompt blow past 512 tokens? I read through the truncated outputs. Three patterns covered almost all of them:

  1. Long threads produced long summaries. An issue with 60 comments and three abandoned fixes made the model want to explain the whole saga. It wrote six or seven sentences, sometimes with a bulleted list of "related changes".
  2. Code leaked in. Issues with stack traces or config snippets often got a summary that quoted the offending line in a code block. Code is token-dense; a short YAML block can eat 150 tokens.
  3. Preambles. Some responses opened with "This issue concerns..." and a restatement of the title before getting to the point.

So the averages lied to me. My median summary was well under the limit, which is exactly why I never noticed. The truncations clustered on the gnarliest bugs, the ones with the longest discussions, which are precisely the entries a changelog reader cares about. The 1.2% bucket was mostly typo fixes. The 42.5% bucket was race conditions and data loss.

A silent failure that scales with importance is the worst kind.

Does raising max_tokens cost more money?

Raising max_tokens costs almost nothing by itself, because you are billed for output tokens actually generated, not for the ceiling you set. A summary that finishes at 290 tokens costs the same whether the limit was 512 or 4,096.

I reran the full 1,600 issues at max_tokens=2048 and measured:

  • Truncations: 0
  • Median output: 290 tokens
  • p95 output: 780 tokens
  • Longest output: 1,410 tokens
  • Total output tokens vs. the 512 run: +7.4%

That 7.4% is the 143 summaries finally getting to finish their sentences. I was paying for 512 tokens of half-summary on each of those anyway; the fix bought the other half.

The real cost of a high ceiling is latency and worst-case behavior. A model that rambles will ramble longer before you stop it. That is a prompt problem, and you should fix it in the prompt, not by guillotining the output.

How do you handle stop_reason max_tokens properly?

Check stop_reason on every response, escalate the limit on truncation, and raise an exception if the largest limit still truncates. Here is the version that runs now:

class Truncated(Exception):
    pass

def summarize(issue_text, limits=(1024, 2048, 4096)):
    for limit in limits:
        resp = client.messages.create(
            model=MODEL,
            max_tokens=limit,
            system=SUMMARY_PROMPT,
            messages=[{"role": "user", "content": issue_text}],
        )
        if resp.stop_reason != "max_tokens":
            return resp
        log.warning(
            "truncated at %d tokens, retrying (issue len=%d)",
            limit, len(issue_text),
        )
    raise Truncated(f"still truncated at {limits[-1]} tokens")
Enter fullscreen mode Exit fullscreen mode

A few things I learned getting here:

  • Start higher than you think. With 1,024 as the first step, only 2 of 1,600 issues ever needed the retry. The retry ladder is a safety net, not the main path.
  • Make the last step loud. If something truncates at 4,096 for a four-sentence summary, the model is doing something weird (pasting the whole stack trace, looping). I want that in an alert, not in a changelog.
  • Streaming has the same trap. If you stream, stop_reason arrives in the message_delta event near the end, after all your text deltas. With the Python SDK's client.messages.stream(...), call stream.get_final_message() and check .stop_reason there. Rendering deltas to a UI and never looking at the final event is the streaming version of my bug.
  • Fix the prompt too. I added "Do not quote code. Do not restate the title. Maximum four sentences." to the system prompt. The p95 output dropped from 780 to 410 tokens on the next rerun. The ceiling and the prompt are two different controls; I had been using one to do the job of the other.

Why JSON output would have saved me

Here is the irony. If I had asked for JSON, truncation would have been loud. A half-written {"summary": "Fixes a race condition in the fails json.loads instantly and the job crashes. I would have found this on night one.

Plain text has no closing brace. "Logged out when the" is a perfectly valid string. Free-form text output is where max_tokens truncation hides best, because nothing downstream has a reason to complain.

That does not mean "always use JSON". It means plain-text pipelines need an explicit check, because they will never generate an implicit one.

The checklist I now apply to every LLM call

  • Log stop_reason and usage.output_tokens for every request. Both, always.
  • Alert when the max_tokens rate goes above zero for jobs where truncation is never acceptable.
  • Bucket failure rates by input size. Averages hide the tail, and the tail is where the hard cases live.
  • Treat the max_tokens value in any copied example as a placeholder, not a recommendation.

So why did the Claude API cut off my summaries?

The Claude API cut off 143 of my 1,600 summaries because I set max_tokens=512, the model needed more than 512 output tokens for long, complex issues, and the API treats hitting that ceiling as a normal successful response, flagged only by stop_reason == "max_tokens". My code read the text and ignored stop_reason, so the truncation was invisible. Truncation hit 42.5% of my longest inputs versus 1.2% of short ones. Checking stop_reason, starting at 1,024 tokens with a retry ladder up to 4,096, and tightening the prompt dropped truncations to zero for a 7.4% increase in output tokens.


Written by the developer behind Preterview, an interview prep platform.

Top comments (0)