Far parlare un modello in JSON: output strutturato che non si rompe

«Certo! Ecco il JSON che hai chiesto:» — e la pipeline si ferma alle tre di notte. Chiedere un formato in un prompt è una speranza, non una garanzia. I tre livelli di vincolo che un'API può darti, quale scegliere, e perché anche con lo schema garantito devi validare lo stesso.

Un estrattore di dati da fatture funziona per tre settimane. Poi, una notte, si ferma: il modello ha risposto Certo! Ecco il JSON che hai chiesto: e poi il JSON. Il parser ha visto una C dove si aspettava una graffa e ha sollevato un’eccezione. Il prompt diceva chiaramente «rispondi solo con JSON, senza testo intorno», e per tremila fatture aveva funzionato.

È il guasto più caratteristico dell’integrazione di un modello linguistico in un programma, e ha una causa precisa: un prompt è una richiesta, non un vincolo. Il modello campiona il token successivo da una distribuzione di probabilità, e la probabilità che quel token sia una graffa può essere alta quanto vuoi senza mai essere uno. Su tremila chiamate esce quasi sempre; è la quattromillesima che ti sveglia.

Le API offrono oggi tre livelli diversi di garanzia, e sceglierne uno consapevolmente è la differenza fra un servizio che regge e uno che regge quasi sempre.

Salendo di livello si eliminano guasti, non tutti: l’ultima riga resta tua. 0. Lo chiedi «rispondi solo in JSON» può uscire prosa può uscire JSON rotto può mancare un campo può essere sbagliato 1. Vincoli il formato modalità JSON prosa: esclusa JSON rotto: escluso può mancare un campo può essere sbagliato 2. Vincoli lo schema schema JSON, strumento prosa: esclusa JSON rotto: escluso campo mancante: escluso può essere sbagliato Nessun livello garantisce che il contenuto sia vero. «importo»: 1200 è uno schema valido anche quando in fattura c’era scritto 12,00

I tre livelli di garanzia, con ciò che ciascuno smette di poter sbagliare. La riga in basso è quella che nessuna API può togliere di mezzo, e che si affronta solo con la validazione applicativa.

Livello 0: chiedere, e sperare

Il livello zero è il prompt, ed è la scelta giusta esattamente in un caso: quando l’output lo legge una persona. Appena lo legge un programma, il costo della coda lunga arriva. Il repertorio dei modi in cui salta è noto: il preambolo cortese, il JSON avvolto in un blocco di codice markdown, la virgola finale, le virgolette tipografiche al posto di quelle dritte, un campo che a volte è stringa e a volte numero.

Il rimedio artigianale — un’espressione regolare che estrae la prima graffa fino all’ultima — funziona quasi sempre e maschera il problema fino a quando non c’è una graffa dentro una stringa. Non è una strada, è un rinvio.

Perché un vincolo funzioni bisogna sapere che cosa vincola: il modello produce un token alla volta campionando da una distribuzione, e i metodi di questo articolo agiscono lì, azzerando le probabilità dei token che romperebbero lo schema. Il meccanismo è nel libro.

Leggi «I grandi modelli linguistici» nel libro →

Livello 1: vincolare il formato

La modalità JSON dei provider garantisce che ciò che esce sia JSON sintatticamente valido. Non garantisce che sia il tuo JSON: i campi possono mancare, avere nomi inventati, cambiare tipo fra una chiamata e l’altra. È un miglioramento reale — elimina di colpo il preambolo e le virgole finali — ma sposta il guasto invece di eliminarlo. Il codice a valle continua a dover fare dati.get("importo") e a chiedersi che fare quando è None.

Livello 2: vincolare lo schema

Il livello utile è quello in cui lo schema diventa parte della richiesta e il generatore non può violarlo. Su OpenAI si passa uno schema JSON in response_format; su Anthropic la strada naturale è definire uno strumento il cui unico scopo è avere la forma giusta, e obbligare il modello a chiamarlo. La documentazione segnala che aggiungendo strict: true alla definizione dello strumento le chiamate corrispondono esattamente allo schema.

import anthropic

