Chiedi a un modello di rispondere in JSON e nella maggior parte dei casi json.loads() funziona senza problemi. I casi che ti costano una sessione di debug sono altri: un commento su una riga dentro l’oggetto, un code fence markdown che lo avvolge, una chiave scritta userEmail quando il tuo codice si aspetta user_email. Un prompt più rigido (“rispondi SOLO con l’oggetto JSON, nessun altro testo”) rende questi casi più rari. Non li rende impossibili, perché il modello può comunque ignorare la richiesta. Con l’output strutturato la richiesta diventa superflua: passi all’API un JSON Schema, che vincola il campionamento dei token del modello così che la risposta debba rispettarlo.

JSON mode contro output strutturato: cosa garantisce ciascuno

Il “JSON mode” (response_format: {"type": "json_object"} nella vecchia API di OpenAI, ed equivalenti sulla maggior parte degli altri provider) garantisce una cosa sola: l’output è JSON sintatticamente valido. Non dice nulla su quali chiavi esistono, che tipo contengono, o se un campo da cui dipendi è presente davvero. Un modello in JSON mode può restituire {"result": "yes"} quando il tuo codice si aspetta {"status": "approved", "confidence": 0.91}, e niente ti avvisa che qualcosa è andato storto.

L’output strutturato (OpenAI lo chiama Structured Outputs, Anthropic lo chiama JSON outputs) è una garanzia diversa: la risposta rispetta il JSON Schema che fornisci, con le chiavi richieste presenti e il tipo corretto su ciascuna. Il function calling risolve un problema adiacente: fa sì che sia il modello a chiedere al tuo codice di eseguire qualcosa. I due si confondono facilmente perché entrambi passano uno schema. Quello che cambia è cosa lo schema modella:

FunzioneCosa garantisceA cosa serve
JSON mode (json_object)Solo sintassi JSON validaFallback legacy, nessuno schema da far rispettare
Output strutturato (schema strict)Il JSON rispetta esattamente il tuo schemaDare forma alla risposta per l’utente o il tuo codice
Function calling / tool useNome del tool e argomenti conformi al suo schemaIl modello decide di invocare il tuo codice

Una pipeline in produzione usa spesso le ultime due insieme. Il tool use strict evita che le chiamate di funzione di un agente arrivino malformate, e uno schema strict sulla risposta finale tipizza il riassunto che restituisce all’utente.

Come il constrained decoding forza la corrispondenza

Il modello non confronta mai la propria risposta con il tuo schema dopo averla generata. Lo schema cambia cosa gli è permesso generare, un token alla volta.

Il livello di serving compila il tuo JSON Schema in una grammatica: una macchina a stati che traccia, a ogni posizione dell’output, quali token porterebbero ancora a qualcosa di valido. Prima che ogni token venga campionato, ogni token del vocabolario escluso dalla grammatica in quella posizione ha il suo logit spinto a meno infinito, così ha probabilità zero dopo il softmax. Il modello campiona da quello che resta, e le probabilità relative tra i token sopravvissuti vengono mantenute. Una chiave scritta male, una parentesi graffa nel posto sbagliato, una stringa dove lo schema richiede un numero: una volta che la grammatica li esclude, quei token non esistono più come opzione.

Quella fase di compilazione ha un costo. Costruire una grammatica per un nuovo schema richiede lavoro vero, quindi la prima richiesta con quello schema è più lenta delle successive. L’API di Anthropic mette in cache la grammatica compilata per 24 ore dal suo ultimo uso, e le chiamate successive con lo stesso schema saltano la compilazione.

Il meccanismo spiega anche il limite. Il constrained decoding fissa solo la forma, nient’altro. La grammatica sa quali token sono sintatticamente legali, non quale sia vero, quindi il modello può comunque riempire un campo "email" valido con un indirizzo inventato.

Come ottenere JSON conforme allo schema da OpenAI, Claude e Ollama

La Responses API di OpenAI riceve lo schema in text.format, con strict attivo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from openai import OpenAI

client = OpenAI()

response = client.responses.create(
    model="gpt-6-astra",
    input="Extract the ticket details: 'Login fails with a 500 after the "
          "password reset flow, seen first on build 4.12, blocking release.'",
    text={
        "format": {
            "type": "json_schema",
            "name": "ticket",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "summary": {"type": "string"},
                    "build": {"type": "string"},
                    "severity": {"type": "string", "enum": ["low", "medium", "high", "blocking"]},
                },
                "required": ["summary", "build", "severity"],
                "additionalProperties": False,
            },
        }
    },
)
print(response.output_text)

Claude riceve lo schema in output_config.format, non in una definizione di tool:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
curl https://api.anthropic.com/v1/messages \
  -H "content-type: application/json" \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2026-10-21" \
  -d '{
    "model": "claude-sonnet-5-5",
    "max_tokens": 1024,
    "messages": [{"role": "user", "content": "Extract the ticket details: ..."}],
    "output_config": {
      "format": {
        "type": "json_schema",
        "schema": {
          "type": "object",
          "properties": {
            "summary": {"type": "string"},
            "build": {"type": "string"},
            "severity": {"type": "string", "enum": ["low", "medium", "high", "blocking"]}
          },
          "required": ["summary", "build", "severity"],
          "additionalProperties": false
        }
      }
    }
  }'

Il JSON torna nel contenuto testuale della risposta, non in un blocco di tool call. Il vecchio workaround (definire un tool finto, forzare il modello a chiamarlo, leggere gli argomenti) funziona ancora sui modelli che supportano solo il tool use. Dove esiste l’output strutturato nativo non ne hai bisogno: il decoder fa rispettare lo schema direttamente, invece di sperare che gli argomenti del tool coincidano per caso.

