Torna al blog
Structured Output: Far Restituire JSON Affidabile agli LLM

Structured Output: Far Restituire JSON Affidabile agli LLM

8 min di lettura1 visualizzazioni
di Marco Salmaso
Gli LLM scrivono testo, ma i programmi hanno bisogno di dati ordinati. Guida pratica agli structured output: schema JSON, tool calling, decoding vincolato, validazione con Pydantic e retry, più gli errori tipici da mettere in conto quando il modello è in produzione.

Chiedere a un LLM di "estrarre i dati" da un testo è facile. Far sì che restituisca sempre lo stesso formato, ogni volta, con i campi giusti e i tipi giusti, è un problema di ingegneria. Ed è il problema che separa una demo che funziona sul tuo portatile da un servizio che gira in produzione senza svegliarti di notte.

Questo articolo riguarda gli structured output: come ottenere dal modello dati strutturati — tipicamente JSON — che il codice possa consumare senza rompersi.

Il problema: il testo libero non è un'interfaccia

Un LLM, per come è addestrato, produce testo. Se gli chiedi l'importo di una fattura, può risponderti in dieci modi diversi:

  • L'importo è di 1.250,00 euro
  • Importo: EUR 1250
  • "importo": "1250.00"
  • un blocco markdown con il JSON dentro
  • una frase di cortesia prima del JSON e una nota dopo

Tutte risposte corrette per un essere umano, tutte potenzialmente disastrose per un parser. La tentazione è scrivere una regex. La regex funziona finché il modello non cambia una virgola, e i modelli cambiano virgole di continuo: versioni nuove, prompt leggermente diversi, temperature diverse.

L'obiettivo, quindi, non è "convincere" il modello a essere ordinato. È vincolarlo e poi verificare quello che ha prodotto.

Quattro strade, con livelli di garanzia diversi

1. Schema scritto nel prompt

La tecnica più semplice: descrivi il formato desiderato nel system prompt, magari con un esempio completo. Funziona sorprendentemente bene con i modelli moderni, ma non offre nessuna garanzia. Il modello può aggiungere un campo, dimenticarne uno, restituire un numero come stringa.

Utile come base per tutte le altre tecniche, insufficiente da sola.

2. Modalità JSON nativa

Molte API espongono un parametro tipo response_format: { "type": "json_object" }. Il modello è costretto a produrre JSON sintatticamente valido. Attenzione: valido non significa corretto. Il JSON può avere la struttura sbagliata, campi mancanti o nomi diversi da quelli che ti aspetti. La sintassi è garantita, la semantica no.

3. Tool calling / function calling

Definisci una funzione con i suoi parametri e il modello, invece di scrivere testo, "chiama" quella funzione compilando gli argomenti. È il meccanismo più diffuso per estrazione dati, perché gli schemi sono già formalizzati nel protocollo. Il modello può comunque sbagliare i valori, o inventare un campo opzionale, o scegliere il tool sbagliato: va sempre validato.

4. Decoding vincolato

La garanzia più forte: si interviene a livello di generazione, limitando i token che il modello può produrre secondo una grammatica. Librerie come Outlines o i grammar file di llama.cpp (formato GBNF) rendono impossibile generare JSON non conforme allo schema. Il costo è una perdita di flessibilità e, in certi casi, una generazione un po' più lenta.

MetodoGaranzia di formatoSemantica correttaNote
Schema nel promptBassaNoSemplice, portabile, fragile
Modalità JSONSintassi sìNoAttenzione ai campi mancanti
Tool callingAltaNoStandard nelle API dei provider
Decoding vincolatoMassimaNoRichiede controllo sul runtime

Nota che l'ultima colonna è sempre "No". Nessuna tecnica ti esonera dalla validazione: il modello può produrre un JSON perfetto con dentro un IBAN inventato.

Un esempio concreto: validare e riprovare

Il pattern che uso più spesso è: schema esplicito + validazione + retry guidato dall'errore. Lo schema lo definisco una volta sola, in codice, e lo riuso sia per generare il prompt sia per validare la risposta.

Con Pydantic si scrive una volta e si ottiene tutto il resto:

from typing import Literal
from pydantic import BaseModel, Field, ValidationError

class Fattura(BaseModel):
    fornitore: str
    numero: str
    data: str = Field(description="Data in formato ISO 8601: YYYY-MM-DD")
    imponibile: float
    aliquota_iva: float
    valuta: Literal["EUR", "USD", "CHF"]

SCHEMA = Fattura.model_json_schema()

Da questo schema si genera il testo da passare al modello (o direttamente il parametro json_schema, se il provider lo accetta) e poi si valida la risposta. Il ciclo di retry è la parte che fa la differenza:

SYSTEM = (
    "Estrai i dati della fattura dal testo. "
    "Restituisci solo JSON conforme allo schema fornito. "
    "Se un campo non è presente, usa null. Non inventare valori."
)

