Come ottenere JSON affidabile da un LLM (senza rincorrere i parse error)

La prima volta che provi a far "sputare JSON" a un modello sembra funzionare: chiedi un oggetto con tre campi, il modello risponde con tre campi, tu fai json.loads e via. Poi lo lanci su cento input veri e il terzo si rompe. Il modello ha aggiunto una frase gentile prima delle parentesi ("Ecco il JSON che hai chiesto:"), ha racchiuso tutto in un blocco markdown con i backtick, oppure ha inventato un campo che non avevi previsto. Il tuo json.loads esplode, lo script si ferma, e ti ritrovi a scrivere espressioni regolari per ripulire l'output di un modello. È il momento in cui capisci che "chiedere JSON per favore" non è una strategia.

La buona notizia è che nel 2026 questo problema è, in larga parte, risolto — ma non con un prompt più insistente. Si risolve dicendo all'API, in modo formale, quale forma deve avere la risposta. Questo articolo ti porta da "spero che sia JSON valido" a "è garantito che sia JSON valido e con i campi giusti", con esempi su entrambi gli ecosistemi più comuni e senza saltare i pezzi che contano davvero in produzione.

Perché "chiedere JSON" nel prompt non basta

Un LLM genera testo prevedendo un token alla volta. Quando gli scrivi "rispondi in JSON", stai influenzando quella previsione, non vincolandola: nella stragrande maggioranza dei casi otterrai qualcosa di simile a JSON, ma "quasi sempre" è esattamente il tipo di affidabilità che ti fa perdere le giornate. Basta un input un po' diverso, una temperatura un filo alta, o un modello che quel giorno decide di essere loquace, e la struttura salta.

Le misure lo dicono chiaro: senza alcun vincolo, una quota non trascurabile di risposte "JSON" fallisce il parsing per dettagli banali — una virgola di troppo, del testo prima o dopo, le virgolette sbagliate. In un prototipo lo tolleri; in un programma che gira da solo, ogni parse fallito è un errore da gestire, un retry da pagare, un caso limite da inseguire. Serve un livello di garanzia diverso.

Il salto: dal prompt allo schema (structured output)

La svolta si chiama structured output (o output strutturato) e si appoggia a una tecnica chiamata constrained decoding: l'API non lascia più che il modello scelga liberamente il prossimo token, ma lo obbliga a rispettare uno schema che gli hai dato. In pratica, mentre genera, il modello può produrre solo token che mantengono l'output valido rispetto alla struttura richiesta. Il risultato non è "probabilmente JSON": è JSON conforme, per costruzione.

Lo schema si descrive con JSON Schema, uno standard che esisteva da molto prima degli LLM: dici quali campi ci sono, di che tipo sono (stringa, numero, booleano, lista, oggetto annidato), quali sono obbligatori e quali valori sono ammessi. Non stai più chiedendo una forma con le parole: la stai imponendo con una specifica che l'API fa rispettare.

Il vantaggio pratico è triplo. La risposta è sempre parsabile, quindi niente più json.loads che esplode. I tipi sono garantiti: se hai detto che priorita è un intero, non ti arriverà mai la stringa "alta". E i campi obbligatori ci sono sempre, così il codice a valle non deve difendersi da chiavi mancanti. Sparisce un'intera categoria di bug.

Definire lo schema una volta sola

Scrivere JSON Schema a mano è verboso e facile da sbagliare. Il modo comodo, in Python, è dichiarare la struttura come una classe Pydantic; in TypeScript si usa Zod. Sono librerie di validazione che, oltre a controllare i dati, sanno generare lo schema JSON per te. Definisci l'oggetto una volta, e la stessa definizione serve sia per istruire il modello sia per validare la risposta.

from pydantic import BaseModel

class Contatto(BaseModel):
    nome: str
    email: str
    priorita: int  # 1 = bassa, 3 = urgente

Questa classe è la tua unica fonte di verità: descrive cosa vuoi, in un formato che sia il modello sia il tuo programma capiscono.

In pratica con OpenAI

L'SDK di OpenAI accetta direttamente il modello Pydantic e restituisce un oggetto già pronto, senza che tu tocchi mai il JSON grezzo. Il metodo parse fa il lavoro sporco: passa lo schema all'API, riceve la risposta vincolata e la trasforma nell'oggetto.

from openai import OpenAI
from pydantic import BaseModel

client = OpenAI()  # legge OPENAI_API_KEY dall'ambiente

class Contatto(BaseModel):
    nome: str
    email: str
    priorita: int

risposta = client.chat.completions.parse(
    model="gpt-4o-mini",
    messages=[
        {"role": "system", "content": "Estrai i dati di contatto dal testo."},
        {"role": "user", "content": "Scrive Anna Rossi (anna@esempio.it), dice che è urgente."}
    ],
    response_format=Contatto,
)

contatto = risposta.choices[0].message.parsed
print(contatto.email)      # anna@esempio.it
print(contatto.priorita)   # 3, come intero

La riga che cambia tutto è response_format=Contatto: da lì in poi message.parsed è già un oggetto Contatto, con i tipi giusti. Non c'è nessun json.loads, nessuna pulizia, nessun controllo difensivo sui campi.

