Você pediu ao modelo um objeto JSON contendo dados de nota fiscal. O prompt foi claro: "Retorne apenas JSON válido. Sem explicação." O que voltou foi um code fence em markdown, duas sentenças de comentário, um objeto JSON — e depois uma nota prestativa no rodapé explicando cada campo. Em produção, às 2 da manhã, com o pipeline de dados de um cliente travado. Se você está construindo qualquer coisa em cima de APIs de LLM, já conhece essa dor. LLMs não são serializadores JSON. Eles são geradores de texto que normalmente produzem JSON válido — até não produzirem. Este artigo cobre as cinco formas que eles quebram e os padrões testados em batalha para lidar com cada uma.
As 5 formas que LLMs quebram JSON
Isso não são casos de borda. Cada um desses vai acontecer com você em produção, normalmente no momento em que você parar de checar.
- Code fences markdown — O modelo embrulha o JSON em
```json\n...\n```porque os dados de treinamento estão cheios de docs e arquivos README que apresentam JSON desse jeito. - Comentário depois — O modelo anexa uma sentença ou parágrafo depois da chave de fechamento: "Nota: o campo
totalestá em USD." - Truncamento — Saídas longas são cortadas no meio do objeto quando a resposta bate o limite de tokens, deixando você com JSON estruturalmente quebrado e sem chaves de fechamento.
- Chaves alucinadas — O modelo inventa nomes de campo que não estão no seu schema. Você pediu
invoice_number, recebeuinvoiceNumber,invoice_no, eref_id— às vezes na mesma resposta. - Tipos errados — Números chegam como strings (
"49.99"em vez de49.99), booleanos como"true", arrays como strings separadas por vírgula. Bugs de coerção de tipo disfarçados.
Padrão 1: remover code fences markdown
Essa é a quebra mais comum e a mais fácil de consertar. Uma regex simples remove o fence
independente da tag de linguagem ser json, JSON, ou ausente por completo.
Rode isso antes de qualquer outro processamento — não custa nada e previne uma classe grande de erros.
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); // safePadrão 2: extrair JSON com regex
Quando o modelo adiciona texto antes ou depois do objeto JSON — "Aqui estão os
dados extraídos:", "Me avise se precisar de alterações." — remover fences não é suficiente. Você precisa
achar o bloco {...} mais externo e puxá-lo. O truque é usar um match greedy
que lide com objetos aninhados corretamente. Note que essa abordagem lida com objetos ({});
se seu schema for um array, troque a classe de caracteres de acordo.
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")Padrão 3: usar json-repair para erros estruturais
Truncamento e erros estruturais menores — uma chave de fechamento faltando, uma chave sem aspas, uma vírgula
sobrando — são onde a extração por regex falha. A biblioteca
json-repair
foi construída exatamente para isso. Ela aplica uma série de heurísticas para recuperar o máximo de
estrutura válida possível de JSON quebrado, parecido com como navegadores toleram HTML malformado.
Instale com pip install json-repair, e coloque no seu pipeline de parsing
como a última linha de defesa antes de desistir de uma resposta.
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}]}Padrão 4: retry com prompting explícito
Às vezes o melhor parser é o próprio modelo. Se a saída está bagunçada além do que json-repair consegue consertar — chaves alucinadas, estrutura completamente errada, uma resposta que é mais prosa do que dados — mande a saída quebrada de volta para o modelo com o erro de parse e peça para ele consertar seu próprio erro. Modelos são surpreendentemente bons nisso. Mantenha a contagem de retry baixa (2–3 no máximo) e rastreie tentativas para evitar loops infinitos.
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")Padrão 5: pule o parse — use Structured Outputs
Se você controla a chamada do modelo e pode bancar usar APIs mais novas, structured outputs eliminam a maior parte dessa complexidade por completo. OpenAI Structured Outputs (disponível no GPT-4o e posteriores) e response schema do Gemini ambos restringem a saída do modelo no nível de geração de token — é matematicamente impossível para o modelo retornar um objeto JSON malformado porque tokens inválidos são suprimidos durante a decodificação. A desvantagem: você abre mão de um pouco da criatividade do modelo e essas APIs custam um pouco mais por chamada. Para pipelines de extração em alto volume, normalmente vale.
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}")Um parser pronto para produção (Python)
Aqui está como fica uma função de extração em produção quando você combina os quatro padrões defensivos num utilitário único. Essa é a versão que eu efetivamente rodo em serviços que processam milhares de respostas de LLM por dia. Ela remove fences, extrai a substring JSON, tenta um parse limpo, cai para json_repair, e opcionalmente valida contra um JSON Schema antes de retornar. Se você não está usando structured outputs, essa é sua fundação.
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']}")Versão em JavaScript
A mesma lógica em JavaScript. Para o passo de reparo, o equivalente mais próximo do json_repair é JSON5 para parsing tolerante de JSON quase-válido, ou você pode escrever um wrapper de reparo leve você mesmo. Para trabalho client-side, JSON.parse() com um bom try/catch e um fallback de regex cobre a vasta maioria dos casos de produção.
// 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.99Encerrando
LLMs quebram JSON de cinco formas previsíveis, e cada uma tem um conserto previsível. Fences markdown
e prosa ao redor são cosméticos — algumas regex lidam com eles de forma confiável. Dano estrutural por
truncamento ou pequenos erros de formatação é para o que o
json_repair
foi feito. Quando a estrutura está correta mas o conteúdo está errado — chaves ruins, tipos errados — isso é
um problema de prompting, e um loop de retry com a mensagem de erro devolvida ao modelo é sua melhor ferramenta.
E se você consegue usar
Structured Outputs,
use — isso elimina o problema na fonte em vez de tratar os sintomas. Para debug ad-hoc
quando uma resposta específica está mal se comportando, o JSON Fixer e o
JSON Formatter vão te economizar tempo. Construa o utilitário parse_llm_json
uma vez, teste ele contra suas piores respostas históricas, e siga em frente — tem
problemas melhores para gastar suas horas de debug.