7. Output Strutturato con Pydantic
Nei capitoli precedenti, abbiamo lavorato con gli output degli LLM come stringhe di testo grezze. Questo funziona bene per i chatbot dove gli esseri umani leggono le risposte, ma quando si costruiscono agenti AI dove i programmi devono analizzare e interpretare gli output degli LLM, abbiamo bisogno di dati strutturati e prevedibili. In questo capitolo, imparerai come usare gli schemi Pydantic per far sì che l'LLM restituisca oggetti Python strutturati.
7.1) Perché l'Output Strutturato?
Il Problema con l'Output LLM in Testo Libero
Iniziamo comprendendo perché le risposte in testo grezzo creano problemi nelle applicazioni reali. Considera questo scenario comune:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini")
# Chiedi all'LLM informazioni su un prodotto
message = HumanMessage(content="""
Estrai le informazioni sul prodotto da questo testo:
"L'UltraWidget Pro costa $299.99 ed è attualmente disponibile."
""")
response = llm.invoke([message])
print(response.content)Output:
Nome Prodotto: UltraWidget Pro
Prezzo: $299.99
Disponibilità: DisponibileL'output sembra buono. Ma ora supponi di dover usare questi dati nella tua applicazione Python. Come estrai il prezzo come numero? Come verifichi la disponibilità programmaticamente? Potresti provare un'analisi di stringhe come questa:
# Approccio di analisi fragile
text = response.content
price_line = [line for line in text.split('\n') if 'Prezzo:' in line][0]
price_str = price_line.split('$')[1]
price = float(price_str) # Fragile - cosa succede se il formato cambia?Questa analisi sembra funzionare. Ma in realtà non funziona. Ecco perché:
Perché Questo Approccio Fallisce:
- L'LLM potrebbe formattare la risposta in modo diverso la prossima volta ("Prezzo: 299.99 USD" o "Prezzo al Dettaglio: $299.99")
Ecco esempi di output diversi che possono verificarsi per lo stesso prompt:
# Esempio 1
"Il prodotto è UltraWidget Pro, al prezzo di $299.99, ed è disponibile."
# Esempio 2
"Prodotto: UltraWidget Pro
Costo: 299.99 dollari
Stato: Disponibile"
# Esempio 3
"UltraWidget Pro - $299.99 (disponibile)"
# Esempio 4
"Ho trovato l'UltraWidget Pro. Costa $299.99 ed è attualmente disponibile per l'acquisto."Quando la risposta dell'LLM cambia, hai bisogno di una logica di analisi completamente diversa. Questo rende difficile costruire applicazioni affidabili.
- Le risposte degli LLM sono imprevedibili: Lo stesso prompt può produrre formati diversi ogni volta
- L'analisi di stringhe è più difficile di quanto sembri: Devi gestire
$, spazi, newline, virgole e altro - Nessuna type safety: Non puoi essere sicuro se
pricesia un float, una stringa o None - La gestione degli errori è difficile: Se l'LLM dice "Prezzo non disponibile", la tua chiamata
float()si blocca - Non manutenibile: Cambia leggermente il prompt e devi riscrivere tutto il codice di analisi
L'Idea Centrale: Python Ha Bisogno di Contratti, Non di Prosa
Pensa a quando comunichi con un server API in Python. Quando chiami una specifica API REST, ti aspetti che restituisca una risposta JSON definita:
# Ti aspetti questa struttura
{
"product_name": "UltraWidget Pro",
"price": 299.99,
"in_stock": true
}Quando costruisci applicazioni AI, hai bisogno dello stesso principio. L'output dell'LLM dovrebbe essere restituito come dati con una struttura definita, non come testo in forma libera ogni volta.
Prosa vs Contratto:
- Prosa: Testo naturale in forma libera. Buono per gli esseri umani da leggere, ma difficile da elaborare per i programmi.
- Contratto: Dati con struttura e tipi definiti. Una promessa che "questi campi esisteranno con questi tipi."
Output strutturato significa definire un contratto: "LLM, ho bisogno esattamente di questi campi, con esattamente questi tipi, in esattamente questo formato."
È qui che entra in gioco Pydantic. Pydantic è la libreria di validazione dati più popolare di Python, e LangChain la usa per ricevere gli output degli LLM in forma strutturata.
Il Cambio di Modello Mentale:
- Prima: "LLM, dimmi qualcosa su questo prodotto" → Analizza testo imprevedibile
- Dopo: "LLM, rispondi in un formato definito" → Ricevi oggetto Python strutturato
Questo passaggio dalla prosa ai contratti è fondamentale per costruire agenti AI affidabili. Quando un agente deve decidere la sua prossima azione basandosi sulle risposte dell'LLM (ad esempio, acquistare se disponibile, registrarsi per una notifica se non disponibile), deve ricevere risposte in un formato definito.
7.2) Il Tuo Primo Output Strutturato
In 7.1, abbiamo imparato perché gli LLM dovrebbero rispondere con una struttura definita invece di testo in forma libera. Ora vediamo come implementarlo effettivamente.
L'idea chiave: Semplicemente chiedere all'LLM "per favore rispondi in questo formato" non è sufficiente. Devi definire l'esatta struttura dati nel codice Python e far sì che LangChain la passi all'LLM. Questa struttura dati definita è chiamata schema.
Cos'è uno Schema?
Uno schema è un progetto che definisce la struttura dei dati. Specifica:
- Quali campi devono essere presenti
- Quale tipo dovrebbe avere ogni campo (stringa, numero, booleano, ecc.)
- Quali vincoli si applicano (opzionale vs obbligatorio, intervalli validi, ecc.)
In Python, definiamo gli schemi usando la classe BaseModel di Pydantic. Ecco l'esempio più semplice possibile:
from pydantic import BaseModel
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: boolQuesto schema dice: "Un oggetto ProductInfo deve avere esattamente tre campi: un product_name (stringa), un price (float) e un in_stock (booleano)."
Il Pattern in Tre Passi: Definire, Collegare, Invocare
Usare l'output strutturato è semplice. Ricorda solo tre passi:
- Definire: Crea uno schema con una classe Pydantic
- Collegare: Connetti lo schema all'LLM usando
.with_structured_output() - Invocare: Chiama
.invoke()per ottenere un oggetto tipizzato
Questo è il template standard che userai per la maggior parte delle attività di estrazione strutturata:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
# Passo 1: Definire lo schema
class ProductInfo(BaseModel):
product_name: str = Field(description="Il nome completo del prodotto")
price: float = Field(description="Prezzo in USD")
in_stock: bool = Field(description="Se il prodotto è disponibile")
# Passo 2: Collegare lo schema all'LLM
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
# Passo 3: Invocare e ottenere oggetto tipizzato
message = HumanMessage(content="""
Estrai le informazioni sul prodotto da questo testo:
"L'UltraWidget Pro costa $299.99 ed è attualmente disponibile."
""")
result = structured_llm.invoke([message])
# result è ora un oggetto ProductInfo, non una stringa
print(type(result)) # <class '__main__.ProductInfo'>
print(result.product_name) # UltraWidget Pro
print(result.price) # 299.99
print(result.in_stock) # TrueCosa È Appena Successo?
- Definizione Schema: Abbiamo definito i campi e i tipi che vogliamo
- Collegamento:
.with_structured_output(ProductInfo)configura l'LLM per usare l'output strutturato - Invocazione & Risposta: Quando viene chiamato
.invoke(), LangChain passa lo JSON Schema all'LLM, e l'LLM risponde con JSON che corrisponde a quella struttura - Conversione Automatica: LangChain converte il JSON in un oggetto
ProductInfo- nessun codice di analisi necessario
Nessuna analisi. Nessuna conversione di tipo. Nessun errore.
Usa questo pattern in 3 passi come tuo template. Seguilo ogni volta che hai bisogno di output strutturato.
Descrizioni dei Campi: La Chiave per Guidare l'LLM
Nell'esempio di definizione dello schema sopra, abbiamo usato Field(description="..."). Questa descrizione non è solo documentazione. Sono istruzioni che l'LLM legge e segue.
Nell'uso tipico di Pydantic, le descrizioni dei Field sono opzionali:
# Pydantic normale - la descrizione è documentazione per gli esseri umani
class User(BaseModel):
name: str = Field(description="Nome dell'utente") # Funziona bene anche senzaMa quando si lavora con gli LLM, sono essenziali:
# Con gli LLM - la descrizione determina il comportamento dell'LLM
class CustomerFeedback(BaseModel):
sentiment: str = Field(
description="Sentimento generale: 'positive', 'negative' o 'neutral'"
)L'LLM legge questa descrizione e la usa per decidere come rispondere.
Vediamolo in azione:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
class CustomerFeedback(BaseModel):
sentiment: str = Field(
description="Sentimento generale: 'positive', 'negative' o 'neutral'"
)
main_issue: str = Field(
description="Il reclamo o la preoccupazione principale, se presente. Usa 'none' se non vengono menzionati problemi."
)
urgency: str = Field(
description="Quanto è urgente il problema: 'low', 'medium' o 'high'"
)
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(CustomerFeedback)
message = HumanMessage(content="""
Analizza questo feedback del cliente:
"Il prodotto funziona bene, ma la spedizione ha richiesto 3 settimane. Ho già perso la scadenza del mio progetto. Per favore rispondete immediatamente."
""")
result = structured_llm.invoke([message])
print(result.sentiment) # negative
print(result.main_issue) # Spedizione lenta
print(result.urgency) # highCome le descrizioni modellano le decisioni dell'LLM:
- Descrizione di
sentiment→ L'LLM apprende che i valori validi sono 'positive', 'negative', 'neutral' → Il problema di spedizione ha causato la perdita della scadenza, quindi sceglie 'negative' - Descrizione di
main_issue→ L'LLM è istruito a "trovare il reclamo principale" → Identifica "spedizione lenta" come il problema - Descrizione di
urgency→ L'LLM apprende che l'urgenza deve essere 'low', 'medium' o 'high' → Vede "Per favore rispondete immediatamente" e sceglie 'high'
Cosa succede senza descrizioni?
sentiment: str # Nessuna descrizioneL'LLM potrebbe restituire "negative", "bad", "unsatisfied", "2/5", "disappointed" in formati imprevedibili, rendendo difficile per il tuo codice gestire i valori.
Punto chiave: Le descrizioni dei campi sono parte del tuo codice che controlla il comportamento dell'LLM. Scrivile in modo chiaro e specifico.
Campi Categorici: Specificare i Valori Consentiti
Nell'esempio sopra, il campo sentiment può avere solo tre valori: 'positive', 'negative' o 'neutral'. I campi che devono essere uno di un insieme specifico di valori sono chiamati campi categorici.
Per i campi categorici, elenca tutti i valori possibili nella descrizione:
sentiment: str = Field(
description="Sentimento: esattamente 'positive', 'negative' o 'neutral' (minuscolo)"
)Specificando "esattamente" e "(minuscolo)", enfatizziamo che l'LLM dovrebbe rispondere con precisamente uno di questi tre valori.
Tuttavia, non c'è garanzia che l'LLM risponda sempre con uno dei valori specificati. Ecco perché devi scrivere codice difensivo.
Casi in cui l'LLM restituisce valori inaspettati:
result.sentiment = "Positive" # Maiuscolo
result.sentiment = "NEGATIVE" # Tutto maiuscolo
result.sentiment = "good" # Parola completamente diversaScrivere codice difensivo:
allowed = {"positive", "negative", "neutral"}
# Converti in minuscolo e verifica
sentiment = result.sentiment.lower()
if sentiment not in allowed:
sentiment = "neutral" # Usa il valore predefinito per valori inaspettati
# Ora sentiment è garantito essere uno dei valori consentitiConclusione chiave:
- Specifica i valori consentiti nella descrizione → L'LLM è più probabile che risponda correttamente
- Valida nel codice → Gestisci i valori inaspettati in modo sicuro
Nota: Il Capitolo 18 mostra pattern più forti usando gli enum di Python per l'applicazione.
Confronto: Analisi Manuale vs Output Strutturato
Confrontiamo lo stesso compito con e senza output strutturato per vedere la differenza:
Analisi Manuale:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini")
message = HumanMessage(content="""
Estrai il nome del prodotto, il prezzo e la disponibilità da:
"L'UltraWidget Pro costa $299.99 ed è attualmente disponibile."
Formato: nome | prezzo | disponibilità
""")
response = llm.invoke([message])
text = response.content
# Analisi manuale
parts = text.split('|')
product_name = parts[0].strip()
price_str = parts[1].strip().replace('$', '')
price = float(price_str)
availability = parts[2].strip().lower()
in_stock = 'disponibile' in availability or 'available' in availability
print(f"Nome: {product_name}")
print(f"Prezzo: ${price}")
print(f"Disponibile: {in_stock}")Output Strutturato:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
class ProductInfo(BaseModel):
product_name: str = Field(description="Il nome completo del prodotto")
price: float = Field(description="Prezzo in USD")
in_stock: bool = Field(description="Se il prodotto è disponibile")
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
message = HumanMessage(content="""
Estrai le informazioni sul prodotto da:
"L'UltraWidget Pro costa $299.99 ed è attualmente disponibile."
""")
result = structured_llm.invoke([message])
print(f"Nome: {result.product_name}")
print(f"Prezzo: ${result.price}")
print(f"Disponibile: {result.in_stock}")Differenze Chiave:
- Nessuna Logica di Analisi: La versione strutturata ha zero codice di analisi
- Type Safety:
result.priceè garantito essere un float - Codice Più Semplice: Nessun regex, nessuna divisione di stringhe, nessuna conversione manuale di tipo
- Validazione: Pydantic assicura che tutti i campi richiesti siano presenti
- Manutenibilità: Cambiare lo schema è più facile che aggiornare la logica di analisi
7.3) Considerazioni sulla Progettazione dello Schema
Ora che sai come usare l'output strutturato, impariamo come progettare buoni schemi. Questa sezione copre i principi pratici di progettazione per distinguere i campi obbligatori da quelli opzionali.
Campi Obbligatori
Per impostazione predefinita, tutti i campi in un modello Pydantic sono obbligatori. Questo significa che l'LLM deve estrarre o inferire un valore per ogni campo obbligatorio dal prompt dell'utente e fornirlo nella risposta.
from pydantic import BaseModel
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: boolQuando usi questo schema, l'LLM tenterà di trovare valori per tutti e tre i campi (product_name, price, in_stock) nel testo di input.
Ma cosa succede quando il prompt manca di informazioni per un campo obbligatorio?
Potremmo aspettarci il seguente comportamento:
- L'LLM non può trovare le informazioni nel prompt
- L'LLM omette quel campo dalla sua risposta
- LangChain non può creare un'istanza valida di
ProductInfo - Viene sollevato
ValidationError
Tuttavia, questo non sempre accade.
Il motivo è che diversi LLM possono gestire le informazioni mancanti in modo diverso.
Alcuni LLM (come i modelli OpenAI) tendono a generare valori anche quando le informazioni richieste non sono presenti nel prompt. In questo caso, ValidationError non si verifica, ma questo può causare problemi più grandi perché la tua applicazione Python potrebbe elaborare informazioni fabbricate come se fossero reali.
Affronteremo come risolvere questo problema nella Sezione 7.4: Quando le Cose Vanno Male.
Per ora, sii solo consapevole che non tutti gli LLM gestiscono le informazioni mancanti allo stesso modo.
Campi Opzionali
Potresti aver bisogno di campi che possono legittimamente essere presenti o assenti, anche in casi normali. Ad esempio, una nota di consegna (delivery_note) può essere fornita o meno dal cliente, anche per un ordine valido.
Quando usare Optional:
- I dati stessi potrebbero non esistere (ad esempio, quando sono consentite recensioni anonime, le recensioni anonime non hanno nome del recensore)
- Vuoi che l'LLM indichi esplicitamente le informazioni mancanti piuttosto che fabbricare un valore
Per rendere un campo opzionale, usa il tipo Optional di Python dal modulo typing:
from typing import Optional
class ProductReview(BaseModel):
rating: int
review_text: str
reviewer_name: Optional[str] = None # Le recensioni anonime non hanno nome del recensoreNota: Gli utenti di Python 3.10+ possono usare
str | Noneinvece diOptional[str].
Quando un campo è Optional:
- L'LLM può ometterlo dalla risposta se le informazioni non vengono trovate nel prompt
- I campi omessi vengono impostati al valore predefinito (
None)
Ecco un esempio completo:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
from typing import Optional
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
discount_percentage: Optional[float] = None
warranty_years: Optional[int] = None
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
message = HumanMessage(content="""
Estrai le informazioni sul prodotto: "L'UltraWidget Pro costa $299.99 ed è disponibile."
""")
result = structured_llm.invoke([message])
print(result.product_name) # UltraWidget Pro
print(result.price) # 299.99
print(result.in_stock) # True
print(result.discount_percentage) # None (non menzionato)
print(result.warranty_years) # None (non menzionato)Checklist per la Progettazione dello Schema
Prima di finalizzare il tuo schema, chiediti:
Selezione dei Campi:
- I campi obbligatori sono veramente essenziali? (Cosa succede se questo campo manca dal prompt?)
- I campi opzionali possono legittimamente essere assenti anche in casi normali?
Specifica dei Campi:
- Ogni campo ha una descrizione chiara?
- I campi categorici sono esplicitamente vincolati? (ad esempio, "deve essere esattamente 'A', 'B' o 'C'")
7.4) Quando le Cose Vanno Male
Possono verificarsi due problemi quando si usa l'output strutturato:
- L'LLM omette i valori dei campi obbligatori → Si verifica ValidationError
- L'LLM fabbrica informazioni mancanti → Nessun ValidationError, ma il tuo codice elabora dati errati
Questa sezione copre come gestire ciascuno.
Comprendere gli Errori di Validazione
Quando il prompt dell'utente manca di informazioni per i campi obbligatori definiti nello schema, l'LLM non può fornire valori per quei campi. Il programma Python solleva quindi un ValidationError:
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, ValidationError
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
llm = ChatAnthropic(model='claude-sonnet-4-5')
structured_llm = llm.with_structured_output(ProductInfo)
# Input mancante di informazioni richieste
message = HumanMessage(content="""
Estrai le informazioni sul prodotto da: "Il widget è fantastico! Altamente raccomandato."
""")
try:
result = structured_llm.invoke([message])
print(result)
except ValidationError as e:
print("Si è verificato ValidationError")Nota: Quando il prompt manca di informazioni per i campi obbligatori, alcuni LLM possono fabbricare valori e fornirli nella risposta. In questo caso,
ValidationErrornon si verificherà, ma sorge un problema più grande. Lo tratteremo nella prossima sezione.
Quando il prompt manca di informazioni per i campi obbligatori e si verifica ValidationError, questo è effettivamente utile per la tua applicazione Python. L'applicazione può rilevare che si è verificato un problema e gestire l'errore in modo controllato. Le strategie di recupero dagli errori sono trattate nel Capitolo 14 (recupero dagli errori a livello di agente) e nel Capitolo 17 (logica di retry con gestione dello stato).
Il Problema Più Grande: L'LLM Fabbrica Informazioni Mancanti
Come abbiamo discusso nella Sezione 7.3, alcuni LLM mostrano un comportamento più pericoloso: fabbricano valori e li forniscono nelle risposte quando le informazioni mancano dal prompt.
Come si verifica il problema:
- Il prompt manca di informazioni richieste
- L'LLM genera comunque valori dall'aspetto plausibile
ValidationErrorNON si verifica- La tua applicazione Python elabora dati fabbricati come se fossero reali
Esempio:
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
message = HumanMessage(content="""
Estrai le informazioni sul prodotto da: "Il widget è fantastico!"
""")
# Quando alcuni LLM fabbricano valori per product_name, price e in_stock
result = structured_llm.invoke([message])
# Nessun errore sollevato!
print(result.product_name) # "widget" (estratto dal testo)
print(result.price) # 0.0 (fabbricato!)
print(result.in_stock) # False (fabbricato!)
# Problema: Non puoi dire quali valori sono reali vs fabbricatiQuesto è peggio di un ValidationError perché:
- La tua applicazione Python continua l'esecuzione con dati errati
- Non sai quali campi sono reali vs fabbricati
- La logica a valle può prendere decisioni sbagliate basate su dati falsi
Soluzione: Usa Campi Optional con Validazione
La soluzione è definire tutti i campi obbligatori come Optional, quindi usare un validatore per verificare che tutti i campi obbligatori abbiano valori.
from typing import Optional
from pydantic import BaseModel, model_validator, ValidationError
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
class ProductInfo(BaseModel):
# Questi sono effettivamente obbligatori, ma dichiarati come Optional
# La validazione reale avviene nel validatore sotto
product_name: Optional[str] = None
price: Optional[float] = None
in_stock: Optional[bool] = None
@model_validator(mode='after')
def check_required_fields(self):
"""Valida che tutti i campi essenziali siano presenti"""
if self.product_name is None or self.price is None or self.in_stock is None:
raise ValueError("Tutti i campi (product_name, price, in_stock) devono essere forniti")
return self
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
# Test con dati incompleti
message = HumanMessage(content="""
Estrai le informazioni sul prodotto da: "Il widget è fantastico!"
""")
try:
result = structured_llm.invoke([message])
# Se arriviamo qui, tutti i campi sono garantiti essere presenti
print(f"Prodotto: {result.product_name}")
print(f"Prezzo: ${result.price}")
except ValidationError as e:
# I campi obbligatori mancano - estrazione fallita
print(f"Estrazione incompleta: {e}")Cosa sta succedendo qui:
@model_validatorè il decoratore di Pydantic che aggiunge logica di validazione personalizzatamode='after'significa che la validazione viene eseguita dopo che tutti i campi sono stati analizzati- Se qualsiasi campo è
None, solleviamoValueErrorper segnalare dati incompleti - Pydantic avvolge automaticamente questo
ValueErrorin unValidationError
Perché funziona:
Quando il prompt manca di informazioni per i campi:
- L'LLM non fabbrica valori e omette quei campi dalla risposta
- In questo caso, quei campi diventano
None - Se quei campi sono effettivamente obbligatori, il validatore Pydantic solleva
ValueError - Pydantic lo avvolge come
ValidationError
In questo modo, la tua applicazione Python riceve un errore esplicito da gestire, piuttosto che dati fabbricati.
Conclusione chiave: Quando il prompt manca di informazioni per i campi obbligatori, ottenere un ValidationError è perfettamente normale e previsto. Il vero pericolo sono i dati fabbricati. Usa campi Optional con validatori per impedire all'LLM di fabbricare informazioni mancanti, rilevando esplicitamente quando i campi obbligatori mancano.
Riepilogo del Capitolo:
In questo capitolo, hai imparato come trasformare l'output degli LLM in oggetti Python affidabili:
- Perché è importante: L'analisi di testo libero è fragile; l'output basato su schema fornisce type safety
- Come usarlo: Definisci schemi con
BaseModeldi Pydantic → Collega con.with_structured_output() - Principi di progettazione: Scegli campi obbligatori vs opzionali, guida l'LLM con descrizioni dei campi
- Gestisci i problemi: ValidationError è normale; il vero pericolo sono i dati fabbricati