In pratica con Anthropic

Anche l'API di Claude offre gli structured output, con lo stesso principio e una sintassi vicina. Si passa lo schema JSON nel parametro output_format e si attiva la funzione con un header beta dedicato; la risposta conforme arriva nel contenuto del messaggio.

from anthropic import Anthropic

client = Anthropic()  # legge ANTHROPIC_API_KEY dall'ambiente

schema = {
    "type": "object",
    "properties": {
        "nome": {"type": "string"},
        "email": {"type": "string"},
        "priorita": {"type": "integer"}
    },
    "required": ["nome", "email", "priorita"]
}

risposta = client.beta.messages.create(
    model="...",  # un modello Claude recente
    max_tokens=1024,
    betas=["structured-outputs-2025-11-13"],
    output_format={"type": "json_schema", "schema": schema},
    messages=[
        {"role": "user", "content": "Estrai contatto: Anna Rossi anna@esempio.it, urgente."}
    ],
)

print(risposta.content[0].text)  # JSON conforme allo schema

Il meccanismo è identico nella sostanza: descrivi la forma, l'API garantisce che l'output la rispetti. Cambia il nome dei parametri, non l'idea. Anthropic offre inoltre due modalità — una pensata per l'estrazione di dati in JSON, l'altra ("strict tools") pensata per i tool degli agenti — ma per il caso classico "trasforma questo testo in un oggetto" quella JSON è quella che ti serve.

Validare comunque, anche quando è garantito

Sembra contraddittorio, ma è la regola d'oro di chi lavora bene: valida l'output anche quando l'API ti garantisce lo schema. La garanzia copre la forma (i campi ci sono, i tipi sono giusti), non la sostanza. Il modello può restituirti un'email sintatticamente valida ma inventata, o una priorità "3" quando nel testo non c'era alcuna urgenza. Lo schema non lo sa: per lui basta che sia un intero.

Cosa controlla lo schema (e cosa no)

Lo schema garantisce che email sia una stringa; non garantisce che sia un indirizzo reale o formattato bene. Per questo Pydantic e Zod restano utili dopo la chiamata: puoi aggiungere vincoli più fini (un formato email, un intervallo di valori per priorita, una lista non vuota) e rifiutare i dati che non tornano. È lo stesso principio di ogni input non fidato: la struttura te la dà l'API, il significato lo verifichi tu.

Regole di business separate dal modello

Le decisioni che dipendono dalla tua logica — "se la priorità è 3 apri un ticket" — vivono nel tuo codice, non nel prompt. Il modello estrae e struttura; il programma decide. Tenere separati i due livelli rende il sistema prevedibile e testabile.

Gli errori e i limiti che incontrerai

Il primo inciampo è quasi sempre lo schema troppo libero: se lasci campi opzionali dove non servono, dai al modello margine per omettere informazioni. Rendi obbligatorio tutto ciò che ti aspetti sempre. Il secondo è chiedere strutture troppo profonde o troppo grandi: gli schemi molto annidati aumentano la latenza e a volte urtano contro i limiti della funzione. Se ti accorgi che stai descrivendo un oggetto enorme, spesso conviene spezzare il compito in due chiamate più semplici.

Attenzione anche al fatto che lo structured output è, su alcuni ecosistemi, ancora una funzione in evoluzione (l'header beta di Anthropic lo ricorda): controlla la documentazione ufficiale per la sintassi aggiornata e per quali costrutti di JSON Schema sono supportati, perché non sempre lo sono tutti. Infine, ricordati che vincolare l'output ha un piccolo costo: il modello ha meno libertà, e per compiti che richiedono ragionamento in linguaggio naturale a volte conviene farlo "pensare" prima in testo libero e poi chiedere il JSON in un secondo passaggio.

Prova subito

Prenditi venti minuti e trasforma un output fragile in uno solido. Parti da un compito di estrazione che ti serve davvero: prendi tre o quattro righe di testo disordinato — un'email di un cliente, la descrizione di un prodotto, un annuncio — e definisci con Pydantic (o Zod) l'oggetto che vuoi ricavarne, con i campi obbligatori e i tipi giusti. Fai la chiamata con response_format (OpenAI) o output_format (Anthropic) e stampa l'oggetto: nota che non hai scritto una sola riga di parsing. Poi rompilo di proposito: dai in pasto al modello un testo in cui manca l'informazione di un campo obbligatorio e guarda cosa succede. Infine aggiungi una validazione tua — per esempio, controlla che l'email contenga una chiocciola — e gestisci il caso in cui non torna. Quando avrai fatto girare lo stesso schema su cinque input diversi senza un solo parse error, avrai in mano il pezzo che separa un prototipo da qualcosa che puoi lasciare lavorare da solo.

Per rivedere le basi della chiamata — chiave, SDK, struttura della richiesta e della risposta — c'è l'articolo sulla prima chiamata API e il capitolo corrispondente nella dispensa del Livello 2: gli structured output sono il passo naturale subito dopo, quando la risposta del modello smette di essere qualcosa da leggere e diventa qualcosa da usare nel codice.