Du hast das Modell nach einem JSON-Objekt mit Rechnungsdaten gefragt. Der Prompt war klar: "Gib nur valides JSON zurück. Keine Erklärung." Was zurückkam, war ein Markdown-Code-Fence, zwei Sätze Kommentar, ein JSON-Objekt — und dann unten eine hilfsbereite Notiz, die jedes Feld erklärt. In Produktion, um 2 Uhr nachts, mit der Datenpipeline eines Kunden im Stillstand. Wenn du irgendetwas auf LLM-APIs baust, kennst du den Schmerz bereits. LLMs sind keine JSON-Serializer. Sie sind Text-Generatoren, die meistens valides JSON produzieren — bis sie es nicht tun. Dieser Artikel behandelt die fünf Wege, auf denen sie es kaputt machen, und die kampferprobten Patterns, um jeden davon zu behandeln.

Die 5 Wege, auf denen LLMs JSON kaputt machen

Das sind keine Edge Cases. Jeder einzelne davon wird dir in Produktion passieren, meistens genau in dem Moment, in dem du aufhörst, danach zu schauen.

  • Markdown-Code-Fences — Das Modell wickelt das JSON in ```json\n...\n```, weil seine Trainingsdaten voll mit Docs und README-Dateien sind, die JSON so präsentieren.
  • Nachgestellter Kommentar — Das Modell hängt einen Satz oder Absatz nach der schließenden Klammer an: "Hinweis: das Feld total ist in USD."
  • Truncation — Lange Outputs werden mitten im Objekt abgeschnitten, wenn die Response das Token-Limit trifft, und du bleibst mit strukturell kaputtem JSON ohne schließende Klammern zurück.
  • Halluzinierte Keys — Das Modell erfindet Feldnamen, die nicht in deinem Schema sind. Du hast nach invoice_number gefragt, bekommen hast du invoiceNumber, invoice_no und ref_id — manchmal in derselben Response.
  • Falsche Typen — Zahlen kommen als Strings an ("49.99" statt 49.99), Booleans als "true", Arrays als komma-separierte Strings. Type-Coercion-Bugs in Verkleidung.

Pattern 1: Markdown-Code-Fences entfernen

Das ist die häufigste Kaputtheit und die einfachste zu fixen. Eine simple Regex entfernt den Fence unabhängig davon, ob das Language-Tag json, JSON oder komplett weg ist. Lauf das vor jeder anderen Verarbeitung — kostet nichts und verhindert eine große Klasse von Fehlern.

python
import re

def strip_code_fences(text: str) -> str:
    """Remove markdown code fences from LLM output."""
    # Handles ```json, ```JSON, ``` (no lang tag), etc.
    pattern = r'^```(?:json|JSON)?\s*\n?(.*?)\n?```$'
    match = re.search(pattern, text.strip(), re.DOTALL)
    if match:
        return match.group(1).strip()
    return text.strip()

# Example: model returned a fenced block
raw = """
```json
{
  "invoice_number": "INV-2024-0192",
  "vendor": "Acme Supplies",
  "total": 1249.99,
  "currency": "USD"
}
```
"""

clean = strip_code_fences(raw)
invoice = json.loads(clean)  # now safe
js
function stripCodeFences(text) {
  // Handles ```json, ```JSON, bare ``` (no lang), etc.
  const match = text.trim().match(/^```(?:json|JSON)?\s*\n?([\s\S]*?)\n?```$/s);
  return match ? match[1].trim() : text.trim();
}

// raw response contains a triple-backtick fence (shown here as a single-quoted string)
const raw = '```json\n{\n  "invoice_number": "INV-2024-0192",\n  "vendor": "Acme Supplies",\n  "total": 1249.99\n}\n```';

const clean = stripCodeFences(raw);
const invoice = JSON.parse(clean); // safe

Pattern 2: JSON mit Regex extrahieren