def estrai(testo: str, max_tentativi: int = 3) -> Fattura:
    messaggi = [
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": testo},
    ]
    for _ in range(max_tentativi):
        risposta = chiama_modello(messaggi, schema=SCHEMA)
        try:
            return Fattura.model_validate_json(risposta)
        except ValidationError as e:
            # Rimando l'errore al modello invece di riprovare a caso
            messaggi.append({"role": "assistant", "content": risposta})
            messaggi.append({
                "role": "user",
                "content": f"Output non valido: {e}. Correggi e restituisci solo il JSON.",
            })
    raise RuntimeError("Estrazione non riuscita dopo i tentativi previsti")

Due dettagli importanti in poche righe:

  • il messaggio di errore viene rimandato al modello. Spesso è sufficiente: la seconda volta capisce che 1.250,00 non è un float valido per il suo parser.
  • il retry è limitato. Un modello che sbaglia tre volte di fila raramente si riprende alla quarta; meglio far fallire l'elaborazione e gestire il caso a monte.

Lo stesso schema, passato a un provider con supporto agli structured output nativi, diventa un vincolo di generazione e i retry scendono quasi a zero. Ma il codice di validazione resta: è la tua rete di sicurezza, non un'ammissione di sfiducia.

Schemi che funzionano (e schemi che no)

Il modo in cui scrivi lo schema ha un impatto enorme sul tasso di successo. Alcune regole che ho imparato a forza di errori:

  • Piatto batte annidato. Un oggetto con sei campi piani funziona meglio di un albero con liste di oggetti, salvo che la struttura sia davvero necessaria.
  • Enum invece di testo libero. Se la categoria può essere solo una tra dieci, dichiara le dieci. Il modello sceglie, non inventa.
  • Campi opzionali espliciti. Se un dato può mancare, rendilo nullable. Altrimenti il modello, per riempire lo schema, si sentirà autorizzato a inventare.
  • Descrizioni brevi e operative. data con descrizione "formato ISO 8601" è molto più efficace di data e basta.
  • Istruzioni negative nel prompt. "Non inventare valori" e "se non trovi il dato, usa null" riducono parecchio le allucinazioni sui campi.
  • Niente catene di ragionamento dentro il JSON. Se vuoi che il modello ragioni, fallo fare prima, in un campo separato o in un passo precedente. Mescolare spiegazioni e dati in un unico oggetto sporca tutto.

C'è poi una questione di streaming. Se mostri l'output mentre arriva, ricorda che un JSON parziale è quasi sempre invalido: {"importo": 1 non si può parsare. O accetti di mostrare solo il testo grezzo durante lo streaming, o aspetti la fine e mostri il risultato strutturato. Le due cose insieme richiedono un parser incrementale, che è un progetto a sé.

Validare, riparare, monitorare

La validazione non è solo "giusto o sbagliato". Puoi costruire livelli:

  1. Sintassi: il JSON si parsa? (quasi garantito dalle tecniche moderne)
  2. Schema: i campi e i tipi corrispondono?
  3. Dominio: i valori hanno senso? Una data futura per una fattura è sospetta, un importo negativo pure, un IBAN con la lunghezza sbagliata è un errore certo.
  4. Coerenza incrociata: imponibile più IVA uguale al totale? Se la somma non torna, o il modello ha letto male, o il documento è strano. In entrambi i casi vuoi saperlo.

Il terzo e quarto livello sono quelli che distinguono un sistema utile da uno che ti riempie il database di spazzatura formalmente perfetta. Sono anche i più economici da implementare: sono normali controlli di business logic, come li faresti su un import CSV.

Sul versante monitoraggio, in produzione vale la pena tenere traccia di:

  • tasso di validità al primo tentativo, per modello e per tipo di documento;
  • tasso di retry e motivi degli errori più frequenti;
  • distribuzione dei valori sui campi critici, per accorgersi se un giorno il modello inizia a restituire tutti zeri;
  • casi di fallimento salvati come dataset: sono il materiale migliore per migliorare prompt e schema, e per costruire una eval come si farebbe per qualsiasi altro componente software.

Una nota di costo: i retry costano token e latenza. Se il tasso di validità al primo tentativo è basso, la soluzione quasi mai è aumentare i tentativi — è cambiare schema, prompt o modello.

Conclusione

Riassumendo i punti che contano davvero:

  • Il formato non è un problema di fiducia, è un problema di vincoli. Usa schema nel prompt, modalità JSON, tool calling o decoding vincolato a seconda di quanto controllo hai sul runtime.
  • Nessuna tecnica garantisce la correttezza semantica. Il JSON perfetto con dentro un dato inventato resta il rischio principale.
  • Definisci lo schema una volta sola, in codice, e riusalo per generare il prompt e per validare la risposta: elimini l'incoerenza tra le due.
  • Il retry guidato dall'errore funziona meglio del retry alla cieca, ma va limitato e monitorato.
  • Aggiungi controlli di dominio e di coerenza incrociata: costano poco e filtrano gli errori che lo schema da solo non vede.
  • Tratta i fallimenti come dataset. I casi in cui l'estrazione non riesce sono la materia prima per migliorare il sistema, non rumore da nascondere.

Gli structured output sono, in fondo, un problema di integrazione software travestito da problema di intelligenza artificiale. E come ogni integrazione, si progetta per il caso peggiore, non per la demo che ha funzionato una volta.


Foto di copertina di Ilya Pavlov su Unsplash.