Salta al contenuto principale

86 - Output strutturato: far restituire all'agente dati che puoi validare

Come dichiarare il tipo di output di un agente, che cosa fa l'SDK quando la risposta non torna e come ritentare con il motivo preciso dell'errore.

Cosa ne pensi di questo articolo?

86 - Output strutturato: far restituire all'agente dati che puoi validare
Machine Learning Engineerfedericocalo.dev

In Breve

Come dichiarare il tipo di output di un agente, che cosa fa l'SDK quando la risposta non torna e come ritentare con il motivo preciso dell'errore.

Finché un agente risponde in prosa, chi sta a valle deve indovinare: estrarre un numero con una regex, capire se «sì, direi di procedere» significa approvato. Con l'output strutturato dichiari il tipo della risposta e ottieni due soli esiti: un oggetto che rispetta lo schema, oppure un errore esplicito. Non è una comodità di formattazione: è il punto in cui un agente smette di essere un chatbot e diventa un componente che puoi collegare a un altro pezzo di software. Qui vedi come si dichiara, che cosa fa davvero l'SDK quando la risposta non torna, come si recupera, e che cosa lo schema non ti garantisce.

Che cosa ho eseguito, e che cosa no. Codice provato il 2026-11-12 con openai-agents 0.24.0, pydantic 2.14.3 e Python 3.14.4, in un ambiente virtuale temporaneo. Nessun modello è stato chiamato, nemmeno Ollama: il modello è finto e risponde con un copione che ho scritto io, attraverso un trasporto HTTP simulato (nessuna rete, nessuna porta). Quindi le uscite qui sotto provano cosa fa l'SDK e cosa fa Pydantic con una risposta data, non quanto bene un modello vero riempie uno schema. Ogni listato Python è stato eseguito due volte con uscita identica. I nomi modello, leggi_giacenza e leggi_ordini_in_arrivo vengono dalle lezioni 09-04 e 09-05; nei listati li importo da un modulo lezioni tuo, e nella prova modello() restituisce il modello finto. Fissa la versione dell'SDK: i nomi di questa API sono cambiati fra le serie.

L'analogia: la ricetta medica contro il consiglio del farmacista

Il farmacista che ti dice «per quel dolore prenderei un antinfiammatorio, magari dopo i pasti» ti dà un'informazione utile e non processabile da un sistema. La ricetta elettronica ha campi obbligatori: codice del farmaco, dosaggio, numero di confezioni, posologia. Lo stesso contenuto, ma la seconda forma si può trasmettere, archiviare, validare e rifiutare se è incompleta. L'output strutturato è il passaggio dal consiglio alla ricetta.

Le due architetture a valle, messe a confronto

La differenza non è estetica: cambia dove finisce l'incertezza. Con la prosa, l'incertezza si sposta nel tuo codice di parsing e diventa un bug intermittente. Con il tipo dichiarato, l'incertezza resta al confine e diventa un errore esplicito che puoi gestire.

Output in prosa contro output tipizzato: dove finisce l'incertezza A - risposta in prosa agente testo libero il tuo parser regex, euristiche, "se contiene si" il tuo sistema riceve dati forse sbagliati l'incertezza entra nel sistema, in silenzio B - tipo di output dichiarato agente output_type=Decisione validazione schema + Pydantic oggetto tipizzato d.esito, d.quantita errore esplicito ModelBehaviorError → ritenta due soli esiti, entrambi gestibili In A l'errore si scopre in produzione, giorni dopo. In B si scopre al confine, subito.
Lo stesso contenuto, due architetture. Cambia il momento in cui scopri che qualcosa non torna.

Il meccanismo: un tipo Pydantic al posto della stringa

Il parametro è output_type di Agent (si importa da agents). Accetta un modello Pydantic, ma la documentazione dell'SDK dice che va bene qualunque tipo che si possa avvolgere in un TypeAdapter: dataclass, liste, TypedDict. Se non lo scrivi vale None e l'agente produce testo: final_output è una stringa. Qui uso un modello Pydantic perché i vincoli (Literal, ge, max_length) diventano parte dello schema e della validazione con la stessa dichiarazione. Il risultato è che l'SDK fa tre cose: genera lo schema JSON del tipo, lo manda al modello insieme alla richiesta, e valida la risposta prima di consegnartela.

import asyncio
from typing import Literal
from pydantic import BaseModel, Field
from agents import Agent, Runner
from lezioni import modello, leggi_giacenza, leggi_ordini_in_arrivo   # definiti in 09-04 e 09-05

class Decisione(BaseModel):
    sku: str
    esito: Literal["riordina", "non_riordinare", "dati_insufficienti"]
    quantita: int = Field(ge=0, le=5000, description="0 se non si riordina")
    motivo: str = Field(max_length=200)
    dati_usati: list[str] = Field(description="Nomi dei tool effettivamente consultati")

agente = Agent(
    name="Decisore riordino",
    instructions=("Decidi se riordinare. Leggi i dati con i tool. "
                  "Se un dato manca, usa esito='dati_insufficienti' e quantita=0."),
    tools=[leggi_giacenza, leggi_ordini_in_arrivo],
    output_type=Decisione,
    model=modello(),
)

def crea_proposta(sku: str, quantita: int) -> None:
    print(f"proposta d'ordine: {quantita} x {sku}")      # segnaposto: qui c'è il tuo gestionale

async def main():
    esito = await Runner.run(agente, "Lo SKU-11 va riordinato?")
    d = esito.final_output                  # istanza di Decisione, non stringa
    print(type(d).__name__, d.esito, d.quantita, "-", d.motivo)
    if d.esito == "riordina":
        crea_proposta(d.sku, d.quantita)    # codice normale, nessun parsing

asyncio.run(main())

# Uscita:
# Decisione non_riordinare 0 - 4 in giacenza ma 20 in arrivo