Un modello eseguito in locale con Ollama riceve lo schema tramite il campo format di /api/chat:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
curl http://localhost:11434/api/chat -d '{
  "model": "llama3.2",
  "messages": [{"role": "user", "content": "Extract the ticket details: ..."}],
  "format": {
    "type": "object",
    "properties": {
      "summary": {"type": "string"},
      "build": {"type": "string"},
      "severity": {"type": "string", "enum": ["low", "medium", "high", "blocking"]}
    },
    "required": ["summary", "build", "severity"]
  },
  "stream": false
}'

Il meccanismo è lo stesso. Il runtime llama.cpp di Ollama costruisce una grammatica dallo schema e maschera i token come fanno le API ospitate.

Quali funzionalità di JSON Schema supportano i provider?

Ogni provider fa rispettare un sottoinsieme di JSON Schema, e i sottoinsiemi differiscono abbastanza da rompere uno schema copiato da un’API all’altra.

OpenAI richiede che ogni oggetto porti "additionalProperties": false con ogni sua proprietà elencata in required — non c’è modo di marcare un campo davvero opzionale, quindi modellalo come required e nullable. Anthropic è più permissivo: una proprietà esclusa da required è genuinamente opzionale, e le proprietà required compaiono prima nell’output, seguite da quelle opzionali presenti. Oltre questo, i due divergono ancora. Anthropic supporta $ref/$defs locali, anyOf e const, ma rifiuta gli schemi ricorsivi, i limiti numerici come minimum e maximum, e i vincoli sulla lunghezza delle stringhe come maxLength. Gli esempi di OpenAI includono un $ref: "#" ricorsivo per output ad albero, che la documentazione di Anthropic esclude esplicitamente.

Quando il tuo schema ha bisogno di un vincolo che il provider non supporta, spostalo nella description del campo (“deve essere un intero positivo sotto 100”) e controllalo tu dopo il parsing. Gli helper degli SDK su entrambe le piattaforme lo fanno automaticamente quando costruisci lo schema da un modello Pydantic o Zod invece di scrivere JSON Schema a mano.

Come validare l’output strutturato dopo il parsing

Una forma valida non è una risposta corretta. Lo schema garantisce che severity sia uno dei tuoi quattro valori enum. Se “blocking” sia la scelta giusta per questo ticket, è una domanda diversa. Tratta l’oggetto parsato come tratteresti l’invio di un form: input da validare, non verità di fatto.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from pydantic import BaseModel, field_validator

class Ticket(BaseModel):
    summary: str
    build: str
    severity: str

    @field_validator("build")
    @classmethod
    def must_look_like_a_build_number(cls, v: str) -> str:
        if not v.replace(".", "").isdigit():
            raise ValueError(f"'{v}' doesn't look like a build number")
        return v

Instructor avvolge OpenAI, Anthropic e la maggior parte degli altri provider in un ciclo di validazione e retry. Gli passi un modello Pydantic. Su un fallimento di validazione rimanda l’errore al modello e chiede di nuovo, fino a un limite di retry che imposti tu, invece di lanciare una ValidationError al primo errore. È anche la via pratica per ottenere una garanzia di schema su un provider o un modello più vecchio senza strict mode nativo: il ciclo di retry sostituisce l’enforcement a livello di decoder, e ogni errore costa un round trip in più.

I validator sui campi colgono valori malformati in una singola risposta. Per verificare se le risposte sono corrette su centinaia di input, e se una modifica al prompt le ha peggiorate, serve un eval suite.

Quando l’output strutturato fallisce comunque

Lo strict mode non copre questi casi. Controllali prima di andare a caccia di un bug nel tuo codice:

  • Refusal. Un modello può rifiutarsi di rispondere, e il testo del refusal non rispetta il tuo schema. Controlla stop_reason (o l’equivalente) prima del parsing, non dopo che il parsing lancia un errore.
  • Troncamento. Raggiungere max_tokens a metà di un oggetto produce JSON incompleto. La grammatica controlla quale token viene dopo, non quanto spazio resta per finire. Aumenta il limite o riduci lo schema.
  • Percorsi non vincolati. JSON mode, una libreria software di retry su un modello senza strict mode, o un provider che ignora silenziosamente una keyword dello schema che non supporta possono tutti restituire "Blocking" quando il tuo enum dice "blocking". Se una richiesta nel tuo sistema passa per uno di questi percorsi, normalizza i valori in ingresso.
  • Dimensione dello schema. Uno schema grande con molti rami anyOf aggiunge token a ogni richiesta, oltre al ritardo di compilazione per un nuovo schema. Mantieni gli schemi quanto più piatti e piccoli possibile per il compito.

La garanzia del provider è reale, ma il tuo codice di parsing non dovrebbe assumere che abbia tenuto.

JSON mode, schema strict o function calling: quale usare

Usa il JSON mode semplice solo con un modello o un percorso SDK senza supporto per gli schemi, come fallback e non come scelta predefinita. Usa un JSON Schema strict quando devi dare forma a una risposta per il tuo codice o per un utente: estrazione, classificazione, un riassunto tipizzato. Usa il function calling quando è il modello a decidere se eseguire qualcosa. Attiva lo strict mode anche sulla definizione del tool, così una chiamata malformata fallisce all’API invece che dentro il tuo codice.

Se tutto ciò che serve è un’etichetta da un enum breve, confronta anche un modello system one. Quando l’output è limitato a un insieme noto di risposte, un modello può scegliere invece di generare, e i compromessi su latenza e costo differiscono abbastanza che vale la pena misurarli sul tuo traffico reale.

Per tutto il resto, la scelta predefinita è uno schema strict più un validator sul risultato parsato. Lo schema mantiene corretta la forma. Verificare che il contenuto sia corretto resta compito tuo.

Articoli correlati