schema = {
    "type": "object",
    "properties": {
        "numero":   {"type": "string"},
        "data":     {"type": "string", "description": "AAAA-MM-GG"},
        "totale":   {"type": "number"},
        "valuta":   {"type": "string", "enum": ["EUR", "USD", "CHF"]},
    },
    "required": ["numero", "data", "totale", "valuta"],
}

client = anthropic.Anthropic()
risposta = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    tools=[{
        "name": "registra_fattura",
        "description": "Registra i dati estratti da una fattura.",
        "input_schema": schema,
        "strict": True,
    }],
    # non «puoi usarlo se vuoi»: devi usare questo
    tool_choice={"type": "tool", "name": "registra_fattura"},
    messages=[{"role": "user", "content": testo_fattura}],
)

blocco = next(b for b in risposta.content if b.type == "tool_use")
dati = blocco.input        # già un dizionario, già conforme allo schema

Il punto che fa la differenza è tool_choice. Lasciato su auto, il modello decide se chiamare lo strumento o rispondere a parole, e sei tornato al livello zero con più cerimoniale. Puntandolo su uno strumento preciso, la risposta è quella chiamata.

Un avvertimento che risparmia un pomeriggio: il sottoinsieme di JSON Schema accettato è più piccolo dello standard. La documentazione lo dichiara esplicitamente — alcune funzionalità non ci sono, per ragioni di prestazioni o tecniche — quindi conviene leggere quali prima di disegnare uno schema profondo, invece di scoprire a valle che il costrutto su cui poggia l’intero modello dati non è supportato. Regola pratica: schemi piatti, campi espliciti, niente astuzie di composizione.

Validare comunque

Lo schema garantisce la forma, mai il contenuto. {"totale": 1200} è perfettamente conforme anche quando in fattura c’era 12,00 euro e il modello ha perso la virgola. Il livello 2 elimina gli errori del tuo programma, non quelli del modello.

from datetime import date
from pydantic import BaseModel, field_validator

class Fattura(BaseModel):
    numero: str
    data: date
    totale: float
    valuta: str

    @field_validator("totale")
    @classmethod
    def plausibile(cls, v):
        if not 0 < v < 1_000_000:
            raise ValueError(f"totale fuori scala: {v}")
        return v

fattura = Fattura.model_validate(dati)   # e qui, finalmente, ci si può fidare

Il controllo di plausibilità è il pezzo che quasi tutti saltano, ed è quello che intercetta gli errori veri: importi fuori scala, date nel futuro, partite IVA della lunghezza sbagliata. Costa dieci righe e trasforma un’estrazione «di solito giusta» in una che sa dire quando non lo è.

Più chiamate in un colpo solo

Quando gli strumenti sono più di uno, i modelli recenti ne chiedono diversi nella stessa risposta: la documentazione di Anthropic descrive risposte che contengono uno o più blocchi tool_use. Se le tre chiamate sono indipendenti — tre città di cui sapere il meteo — eseguirle in parallelo taglia la latenza in modo netto, ed è il caso in cui conviene.

Ma il codice deve essere scritto per questo. Il ciclo che estrae il primo blocco tool_use e ignora il resto è la forma più comune del bug: funziona in tutti i collaudi, e in produzione perde silenziosamente due richieste su tre. O li si gestisce tutti — un tool_result per ciascuno, con il tool_use_id giusto — oppure si dichiara che non se ne vogliono, con tool_choice={"type": "auto", "disable_parallel_tool_use": True}. La scelta sbagliata è non farla.

Cosa portarti a casa

La progressione è semplice e conviene percorrerla tutta: se l’output lo legge un programma, sali al livello 2; poi valida lo stesso, perché la forma non è il contenuto; poi decidi esplicitamente se vuoi chiamate parallele.

Un prompt che chiede un formato è documentazione. Uno schema che lo impone è un contratto. Solo il secondo si può mettere in produzione.

C’è anche una ricaduta sui costi che si nota subito. Le pipeline che ripiegano su «se il JSON non si parsa, richiedi» pagano due volte la stessa estrazione ogni volta che il modello si è distratto, e il tasso di distrazione non è mai zero. Vincolare lo schema toglie quella spesa dal conto e la sostituisce con qualche decina di token di descrizione dello strumento, che si pagano una volta per chiamata e sono prevedibili.

I commenti sono riservati agli iscritti.

Accedi per commentare