Abgeschnittenes LLM-JSON: Die finish_reason-Falle zwischen OpenAI und Vertex
Eine abgeschnittene LLM-JSON-Antwort warf in der Produktion einen kryptischen Unterminated-string-Fehler. Der Fix war nicht ein höheres max_tokens — sondern ein finish_reason-Guard, der OpenAIs length und Vertex/Geminis MAX_TOKENS im selben Code-Pfad behandelt.
Abgeschnittenes LLM-JSON: Die finish_reason-Falle zwischen OpenAI und Vertex
Ein produktiver Endpoint lieferte plötzlich JSONDecodeError: Unterminated string starting at: line 47 column 12 (char 1832) an die Endnutzer aus. Der Endpoint erzeugt einen deutschen Interview-Vorbereitungs-Leitfaden — fünf Kategorien, Beispielfragen, vorgeschlagene Gesprächspunkte — indem er das Modell um ein strukturiertes JSON-Objekt bittet und es auf dem Rückweg parst. In der Entwicklung sahen die Ausgaben sauber aus. Im Staging ebenfalls. Der Fehler tauchte nur in der Produktion auf, nur auf Deutsch und nur bei den längeren Leitfäden.
Die Ursache war kein Parser-Bug. Das Modell hatte mitten in der Antwort den max_tokens-Deckel erreicht und das JSON in der Mitte gekappt. Der Fix umfasste fünf Zeilen und einen parametrisierten Test. Das Verständnis — warum das an unseren Tests vorbeischlüpfte, warum unser Guard für abgeschnittenes LLM-JSON auf Vertex AI nicht auslöste, obwohl er auf OpenAI ansprang — kostete den Rest des Nachmittags. Das ist die Aufzeichnung.
Der Bug zeigte sich in Wield, unserem Recruiting-Pipeline-Produkt (intern die cvflow-Codebasis), wo der Interview-Prep-Service ein LLM auffordert, einen JSON-Leitfaden von rund 6'000 bis 8'000 Output-Token zu produzieren. Dasselbe Muster lauert in jedem Python-Service, der per openai.AsyncOpenAI gegen einen Vertex- oder OpenRouter-kompatiblen Endpoint mit mehreren LLM-Anbietern spricht. Wer die Truncation-Erkennung nicht zentralisiert, bekommt im Error-Tracker eine lange Schleppe mysteriöser JSON-Parse-Fehler — alle aus demselben Code-Pfad, keiner auf Knopfdruck reproduzierbar.
Warum ein gesunder LLM-Call kaputtes JSON zurückgibt
Der Kontrakt für Chat Completions mit Structured Output ist geradlinig: Frag das Modell per response_format={"type": "json_object"} nach einem JSON-Objekt, erhalte etwas, womit json.loads umgehen kann. Was der Kontrakt nicht verspricht — und was eine erstaunliche Menge Code stillschweigend voraussetzt — ist, dass das Modell seine Antwort innerhalb des Token-Budgets unterbringt.
Wenn dem Modell die Token ausgehen, kommt die Antwort trotzdem zurück. Der HTTP-Status ist 200. Das Feld choices[0].message.content enthält einen String. Der String ist genau so lang, wie es max_tokens zuliess — er endet mitten im Zeichen, mitten im Key, mitten im Wert, mitten im Array. json.loads stirbt dann mit einer der unbrauchbaren Meldungen, die die Standard Library für feindliche Inputs reserviert: Unterminated string, Expecting value, Expecting property name enclosed in double quotes. Zeile und Spalte zeigen ins Innere der Modellantwort, die nicht in deinem Repo liegt — die Fehlermeldung sagt dir also nichts darüber, wie du das Problem beheben sollst.
Der Grund, weshalb das die Tests übersteht: In der Entwicklung schreibst du kurze Prompts, die kurze Antworten produzieren. Unsere deutschen Leitfäden trafen max_tokens=4096, weil deutsche Sätze rund 10 bis 15 Prozent länger sind als englische, der Prompt fünf statt drei Kategorien verlangte und das Modell mit Beispielen freigiebig umging. Die englischen Tests stiessen nie an den Deckel.
Das Signal, das du brauchst, liegt ein Feld daneben. Das OpenAI-SDK exponiert pro Choice ein finish_reason, dokumentiert hier. Bei sauberem Stopp ist es "stop". Bei einem Tool-Call "tool_calls". Bei einer abgeschnittenen Antwort "length". Jede abgeschnittene Antwort trägt die Diagnose mit sich; nichts im SDK zwingt dich, sie vor dem Parsen anzusehen.
Der erste Fix war nicht der eigentliche Fix
Der offensichtliche Zug ist, max_tokens so weit hochzuschrauben, bis der Leitfaden passt. Genau das haben wir gemacht — von 4096 auf 8192 — und das akute Sentry-Issue verstummte. Echte Leitfäden umfassen 6'000 bis 8'000 Output-Token, 8'192 lassen also für neunundneunzig Prozent der Fälle Luft. Wir hätten dort aufhören können. Haben wir nicht, weil derselbe Code-Pfad für ein Dutzend Prompt-Templates läuft, jedes mit eigenem max_tokens — wir hätten nur darauf gewartet, dass die nächste deutsche Edge-Case-Antwort wieder ankantet.
Der richtige Fix ist Zentralisierung. Die LLM-Provider-Registry — die dünne Schicht, die openai.AsyncOpenAI umhüllt und über Provider hinweg dispatcht — ist die einzige Stelle, durch die jeder Completion-Call läuft. Dort gehört der Truncation-Guard hin:
choice = response.choices[0] if response.choices else None
message = getattr(choice, "message", None) if choice else None
content = getattr(message, "content", None) if message else None
finish_reason = getattr(choice, "finish_reason", "unknown") if choice else "no_choices"
if content is None:
raise ServiceError(
service="llm_registry",
operation="complete",
message=f"LLM returned empty content ({provider}/{model}, finish_reason={finish_reason})",
)
if str(finish_reason or "").lower() in ("length", "max_tokens"):
raise ServiceError(
service="llm_registry",
operation="complete",
message=(
f"LLM response truncated at max_tokens={max_tokens} "
f"({provider}/{model}, finish_reason={finish_reason}). "
"Raise max_tokens or shorten the prompt."
),
)
Das verwandelt den JSONDecodeError 200 Meter weiter unten in einen typisierten ServiceError an der Grenze, mit Provider, Modell, max_tokens-Deckel und dem tatsächlichen finish_reason in der Meldung. Der Aufrufer — bei uns InterviewPrepService.generate_questions — kann ihn dem Nutzer als «der Leitfaden passte nicht, versuche es mit weniger Kategorien» präsentieren statt mit «Unterminated string». Entscheidend: Das partielle JSON erreicht json.loads gar nicht erst; wir scheitern auf der Schicht, die den Kontext zur Fehlerbehebung hat.
Derselbe Reflex zeigt sich in unserem Beitrag zu FastAPI Rate-Limit-Headern: Wenn eine Middleware-Schicht ihr Verhalten still umschaltet, je nachdem welche Werte sie in kwargs findet, landet der Bug immer zwei Abstraktionsebenen tiefer als die Ursache. Das Signal an der Aufrufstelle abzufangen, mit dem Kontext der Aufrufstelle, ist günstiger als Reverse-Engineering aus einem Stacktrace.
Der Cross-Provider-Treffer, der den Guard fast zu totem Code machte
Wir haben den Guard deployt. Sentry blieb einen Tag still. Dann kam derselbe Unterminated string-Fehler zurück, vom selben Endpoint, bei denselben deutschen Leitfäden.
Der strikte Check lautete finish_reason == "length". Diese Zeichenkette ist das Literal aus den OpenAI Chat Completions. Unser Produktionspfad läuft nicht auf OpenAI. Wir haben den direkten OpenAI-Provider vor Monaten entfernt und auf Vertex AI für Gemini konsolidiert. Der Vertex-Endpoint spricht das OpenAI-kompatible Protokoll über Googles Shim — was für fünfundneunzig Prozent der Felder grossartig ist, beim finish_reason aber die native Enum durchsickern lässt. Geminis Enum — dokumentiert in der Vertex AI Gemini API-Referenz — verwendet MAX_TOKENS, nicht length. Das Compat-Shim übersetzt das nicht. Unser Guard verglich gegen einen String, den der Produktionspfad nie aussendet.
Das ist der Fehlermodus, der vendor-übergreifenden LLM-Code gefährlich macht. Die Kompatibilitätsschicht ist opt-in: Sie übersetzt die meisten Felder in OpenAI-Formen, lässt den Rest als native Werte stehen und sagt dir nicht, was was ist. Wer dem Wrapper Ende-zu-Ende vertraut, schreibt Code gegen ein synthetisches Interface, das kein realer Anbieter produziert. Die Unit-Tests laufen durch, weil die Mock-Library zurückgibt, was du als Literal hingeschrieben hast.
Der echte Check ist case-insensitive gegen beide Literale:
if str(finish_reason or "").lower() in ("length", "max_tokens"):
...
"MAX_TOKENS" von Vertex, "max_tokens" von einer forwardkompatiblen OpenRouter-Route, "Length" von einem zukünftigen SDK, das seine Enums in Title-Case casest, "length" von OpenAI selbst — alle kollabieren in denselben Branch. Der Regressionstest ist über alle vier Schreibweisen parametrisiert, was die einzige Möglichkeit ist, Cross-Provider-Coverage ohne vier separate Fixtures festzuzurren:
@pytest.mark.parametrize(
"finish_reason",
["length", "MAX_TOKENS", "max_tokens", "Length"],
)
async def test_complete_raises_when_response_truncated_at_max_tokens(finish_reason):
...
Test-Parametrisierung ist die kleinstmögliche Versicherung gegen die kuriose Enum-Schreibweise des nächsten Anbieters. Wer nur gegen das Literal des aktuellen Providers testet, schreibt einen Guard, den die nächste Migration stillschweigend deaktiviert.
Drei Punkte für deinen eigenen LLM-Wrapper
Die Schlagzeile ist eine Zeile: Prüfe finish_reason, bevor du parst. Die Gründe, weshalb das verallgemeinerbar ist, sind den längeren Text wert.
Zentralisiere den Truncation-Guard an der Provider-Grenze. Jeder Completion-Call läuft durch einen einzigen Wrapper. Wer den Guard pro Aufrufer einbaut, hat zwanzig Implementierungen, zwanzig Testdateien — und eine davon ist immer veraltet. Wer ihn im Wrapper platziert, hat einen einzigen Regressionstest, der jedes Prompt-Template absichert, das du je ausrollst. Dasselbe Prinzip steckt hinter dem Schema-Level-Tool-Filtering, über das wir in AI-Agent Approval Gates in Next.js geschrieben haben: Die Policy gehört an die Grenze, nicht an jede einzelne Aufrufstelle.
Behandle das OpenAI-Kompat-Shim als undichte Abstraktion. Vertex, OpenRouter, Azure OpenAI und die meisten «OpenAI-kompatiblen» Endpoints übersetzen die bequemen Felder und lassen den Rest durchsickern. finish_reason ist eines der undichtesten Felder. usage.prompt_tokens versus usage.input_tokens ebenfalls. Auditiere jedes Feld, auf das dein Code verzweigt. Der günstige Fix: an der Grenze normalisieren — Enum in Kleinbuchstaben, Token-Felder aliasen — damit der Downstream-Code nicht wissen muss, mit welchem Anbieter er spricht.
Parametrisiere den Test, nicht das Literal. Cross-Provider-Regressionen sind fast immer Casing- oder Namensunterschiede. Ein pytest.mark.parametrize über ["length", "MAX_TOKENS", "max_tokens", "Length"] sind drei Zeilen Testcode und decken die gesamte Menge plausibler Anbietervariationen ab. Fehlgeschlagene Tests sind einfacher zu beheben als mysteriöse Produktionsfehler, und die Test-Fixtures dienen als Dokumentation für die nächste Entwicklerin, die sich fragt, warum der Vergleich case-insensitive ist.
Dasselbe Muster, das unseren deutschen Interview-Leitfaden zerschoss, ist das Muster, das jeden Structured-Output-Workload zerschiesst, der Anbietergrenzen überschreitet. Wer den Datenpfad für ein LLM-Feature plant und vor dem Produktivgang ein zweites Augenpaar auf Grenzcode, Wrapper-Schicht und Truncation-Behandlung möchte — buche einen kostenlosen AI Potenzial-Check — oder lies, wie wir über die Skalierung von AI-Agenten vom Pilot zur Produktion denken, für den breiteren architektonischen Kontext.
wield · Die Recruiting‑Pipeline, die mit deinem Volumen mitskaliert.
CV‑Pipeline mit KI‑gestützter Dossier‑Generierung und Bewertung. Für Recruiter, die hundert Bewerbungen in einer Stunde sortieren — ohne Qualität zu verlieren.