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 total está 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, recebeu invoiceNumber, invoice_no, e ref_id — às vezes na mesma resposta.
  • Tipos errados — Números chegam como strings ("49.99" em vez de 49.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.

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

Padrã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.

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")

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.

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}]}
Dica de debug manual: quando você está investigando uma resposta quebrada específica, cole no JSON Fixer para ver exatamente o que json-repair faz com ela — ou use o JSON Validator para identificar a linha e posição de caractere exatas do erro de sintaxe antes de decidir se deve reparar ou reprompt.

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.

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")

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.

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}")

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.

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']}")

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.

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

Encerrando

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.