Poprosiłeś model o obiekt JSON zawierający dane z faktury. Prompt był jasny: "Return only valid JSON. No explanation." To, co wróciło, to markdownowy code fence, dwa zdania komentarza, obiekt JSON — a potem uprzejma notka na dole wyjaśniająca każde pole. Na produkcji, o 2 w nocy, z zablokowanym pipeline'em danych klienta. Jeśli budujesz cokolwiek na API LLM-ów, już znasz ten ból. LLM-y nie są serializatorami JSON. Są generatorami tekstu, które zazwyczaj produkują poprawny JSON — dopóki nie produkują. Ten artykuł opisuje pięć sposobów, na które go psują, i sprawdzone w boju wzorce do obsługi każdego z nich.
5 sposobów, na które LLM-y psują JSON
To nie są edge case'y. Każdy z tych przypadków zdarzy ci się na produkcji, zazwyczaj w momencie, w którym przestaniesz na nie uważać.
- Markdownowe code fence'e — Model zawija JSON w
```json\n...\n```, bo jego dane treningowe są pełne dokumentacji i README, które prezentują JSON w ten sposób. - Dopisany komentarz na końcu — Model dokleja zdanie albo akapit po klamrze zamykającej: "Note: the
totalfield is in USD." - Truncation — Długie wyjścia są ucinane w środku obiektu, kiedy odpowiedź trafia w limit tokenów, zostawiając ci strukturalnie zepsuty JSON bez klamer zamykających.
- Halucynowane klucze — Model wymyśla nazwy pól, których nie ma w twojej schemie. Poprosiłeś o
invoice_number, dostałeśinvoiceNumber,invoice_noiref_id— czasem w tej samej odpowiedzi. - Złe typy — Liczby przychodzą jako stringi (
"49.99"zamiast49.99), booleany jako"true", tablice jako stringi z przecinkami. Bugi z type coercion w przebraniu.
Wzorzec 1: Zdejmowanie markdownowych code fence'ów
To najczęstsza usterka i najłatwiejsza do naprawienia. Prosty regex zdejmuje fence,
niezależnie od tego, czy tag języka to json, JSON, czy w ogóle go nie ma.
Odpal to przed jakimkolwiek innym przetwarzaniem — nic nie kosztuje, a zapobiega dużej klasie błędów.
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); // safeWzorzec 2: Wyciągnij JSON regexem
Kiedy model dodaje tekst przed albo po obiekcie JSON — "Here is the
extracted data:", "Let me know if you need changes." — zdjęcie fence'ów nie wystarczy. Musisz
znaleźć najbardziej zewnętrzny blok {...} i go wyciągnąć. Sztuczka to użycie greedy matcha
poprawnie obsługującego zagnieżdżone obiekty. Zauważ, że to podejście obsługuje obiekty ({});
jeśli twoja schema to tablica, zamień odpowiednio klasę znaków.
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")Wzorzec 3: Użyj json-repair dla błędów strukturalnych
Truncation i drobne błędy strukturalne — brakująca klamra zamykająca, klucz bez cudzysłowów, końcowy
przecinek — to przypadki, w których wyciąganie regexem zawodzi. Biblioteka
json-repair
została zbudowana dokładnie do tego. Stosuje serię heurystyk, żeby odzyskać jak najwięcej poprawnej
struktury ze zepsutego JSON-a, podobnie jak przeglądarki tolerują zepsuty HTML.
Zainstaluj ją przez pip install json-repair i wrzuć do swojego pipeline'u parsowania
jako ostatnią linię obrony, zanim poddasz się odnośnie odpowiedzi.
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}]}Wzorzec 4: Retry z jawnym promptem
Czasem najlepszym parserem jest sam model. Jeśli wyjście jest zepsute bardziej, niż json-repair potrafi naprawić — halucynowane klucze, kompletnie zła struktura, odpowiedź, która jest bardziej prozą niż danymi — wyślij zepsute wyjście z powrotem do modelu wraz z błędem parsowania i poproś, żeby naprawił własny błąd. Modele są w tym zaskakująco dobre. Trzymaj liczbę prób nisko (2–3 max) i śledź próby, żeby uniknąć nieskończonej pętli.
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")Wzorzec 5: Pomiń parsowanie — użyj Structured Outputs
Jeśli kontrolujesz wywołanie modelu i możesz sobie pozwolić na nowsze API, structured outputs eliminują większość tej złożoności w ogóle. OpenAI Structured Outputs (dostępne dla GPT-4o i nowszych) oraz response schema Gemini oba ograniczają wyjście modelu na poziomie generowania tokenów — matematycznie niemożliwe jest, żeby model zwrócił niepoprawny obiekt JSON, bo niepoprawne tokeny są tłumione podczas dekodowania. Minus: rezygnujesz z części kreatywności modelu, a te API kosztują trochę więcej za wywołanie. Dla pipeline'ów ekstrakcji o dużym wolumenie są zwykle warte ceny.
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}")Produkcyjny parser (Python)
Tak wygląda produkcyjna funkcja ekstrakcji, kiedy połączysz wszystkie cztery defensywne wzorce w jedno narzędzie. To wersja, którą faktycznie puszczam w serwisach przetwarzających tysiące odpowiedzi LLM dziennie. Zdejmuje fence'y, wyciąga substring JSON, próbuje czystego parse'a, cofa się do json_repair i opcjonalnie waliduje względem JSON Schema przed zwrotem. Jeśli nie używasz structured outputs, to jest twój 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']}")Wersja JavaScript
Ta sama logika w JavaScripcie. Dla kroku naprawy najbliższym odpowiednikiem json_repair jest JSON5 do tolerancyjnego parsowania niemal-poprawnego JSON-a, albo możesz sam napisać lekki wrapper naprawczy. Dla pracy po stronie klienta, JSON.parse() z porządnym try/catchem i fallbackiem regexowym pokrywa przytłaczającą większość przypadków produkcyjnych.
// 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.99Podsumowanie
LLM-y psują JSON na pięć przewidywalnych sposobów, a każdy z nich ma przewidywalny fix. Markdownowe fence'y
i otaczająca proza to kosmetyka — kilka regexów radzi sobie z nimi niezawodnie. Uszkodzenie strukturalne z
truncation albo drobnych błędów formatowania to coś, do czego zbudowano
json_repair.
Kiedy struktura jest poprawna, ale treść jest zła — złe klucze, złe typy — to
problem promptowy, a pętla retry z błędem zwrotnym do modelu jest twoim najlepszym narzędziem.
A jeśli możesz użyć
Structured Outputs,
zrób to — eliminują problem u źródła zamiast leczyć symptomy. Do ad-hoc
debugowania, kiedy konkretna odpowiedź się źle zachowuje, JSON Fixer i
JSON Formatter oszczędzą ci czasu. Zbuduj narzędzie parse_llm_json
raz, przetestuj je na swoich najgorszych historycznych odpowiedziach i idź dalej — są lepsze
problemy, na które warto wydać godziny debugowania.