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
totalfield 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 fikinvoiceNumber,invoice_noogref_id— nogle gange i samme svar. - Forkerte typer — Tal kommer som strenge (
"49.99"i stedet for49.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.
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 safefunction 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); // safeMø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.
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.
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}]}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.
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.
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.
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.
// 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.99Afrunding
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å.