Vous avez demandé au modèle un objet JSON contenant les données d'une facture. Le prompt était clair : « Ne retournez que du JSON valide. Pas d'explication. » Ce qui est revenu, c'était une clôture de code markdown, deux phrases de commentaire, un objet JSON — puis une note utile en bas expliquant chaque champ. En production, à 2 heures du matin, avec le pipeline de données d'un client à l'arrêt. Si vous construisez quoi que ce soit par-dessus des API LLM, vous connaissez déjà cette douleur. Les LLM ne sont pas des sérialiseurs JSON. Ce sont des générateurs de texte qui produisent habituellement du JSON valide — jusqu'à ce qu'ils ne le fassent plus. Cet article couvre les cinq façons dont ils le cassent et les patterns éprouvés au combat pour gérer chacune d'elles.

Les 5 façons dont les LLM cassent le JSON

Ce ne sont pas des cas limites. Chacune d'entre elles vous arrivera en production, généralement au moment où vous arrêtez de les surveiller.

  • Clôtures de code markdown — Le modèle enveloppe le JSON dans ```json\n...\n``` parce que ses données d'entraînement sont pleines de docs et de fichiers README qui présentent le JSON ainsi.
  • Commentaire final — Le modèle ajoute une phrase ou un paragraphe après l'accolade de fermeture : « Note : le champ total est en USD. »
  • Troncature — Les sorties longues sont coupées en plein milieu d'un objet quand la réponse atteint la limite de tokens, vous laissant avec un JSON structurellement cassé et sans accolades de fermeture.
  • Clés hallucinées — Le modèle invente des noms de champs qui ne sont pas dans votre schéma. Vous avez demandé invoice_number, vous obtenez invoiceNumber, invoice_no, et ref_id — parfois dans la même réponse.
  • Mauvais types — Les nombres arrivent sous forme de chaînes ("49.99" au lieu de 49.99), les booléens comme "true", les tableaux comme des chaînes séparées par des virgules. Des bugs de coercition de type déguisés.

Pattern 1 : Retirer les clôtures de code markdown

C'est la casse la plus courante et la plus facile à corriger. Une simple regex retire la clôture peu importe que l'étiquette de langue soit json, JSON, ou manquante entièrement. Exécutez ceci avant tout autre traitement — ça ne coûte rien et prévient une large classe d'erreurs.

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

Pattern 2 : Extraire du JSON avec une regex

Quand le modèle ajoute du texte avant ou après l'objet JSON — « Voici les données extraites : », « Faites-moi savoir si vous avez besoin de changements. » — retirer les clôtures ne suffit pas. Vous devez trouver le bloc {...} le plus externe et l'extraire. L'astuce est d'utiliser un match gourmand qui gère correctement les objets imbriqués. Notez que cette approche gère les objets ({}) ; si votre schéma est un tableau, échangez la classe de caractères en conséquence.

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

Pattern 3 : Utiliser json-repair pour les erreurs structurelles

La troncature et les erreurs structurelles mineures — une accolade de fermeture manquante, une clé non entourée de guillemets, une virgule finale — c'est là que l'extraction par regex atteint ses limites. La bibliothèque json-repair a été construite exactement pour ça. Elle applique une série d'heuristiques pour récupérer autant de structure valide que possible depuis du JSON cassé, de la même manière que les navigateurs tolèrent du HTML malformé. Installez-la avec pip install json-repair, puis insérez-la dans votre pipeline de parsing comme dernière ligne de défense avant d'abandonner sur une réponse.

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}]}
Astuce de débogage manuel : Quand vous enquêtez sur une réponse cassée spécifique, collez-la dans le JSON Fixer pour voir exactement ce que json-repair en fait — ou utilisez le Validateur JSON pour identifier la ligne exacte et la position du caractère de l'erreur de syntaxe avant de décider s'il faut réparer ou re-prompter.

Pattern 4 : Retenter avec un prompt explicite

Parfois le meilleur parseur est le modèle lui-même. Si la sortie est brouillée au-delà de ce que json-repair peut corriger — clés hallucinées, structure complètement fausse, une réponse qui est plus de la prose que des données — renvoyez la sortie cassée au modèle avec l'erreur de parse et demandez-lui de corriger sa propre erreur. Les modèles sont étonnamment bons à ça. Gardez le compteur de tentatives bas (2–3 max) et suivez les tentatives pour éviter les boucles infinies.

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

Pattern 5 : Sauter le parsing — utiliser Structured Outputs à la place

Si vous contrôlez l'appel au modèle et pouvez vous permettre d'utiliser des API plus récentes, les structured outputs éliminent la plupart de cette complexité entièrement. OpenAI Structured Outputs (disponible sur GPT-4o et ultérieur) et le response schema de Gemini contraignent tous deux la sortie du modèle au niveau de la génération de tokens — il est mathématiquement impossible pour le modèle de retourner un objet JSON malformé parce que les tokens invalides sont supprimés pendant le décodage. L'inconvénient : vous abandonnez un peu de créativité du modèle et ces API coûtent légèrement plus par appel. Pour les pipelines d'extraction à haut volume, elles en valent habituellement la peine.

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

Un parseur prêt pour la production (Python)

Voici à quoi ressemble une fonction d'extraction de production quand vous combinez les quatre patterns défensifs en un seul utilitaire. C'est la version que j'exécute réellement dans des services qui traitent des milliers de réponses LLM par jour. Elle retire les clôtures, extrait la sous-chaîne JSON, tente un parse propre, retombe sur json_repair, et valide optionnellement contre un JSON Schema avant de retourner. Si vous n'utilisez pas structured outputs, c'est votre fondation.

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

Version JavaScript

La même logique en JavaScript. Pour l'étape de réparation, l'équivalent le plus proche de json_repair est JSON5 pour un parsing tolérant de JSON presque valide, ou vous pouvez écrire vous-même un wrapper de réparation léger. Pour le travail côté client, JSON.parse() avec un bon try/catch et un fallback regex couvre la grande majorité des cas en production.

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

Pour conclure

Les LLM cassent le JSON de cinq façons prévisibles, et chacune a un correctif prévisible. Les clôtures markdown et la prose environnante sont cosmétiques — quelques regex les gèrent de manière fiable. Les dommages structurels dus à la troncature ou à de mineures erreurs de formatage sont ce pour quoi json_repair a été construit. Quand la structure est correcte mais le contenu est faux — mauvaises clés, mauvais types — c'est un problème de prompting, et une boucle de retry avec le message d'erreur renvoyé au modèle est votre meilleur outil. Et si vous pouvez utiliser Structured Outputs, faites-le — ça élimine le problème à la source plutôt que de traiter les symptômes. Pour le débogage ponctuel quand une réponse spécifique se comporte mal, le JSON Fixer et le JSON Formatter vous feront gagner du temps. Construisez l'utilitaire parse_llm_json une fois, testez-le contre vos pires réponses historiques, et passez à autre chose — il y a de meilleurs problèmes sur lesquels passer vos heures de débogage.