Wenn das Modell Text vor oder nach dem JSON-Objekt hinzufügt — "Hier sind die extrahierten Daten:", "Sag Bescheid, wenn du Änderungen brauchst." — reicht Fence-Stripping nicht. Du musst den äußersten {...}-Block finden und ihn rausziehen. Der Trick ist, einen Greedy-Match zu benutzen, der verschachtelte Objekte korrekt behandelt. Beachte, dass dieser Ansatz Objekte ({}) behandelt; wenn dein Schema ein Array ist, tausch die Character-Class entsprechend.

python
import re
import json

def extract_json_object(text: str) -> str | None:
    """
    Extract the first complete JSON object from a string that may
    contain surrounding prose or commentary.
    """
    # Find the first { and last } to grab the outermost object
    match = re.search(r'\{.*\}', text, re.DOTALL)
    if not match:
        # Fall back to array extraction if no object found
        match = re.search(r'\[.*\]', text, re.DOTALL)
    return match.group(0) if match else None

# Model returned prose + JSON + footnote
raw_response = """
Based on the document you provided, here is the structured data:

{
  "invoice_number": "INV-2024-0192",
  "vendor": "Acme Supplies",
  "line_items": [
    {"description": "Office chairs", "qty": 4, "unit_price": 299.99},
    {"description": "Standing desk", "qty": 1, "unit_price": 649.99}
  ],
  "total": 1849.95
}

Note: unit prices are pre-tax. Let me know if you need the tax breakdown.
"""

json_str = extract_json_object(raw_response)
if json_str:
    invoice = json.loads(json_str)
    print(f"Parsed invoice: {invoice['invoice_number']}")
else:
    raise ValueError("No JSON object found in LLM response")

Pattern 3: json-repair für strukturelle Fehler nutzen

Truncation und kleinere strukturelle Fehler — eine fehlende schließende Klammer, ein Key ohne Quotes, ein Trailing Comma — sind Stellen, wo Regex-Extraktion zu kurz greift. Die Library json-repair wurde genau dafür gebaut. Sie wendet eine Serie von Heuristiken an, um so viel valide Struktur wie möglich aus kaputtem JSON zu retten, ähnlich wie Browser malformed HTML tolerieren. Installier sie mit pip install json-repair, und pack sie als letzte Verteidigungslinie in deine Parsing-Pipeline, bevor du eine Response aufgibst.

python
import json
import json_repair  # pip install json-repair

def parse_with_repair(text: str) -> dict | list | None:
    """
    Attempt standard parse first; fall back to json_repair for
    structurally broken responses (truncation, missing braces, etc.).
    """
    # First pass: clean up fences and extract the JSON substring
    cleaned = extract_json_object(strip_code_fences(text))
    if not cleaned:
        return None

    # Second pass: try the fast standard parse
    try:
        return json.loads(cleaned)
    except json.JSONDecodeError:
        pass

    # Third pass: let json_repair reconstruct broken structure
    try:
        repaired = json_repair.repair_json(cleaned, return_objects=True)
        return repaired if repaired else None
    except Exception:
        return None

# Works even on truncated output from a token-limited response
truncated = """
{
  "invoice_number": "INV-2024-0192",
  "vendor": "Acme Supplies",
  "line_items": [
    {"description": "Office chairs", "qty": 4
"""

result = parse_with_repair(truncated)
# Returns {"invoice_number": "INV-2024-0192", "vendor": "Acme Supplies",
#          "line_items": [{"description": "Office chairs", "qty": 4}]}
Manueller Debug-Tipp: Wenn du eine bestimmte kaputte Response untersuchst, paste sie in den JSON Fixer, um genau zu sehen, was json-repair damit macht — oder nutz den JSON Validator, um die genaue Zeile und Zeichenposition des Syntaxfehlers zu identifizieren, bevor du entscheidest, ob du reparieren oder neu prompten willst.

Pattern 4: Retry mit explizitem Prompting

