Du bad modellen om ett JSON-objekt som innehåller fakturadata. Prompten var tydlig: "Return only valid JSON. No explanation." Det som kom tillbaka var en markdown-code-fence, två meningars kommentar, ett JSON-objekt — och sedan en hjälpsam notering längst ner som förklarade varje fält. I produktion, kl 02.00, med en kunds datapipeline stopp. Om du bygger något ovanpå LLM-API:er känner du redan till den här smärtan. LLM:er är inte JSON-serialiserare. De är textgeneratorer som oftast producerar giltig JSON — tills de inte gör det. Den här artikeln täcker de fem sätten de bryter den på, och de beprövade mönstren för att hantera var och en.

De 5 sätten LLM:er bryter JSON

Det här är inte edge cases. Vart och ett av dessa kommer att hända dig i produktion, oftast i det ögonblick du slutar kolla efter dem.

  • Markdown-code-fences — Modellen slår in JSON:en i ```json\n...\n``` eftersom dess träningsdata är full av docs och README-filer som presenterar JSON på det sättet.
  • Efterföljande kommentarer — Modellen lägger till en mening eller ett stycke efter den avslutande klammerparentesen: "Note: the total field is in USD."
  • Truncation — Lång utdata kapas mitt i ett objekt när svaret slår i tokengränsen, vilket lämnar dig med strukturellt trasig JSON utan avslutande klammerparenteser.
  • Hallucinerade nycklar — Modellen hittar på fältnamn som inte finns i ditt schema. Du bad om invoice_number, du fick invoiceNumber, invoice_no och ref_id — ibland i samma svar.
  • Fel typer — Tal kommer som strängar ("49.99" istället för 49.99), booleans som "true", arrayer som komma-separerade strängar. Typkonverteringsbuggar i förklädnad.

Mönster 1: Strippa markdown-code-fences

Detta är det vanligaste brottet och det enklaste att fixa. En enkel regex strippar fence:en oavsett om språktaggen är json, JSON eller saknas helt. Kör den före all annan bearbetning — den kostar ingenting och förhindrar en stor klass av fel.

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

När modellen lägger till text före eller efter JSON-objektet — "Here is the extracted data:", "Let me know if you need changes." — räcker det inte att strippa fences. Du behöver hitta det yttersta {...}-blocket och dra ut det. Tricket är att använda en girig match som hanterar nästlade objekt korrekt. Notera att detta tillvägagångssätt hanterar objekt ({}); om ditt schema är en array, byt teckenklassen därefter.

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: Använd json-repair för strukturella fel

Truncation och mindre strukturella fel — en saknad avslutande klammerparentes, en ociterad nyckel, ett efterföljande komma — är där regex-extraktion inte räcker till. Biblioteket json-repair byggdes exakt för det här. Det applicerar en serie heuristiker för att återställa så mycket giltig struktur som möjligt från trasig JSON, på liknande sätt som webbläsare tolererar malformad HTML. Installera det med pip install json-repair, och släpp in det i din parse-pipeline som sista försvarslinjen innan du ger upp på ett 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}]}
Manuellt debug-tips: När du undersöker ett specifikt trasigt svar, klistra in det i JSON Fixer för att se exakt vad json-repair gör med det — eller använd JSON Validator för att identifiera den exakta raden och teckenpositionen för syntaxfelet innan du bestämmer dig för att reparera eller fråga modellen igen.

Mönster 4: Försök igen med explicit prompting

Ibland är den bästa parsern modellen själv. Om utdatan är så sönderslagen att json-repair inte kan fixa den — hallucinerade nycklar, helt fel struktur, ett svar som är mer prosa än data — skicka tillbaka det trasiga svaret till modellen med parse-felet och be den fixa sitt eget misstag. Modeller är förvånansvärt bra på det här. Håll retry-antalet lågt (max 2–3) och spåra försök för att undvika oändliga loopar.

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: Hoppa över parsning — använd Structured Outputs istället

Om du styr modell-anropet och kan använda nyare API:er eliminerar structured outputs det mesta av den här komplexiteten helt. OpenAI Structured Outputs (tillgängligt på GPT-4o och senare) och Geminis response schema begränsar båda modellens utdata på token-genereringsnivå — det är matematiskt omöjligt för modellen att returnera ett malformat JSON-objekt eftersom ogiltiga tokens undertrycks under decoding. Nackdelen: du ger upp lite modell-kreativitet, och de här API:erna kostar lite mer per anrop. För högvolymiga extraktionspipelines är de oftast värda 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 produktionsklar parser (Python)

Så här ser en produktionsklar extraktionsfunktion ut när du kombinerar alla fyra defensiva mönstren till en enda utility. Det här är versionen jag faktiskt kör i tjänster som bearbetar tusentals LLM-svar per dag. Den strippar fences, extraherar JSON-substrängen, försöker en ren parse, faller tillbaka till json_repair, och validerar valfritt mot ett JSON Schema innan retur. Om du inte använder structured outputs är det här din grund.

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

Samma logik i JavaScript. För reparationssteget är den närmaste motsvarigheten till json_repair JSON5 för tolerant parsning av nära-giltig JSON, eller så kan du skriva en lättvikts-reparationswrapper själv. För klientsidesarbete täcker JSON.parse() med ett bra try/catch och en regex-fallback den stora majoriteten av produktionsfall.

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

Avslutningsvis

LLM:er bryter JSON på fem förutsägbara sätt, och vart och ett har en förutsägbar fix. Markdown-fences och omgivande prosa är kosmetik — ett par regexar hanterar dem pålitligt. Strukturell skada från truncation eller mindre formateringsfel är vad json_repair byggdes för. När strukturen är korrekt men innehållet är fel — dåliga nycklar, fel typer — är det ett promptingproblem, och en retry-loop med felmeddelandet matat tillbaka till modellen är ditt bästa verktyg. Och om du kan använda Structured Outputs, gör det — det eliminerar problemet vid källan istället för att behandla symptomen. För ad hoc- debugning när ett specifikt svar beter sig illa, sparar JSON Fixer och JSON Formatter tid åt dig. Bygg parse_llm_json- utility:n en gång, testa den mot dina värsta historiska svar, och gå vidare — det finns bättre problem att spendera dina debug-timmar på.