Il listato definisce un agente che decide un riordino con due tool (quelli della lezione precedente) e un tipo di output Decisione. L'agente è provato contro un copione di tre risposte: chiama leggi_giacenza, chiama leggi_ordini_in_arrivo, poi risponde con un testo JSON. Quel testo è scritto da me, non prodotto da un modello: ciò che l'uscita dimostra è che esito.final_output è un'istanza di Decisione, non una stringa. Il guadagno sta nelle ultime righe: d.esito == "riordina" è un confronto fra valori di un Literal, non un'interpretazione di testo, e d.quantita è un int nell'intervallo dichiarato, non una stringa da convertire sperando bene. Con i dati del magazzino finto (4 pezzi, soglia 10, 20 in arrivo) la decisione coerente è «non riordinare», e infatti crea_proposta non viene chiamata.

Che cosa viaggia davvero verso il modello

Lo schema che l'SDK costruisce dal tipo si può guardare senza alcun modello. Il listato continua nello stesso file, e stampa lo schema in modalità «strict», quella predefinita.

import json
from agents import AgentOutputSchema

schema = AgentOutputSchema(Decisione)
print("strict:", schema.is_strict_json_schema())
print(json.dumps(schema.json_schema(), indent=2, ensure_ascii=False))

# Uscita:
# strict: True
# {
#   "properties": {
#     "sku": {
#       "title": "Sku",
#       "type": "string"
#     },
#     "esito": {
#       "enum": [
#         "riordina",
#         "non_riordinare",
#         "dati_insufficienti"
#       ],
#       "title": "Esito",
#       "type": "string"
#     },
#     "quantita": {
#       "description": "0 se non si riordina",
#       "maximum": 5000,
#       "minimum": 0,
#       "title": "Quantita",
#       "type": "integer"
#     },
#     "motivo": {
#       "maxLength": 200,
#       "title": "Motivo",
#       "type": "string"
#     },
#     "dati_usati": {
#       "description": "Nomi dei tool effettivamente consultati",
#       "items": {
#         "type": "string"
#       },
#       "title": "Dati Usati",
#       "type": "array"
#     }
#   },
#   "required": [
#     "sku",
#     "esito",
#     "quantita",
#     "motivo",
#     "dati_usati"
#   ],
#   "title": "Decisione",
#   "type": "object",
#   "additionalProperties": false
# }

Ci sono quattro cose da leggere. Primo: required contiene tutti i campi, e additionalProperties è false: è la forma «strict» che l'SDK impone. Secondo: i vincoli minimum, maximum e maxLength sono nello schema, quindi il modello li «vede»; che un server li faccia rispettare in generazione è un'altra questione (sotto). Terzo: i campi sono nell'ordine in cui li hai scritti nella classe, e questo conta per il campo di ragionamento, più avanti. Quarto: nella richiesta HTTP che ho letto dal trasporto simulato, lo schema è dentro response_format con type: json_schema e strict: true, accanto a tools nella stessa richiesta. La pagina di compatibilità OpenAI di Ollama elenca response_format fra i campi supportati di /v1/chat/completions, e la pagina «Structured outputs» dice che lo schema JSON si può imporre sulle risposte. Non ho verificato su un Ollama vero se un dato modello rispetti strict o ignori i vincoli numerici.

Che cosa succede quando la risposta non torna

La domanda che separa un esempio da un sistema è questa: cosa vede il tuo codice se il modello sbaglia? L'SDK valida con Pydantic in modo strict, e se fallisce solleva ModelBehaviorError. Ho passato allo stesso validatore otto risposte (una valida e sette varianti: sei difettose, una con un campo in più), chiamando validate_json direttamente (è il metodo che la run usa sul testo del modello).

from agents import AgentOutputSchema
from agents.exceptions import ModelBehaviorError

schema = AgentOutputSchema(Decisione)
OK = '{"sku": "SKU-11", "esito": "riordina", "quantita": 250, "motivo": "sotto soglia", "dati_usati": []}'
casi = {
    "valido":            OK,
    "fuori intervallo":  OK.replace("250", "9999"),
    "valore non ammesso": OK.replace('"riordina"', '"forse"'),
    "campo mancante":    '{"sku": "SKU-11", "esito": "riordina"}',
    "testo davanti":     "Ecco il risultato: " + OK,
    "recinto markdown":  "```json\n" + OK + "\n```",
    "numero come testo": OK.replace("250", '"250"'),
    "campo in più":      OK.replace("}", ', "note": "x"}'),
}
for nome, testo in casi.items():
    try:
        schema.validate_json(testo)
        print(f"{nome:20} -> accettato")
    except ModelBehaviorError as e:
        print(f"{nome:20} -> ModelBehaviorError: {e.message}")

# Uscita:
# valido               -> accettato
# fuori intervallo     -> ModelBehaviorError: Invalid JSON when parsing model output
# valore non ammesso   -> ModelBehaviorError: Invalid JSON when parsing model output
# campo mancante       -> ModelBehaviorError: Invalid JSON when parsing model output
# testo davanti        -> ModelBehaviorError: Invalid JSON when parsing model output
# recinto markdown     -> ModelBehaviorError: Invalid JSON when parsing model output
# numero come testo    -> ModelBehaviorError: Invalid JSON when parsing model output
# campo in più         -> accettato