Manchmal ist der beste Parser das Modell selbst. Wenn der Output verzerrter ist, als json-repair beheben kann — halluzinierte Keys, komplett falsche Struktur, eine Response, die mehr Prosa als Daten ist — schick den kaputten Output mit dem Parse-Fehler zurück an das Modell und bitte es, seinen eigenen Fehler zu korrigieren. Modelle sind überraschend gut darin. Halte den Retry-Count niedrig (max 2–3) und trackt Versuche, um Endlosschleifen zu vermeiden.

python
import json
from openai import OpenAI

client = OpenAI()

def call_model(messages: list) -> str:
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=messages
    )
    return response.choices[0].message.content

def extract_invoice_data(document_text: str, max_retries: int = 3) -> dict:
    """Extract structured invoice data with automatic retry on parse failure."""
    system_prompt = """Extract invoice data and return ONLY a JSON object with these fields:
{
  "invoice_number": string,
  "vendor": string,
  "issue_date": string (YYYY-MM-DD),
  "due_date": string (YYYY-MM-DD) or null,
  "line_items": [{"description": string, "qty": number, "unit_price": number}],
  "subtotal": number,
  "tax": number,
  "total": number,
  "currency": string (ISO 4217)
}
Return ONLY the JSON object. No markdown. No explanation."""

    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": f"Extract invoice data from:\n\n{document_text}"}
    ]

    for attempt in range(max_retries):
        raw = call_model(messages)

        try:
            cleaned = extract_json_object(strip_code_fences(raw))
            return json.loads(cleaned)
        except (json.JSONDecodeError, TypeError) as e:
            if attempt == max_retries - 1:
                raise ValueError(
                    f"Failed to parse JSON after {max_retries} attempts. "
                    f"Last error: {e}. Last response: {raw[:200]}"
                )

            # Feed the error back — the model often corrects itself
            messages.append({"role": "assistant", "content": raw})
            messages.append({
                "role": "user",
                "content": (
                    f"That response caused a JSON parse error: {e}\n"
                    f"Please return ONLY a valid JSON object. No markdown fences, "
                    f"no commentary, just the raw JSON."
                )
            })

    raise ValueError("Unexpected exit from retry loop")

Pattern 5: Parsing überspringen — stattdessen Structured Outputs nutzen

Wenn du den Model-Call kontrollierst und dir leisten kannst, neuere APIs zu nutzen, eliminieren structured outputs den Großteil dieser Komplexität komplett. OpenAI Structured Outputs (verfügbar auf GPT-4o und später) und Geminis Response-Schema beschränken beide den Output des Modells auf Token-Generierungsebene — es ist mathematisch unmöglich für das Modell, ein malformed JSON-Objekt zurückzugeben, weil invalide Tokens beim Decoding unterdrückt werden. Der Nachteil: du gibst etwas Modell-Kreativität auf und diese APIs kosten pro Call etwas mehr. Für High-Volume-Extraktions-Pipelines lohnt es sich normalerweise.

python
from pydantic import BaseModel
from openai import OpenAI

client = OpenAI()

class LineItem(BaseModel):
    description: str
    qty: int
    unit_price: float

class Invoice(BaseModel):
    invoice_number: str
    vendor: str
    issue_date: str          # YYYY-MM-DD
    total: float
    currency: str            # ISO 4217
    line_items: list[LineItem]

def extract_invoice_structured(document_text: str) -> Invoice:
    """
    Extract invoice using OpenAI Structured Outputs.
    The API guarantees the response matches the Invoice schema —
    no manual parsing or repair needed.
    """
    completion = client.beta.chat.completions.parse(
        model="gpt-4o-2024-08-06",
        messages=[
            {
                "role": "system",
                "content": "Extract invoice data from the provided document."
            },
            {"role": "user", "content": document_text}
        ],
        response_format=Invoice
    )
    return completion.choices[0].message.parsed

invoice = extract_invoice_structured(document_text)
print(f"Invoice {invoice.invoice_number}: ${invoice.total:.2f} {invoice.currency}")

Ein produktionsreifer Parser (Python)

So sieht eine produktionsreife Extraktionsfunktion aus, wenn du alle vier defensiven Patterns in einem einzigen Utility kombinierst. Das ist die Version, die ich tatsächlich in Services laufen lasse, die Tausende von LLM-Responses pro Tag verarbeiten. Sie entfernt Fences, extrahiert den JSON-Substring, versucht einen sauberen Parse, fällt auf json_repair zurück, und validiert optional gegen ein JSON Schema, bevor sie zurückgibt. Wenn du keine Structured Outputs nutzt, ist das deine Grundlage.

python
import re
import json
from typing import Any
import json_repair        # pip install json-repair
import jsonschema         # pip install jsonschema

def strip_code_fences(text: str) -> str:
    match = re.search(r'^```(?:\w+)?\s*\n?(.*?)\n?```$', text.strip(), re.DOTALL)
    return match.group(1).strip() if match else text.strip()

def extract_json_substring(text: str) -> str | None:
    match = re.search(r'\{.*\}', text, re.DOTALL) or re.search(r'\[.*\]', text, re.DOTALL)
    return match.group(0) if match else None

def parse_llm_json(text: str, schema: dict | None = None) -> Any:
    """
    Robustly parse JSON from LLM output.

    Steps:
      1. Strip markdown code fences
      2. Extract outermost JSON object/array (handles surrounding prose)
      3. Fast-path: standard json.loads
      4. Slow-path: json_repair for structurally broken responses
      5. Optional: validate against a JSON Schema

    Args:
        text:   Raw text returned by the LLM
        schema: Optional JSON Schema dict to validate the parsed result

    Returns:
        Parsed Python object (dict or list)

    Raises:
        ValueError: If parsing fails after all recovery attempts
        jsonschema.ValidationError: If schema validation fails
    """
    if not text or not text.strip():
        raise ValueError("LLM returned an empty response")

    # Step 1 — strip fences
    text = strip_code_fences(text)

    # Step 2 — extract JSON substring (handles prose before/after)
    json_str = extract_json_substring(text)
    if not json_str:
        raise ValueError(f"No JSON object or array found in response: {text[:200]!r}")

    # Step 3 — standard parse (fast path, no overhead)
    parsed = None
    try:
        parsed = json.loads(json_str)
    except json.JSONDecodeError as original_error:
        # Step 4 — repair and retry
        try:
            repaired = json_repair.repair_json(json_str, return_objects=True)
            if repaired is not None:
                parsed = repaired
        except Exception as repair_error:
            raise ValueError(
                f"JSON parse failed and repair also failed.\n"
                f"Parse error: {original_error}\n"
                f"Repair error: {repair_error}\n"
                f"Input (first 500 chars): {json_str[:500]!r}"
            ) from original_error

    if parsed is None:
        raise ValueError(f"Parsing returned None for input: {json_str[:200]!r}")

    # Step 5 — optional schema validation
    if schema is not None:
        jsonschema.validate(parsed, schema)  # raises ValidationError on mismatch

    return parsed


# --- Usage ---

INVOICE_SCHEMA = {
    "type": "object",
    "required": ["invoice_number", "vendor", "total"],
    "properties": {
        "invoice_number": {"type": "string"},
        "vendor":         {"type": "string"},
        "total":          {"type": "number"},
        "currency":       {"type": "string"},
        "line_items":     {"type": "array"}
    }
}

llm_response = """
Sure! Here's the structured data:

```json
{
  "invoice_number": "INV-2024-0192",
  "vendor": "Acme Supplies",
  "total": 1849.95,
  "currency": "USD",
  "line_items": [
    {"description": "Office chairs", "qty": 4, "unit_price": 299.99}
  ]
}
```

Let me know if you need any changes!
"""

invoice = parse_llm_json(llm_response, schema=INVOICE_SCHEMA)
print(f"Vendor: {invoice['vendor']}, Total: ${invoice['total']}")

JavaScript-Version

Dieselbe Logik in JavaScript. Für den Repair-Schritt ist das nächste Äquivalent zu json_repair JSON5 für tolerantes Parsing von fast-validem JSON, oder du schreibst selbst einen leichten Repair-Wrapper. Für Client-Side-Arbeit deckt JSON.parse() mit einem guten try/catch und einem Regex-Fallback die große Mehrheit der Produktionsfälle ab.

js
// npm install json5   (optional — for tolerant parsing of near-valid JSON)
import JSON5 from 'json5';

function stripCodeFences(text) {
  const match = text.trim().match(/^```(?:\w+)?\s*\n?([\s\S]*?)\n?```$/);
  return match ? match[1].trim() : text.trim();
}

function extractJsonSubstring(text) {
  // Greedy match for outermost object or array
  const objectMatch = text.match(/\{[\s\S]*\}/);
  if (objectMatch) return objectMatch[0];
  const arrayMatch = text.match(/\[[\s\S]*\]/);
  return arrayMatch ? arrayMatch[0] : null;
}

/**
 * Robustly parse JSON from LLM output.
 * Steps: strip fences → extract substring → JSON.parse → JSON5 fallback
 *
 * @param {string} text - Raw LLM response text
 * @returns {object|Array} Parsed JavaScript value
 * @throws {Error} If all parse attempts fail
 */
function parseLlmJson(text) {
  if (!text || !text.trim()) {
    throw new Error('LLM returned an empty response');
  }

  // Step 1 — strip markdown fences
  let cleaned = stripCodeFences(text);

  // Step 2 — extract JSON substring (skip surrounding prose)
  const jsonStr = extractJsonSubstring(cleaned);
  if (!jsonStr) {
    throw new Error(`No JSON object or array found in response: ${text.slice(0, 200)}`);
  }

  // Step 3 — standard JSON.parse (fast path)
  try {
    return JSON.parse(jsonStr);
  } catch (stdError) {
    // Step 4 — JSON5 tolerant parser (handles trailing commas, unquoted keys, etc.)
    try {
      return JSON5.parse(jsonStr);
    } catch (json5Error) {
      throw new Error(
        `JSON parse failed.\nStandard error: ${stdError.message}\nJSON5 error: ${json5Error.message}\nInput: ${jsonStr.slice(0, 300)}`
      );
    }
  }
}

// --- Usage ---

const llmResponse = `
Here is the product data you requested:

\`\`\`json
{
  "product_id": "SKU-8821-B",
  "name": "Ergonomic Office Chair",
  "price": 299.99,
  "in_stock": true,
  "tags": ["furniture", "ergonomic", "office"]
}
\`\`\`

Let me know if you need the full catalog!
`;

const product = parseLlmJson(llmResponse);
console.log(`Product: ${product.name} — $${product.price}`);
// → Product: Ergonomic Office Chair — $299.99

Fazit

LLMs machen JSON auf fünf vorhersehbare Arten kaputt, und jede hat einen vorhersehbaren Fix. Markdown-Fences und umgebende Prosa sind kosmetisch — ein paar Regexes behandeln sie verlässlich. Struktureller Schaden durch Truncation oder kleinere Formatierungsfehler ist das, wofür json_repair gebaut wurde. Wenn die Struktur korrekt ist, aber der Inhalt falsch — schlechte Keys, falsche Typen — ist das ein Prompting-Problem, und eine Retry-Schleife mit zurückgegebener Fehlermeldung ans Modell ist dein bestes Werkzeug. Und wenn du Structured Outputs nutzen kannst, tu es — es eliminiert das Problem an der Quelle, statt Symptome zu behandeln. Für Ad-hoc- Debugging, wenn eine bestimmte Response sich danebenbenimmt, sparen dir der JSON Fixer und der JSON Formatter Zeit. Bau das parse_llm_json-Utility einmal, teste es gegen deine schlimmsten historischen Responses, und geh weiter — es gibt bessere Probleme, für die du deine Debugging-Stunden ausgeben kannst.