Du bad modellen om et JSON-objekt med fakturadata. Prompten var klar: "Return only valid JSON. No explanation." Det der kom tilbage var en markdown-code-fence, to sætningers kommentar, et JSON-objekt — og så en hjælpsom note i bunden, der forklarede hvert felt. I produktion, kl. 02.00, med en kundes datapipeline standset. Hvis du bygger noget ovenpå LLM-API'er, kender du allerede den smerte. LLM'er er ikke JSON-serialisere. De er tekstgeneratorer, der som regel producerer gyldig JSON — indtil de ikke gør det. Denne artikel dækker de fem måder de ødelægger den på, og de kamptestede mønstre til at håndtere hver af dem.

De 5 måder LLM'er ødelægger JSON

Det her er ikke edge cases. Hver eneste af disse vil ske for dig i produktion, som regel lige i det øjeblik du holder op med at tjekke for dem.

  • Markdown-code-fences — Modellen pakker JSON'en ind i ```json\n...\n```, fordi dens træningsdata er fulde af docs og README-filer, der præsenterer JSON på den måde.
  • Efterfølgende kommentarer — Modellen tilføjer en sætning eller et afsnit efter den afsluttende krøllede parentes: "Note: the total field is in USD."
  • Truncation — Lange outputs bliver skåret af midt i et objekt, når svaret rammer tokengrænsen, hvilket efterlader dig med strukturelt ødelagt JSON uden afsluttende parenteser.
  • Hallucinerede nøgler — Modellen opfinder feltnavne, der ikke er i dit skema. Du bad om invoice_number, du fik invoiceNumber, invoice_no og ref_id — nogle gange i samme svar.
  • Forkerte typer — Tal kommer som strenge ("49.99" i stedet for 49.99), booleans som "true", arrays som kommasepareret strenge. Type-coercion-bugs i forklædning.

Mønster 1: Strip markdown-code-fences

Dette er den mest almindelige fejl og den letteste at fixe. En simpel regex stripper fencen uanset om sprogtag'et er json, JSON eller helt mangler. Kør den før al anden bearbejdning — den koster ingenting og forhindrer en stor klasse af fejl.

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

Mønster 2: Udtræk JSON med regex

Når modellen tilføjer tekst før eller efter JSON-objektet — "Here is the extracted data:", "Let me know if you need changes." — er det ikke nok at strippe fences. Du skal finde det yderste {...}-blok og trække det ud. Tricket er at bruge et greedy match, der håndterer nestede objekter korrekt. Bemærk at denne tilgang håndterer objekter ({}); hvis dit skema er et array, byt tegnklassen tilsvarende.

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")

Mønster 3: Brug json-repair til strukturelle fejl

Truncation og mindre strukturelle fejl — en manglende afsluttende parentes, en uciteret nøgle, et efterfølgende komma — er hvor regex-udtræk kommer til kort. json-repair- biblioteket blev bygget præcis til det. Det anvender en serie heuristikker for at genoprette så meget gyldig struktur som muligt fra ødelagt JSON, på samme måde som browsere tolererer malformet HTML. Installer det med pip install json-repair, og smid det ind i din parse-pipeline som sidste forsvarslinje, før du giver op på et svar.

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}]}
Manuelt debug-tip: Når du undersøger et specifikt ødelagt svar, indsæt det i JSON Fixer for at se præcis, hvad json-repair gør ved det — eller brug JSON Validator til at identificere den nøjagtige linje og tegnposition for syntaksfejlen, før du beslutter dig for at reparere eller prompte igen.

Mønster 4: Prøv igen med eksplicit prompting

Nogle gange er den bedste parser selve modellen. Hvis outputtet er mere ødelagt end json-repair kan fixe — hallucinerede nøgler, helt forkert struktur, et svar der er mere prosa end data — send det ødelagte output tilbage til modellen med parse-fejlen, og bed den rette sin egen fejl. Modeller er overraskende gode til dette. Hold retry-antallet lavt (2–3 max) og spor forsøg for at undgå uendelige løkker.

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")

Mønster 5: Spring parsning over — brug Structured Outputs i stedet

Hvis du kontrollerer modelkaldet og har råd til at bruge nyere API'er, eliminerer structured outputs det meste af denne kompleksitet helt. OpenAI Structured Outputs (tilgængeligt på GPT-4o og senere) og Geminis response schema begrænser begge modellens output på token-genereringsniveauet — det er matematisk umuligt for modellen at returnere et malformet JSON-objekt, fordi ugyldige tokens undertrykkes under decoding. Ulempen: du giver afkald på lidt model-kreativitet, og disse API'er koster lidt mere per kald. Til høj-volumen-udtrækspipelines er de som regel det værd.

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}")

En produktionsklar parser (Python)

Sådan ser en produktionsklar udtræksfunktion ud, når du kombinerer alle fire defensive mønstre i en enkelt utility. Det er den version, jeg faktisk kører i tjenester, der behandler tusindvis af LLM-svar om dagen. Den stripper fences, udtrækker JSON-substrengen, forsøger en ren parse, falder tilbage til json_repair, og validerer valgfrit mod et JSON Schema før retur. Hvis du ikke bruger structured outputs, er det her dit fundament.

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

Samme logik i JavaScript. Til reparationsskridtet er det nærmeste til json_repair JSON5 til tolerant parsning af næsten-gyldig JSON, eller du kan selv skrive en letvægts reparations-wrapper. Til klient-sideligt arbejde dækker JSON.parse() med et godt try/catch og en regex-fallback langt størstedelen af produktionstilfælde.

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

Afrunding

LLM'er ødelægger JSON på fem forudsigelige måder, og hver af dem har en forudsigelig fix. Markdown-fences og omgivende prosa er kosmetik — et par regexes håndterer dem pålideligt. Strukturel skade fra truncation eller mindre formateringsfejl er, hvad json_repair blev bygget til. Når strukturen er korrekt, men indholdet er forkert — dårlige nøgler, forkerte typer — er det et prompting-problem, og en retry-løkke med fejlmeddelelsen fodret tilbage til modellen er dit bedste værktøj. Og hvis du kan bruge Structured Outputs, gør det — det eliminerer problemet ved kilden frem for at behandle symptomerne. Til ad hoc- debugging når et specifikt svar opfører sig dårligt, vil JSON Fixer og JSON Formatter spare dig tid. Byg parse_llm_json- utilityen én gang, test den mod dine værste historiske svar, og gå videre — der er bedre problemer at bruge dine debug-timer på.