Due osservazioni, entrambe scomode. La prima: il messaggio è sempre lo stesso, «Invalid JSON when parsing model output», qualunque sia il difetto. L'SDK non ti dice quale campo è sbagliato; nel mio test l'eccezione sollevata da Runner.run non portava nemmeno i dati della run (run_data era None). Per un ritentativo utile, quel dettaglio devi ricostruirlo tu. La seconda: il testo attorno al JSON e il recinto markdown sono rifiutati, perché la risposta deve essere solo JSON; e un campo in più è invece accettato, perché Pydantic ignora i campi extra a meno che tu non scriva model_config = ConfigDict(extra="forbid") (provato: con quell'opzione il campo in più viene rifiutato). Anche «250» fra virgolette è rifiutato: la validazione è strict e non converte le stringhe in numeri. Un avviso su stderr del tipo «No active span; trace error was not attached» compare quando chiami validate_json fuori da una run: non fa parte dell'uscita e non è un errore del tuo codice.

Decodifica vincolata: il meccanismo, e dove si ferma la garanzia

Diversi dei difetti dell'elenco di otto risposte sarebbero un problema in meno se il modello non potesse sbagliare la forma. Esiste un meccanismo che lo impedisce, la decodifica vincolata, e vale la pena capirlo, perché spiega sia la sua forza sia i suoi limiti. Quello che segue è un'illustrazione concettuale, non il codice di un server: non l'ho osservato su Ollama.

Un modello linguistico genera un token alla volta: a ogni passo produce una distribuzione di probabilità su tutto il vocabolario e poi ne sceglie uno. La decodifica vincolata si inserisce fra questi due momenti: prima della scelta, azzera la probabilità dei token che renderebbero il testo non più completabile in un JSON valido rispetto allo schema. Con uno schema a un solo campo, esito, che può valere "riordina" o "non_riordinare", i passi sono questi.

stato: ""                  ammessi: solo "{"            (lo schema è un oggetto)
stato: "{"                 ammessi: solo "\""           (serve una chiave)
stato: "{\""               ammessi: solo i token che iniziano "esito"
                                     (è l'unica chiave possibile)
stato: "{\"esito\""        ammessi: solo ":"
stato: "{\"esito\":"       ammessi: solo "\""
stato: "{\"esito\": \""    ammessi: solo i token che iniziano "riordina"
                                     oppure "non_riordinare"
stato: "...\"riordina"     ammessi: solo "\""           (l'enum è completo)
stato: "...\"riordina\""   ammessi: solo "}"            (nessun altro campo)

A ogni passo lo spazio delle possibilità è ridotto a quello che lo schema consente. Il modello sceglie dentro quel sottoinsieme: può decidere fra «riordina» e «non_riordinare», ma non può emettere «forse», non può dimenticare la virgoletta e non può aggiungere un campo. Il risultato è valido per costruzione, non per fortuna. Da qui discendono tre conseguenze.

Il server decide quanto schema applica. Per tenere lo stato della grammatica, un server supporta in genere solo una parte di JSON Schema, e quale parte dipende da lui: leggi la sua documentazione. Quello che ho osservato io sta un gradino prima, nell'SDK, ed è nel listato di sotto: un tipo che lo schema strict non sa rappresentare viene rifiutato prima di spedire qualsiasi cosa, con un UserError.

La forma è garantita, il contenuto no. Il filtro agisce sulla validità sintattica: fra «riordina» e «non_riordinare» sono entrambi ammessi, e la scelta la fa il modello con le sue probabilità. Nessun meccanismo di decodifica può sapere qual è la risposta giusta. Per questo la validazione di Pydantic resta indispensabile anche dove la decodifica vincolata c'è: vincoli come le=5000 possono non essere applicati dal server (non l'ho verificato su Ollama), e l'SDK li ricontrolla comunque.

Vincolare il formato cambia ciò che il modello può scrivere. Un modello che, lasciato libero, avrebbe scritto due frasi di ragionamento prima di concludere, sotto vincolo deve emettere subito il campo richiesto. Il rimedio è mettere un campo di ragionamento prima dei campi di decisione: i modelli generano da sinistra a destra, quindi un ragionamento scritto per primo può influenzare ciò che segue, mentre uno scritto dopo è una giustificazione. L'ordine dei campi nello schema è quello della classe (lo hai visto sopra); che un dato server generi i campi in quell'ordine non l'ho verificato.

Quando il server non vincola: tollera e ritenta

Con un modello locale piccolo, o con un server che accetta lo schema e poi lo ignora, la decodifica vincolata può mancare, e serve una rete di sicurezza. La rete ha due pezzi: accettare un JSON circondato da testo («Ecco il risultato: ...», un recinto markdown), e rimandare al modello il motivo preciso del rifiuto, che l'SDK non fornisce. Il modo che ho provato è una sottoclasse di AgentOutputSchema che sovrascrive validate_json: usa lo stesso TypeAdapter dell'SDK, in modalità strict, ma prima estrae il primo oggetto {...} bilanciato, e quando fallisce ricorda il perché.

import json
from agents import AgentOutputSchema, Runner
from agents.exceptions import ModelBehaviorError
from pydantic import ValidationError

def estrai_json(testo: str) -> str:
    """Il primo oggetto {...} bilanciato; se non c'è, il testo com'è."""
    inizio = testo.find("{")
    if inizio < 0:
        return testo
    profondita, in_stringa, saltato = 0, False, False
    for i in range(inizio, len(testo)):
        c = testo[i]
        if in_stringa:
            if saltato:
                saltato = False
            elif c == "\\":
                saltato = True
            elif c == '"':
                in_stringa = False
        elif c == '"':
            in_stringa = True
        elif c == "{":
            profondita += 1
        elif c == "}":
            profondita -= 1
            if profondita == 0:
                return testo[inizio:i + 1]
    return testo

class SchemaTollerante(AgentOutputSchema):
    """Come AgentOutputSchema, ma estrae il JSON dal testo e ricorda perché ha rifiutato."""
    def __init__(self, tipo):
        super().__init__(tipo)
        self.ultimo_errore: str | None = None

    def validate_json(self, json_str: str):
        try:
            oggetto = self._type_adapter.validate_json(estrai_json(json_str), strict=True)
        except ValidationError as e:
            self.ultimo_errore = "; ".join(
                f"{'.'.join(map(str, d['loc']))}: {d['msg']}" for d in e.errors())
            raise ModelBehaviorError("output non conforme") from e
        self.ultimo_errore = None
        return oggetto

async def strutturato_con_recupero(agente, richiesta, schema, tentativi=2):
    messaggio = richiesta
    for _ in range(tentativi):
        try:
            return (await Runner.run(agente, messaggio)).final_output
        except ModelBehaviorError:
            messaggio = (f"{richiesta}\n\nLa risposta precedente non era valida ({schema.ultimo_errore}). "
                         "Rispondi SOLO con un oggetto JSON conforme allo schema.")
    raise ValueError(f"output non conforme dopo {tentativi} tentativi: {schema.ultimo_errore}")

Tre dettagli. estrai_json conta le graffe ma ignora quelle dentro le stringhe, perché un motivo come «sotto la soglia {10}» le contiene. SchemaTollerante ricorda l'errore in un attributo dell'istanza: va bene per un agente usato in sequenza, non per run concorrenti sullo stesso oggetto (in quel caso serve uno schema per run). E strutturato_con_recupero cattura ModelBehaviorError, non ValidationError: è l'eccezione che l'SDK solleva, e catturare l'altra non prenderebbe mai nulla. Ecco cosa dice la diagnosi sugli stessi otto casi di prima.

tollerante = SchemaTollerante(Decisione)
for nome, testo in casi.items():
    try:
        tollerante.validate_json(testo)
        print(f"{nome:20} -> accettato")
    except ModelBehaviorError:
        print(f"{nome:20} -> rifiutato: {tollerante.ultimo_errore}")

# Uscita:
# valido               -> accettato
# fuori intervallo     -> rifiutato: quantita: Input should be less than or equal to 5000
# valore non ammesso   -> rifiutato: esito: Input should be 'riordina', 'non_riordinare' or 'dati_insufficienti'
# campo mancante       -> rifiutato: quantita: Field required; motivo: Field required; dati_usati: Field required
# testo davanti        -> accettato
# recinto markdown     -> accettato
# numero come testo    -> rifiutato: quantita: Input should be a valid integer
# campo in più         -> accettato

Ora il testo attorno al JSON e il recinto passano, mentre «fuori intervallo», «valore non ammesso», «campo mancante» e «numero come testo» sono rifiutati con il motivo. Quel motivo è ciò che rende efficace il secondo tentativo: il modello corregge un campo specifico invece di riprovare a caso. Lo provo con un copione di due risposte: la prima ha quantita a 9999 (circondata da testo), la seconda è valida (con una frase di cortesia davanti e dietro).

import asyncio
from agents import Agent

schema = SchemaTollerante(Decisione)
agente_tollerante = Agent(name="Decisore riordino", instructions="Decidi se riordinare.",
                          output_type=schema, model=modello())

async def prova():
    d = await strutturato_con_recupero(agente_tollerante, "Lo SKU-11 va riordinato?", schema)
    print("recuperato:", d.esito, d.quantita)

asyncio.run(prova())

# Uscita:
# recuperato: non_riordinare 0

Nella seconda richiesta, letta dal trasporto simulato, il messaggio dell'utente era: «Lo SKU-11 va riordinato? La risposta precedente non era valida (quantita: Input should be less than or equal to 5000). Rispondi SOLO con un oggetto JSON conforme allo schema.». Ogni ritentativo è una run intera in più: due tentativi vanno bene, cinque significano che lo schema o l'istruzione sono sbagliati, non il modello. Se anche il secondo fallisce, la funzione solleva ValueError con l'ultimo motivo, e sta al chiamante decidere se mandare il caso in revisione.

Come si progetta uno schema che il modello riempie bene

Le regole che seguono vengono dal modo in cui lo schema è fatto e dal modo in cui l'SDK lo tratta; dove non ho misurato un effetto sulla qualità, lo dico.

  • Chiudi gli insiemi. Ogni campo che può essere un enum deve essere un Literal: lo spazio delle risposte diventa finito ed esplicito. Che questo migliori la qualità non l'ho misurato; che tolga alla risposta ogni valore fuori elenco, sì.
  • Prevedi lo stato «non lo so». Senza un valore come dati_insufficienti, costringi il modello a scegliere fra opzioni sbagliate, e sceglierà.
  • Usa le descrizioni dei campi. Finiscono nello schema (le hai viste sopra) e funzionano come micro-prompt: «0 se non si riordina» evita un campo nullo e una discussione.
  • Tieni lo schema piatto. Meno livelli, meno cose da sbagliare e meno rischi di superare ciò che il server supporta.
  • Chiedi un campo di tracciabilità. Un dati_usati: list[str] non impedisce al modello di mentire, ma rende la bugia confrontabile con il registro reale delle chiamate ai tool.

Le cinque regole hanno un ordine di costo, non di importanza: le prime due sono una riga di dichiarazione, la terza è una stringa, la quarta è una disciplina, la quinta richiede un confronto in codice (lo scrivo più avanti). Prima di fidarti di un tipo, però, vedi cosa ne fa l'SDK: ho passato ad AgentOutputSchema cinque tipi che sembrano innocui.

import datetime, decimal, json
from pydantic import BaseModel
from agents import AgentOutputSchema
from agents.exceptions import UserError

class Scadenza(BaseModel):
    quando: datetime.datetime
class Prezzo(BaseModel):
    importo: decimal.Decimal
class ConDefault(BaseModel):
    note: str = ""
    n: int | None = None
class Mappa(BaseModel):
    giacenze: dict[str, int]
class A(BaseModel):
    a: int
class B(BaseModel):
    b: str
class Unione(BaseModel):
    x: A | B

for T in (Scadenza, Prezzo, ConDefault, Mappa, Unione):
    try:
        s = AgentOutputSchema(T).json_schema()
        props = {k: {c: v for c, v in p.items() if c != "title"} for k, p in s["properties"].items()}
        print(f"{T.__name__:10} required={s['required']} {json.dumps(props, ensure_ascii=False)}")
    except UserError as e:
        print(f"{T.__name__:10} UserError: {e}")

# Uscita:
# Scadenza   required=['quando'] {"quando": {"format": "date-time", "type": "string"}}
# Prezzo     required=['importo'] {"importo": {"anyOf": [{"type": "number"}, {"type": "string"}]}}
# ConDefault required=['note', 'n'] {"note": {"default": "", "type": "string"}, "n": {"anyOf": [{"type": "integer"}, {"type": "null"}]}}
# Mappa      UserError: Strict JSON schema is enabled, but the output type is not valid. Either make the output type strict, or wrap your type with AgentOutputSchema(YourType, strict_json_schema=False)
# Unione     required=['x'] {"x": {"anyOf": [{"$ref": "#/$defs/A"}, {"$ref": "#/$defs/B"}]}}

Quello che si legge: un campo con valore predefinito (note: str = "") finisce comunque in required, quindi nello schema strict il modello deve emettere tutte le chiavi, e un campo «opzionale» diventa al massimo «nullable» (anyOf con null). Un datetime diventa una stringa con format: date-time, e un Decimal diventa «numero o stringa», cioè un tipo che non ti dice quale dei due riceverai: per i soldi è meglio un intero in centesimi o una stringa con formato dichiarato. Un dict[str, int] è rifiutato dall'SDK, perché in strict le chiavi devono essere note; se ti serve davvero, c'è l'opzione AgentOutputSchema(Tipo, strict_json_schema=False), che rinuncia alla garanzia. Le unioni di modelli (A | B) sono accettate e producono anyOf con $defs, ma non so se il tuo server le regge: controlla la sua documentazione prima di usarle.

Un caso completo: dalla mail all'oggetto

L'estrazione strutturata da testo libero è l'uso più redditizio di questa tecnica, perché sostituisce lavoro umano ripetitivo con qualcosa di verificabile. Prendiamo una mail di richiesta d'ordine scritta come la scrivono i clienti veri, cioè male: «12 pz», «SKU 11» senza trattino, «una decina» che è un numero vago, e due righe con urgenze diverse. Lo schema deve prevederle tutte, invece di sperare.

import asyncio, re
from typing import Literal
from pydantic import BaseModel, Field
from agents import Agent, Runner
from lezioni import modello

MAIL = """
Buongiorno,
come da telefonata di ieri avremmo bisogno di 12 pz dello SKU 11 (quelli grigi)
e se possibile anche una decina dei 42. Per i 42 non c'è fretta.
Spedire a Via Roma 3, Lecce. Fatturate come sempre alla Brambilla.
Grazie
Marco
"""

class Riga(BaseModel):
    ragionamento: str = Field(max_length=300,
        description="Da quale frase della mail ricavi questa riga. Citala.")
    sku_grezzo: str = Field(description="Il codice COSÌ COME compare nel testo")
    quantita: int | None = Field(description="Numero esatto, oppure null se il testo è vago")
    quantita_testuale: str | None = Field(description="La dizione originale, es. 'una decina'")
    urgenza: Literal["standard", "urgente", "non_specificata"]

class Estrazione(BaseModel):
    righe: list[Riga]
    indirizzo_spedizione: str | None
    cliente_fatturazione: str | None
    campi_incerti: list[str] = Field(
        description="Nomi dei campi su cui NON sei sicuro. Lista vuota se tutto è chiaro.")

estrattore = Agent(
    name="Estrattore ordini",
    instructions=(
        "Estrai le righe d'ordine dalla mail. Regole non negoziabili:\n"
        "1. Non normalizzare i codici: riportali come appaiono.\n"
        "2. Se una quantità è vaga ('una decina', 'alcuni'), metti quantita=null "
        "e riporta la dizione in quantita_testuale. NON indovinare un numero.\n"
        "3. Ogni riga deve citare la frase da cui proviene.\n"
        "4. Elenca in campi_incerti tutto ciò su cui hai dubbi."
    ),
    output_type=Estrazione,
    model=modello(),
)

def normalizza_sku(grezzo: str) -> str | None:
    """Deterministica: 'SKU 11', 'sku-11' e '11' diventano 'SKU-11'. Altro: None."""
    m = re.fullmatch(r"(?:sku[\s_-]*)?0*(\d+)", grezzo.strip(), flags=re.IGNORECASE)
    return f"SKU-{m.group(1)}" if m else None

async def main():
    e = (await Runner.run(estrattore, MAIL)).final_output
    for r in e.righe:
        print(normalizza_sku(r.sku_grezzo), r.quantita, repr(r.quantita_testuale), r.urgenza)
    vaghe = [r.sku_grezzo for r in e.righe if r.quantita is None]
    if e.campi_incerti or vaghe:
        print("coda di revisione umana:", e.campi_incerti, vaghe)
    else:
        print("passa in automatico")

asyncio.run(main())

# Uscita:
# SKU-11 12 '12 pz' non_specificata
# SKU-42 None 'una decina' standard
# coda di revisione umana: ['quantita della seconda riga'] ['42']

Attenzione a cosa prova questo listato: la risposta del modello è, ancora una volta, il copione che ho scritto io (due righe, la seconda con quantita nulla e la dizione «una decina»). Non dice che un modello vero estrarrebbe così. Prova invece che lo schema con int | None è accettato dall'SDK, che l'oggetto torna tipizzato, e che il codice a valle può instradare il caso: dal momento che una quantità è nulla e campi_incerti non è vuota, la mail finisce in revisione umana. La funzione normalizza_sku è deterministica; sui casi limite:

for prova in ("SKU 11", "sku-11", "11", "0042", "codice 11", "SKU-11-grigio"):
    print(f"{prova!r:16} -> {normalizza_sku(prova)}")

# Uscita:
# 'SKU 11'         -> SKU-11
# 'sku-11'         -> SKU-11
# '11'             -> SKU-11
# '0042'           -> SKU-42
# 'codice 11'      -> None
# 'SKU-11-grigio'  -> None

Quattro scelte di progetto, ognuna con la sua ragione.

  • ragionamento è il primo campo di Riga. Se la citazione viene prima, il modello la cerca nel testo prima di decidere i valori; spostata in fondo diventa una giustificazione a posteriori.
  • quantita: int | None più quantita_testuale. Senza il None, «una decina» diventa 10 e tu non sapresti mai che era un'approssimazione del cliente. È il pattern generale per chiedere a un modello di non inventare: dagli un posto dove mettere l'incertezza.
  • sku_grezzo invece di sku normalizzato. La normalizzazione è una funzione deterministica di cinque righe (quella sopra); chiederla al modello è pagare token per un risultato probabilistico. Il modello estrae, il tuo codice normalizza.
  • campi_incerti. È il canale per far emergere i dubbi: un'estrazione con quella lista non vuota va in una coda di revisione, non in automatico.

Le quattro scelte hanno una logica sola, ripetuta quattro volte: dare al modello un posto dove mettere ciò che non sa, e lasciare al codice tutto ciò che si può decidere con una regola. Il prezzo è uno schema più largo, con campi che sembrano ridondanti; il guadagno è che ogni dubbio diventa un dato, non una cifra falsa.

Il campo che rende una risposta verificabile

C'è un'idea in questa lezione che torna in tutte le successive: uno schema può costringere il modello a dichiarare su cosa si basa. Non impedisce la bugia, ma la rende confrontabile, e quindi rilevabile da codice. Le forme sono tre, in ordine di forza.

La più debole: l'elenco delle fonti usate. Un dati_usati: list[str] con i nomi dei tool consultati. Il modello potrebbe scriverci qualunque cosa, ma tu hai il registro reale delle chiamate nella run, new_items, e puoi confrontare le due liste. Qui il copione dichiara leggi_prezzi, un tool che non esiste e che non è mai stato chiamato:

from agents import ToolCallItem

def tracciabilita_coerente(esito) -> dict:
    """Confronta ciò che il modello DICE di aver usato con ciò che ha usato."""
    dichiarati = set(esito.final_output.dati_usati)
    reali = {i.raw_item.name for i in esito.new_items if isinstance(i, ToolCallItem)}
    return {"coerente": dichiarati == reali,
            "dichiarati_mai_chiamati": sorted(dichiarati - reali),   # il modello ha inventato
            "chiamati_non_dichiarati": sorted(reali - dichiarati)}   # ha omesso

async def prova():
    esito = await Runner.run(agente, "Lo SKU-11 va riordinato?")
    print(tracciabilita_coerente(esito))

asyncio.run(prova())

# Uscita:
# {'coerente': False, 'dichiarati_mai_chiamati': ['leggi_prezzi'], 'chiamati_non_dichiarati': []}

La funzione legge il nome dai soli elementi di tipo ToolCallItem. Un tool «dichiarato ma mai chiamato» è la firma di una risposta costruita a memoria e poi vestita di credibilità; il caso opposto, «chiamato ma non dichiarato», è un'omissione, meno grave. Il segnale costa un campo nello schema e sei righe di confronto.

Più forte: la citazione letterale. Un campo citazione: str con l'obbligo di riportare la porzione esatta del testo di partenza. La verifica è meccanica: la stringa c'è nel documento o non c'è.

def citazione_valida(testo_fonte: str, citazione: str, lunghezza_minima: int = 3) -> bool:
    """La citazione deve comparire nella fonte, a meno di spazi e maiuscole."""
    def norm(s: str) -> str:
        return " ".join(s.lower().split())
    if len(citazione.strip()) < lunghezza_minima:
        return False                          # citazione vuota o inutile
    return norm(citazione) in norm(testo_fonte)

print(citazione_valida(MAIL, "per i 42 non c'è   fretta"))
print(citazione_valida(MAIL, "consegna entro venerdì"))
print(citazione_valida(MAIL, "ok"))

# Uscita:
# True
# False
# False

Le tre uscite si leggono nell'ordine: la prima citazione c'è (spazi e maiuscole sono normalizzati), la seconda non compare nella mail, la terza è troppo corta per essere significativa. Questa funzione trasforma «il modello ha allucinato?» da giudizio soggettivo a controllo booleano, senza un secondo modello. Ha un limite: una citazione vera attaccata a una conclusione sbagliata passa.

La più forte: il riferimento a un'entità del tuo sistema. Un campo ordine_numero: str non si verifica per somiglianza ma con una query: se il numero non esiste o non appartiene all'utente autenticato, la risposta è falsa e lo sai. Qui non c'è un listato perché dipende dal tuo database. La regola generale è la stessa delle altre due: ogni volta che puoi sostituire un giudizio con un confronto, fallo.

Dove sta il guadagno vero: questi campi non migliorano la risposta, la rendono controllabile. Se, per fare un esempio ipotetico, su cento uscite quattro non superassero i controlli automatici, un umano ne rivedrebbe quattro e non cento. Il numero è inventato per l'esempio; il meccanismo, che la revisione umana si sposta dal tutto all'eccezione, è il punto.

Valutare un estrattore (perché «sembra funzionare» non basta)

Uno schema valido non dice nulla sulla correttezza. Per saperlo serve un insieme di casi annotati a mano (una ventina o una trentina di mail, con l'estrazione giusta scritta da te) e tre misure che si comportano in modo diverso. Nel listato ogni caso non conforme conta come interamente sbagliato anche per l'accuratezza per campo, altrimenti i casi peggiori sparirebbero dal denominatore e la misura risulterebbe gonfiata.

from agents.exceptions import ModelBehaviorError

async def valuta(agente, casi: list[tuple[str, dict]]) -> dict:
    """casi = [(testo, estrazione_attesa_come_dict), ...]"""
    conformi = esatti = campi_giusti = campi_totali = 0
    for testo, atteso in casi:
        campi_totali += len(atteso)                    # un caso non conforme conta come tutto sbagliato
        try:
            ottenuto = (await Runner.run(agente, testo)).final_output.model_dump()
        except ModelBehaviorError:
            continue                                   # non conforme: nessun campo giusto
        conformi += 1
        esatti += int(ottenuto == atteso)
        campi_giusti += sum(ottenuto.get(k) == v for k, v in atteso.items())
    return {"conformita": round(conformi / len(casi), 3),      # lo schema regge?
            "esattezza_totale": round(esatti / len(casi), 3),  # tutto giusto?
            "accuratezza_per_campo": round(campi_giusti / max(campi_totali, 1), 3)}

ATTESO = {"sku": "SKU-11", "esito": "non_riordinare", "quantita": 0,
          "motivo": "4 in giacenza ma 20 in arrivo",
          "dati_usati": ["leggi_giacenza", "leggi_ordini_in_arrivo"]}
casi_di_prova = [("Lo SKU-11 va riordinato?", ATTESO)] * 3

print(asyncio.run(valuta(agente, casi_di_prova)))

# Uscita:
# {'conformita': 0.667, 'esattezza_totale': 0.333, 'accuratezza_per_campo': 0.4}

I tre casi della prova sono tutti la stessa domanda, con tre risposte scritte da me: una esatta, una conforme ma con quattro campi su cinque sbagliati, una non conforme («non lo so»). Perciò la conformità è 2/3 (0,667), l'esattezza totale 1/3 (0,333), e l'accuratezza per campo 6/15 (0,4): cinque campi giusti nel primo caso, uno nel secondo, zero nel terzo. La conformità misura il meccanismo: se è sotto 1 con decodifica vincolata c'è qualcosa che non va nello schema, se è bassa senza decodifica ti serve il percorso di recupero. L'esattezza totale è severa e realistica, perché un campo sbagliato su otto rende sbagliata l'estrazione se il risultato va in automatico. L'accuratezza per campo ti dice dove intervenire: scoprire (esempio ipotetico) che l'urgenza è giusta nel 96% dei casi e l'indirizzo nel 71% indirizza il lavoro, mentre un solo punteggio lo nasconde. Quei trenta casi annotati sono la parte più noiosa e più redditizia: senza, ogni modifica allo schema o al prompt è una scommessa.

Il mito da smontare

Lo schema garantisce la forma, non la verità. Un oggetto Decisione(esito="riordina", quantita=250, motivo="giacenza sotto soglia") è perfettamente valido anche quando la giacenza è di 1200 pezzi. La decodifica vincolata e la validazione eliminano gli errori di parsing, non le allucinazioni: hanno reso il dato processabile, non corretto. La verifica di merito resta tua, e si fa confrontando i campi con la fonte: se dati_usati è vuoto, la decisione non si appoggia a nulla.

Quando NON usare l'output strutturato

Sono quattro casi, riconoscibili da sintomi precisi.

Quando il destinatario è un essere umano. Se la risposta va letta da una persona, la prosa è il formato giusto. Costringere a uno schema un testo che poi ricomponi in frasi con un template significa pagare due volte. La regola: struttura ciò che viene elaborato da codice, prosa ciò che viene letto da persone; se servono entrambe le cose, due campi, uno strutturato per il sistema e uno di testo per l'utente.

Quando non sai ancora che forma hanno i dati. Nelle prime esplorazioni su un corpus sconosciuto, lo schema che scriveresti oggi è probabilmente sbagliato: lo scopri dopo aver guardato cinquanta esempi. Conviene chiedere al modello descrizioni libere, raccoglierle e derivare da quelle lo schema; partire dallo schema significa proiettare le proprie aspettative, e i campi che non avevi previsto non li vedrai mai.

Quando il compito è intrinsecamente aperto. «Scrivi tre idee per questo articolo»: la varietà è il valore. Un idee: list[str] funziona, ma dodici campi obbligatori trasformano un lavoro creativo in un modulo.

Quando il modello non regge lo schema e non hai una rete di sicurezza. Se dichiari un tipo e non implementi il percorso di recupero visto sopra, il tuo codice fallirà in produzione su una frazione di richieste, apparentemente a caso: ModelBehaviorError senza motivo. Meglio chiedere prosa e parsarla con tolleranza che chiedere uno schema che non puoi far rispettare.

Lo schema è un contratto: versioni e compatibilità

Il tipo di output di un agente è un'interfaccia fra due pezzi di software, e come ogni interfaccia cambia nel tempo. A differenza di una normale dataclass, però, c'è un terzo attore che deve «capire» il contratto e non ha una versione: il modello. Tre regole evitano la maggior parte dei guai.

Aggiungere campi è compatibile, togliere e rinominare non lo è. Rinominare esito in decisione rompe ogni consumatore e invalida in silenzio i casi di prova annotati. Attenzione però a una conseguenza dello schema strict vista sopra: un campo nuovo con default finisce comunque in required, quindi il modello deve emetterlo; il default aiuta solo il tuo codice quando costruisci l'oggetto a mano.

Allargare un enum è rischioso in modo asimmetrico. Se aggiungi "rinviato" a un Literal che aveva tre voci, il modello inizierà a usarlo, ed è quello che volevi, ma il codice a valle che faceva if esito == "riordina": ... else: non_riordinare() manderà in «non riordinare» anche i casi rinviati. La difesa è un match esaustivo con un ramo finale che solleva invece di assumere un default. Per simulare lo schema allargato uso model_construct, che crea l'oggetto senza validarlo.

coda_revisione: list[Decisione] = []

def applica(d: Decisione) -> None:
    match d.esito:
        case "riordina":
            crea_proposta(d.sku, d.quantita)
        case "non_riordinare":
            pass
        case "dati_insufficienti":
            coda_revisione.append(d)
        case altro:
            # un valore nuovo nello schema arriva QUI, non nel ramo sbagliato
            raise ValueError(f"esito non gestito: {altro!r} - aggiorna applica()")

base = dict(sku="SKU-11", quantita=250, motivo="sotto soglia", dati_usati=[])
applica(Decisione(esito="riordina", **base))
applica(Decisione(esito="dati_insufficienti", **base))
print("in revisione:", len(coda_revisione))
try:
    # simula uno schema allargato: model_construct salta la validazione
    applica(Decisione.model_construct(esito="rinviato", **base))
except ValueError as e:
    print("ValueError:", e)

# Uscita:
# proposta d'ordine: 250 x SKU-11
# in revisione: 1
# ValueError: esito non gestito: 'rinviato' - aggiorna applica()

Nell'uscita si vedono le tre strade: «riordina» crea la proposta, «dati_insufficienti» finisce nella coda di revisione, e il valore nuovo «rinviato» non cade in silenzio in un ramo sbagliato ma arriva alla riga che solleva. Quell'errore, in produzione, lo vedi il primo giorno e non il terzo mese.

Versiona lo schema insieme ai casi di prova. Schema, prompt e insieme di casi annotati sono un'unica unità: cambiarne uno invalida le misure fatte con gli altri. Tenerli nella stessa cartella e nello stesso commit è ciò che permette di dire «questa versione è migliore della precedente» con un confronto onesto.

Un agente con un tipo di output dichiarato è un componente, non un prodotto: è la proprietà che lo rende usabile come sotto-agente in un'orchestrazione (09-09), come classificatore dentro un guardrail (09-10) e come unità testabile in una suite.

Trade-off

  • Struttura contro flessibilità. Un tipo chiuso ti dà controllo e taglia fuori le risposte che non avevi previsto, comprese quelle corrette. Mitigazione: un campo note: str dove il modello può aggiungere ciò che lo schema non prevede.
  • Qualità contro vincolo. Vincolare il formato può peggiorare il contenuto, perché il modello perde lo spazio in cui ragionava scrivendo. Rimedio: un campo ragionamento: str prima dei campi di decisione.
  • Portabilità. La decodifica vincolata è una funzionalità del server, non dell'SDK. Lo stesso codice su un altro server o un altro modello può ricadere sull'istruzione nel prompt: serve il percorso di recupero, altrimenti funziona in sviluppo e non altrove.
  • Costo del retry. Ogni ritentativo è una run intera in più, con i suoi token e la sua latenza.

Le quattro voci sono la stessa tensione vista da lati diversi: ogni garanzia che aggiungi (tipo chiuso, vincolo, validazione, ritentativo) si paga in libertà del modello, in latenza o in codice di recupero. Per un componente che alimenta un altro programma il prezzo vale; per una risposta che legge una persona, di solito no.

Le trappole in cui si cade davvero

  1. Catturare l'eccezione sbagliata. Con output_type l'SDK solleva ModelBehaviorError, senza il campo incriminato; ValidationError non arriva al tuo except.
  2. Mescolare tool e output strutturato senza prevedere l'errore. Se un tool fallisce e lo schema non ha un valore per «non ho potuto decidere», il modello riempie i campi con qualcosa. Lo stato «non lo so» è obbligatorio.
  3. Tutto nullable. Se ogni campo è X | None, il modello può restituire un oggetto di soli null, validissimo. Rendi non nullabile ciò che ti serve davvero.
  4. Mettere il ragionamento dopo la decisione. Un campo motivo dopo esito è una giustificazione a posteriori di una scelta già fatta.
  5. Tipi che lo schema strict non esprime bene. dict[str, int] è rifiutato, Decimal diventa numero-o-stringa: usa str con un formato dichiarato («data ISO 8601, es. 2026-10-09») e converti tu.
  6. Credere che un oggetto valido sia un oggetto vero. È la trappola principale di questa lezione, ed è il motivo per cui esiste il campo di tracciabilità.

L'ordine qui non conta, a differenza dei passi di una procedura: sono sei modi indipendenti di sbagliare. I primi due si scoprono alla prima prova, il terzo e il quarto restano silenziosi finché qualcuno non guarda i dati, e gli ultimi due sono quelli che arrivano in produzione.

Che cosa sai fare adesso

Sai dichiarare il tipo dell'output di un agente e leggere lo schema che l'SDK ne ricava; sai che cosa vede il tuo codice quando la risposta è difettosa e come ricavare il motivo preciso per un ritentativo; sai progettare uno schema che lascia al modello un posto per l'incertezza, e aggiungere un campo che renda la risposta confrontabile con la realtà. Resta fuori ciò che qui non ho potuto provare: come un modello vero, su Ollama, riempie e rispetta uno schema. Nella prossima lezione affrontiamo la parte che rende una conversazione una conversazione: la memoria, e il suo costo.

Fonti

  • OpenAI Agents SDK: Agents - il parametro output_type, i tipi accettati (anche dataclass e TypedDict), e il fatto che attiva gli structured outputs, consultata il 2026-11-12
  • OpenAI Agents SDK: Agent output - AgentOutputSchema, validate_json che deve sollevare ModelBehaviorError se il JSON non è valido, consultata il 2026-11-12; il messaggio senza dettaglio, run_data nullo e i casi della tabella sono osservazioni mie con la 0.24.0
  • Ollama: Structured outputs - imposizione di uno schema JSON sulle risposte e uso via response_format nell'API compatibile, consultata il 2026-11-12
  • Ollama: OpenAI compatibility - response_format fra i campi supportati di /v1/chat/completions, consultata il 2026-11-12
  • Pydantic: JSON - model_validate_json e validate_json con validazione strict, consultata il 2026-11-12
  • Pydantic: JSON Schema - come i tipi diventano schema (Literal in enum, ge/le in minimum/maximum), consultata il 2026-11-12
  • JSON Schema 2020-12: Validation - le parole chiave minimum, maximum, maxLength citate nello schema, consultata il 2026-11-12
  • Python: l'istruzione match - il case con cattura finale usato in applica, consultata il 2026-11-12

Eseguiti due volte con uscita identica (Python 3.14.4, openai-agents 0.24.0, pydantic 2.14.3) contro un modello finto con trasporto HTTP simulato: schema, validazione, otto risposte difettose, schema tollerante e ritentativo, estrazione dalla mail, tracciabilità, citazione, valutazione, match esaustivo, tipi nello schema. Non eseguiti: l'illustrazione token per token (concettuale), il campo con riferimento a un'entità del tuo sistema, e qualunque chiamata a un modello vero (nemmeno Ollama). Gli esempi percentuali (4 su 100, 96% e 71%) sono ipotetici.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti