Torna al blog

Risoluzione dei problemiGuida

json.JSONDecodeError su una risposta LLM: estrai il JSON in Python

json.loads fallisce su una risposta del modello? Leggi exc.pos e finish_reason per trovare la causa, poi estrai il JSON con raw_decode e riprova una volta.

Di CheapTokens TeamPubblicato il 7 min di lettura

Una risposta JSON con fence accanto a un JSONDecodeError alla posizione 0
In questa pagina
  1. Tre modi in cui una risposta rompe json.loads
  2. Risposte troncate: controlla finish_reason prima di fare il parsing
  3. Estrai il JSON dal testo
  4. Riprova una volta con un prompt più rigoroso
  5. Quale sintomo, quale correzione
  6. L'API può forzare JSON valido?

Il tuo script chiede al modello del JSON, chiama json.loads(reply) e muore con json.JSONDecodeError. Di solito il modello ha risposto: ha avvolto il JSON in un ```json fence, put a line of chat in front of it, or ran into max_tokens halfway through. Look at exc.pos and finish_reason to tell which one it is, pull the object out with JSONDecoder.raw_decode, e riprova una volta se non si riesce a salvare nulla.

Tre modi in cui una risposta rompe json.loads#

La documentazione di json di Python (documentazione di Python 3.15, consultata il 2 novembre 2026) dice che JSONDecodeError è una sottoclasse di ValueError con gli attributi msg, doc, pos, lineno e colno. Quello che conta qui è pos: l'indice nella stringa in cui il parsing è fallito.

Supponiamo che uno script estragga campi dalle email dei clienti e chieda {"name": ..., "age": ...}. Queste tre stringhe sono le risposte che lo rompono. Incollale in una shell Python:

python
import json

fenced = '```json\n{"name": "Anna", "age": 31}\n```'
chatty = 'Sure! Here is the JSON:\n{"name": "Anna", "age": 31}'
cut = '{"name": "Anna", "age": 31'

for reply in (fenced, chatty, cut):
    try:
        json.loads(reply)
    except json.JSONDecodeError as exc:
        print(exc.pos, len(reply))

Le prime due falliscono a pos 0: il parser ha guardato il primissimo carattere, un backtick o un S, e ha rinunciato. La terza fallisce alla fine della stringa (pos è uguale a len(reply)): tutto ciò che ha letto andava bene, poi il testo è finito. Questa differenza è la tua diagnosi. La posizione 0 significa che il JSON probabilmente c'è, è solo avvolto. La posizione alla fine significa che la risposta si è interrotta presto.

Il percorso decisionale per una risposta che fallisce: controlla finish_reason, estrai con raw_decode, riprova una volta
L'ordine dei controlli usato in questo articolo

Risposte troncate: controlla finish_reason prima di fare il parsing#

Una risposta che si interrompe a metà non è un problema di parsing, è un problema di lunghezza. L'API te lo dice: la choice ha finish_reason, e "length" significa che la risposta è stata troncata da max_tokens (la storia completa è in Risposta AI troncata? Correggi finish_reason "length" e max_tokens). Guardalo per primo, prima di qualsiasi estrazione.

C'è una trappola insidiosa se salti questo passaggio. Uno scanner che cerca il primo { analizzabile può pescare un oggetto interno completo da uno esterno a metà, e il tuo script va avanti con i dati sbagliati e senza errori. Provalo:

Provalo tu stessoProvalo: cosa restituisce una funzione "trova il primo oggetto" per questa risposta troncata?Mostra risposta

Prendi '{"user": {"name": "Anna"}, "age": 31'. L'oggetto esterno non si chiude mai, ma l'interno {"name": "Anna"} è completo, quindi l'estrattore qui sotto restituisce {'name': 'Anna'}. Nessuna eccezione, risultato sbagliato. Ecco perché il controllo di finish_reason viene prima dell'estrazione.

La soluzione per un vero troncamento è più spazio: alza max_tokens. DeepSeek-V4-Flash consente fino a 32.768 token di output per risposta, GLM-5.3-Flash fino a 8.192, quindi una lunga lista JSON ha più spazio sul primo. I dettagli dei modelli sono su la pagina dei modelli CheapTokens.

Estrai il JSON dal testo#

Per i fence e le chiacchiere, la libreria standard ha già lo strumento. Secondo la stessa pagina di Python, JSONDecoder.raw_decode(s) decodifica un documento JSON da una stringa che inizia con uno e restituisce l'oggetto più l'indice in cui il documento è terminato. La documentazione dice che può essere usato su una stringa con dati estranei alla fine. Richiede che il documento inizi dal primo carattere, quindi taglia prima il testo iniziale: cerca un {, prova a decodificare da lì e passa al { successivo se fallisce.

python
import json

def extract_json(text):
    """Return the first JSON object found in text, or raise ValueError."""
    decoder = json.JSONDecoder()
    for i, ch in enumerate(text):
        if ch == '{':
            try:
                obj, _end = decoder.raw_decode(text[i:])
                return obj
            except json.JSONDecodeError:
                continue
    raise ValueError('no JSON object in the reply')

Gestisce il fence (il ```json is skipped because it is not a {), the sentence before the object, and text after the closing brace. Expecting a list? Look for '[' instead. Don't look for both: a stray [1] in una frase come "vedi nota [1]" è JSON valido e vincerebbe.

Due cose che deliberatamente non fa. Non modifica mai il testo (niente regex che cancella i backtick, niente ipotesi sulle parentesi mancanti), quindi non puoi trasformare una risposta rotta in dati validi ma sbagliati. E fallisce in modo rumoroso quando non c'è nulla da trovare, che è ciò di cui ha bisogno il retry qui sotto.

Prima: json.loads su una risposta con fence. Dopo: extract_json restituisce l'oggetto
La stessa risposta con fence prima e dopo extract_json

Riprova una volta con un prompt più rigoroso#

Se la risposta è stata troncata o non contiene alcun oggetto, chiedi di nuovo. Una volta, non in un ciclo infinito. Su un troncamento, dai più spazio al secondo tentativo; altrimenti di' chiaramente cosa vuoi. Questo usa l'SDK Python di OpenAI contro la base URL di CheapTokens (https://api.cheaptokens.dev/v1) e l'id completo del modello dalla documentazione; la chiave sta in una variabile d'ambiente.

python
import os
from openai import OpenAI

client = OpenAI(
    base_url='https://api.cheaptokens.dev/v1',
    api_key=os.environ['CHEAPTOKENS_API_KEY'],
)
MODEL = 'deepseek-ai/DeepSeek-V4-Flash-0731'
STRICT = '\n\nReply with one JSON object only. No text before or after it, no code fence.'

def ask_json(prompt, max_tokens=800):
    content = prompt
    for attempt in (1, 2):
        resp = client.chat.completions.create(
            model=MODEL,
            messages=[{'role': 'user', 'content': content}],
            max_tokens=max_tokens,
        )
        choice = resp.choices[0]
        if choice.finish_reason == 'length':
            max_tokens *= 2          # cut off: more room next time
        else:
            try:
                return extract_json(choice.message.content or '')
            except ValueError:
                pass                 # no object: try the stricter prompt
        content = prompt + STRICT
    raise ValueError('no valid JSON after one retry')

L'ordine è il punto: prima finish_reason, poi l'estrazione, e solo dopo un retry. Solleva l'errore dopo il secondo tentativo così un job batch può registrare quell'elemento e andare avanti.

Quanto costa un retry è facile da stimare. Supponi una richiesta di 1.200 token di input e 300 di output (un'ipotesi, usa i tuoi numeri): $0.000048 su DeepSeek-V4-Flash o $0.000036 su GLM-5.3-Flash. Se 30 richieste su 1.000 necessitano del secondo tentativo, il conto extra è 30 × quella richiesta, $0.0014 su DeepSeek-V4-Flash. I prezzi sono live, provengono dal listino prezzi attuale.

Metti in ordine

Metti in ordine i passaggi di riparazione

  1. Controlla finish_reason: fermati qui se dice length
  2. Trova la prima { e taglia il testo prima di essa
  3. Decodifica con raw_decode e ignora ciò che segue
  4. Se non è stato trovato nulla, riprova una volta con il prompt più rigoroso

Quale sintomo, quale correzione#

I passaggi sopra stanno in testa fino al prossimo errore strano. Questa è la stessa cosa di una tabella di ricerca: trova ciò che vedi sullo schermo e leggi la correzione.

Scegli il tuo caso

Scegli ciò che vedi

json.loads fallisce a pos 0

La risposta inizia con un fence o una frase. Passala attraverso extract_json; un retry non serve.

pos è alla fine della stringa

La risposta si è interrotta presto. Controlla finish_reason: se è length, alza max_tokens e chiedi di nuovo.

L'errore è da qualche parte nel mezzo

Il modello ha scritto JSON rotto da solo (una virgola mancante, una virgola finale, una virgola non escapata). Non rattopparlo: riprova una volta con il prompt più rigoroso e registra la risposta se fallisce di nuovo.

json.loads funziona ma manca una chiave

Il parsing va bene, il contenuto no. Elenca le chiavi esatte nel prompt e controllale nel codice dopo il parsing.

L'API può forzare JSON valido?#

A volte. La guida agli structured outputs di OpenAI documenta due modalità: la modalità JSON (type: json_object), che restituisce JSON valido ma non segue uno schema, e gli Structured Outputs (type: json_schema, strict: true), che lo fanno; OpenAI raccomanda la seconda dove possibile (fonte: developers.openai.com, consultata il 2 novembre 2026). Quelle sono le opzioni API di OpenAI. Prima di affidarti a response_format con un altro endpoint, controlla la documentazione di CheapTokens; l'estrazione e il retry sopra funzionano con normali chat completions e non richiedono parametri speciali. Altro sulle opzioni lato richiesta in Ottenere JSON valido da un'API LLM: response_format e retry.

Prima di rilasciare il parser, controlla quattro cose: finish_reason viene letto prima che il testo sia analizzato, c'è un solo retry (e il secondo fallimento è registrato con l'id dell'elemento), il codice verifica che le chiavi attese esistano dopo il parsing, e max_tokens lascia spazio al JSON più lungo che ti aspetti.

La prossima volta che json.loads lancia un'eccezione, stampa prima exc.pos e finish_reason. Quei due numeri di solito ti dicono cosa è successo prima che tu legga una riga della risposta.