Stop-Reason max_tokens: 83 Berichte endeten lautlos mitten im JSON
Nutzer bemerkten abgeschnittene Reports erst spät, weil weder Fehlermeldung noch sichtbarer Hinweis erschien. Ursache war die Claude-Stop-Reason max_tokens, die Ausgaben still vorzeitig beendete.

Die Support-E-Mail war zwei Zeilen lang: „Mein Bericht bricht bei Frage 6 ab. Es gab 9 Fragen. Außerdem gibt es keinen „nächste Schritte“-Abschnitt?“
Ich öffnete den Bericht. Er sah in Ordnung aus. Saubere Formatierung, Bewertungen, schriftliches Feedback, keine Fehlermeldung. Er endete nur früh, wie ein Buch, bei dem die letzten drei Kapitel herausgerissen und der Einband wieder aufgeklebt wurden waren. Als Ursache stellte sich ein Feld heraus, das ich nie gelesen hatte: Claudes stop_reason. Wenn dort max_tokens steht, hat das Modell nicht abgeschlossen. Mein Code kümmerte sich nicht darum, und ein JSON-Reparatur-Helfer sorgte dafür, dass es auch niemand anderes bemerkte. Dies ist die Obduktion davon, wie Claude stop_reason max_tokens stillschweigend 83 Berichte abschnitt, und von den vier Änderungen, die dies auf null brachten.
Das System ist der Berichtsgenerator innerhalb von Preterview, einer Interview-Vorbereitungsplattform, die ich gebaut habe und betreibe (vollständige Offenlegung: ich habe sie gebaut). Es führt Sprachinterviews mit drei Interviewer-Stilen durch und sendet dann das vollständige Transkript an Claude mit der Bitte um einen strukturierten schriftlichen Bericht: Feedback pro Frage, Bewertungen nach Rubrik und nächste Schritte. Wie eine Sitzung aussieht, kann man auf preterview.com/en sehen.
Der Berichtsaufruf ist die eine Stelle, an der die Ausgabelänge tatsächlich stark variiert. Eine Sitzung mit 5 Fragen erzeugt einen kurzen Bericht. Eine Sitzung mit 9 Fragen und einem gesprächigen Kandidaten erzeugt einen langen. Ich setzte max_tokens=1500, als jede Sitzung 5 Fragen hatte, und vergaß dann, dass diese Zahl existierte.
stop_reason: „max_tokens“ bedeutet, dass Claude gestoppt hat, weil es das von Ihnen gesetzte max_tokens-Limit erreicht hat, nicht weil es fertig war. Der Text, den man zurückbekommt, ist ein Präfix dessen, was das Modell schreiben wollte. Bei Prosa ist das ein abruptes Ende. Bei JSON ist es ein Dokument mit ungeschlossenen Klammern.
Der erwünschte Wert ist „end_turn“: Das Modell hat von selbst abgeschlossen. Es gibt andere („tool_use“, „stop_sequence“), aber bei einem einfachen „Schreib mir einen JSON-Bericht“-Aufruf verdient alles andere als end_turn Verdacht.
So sah mein ursprünglicher Code ungefähr aus:
resp = client. messages. create ( model = MODEL, max_tokens = 1500, system = REPORT_SYSTEM_PROMPT, messages = [{ " role ": " user ", " content ": transcript_block }], ) raw = resp. content [ 0 ]. text data = json. loads ( repair_json ( raw )) # "nur für den Fall" report = Report. model_validate ( data ) Enter fullscreen mode Exit fullscreen mode Man beachte, was fehlt. Nichts liest resp.stop_reason. Und dieser repair_json-Aufruf ist der eigentliche Schurke.
Denn ich hatte den Parser in einen JSON-Reparatur-Helfer eingepackt (das Paket json_repair), Monate zuvor hinzugefügt, um gelegentliche überzählige Kommas oder abschweifende Markdown-Zäune zu überleben. Reparatur-Bibliotheken sind dafür gebaut, aus kaputtem JSON gültiges JSON zu machen. Ein abgeschnittenes Dokument ist einfach kaputtes JSON mit fehlendem Ende, also schließt es die offenen Strings, Arrays und Objekte und gibt etwas Parsebares zurück.
Die abgeschnittene Ausgabe sah am Ende so aus:
{ "q": 6, "feedback": "Strong STAR structure, but the result was vague. Next time quantify the" Enter fullscreen mode Exit fullscreen mode Nach der Reparatur hatte Frage 6 einen Feedback-String, der mitten im Satz endete, die Fragen 7 bis 9 existierten nicht, und das next_steps-Array war verschwunden. Dann validierte Pydantic es, weil ich next_steps einen Default von [] gegeben und das Feedback pro Frage zu einer Liste beliebiger Länge gemacht hatte. Jede Schicht tat ihren Job. Das Ergebnis war ein überzeugend falscher Bericht.
Von 83 abgeschnittenen Antworten „reparierte“ der Helfer 79. Nur 4 schlugen hart fehl, und zwar diejenigen, die mitten in einem Key starben, sodass der Reparierer es nicht erraten konnte. Mein Fehlermonitoring zeigte also 4 Fehler in drei Wochen, was nach Rauschen aussah. Die tatsächliche Fehlerrate war 20-mal höher.
Gefunden habe ich sie, indem ich stop_reason in meinen Logs zählte, die ich die ganze Zeit speicherte und nie las. Ich logge die rohen API-Antwort-Metadaten für jeden Aufruf, also gab eine einzige Abfrage über drei Wochen die Antwort:
Diese Aufteilung ist die ganze Geschichte. Das 1.500-Token-Limit war auf das Produkt zugeschnitten, das ich gestartet hatte, nicht auf das Produkt, das ich betrieb. Als ich längere Sitzungen hinzufügte, wuchs der Bericht bei etwa 4 von 10 langen Sitzungen über das Limit hinaus, und nichts in der Pipeline war darauf ausgelegt, das zu melden.
Am härtesten getroffen wurden die Nutzer, die die längsten und ernsthaftsten Übungssitzungen machten. Die Menschen, die am meisten in das Produkt investiert waren, bekamen die schlechtesten Berichte. Das traf mich.
Prüfe stop_reason, bevor du parst, bemiss max_tokens für den schlimmsten Fall, ordne dein Schema so, dass kritische Felder zuerst kommen, und repariere nur Ausgaben, die sauber abgeschlossen wurden. Ich habe alle vier umgesetzt.
resp = client. messages. create ( model = MODEL, max_tokens = 4096,...) if resp. stop_reason!= " end_turn ": raise TruncatedOutput ( f " stop_reason= { resp. stop_reason }, " f " output_tokens= { resp. usage. output_tokens } " ) raw = resp. content [ 0 ]. text data = json. loads ( raw ) Enter fullscreen mode Exit fullscreen mode TruncatedOutput löst einen Wiederholungspfad aus (dazu unten mehr) und löst einen Alarm aus. Ein abgeschnittener Bericht ist jetzt ein lauter Fehler statt einer stillen Lüge.
Ich hatte max_tokens wie ein Budget behandelt. Ist es aber nicht. Abgerechnet wird für tatsächlich generierte Output-Tokens, also kostet ein Limit von 4.096 bei einem Bericht, der 1.100 Tokens braucht, genau so viel wie ein Limit von 1.500. Das Limit ist ein Sicherheitsgeländer gegen außer Kontrolle geratene Ausgaben, keine Kostenkontrolle. Ich wählte 4.096, weil mein längster danach beobachteter Bericht bei p99 2.350 Tokens betrug, was echten Spielraum lässt.
3. Schema so umordnen, dass die Bewertungen zuerst kommen.
Claude schreibt JSON von oben nach unten. Was am Ende deines Schemas sitzt, wird abgeschnitten, wenn dir der Platz ausgeht. Meine ursprüngliche Reihenfolge war Zusammenfassung, Feedback pro Frage, Bewertungen, nächste Schritte. Die Bewertungen, der Teil, den Nutzer als Screenshot speichern und Woche für Woche vergleichen, waren in der Gefahrenzone.
Neue Reihenfolge: scores, summary, next_steps, dann per_question zuletzt. Falls jemals wieder etwas abgeschnitten wird, verliert der Nutzer Details zur letzten Frage, nicht seine Bewertung.
Ich behielt repair_json, aber es läuft nur, wenn stop_reason == "end_turn" und json.loads fehlschlägt. Dann korrigiert es Formatierungsunebenheiten aus einer vollständigen Antwort, was genau das ist, worin es gut ist. Es fasst eine abgebrochene Antwort nie an.
Stattdessen wird es als zwei kleinere Aufrufe statt eines größeren wiederholt. Der Wiederholungspfad fordert im ersten Aufruf Bewertungen, Zusammenfassung und nächste Schritte an, dann im zweiten separaten Aufruf das Feedback pro Frage für die zweite Hälfte der Fragen, und führt beides zusammen. Jede Hälfte passt bequem ins Limit, und die Aufteilung kostet etwa 4 Sekunden, was für einen Bericht, den Nutzer nach Sitzungsende lesen, in Ordnung ist.
Bisher ist der Wiederholungspfad 0-mal gelaufen. Über die nächsten 1.300 Berichte nach dem Fix war stop_reason bei jedem einzelnen end_turn. Der geteilte Aufruf existiert, weil „das kann nicht passieren“ genau das war, was ich über den ursprünglichen Bug glaubte.
Die Gesamtzahl der Output-Tokens stieg um etwa 5 %, ausschließlich weil die Berichte mit 8-9 Fragen jetzt fertiggestellt werden, statt früh abzubrechen. Die Latenz bei langen Berichten stieg um ungefähr die Zeit, die es braucht, um das fehlende Ende zu generieren. Keine Änderung bei kurzen Sitzungen. Ich schrieb außerdem den 61 Nutzern hinter den 83 abgeschnittenen Berichten eine E-Mail und regenerierte ihre Berichte aus den gespeicherten Transkripten, was ein Skript und einen unangenehmen Entschuldigungsabsatz kostete.
Claude gab bei 83 von 1.140 Berichten stop_reason: „max_tokens“ zurück, weil ein 1.500-Token-Limit, das für Sitzungen mit 5 Fragen gesetzt worden war, nicht zu Berichten mit 8-9 Fragen passte, und mein Code prüfte nie stop_reason. Ein JSON-Reparatur-Helfer schloss dann die Klammern der abgeschnittenen Ausgabe, sodass die kaputten Berichte geparst, validiert und ausgeliefert wurden, ohne dass ein Fehler auftrat. Die Lösung: Jede Antwort zurückweisen, bei der stop_reason nicht end_turn ist, max_tokens erhöhen (es kostet nichts, bis es genutzt wird), kritische Felder zuerst im Schema platzieren und JSON nur aus sauber abgeschlossenen Antworten reparieren.
Geschrieben von dem Entwickler hinter Preterview, einer Interview-Vorbereitungsplattform.
Automatisch übersetzt. Das Original auf Englisch ist unten aufklappbar.
Original (Englisch) anzeigen
The support email was two lines long: "My report stops at question 6. There were 9 questions. Also there's no 'next steps' section?"
I opened the report. It looked fine. Clean formatting, scores, written feedback, no error banner. It just ended early, like a book with the last three chapters torn out and the cover glued back on. The cause turned out to be one field I never read: Claude's stop_reason. When it says max_tokens, the model didn't finish. My code didn't care, and a JSON repair helper made sure nobody else would notice either. This is the autopsy of how Claude stop_reason max_tokens silently truncated 83 reports, and the four changes that took it to zero.
The system is the report generator inside Preterview, an interview prep platform I built and run (full disclosure: I built it). It runs voice interviews with three interviewer styles, then sends the full transcript to Claude and asks for a structured written report: per-question feedback, rubric scores, and next steps. You can see what a session looks like at preterview.com/en.
The report call is the one place where output length actually varies a lot. A 5-question session produces a short report. A 9-question session with a chatty candidate produces a long one. I set max_tokens=1500 back when every session had 5 questions, and then I forgot that number existed.
stop_reason: "max_tokens" means Claude stopped because it hit the max_tokens limit you set, not because it was done. The text you get back is a prefix of what the model intended to write. For prose, that's an abrupt ending. For JSON, it's a document with unclosed brackets.
The happy value is "end_turn": the model finished on its own. There are others ( "tool_use", "stop_sequence" ), but for a plain "write me a JSON report" call, anything other than end_turn deserves suspicion.
Here's roughly what my original code looked like:
resp = client. messages. create ( model = MODEL, max_tokens = 1500, system = REPORT_SYSTEM_PROMPT, messages = [{ " role ": " user ", " content ": transcript_block }], ) raw = resp. content [ 0 ]. text data = json. loads ( repair_json ( raw )) # "just in case" report = Report. model_validate ( data ) Enter fullscreen mode Exit fullscreen mode Notice what's missing. Nothing reads resp.stop_reason. And that repair_json call is the real villain.
Because I had wrapped the parser in a JSON repair helper (the json_repair package), added months earlier to survive the occasional trailing comma or stray markdown fence. Repair libraries are built to turn broken JSON into valid JSON. A truncated document is just broken JSON with a missing ending, so it closes the open strings, arrays and objects, and returns something parseable.
The truncated output looked like this at the tail:
{ "q": 6, "feedback": "Strong STAR structure, but the result was vague. Next time quantify the" Enter fullscreen mode Exit fullscreen mode After repair, question 6 had a feedback string ending mid-sentence, questions 7 to 9 didn't exist, and the next_steps array was gone. Then Pydantic validated it, because I'd given next_steps a default of [] and made per-question feedback a list of any length. Every layer did its job. The result was a confidently wrong report.
Out of 83 truncated responses, the repair helper "fixed" 79. Only 4 failed hard, and those were the ones that died mid-key in a way the repairer couldn't guess. So my error monitoring showed 4 failures in three weeks, which looked like noise. The real failure rate was 20 times higher.
I found them by counting stop_reason in my logs, which I was storing all along and never reading. I log the raw API response metadata for every call, so a single query over three weeks gave the answer:
That split is the whole story. The 1,500-token cap was sized for the product I launched, not the product I was running. When I added longer sessions, the report grew past the cap for about 4 in 10 long sessions, and nothing in the pipeline was designed to say so.
The users hit hardest were the ones who did the longest, most serious practice sessions. The people most invested in the product got the worst reports. That one stung.
Check stop_reason before you parse, size max_tokens for the worst case, order your schema so critical fields come first, and only repair output that finished cleanly. I shipped all four.
resp = client. messages. create ( model = MODEL, max_tokens = 4096,...) if resp. stop_reason!= " end_turn ": raise TruncatedOutput ( f " stop_reason= { resp. stop_reason }, " f " output_tokens= { resp. usage. output_tokens } " ) raw = resp. content [ 0 ]. text data = json. loads ( raw ) Enter fullscreen mode Exit fullscreen mode TruncatedOutput triggers a retry path (more on that below) and fires an alert. A truncated report is now a loud failure instead of a quiet lie.
I'd been treating max_tokens like a budget. It isn't. You're billed for output tokens actually generated, so a cap of 4,096 on a report that needs 1,100 tokens costs exactly what a cap of 1,500 does. The cap is a safety rail against runaway output, not a cost control. I picked 4,096 because my longest observed report afterward was 2,350 tokens at p99, which leaves real headroom.
3. Reorder the schema so scores come first.
Claude writes JSON top to bottom. Whatever sits at the end of your schema is what gets cut when you run out. My original order was summary, per-question feedback, scores, next steps. The scores, the part users screenshot and compare week to week, were in the danger zone.
New order: scores, summary, next_steps, then per_question last. If something ever truncates again, the user loses detail on the final question, not their score.
I kept repair_json, but it only runs when stop_reason == "end_turn" and json.loads fails. Then it's fixing formatting slop from a complete answer, which is what it's good at. It never touches a response that was cut off.
It gets retried as two smaller calls instead of one bigger one. The retry path asks for scores, summary and next steps in the first call, then per-question feedback for the second half of the questions in a separate call, and merges them. Each half fits comfortably in the cap, and the split adds about 4 seconds, which is fine for a report users read after the session ends.
So far the retry path has run 0 times. Over the next 1,300 reports after the fix, stop_reason was end_turn on every single one. The split call exists because "it can't happen" is exactly what I believed about the original bug.
Total output tokens went up about 5%, entirely because the 8-9 question reports now finish instead of stopping early. Latency on long reports rose by roughly the time it takes to generate the missing tail. No change on short sessions. I also emailed the 61 users behind the 83 truncated reports and regenerated their reports from stored transcripts, which took one script and an awkward apology paragraph.
Claude returned stop_reason: "max_tokens" on 83 of 1,140 reports because a 1,500-token cap set for 5-question sessions couldn't fit 8-9 question reports, and my code never checked stop_reason. A JSON repair helper then closed the brackets on the truncated output, so the broken reports parsed, validated and shipped without an error. The fix: reject any response where stop_reason isn't end_turn, raise max_tokens (it costs nothing until used), put critical fields first in the schema, and only repair JSON from responses that finished cleanly.
Written by the developer behind Preterview, an interview prep platform.