Du ba modellen om et JSON-objekt som inneholder fakturadata. Prompten var tydelig: "Return only valid JSON. No explanation." Det som kom tilbake var en markdown-code-fence, to setningers kommentar, et JSON-objekt — og så en hjelpsom note nederst som forklarte hvert felt. I produksjon, kl. 02.00, med en kundes datapipeline stanset. Hvis du bygger noe oppå LLM-API-er, kjenner du allerede denne smerten. LLM-er er ikke JSON-serialiserere. De er tekstgeneratorer som som regel produserer gyldig JSON — helt til de ikke gjør det. Denne artikkelen dekker de fem måtene de ødelegger den på, og de kamptestede mønstrene for å håndtere hver av dem.

De 5 måtene LLM-er ødelegger JSON

Dette er ikke edge case-er. Hver eneste av disse kommer til å skje deg i produksjon, som regel akkurat i det øyeblikket du slutter å sjekke etter dem.

  • Markdown-code-fences — Modellen pakker JSON-en inn i ```json\n...\n``` fordi treningsdataene dens er fulle av docs og README-filer som presenterer JSON på den måten.
  • Etterfølgende kommentarer — Modellen legger til en setning eller et avsnitt etter den avsluttende krøllparentesen: "Note: the total field is in USD."
  • Truncation — Lange outputs blir kuttet midt i et objekt når svaret treffer tokengrensen, noe som etterlater deg med strukturelt ødelagt JSON uten avsluttende parenteser.
  • Hallusinerte nøkler — Modellen finner opp feltnavn som ikke er i skjemaet ditt. Du ba om invoice_number, du fikk invoiceNumber, invoice_no og ref_id — noen ganger i samme svar.
  • Feil typer — Tall kommer som strenger ("49.99" i stedet for 49.99), booleans som "true", arrays som kommaseparerte strenger. Type-coercion-bugs i forkledning.

Mønster 1: Stripp markdown-code-fences

Dette er den vanligste feilen og den enkleste å fikse. En enkel regex stripper fencen uavhengig av om språktag-en er json, JSON, eller mangler helt. Kjør den før all annen bearbeiding — den koster ingenting og forhindrer en stor klasse feil.

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: Trekk ut JSON med regex

Når modellen legger til tekst før eller etter JSON-objektet — "Here is the extracted data:", "Let me know if you need changes." — er det ikke nok å stripe fences. Du må finne det ytterste {...}-blokken og trekke det ut. Trikset er å bruke et grådig match som håndterer nestede objekter riktig. Merk at denne tilnærmingen håndterer objekter ({}); hvis skjemaet ditt er et array, bytt 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: Bruk json-repair for strukturelle feil

Truncation og mindre strukturelle feil — en manglende avsluttende parentes, en usitert nøkkel, et etterfølgende komma — er der regex-uttrekk kommer til kort. json-repair- biblioteket ble bygget nettopp for dette. Det bruker en rekke heuristikker for å gjenopprette så mye gyldig struktur som mulig fra ødelagt JSON, på samme måte som nettlesere tolererer ugyldig HTML. Installer det med pip install json-repair, og slipp det inn i parse-pipelinen din som siste forsvarslinje før du gir opp 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-tips: Når du undersøker et spesifikt ødelagt svar, lim det inn i JSON Fixer for å se nøyaktig hva json-repair gjør med det — eller bruk JSON Validator for å identifisere nøyaktig linje- og tegnposisjon for syntaksfeilen før du bestemmer deg for å reparere eller prompte på nytt.

Mønster 4: Prøv igjen med eksplisitt prompting

Noen ganger er den beste parseren selve modellen. Hvis outputtet er ødelagt utover det json-repair kan fikse — hallusinerte nøkler, helt feil struktur, et svar som er mer prosa enn data — send det ødelagte outputtet tilbake til modellen med parse-feilen og be den fikse sin egen feil. Modeller er overraskende gode på dette. Hold retry-antallet lavt (2–3 maks) og spor forsøk for å unngå 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: Hopp over parsing — bruk Structured Outputs i stedet

Hvis du kontrollerer modell-kallet og har råd til å bruke nyere API-er, eliminerer structured outputs mesteparten av denne kompleksiteten helt. OpenAI Structured Outputs (tilgjengelig på GPT-4o og nyere) og Geminis response schema begrenser begge modellens output på token-genereringsnivået — det er matematisk umulig for modellen å returnere et ugyldig JSON-objekt fordi ugyldige tokens undertrykkes under dekoding. Ulempen: du gir opp litt av modellens kreativitet, og disse API-ene koster litt mer per kall. For høyvolums-ekstraksjonspipelines er de som regel verdt det.

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 produksjonsklar parser (Python)

Slik ser en produksjonsklar ekstraksjonsfunksjon ut når du kombinerer alle fire defensive mønstre til ett verktøy. Dette er versjonen jeg faktisk kjører i tjenester som behandler tusenvis av LLM-svar per dag. Den stripper fences, trekker ut JSON-substrengen, forsøker en ren parse, faller tilbake til json_repair, og validerer valgfritt mot et JSON Schema før retur. Hvis du ikke bruker structured outputs, er dette fundamentet ditt.

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-versjon

Samme logikk i JavaScript. For reparasjonstrinnet er det nærmeste tilsvarende json_repair JSON5 for tolerant parsing av nesten-gyldig JSON, eller du kan skrive en lettvekts reparasjons-wrapper selv. For arbeid på klientsiden dekker JSON.parse() med et godt try/catch og en regex-fallback den store majoriteten av produksjonstilfeller.

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

Oppsummering

LLM-er ødelegger JSON på fem forutsigbare måter, og hver enkelt har en forutsigbar fiks. Markdown-fences og omkringliggende prosa er kosmetikk — et par regex-er håndterer dem pålitelig. Strukturell skade fra truncation eller mindre formateringsfeil er det json_repair ble bygget for. Når strukturen er riktig, men innholdet er feil — dårlige nøkler, feil typer — er det et prompting-problem, og en retry-løkke med feilmeldingen matet tilbake til modellen er det beste verktøyet. Og hvis du kan bruke Structured Outputs, gjør det — det eliminerer problemet ved kilden i stedet for å behandle symptomene. For ad hoc- debugging når et spesifikt svar oppfører seg dårlig, vil JSON Fixer og JSON Formatter spare deg tid. Bygg parse_llm_json- verktøyet én gang, test det mot de verste historiske svarene dine, og gå videre — det finnes bedre problemer å bruke debug-timene dine på.