Vai al contenuto

I mattoni di CrewAI

13Risultati strutturati e guardrail

JSON e Pydantic spiegati da zero, come ottenere da un task dati con campi precisi (output_pydantic e output_json), come controllare i risultati con i guardrail e come far intervenire una persona con human_input.

Tempo di lettura: 25 minuti

Finora ogni crew ti ha restituito testo libero: va benissimo da leggere, ma è scomodo quando un altro programma deve usarlo. Se vuoi mettere i luoghi di una guida in una pagina web o in un foglio di calcolo, ti serve sapere con certezza dove stanno il titolo, l'elenco dei luoghi e il numero di giorni. È la differenza tra una lettera scritta a mano e un modulo prestampato con le caselle. In questo capitolo impari a far compilare il modulo agli agenti e a mettere un controllo automatico prima che il lavoro passi avanti, come fa un caporedattore che rimanda indietro un articolo troppo lungo.

JSON, spiegato da zero#

JSON: Formato di testo per dati strutturati con graffe, virgolette e due punti. È il formato predefinito dei nuovi progetti CrewAI (crew.jsonc). glossario è un formato di testo per scrivere dati ordinati, usato ovunque: dai siti web alle API: Application Programming Interface: una "porta" con regole precise attraverso cui un programma chiede un servizio a un altro. I modelli online si usano via API. glossario. Ecco la scheda di un weekend scritta in JSON:

JSON
{
  "titolo": "Arezzo in due giorni",
  "luoghi": ["Piazza Grande", "Duomo"],
  "giorni": 2
}

Le regole sono poche:

  • Le parentesi graffe racchiudono un oggetto, cioè un insieme di coppie "chiave": valore separate da virgole.
  • Chiavi e testi vanno tra virgolette doppie.
  • I numeri si scrivono senza virgolette: 2 è un numero, "2" è un testo.
  • Le parentesi quadre racchiudono una lista di valori.
  • true, false e null (nessun valore) si scrivono in minuscolo.
  • Il JSON puro non ammette commenti. Nel capitolo 14 incontrerai il JSONC, una variante che li permette.
Se parti da zero

Il JSON assomiglia molto al Dizionario: Una raccolta di coppie nome → valore tra parentesi graffe: {"citta": "Arezzo"}. glossario e alla Lista: Una sequenza ordinata di valori tra parentesi quadre: [ricercatore, scrittore]. glossario di Python che hai visto nel capitolo 8. Le differenze sono piccole ma importanti: in JSON le virgolette sono sempre doppie e si scrive true e null, in Python True e None. Python sa trasformare il JSON in un dizionario, e viceversa, con una sola istruzione.

Pydantic: descrivere la forma dei dati#

Un modello linguistico sa scrivere JSON, ma niente garantisce che lo scriva giusto: una chiave dimenticata, un numero scritto in lettere. Pydantic: Libreria Python per descrivere la forma dei dati (campi e tipi) e controllarla. CrewAI la usa per ottenere risposte strutturate e verificate. glossario è la Libreria (pacchetto): Codice scritto da altri che installi e riusi nei tuoi programmi. CrewAI si installa come pacchetto Python. glossario Python con cui descrivi la forma che i dati devono avere e controlli che la rispettino. CrewAI la usa già al suo interno, quindi è installata insieme a CrewAI.

La forma si descrive con una Classe e oggetto: Una classe è uno stampo (per esempio Agent); un oggetto è una cosa concreta creata con quello stampo (il tuo agente ricercatore). glossario che parte da BaseModel. Ogni riga è un campo: nome, tipo e, se vuoi, una descrizione.

Python
from pydantic import BaseModel, Field


class SchedaWeekend(BaseModel):
    titolo: str = Field(description="Un titolo breve per il weekend")                  # un testo
    luoghi: list[str] = Field(description="I luoghi da visitare citati negli appunti")  # una lista di testi
    giorni: int = Field(description="Quanti giorni dura il weekend")                   # un numero intero

I tipi sono quelli che hai incontrato con gli strumenti nel capitolo 12: str per il testo, int per i numeri interi, float per i numeri con la virgola; list[str] è una lista di testi. Le descrizioni in Field non sono decorazioni: CrewAI le passa al modello per spiegargli che cosa mettere in ogni campo.

Ecco che cosa fa Pydantic con un JSON accettabile e con uno sbagliato. model_validate_json legge un testo JSON e prova a riempire la classe:

Python
buona = SchedaWeekend.model_validate_json(
    '{"titolo": "Arezzo in due giorni", "luoghi": ["Piazza Grande", "Duomo"], "giorni": "2"}'
)
print(buona)
print(buona.giorni + 1)  # giorni è diventato un numero vero: si può fare il conto

SchedaWeekend.model_validate_json('{"titolo": "Arezzo in due giorni", "giorni": "due"}')

L'output reale, accorciato (abbiamo tolto le righe tecniche dell'errore e i link alla documentazione di Pydantic):

Output
titolo='Arezzo in due giorni' luoghi=['Piazza Grande', 'Duomo'] giorni=2
3
2 validation errors for SchedaWeekend
luoghi
  Field required [type=missing, ...]
giorni
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, ...]

Nel primo caso il testo "2" viene convertito nel numero 2, perché la conversione è sicura. Nel secondo Pydantic si rifiuta e dice esattamente che cosa non va: manca il campo luoghi, e "due" non è un numero intero. È proprio questo controllo che rende affidabile il risultato di un agente.

L'output strutturato di un task#

Per avere un Output strutturato: Un risultato con campi fissi (titolo, lista, numero…) invece di testo libero, così un programma può usarlo senza interpretarlo. In CrewAI: output_pydantic. glossario da un task basta un parametro, output_pydantic, a cui passi la classe:

Python
scheda = Task(
    description="Ricava dagli appunti e dalla presentazione una scheda del weekend a {citta}.",
    expected_output="Una scheda in italiano con titolo, elenco dei luoghi e numero di giorni.",
    agent=redattore,
    output_pydantic=SchedaWeekend,  # la classe, senza parentesi
)

Due regole vengono dalla guida ufficiale sui task. Primo: l'expected_output: La descrizione del risultato atteso di un task: formato, lunghezza, struttura. È il criterio con cui l'agente capisce di aver finito. glossario resta un testo che descrive i campi a parole, non il nome della classe. Secondo: tieni la classe semplice, con pochi campi e senza strutture annidate una dentro l'altra, perché i modelli fanno fatica con le forme complicate e finiscono per riprovare a vuoto.

Dietro le quinte CrewAI passa all'agente la forma richiesta e, alla fine del task, trasforma la risposta in un oggetto SchedaWeekend. Se la risposta del modello non si lascia convertire, l'oggetto non c'è: per questo, negli script, conviene sempre controllare che sia arrivato prima di usarlo.

Attenzione

Scrivi output_pydantic=SchedaWeekend, non output_pydantic=SchedaWeekend(titolo="", luoghi=[], giorni=0). La guida all'aggiornamento di CrewAI segnala questo errore come comune e dice che produce un errore di validazione poco chiaro. In CrewAI 1.15.21 abbiamo verificato un comportamento ancora più insidioso: passando un oggetto al posto della classe, il task si crea senza nessun errore, ma output_pydantic diventa None, cioè vuoto. Il crew gira, e alla fine risultato.pydantic è vuoto senza che niente ti abbia avvisato.

output_json: un dizionario invece di un oggetto#

Se preferisci un dizionario Python, usa output_json con la stessa classe: la forma si descrive allo stesso modo, ma il risultato lo leggi in json_dict. Un task può avere l'uno o l'altro, non entrambi: con tutti e due CrewAI rifiuta il task con l'errore «Only one output type can be set, either output_pydantic or output_json».

Leggere i campi#

Il CrewOutput: L'oggetto restituito da crew.kickoff(): contiene il testo finale (raw), l'eventuale output strutturato, i risultati dei singoli task e i token usati. glossario restituito da kickoff contiene il risultato dell'ultimo task in più forme:

ScriviOttieni
risultato.rawil testo grezzo, sempre presente
risultato.pydanticl'oggetto SchedaWeekend, se l'ultimo task ha output_pydantic; altrimenti None
risultato.pydantic.titoloil valore di un campo
risultato.json_dictil dizionario, se l'ultimo task ha output_json
risultato["titolo"]un campo letto come da un dizionario (funziona con entrambi)
risultato.tasks_output[0]il risultato del primo task, con gli stessi .raw, .pydantic e .json_dict

Se il task ha anche output_file, nel file CrewAI scrive il JSON dei campi invece del testo grezzo.

I guardrail: un controllo prima di andare avanti#

Un Guardrail: Un controllo automatico sul risultato di un task. Se il controllo fallisce, l'errore torna all'agente che riprova. glossario è un controllo automatico sul risultato di un task. Se il controllo passa, il risultato prosegue; se fallisce, CrewAI rimanda all'agente un messaggio con il motivo e l'agente rifà il lavoro. Come il guardrail di una strada, non guida al posto tuo: evita che si finisca fuori strada.

Il percorso di un risultato con guardrail e output strutturato L'agente esegue il task e produce un risultato grezzo. Il guardrail lo controlla. Se restituisce False, il messaggio torna all'agente, che riprova al massimo guardrail_max_retries volte. Se restituisce True, il valore passa avanti: con output_pydantic viene convertito nella classe indicata e finisce in CrewOutput, dove si leggono i campi. un task con guardrail Agente esegue il task Risultato grezzo risultato.raw Guardrail funzione o testo: va bene così? output_pydantic il testo diventa un oggetto con campi CrewOutput risultato.pydantic .titolo .luoghi (True, valore) (False, "messaggio") il messaggio torna all'agente, che riprova al massimo guardrail_max_retries volte (predefinito: 3)
Il percorso di un risultato: prima il guardrail, poi la conversione in oggetto. Segui la freccia rossa per vedere che cosa succede quando il controllo fallisce.

Guardrail a funzione#

Il modo più affidabile è una Funzione: Un blocco di codice con un nome che fa un lavoro e restituisce un risultato. Si definisce con def e si chiama con le parentesi: somma(2, 3). glossario Python: fa un controllo preciso, sempre uguale, senza chiamare il modello. Le regole sono due. La funzione riceve un solo parametro, il risultato del task (un oggetto TaskOutput, di cui di solito usi .raw, il testo). E restituisce una coppia di valori.

Python
from typing import Any

from crewai import TaskOutput


def massimo_80_parole(risultato: TaskOutput) -> tuple[bool, Any]:
    parole = len(risultato.raw.split())  # split() divide il testo negli spazi, len() conta i pezzi
    if parole > 80:
        return (False, f"Il testo ha {parole} parole: riscrivilo con al massimo 80 parole.")
    return (True, risultato.raw.strip())  # strip() toglie gli spazi in eccesso all'inizio e alla fine
Se parti da zero

Una tupla è una sequenza di valori tra parentesi tonde, come (True, "testo"): somiglia a una lista, ma non si può modificare. Qui il primo valore è True (vero: il controllo è passato) oppure False (falso). Il secondo cambia significato: se il controllo passa è il risultato da tenere, se fallisce è il messaggio per l'agente. tuple[bool, Any] scrive proprio questo: una tupla con un vero/falso e un valore qualsiasi. if esegue le righe rientrate sotto di lui solo se la condizione è vera.

Il guardrail si aggancia al task con guardrail=massimo_80_parole, senza parentesi, perché passi la funzione e non il suo risultato. CrewAI controlla subito che la funzione accetti un solo parametro: se ne ha due, il task non si crea e ricevi l'errore «Guardrail function must accept exactly one parameter».

Un consiglio che viene dal codice di CrewAI 1.15.21: quando il controllo passa, restituisci un testo, come risultato.raw.strip(). Se il task ha anche output_pydantic, CrewAI parte da quel testo per costruire l'oggetto.

Guardrail a testo#

Alcuni controlli sono difficili da scrivere in Python: «il tono è cordiale», «non ci sono informazioni inventate». Per questi puoi dare al guardrail una frase:

Python
presentazione = Task(
    description="Scrivi la presentazione di un weekend a {citta}.",
    expected_output="Un solo paragrafo in italiano.",
    agent=redattore,
    guardrail="Il testo deve essere in italiano, avere un tono cordiale e non citare prezzi.",
)

In questo caso a giudicare è un modello: CrewAI usa l'LLM dell'agente del task per valutare il risultato secondo la tua frase. Per questo il task deve avere un agent; senza, CrewAI dà l'errore «Agent is required to use LLMGuardrail». È un controllo flessibile, ma costa una chiamata al modello in più e il giudizio può sbagliare, soprattutto con i modelli piccoli.

Quanti tentativi: guardrail_max_retries#

Un guardrail che fallisce sempre non deve bloccare il crew all'infinito. guardrail_max_retries dice quante volte l'agente può riprovare; il valore predefinito è 3. Esauriti i tentativi, CrewAI si ferma con un errore che comincia con «Task failed guardrail validation after 3 retries» e riporta l'ultimo messaggio del guardrail.

Se vuoi più controlli sullo stesso task, usa guardrails (al plurale) con una lista di funzioni e frasi: vengono eseguiti uno dopo l'altro. Se un task ha guardrails, il singolo guardrail viene ignorato.

Attenzione

Ogni nuovo tentativo è una nuova esecuzione del task. Con un modello a pagamento costa Token: Il pezzetto di testo con cui ragiona un modello: una parola corta, un pezzo di parola o un segno di punteggiatura. Secondo le stime di OpenAI, in inglese un token vale in media circa ¾ di parola. I servizi a pagamento contano (e fanno pagare) i token. glossario, con un modello locale costa tempo. Scrivi istruzioni chiare nella description (per esempio il limite di parole) invece di affidarti solo al guardrail per correggere.

human_input: una persona prima della risposta finale#

A volte il controllo giusto è una persona: un preventivo prima di mandarlo al cliente, un post prima di pubblicarlo. È l'idea di Human in the loop: Un punto del processo in cui una persona controlla o approva prima che il lavoro prosegua. In CrewAI: human_input=True o @human_feedback. glossario vista nel capitolo 3. Nei task di CrewAI si attiva con human_input=True, che hai già incontrato nel capitolo 10; il valore predefinito è False. Qui vediamo che cosa succede in pratica.

Python
presentazione = Task(
    description="Scrivi la presentazione di un weekend a {citta}.",
    expected_output="Un solo paragrafo in italiano.",
    agent=redattore,
    human_input=True,  # prima di chiudere il task, l'agente chiede il tuo parere nel terminale
)

Quando l'agente ha una risposta, il crew si ferma e nel terminale compare un riquadro intitolato «Human Feedback Required». Il testo è in inglese; lo abbiamo letto nel codice di CrewAI 1.15.21 e dice, in sostanza: se il risultato ti va bene, premi Invio senza scrivere nulla; altrimenti scrivi che cosa migliorare, anche per più giri. Ogni tuo commento torna all'agente, che rielabora la risposta.

Uno script con human_input=True aspetta te: non lanciarlo dove nessuno può rispondere, per esempio su un computer che lavora da solo di notte. Per approvazioni più ricche, con strade diverse a seconda della risposta, nel capitolo 16 vedrai i flow.

Lo script completo#

Lo script mette insieme tutto: un solo agente, il redattore, con due task. Il primo scrive una presentazione e passa dal guardrail delle 80 parole; il secondo ricava la scheda strutturata. Un agente solo basta, perché i due lavori richiedono lo stesso mestiere: la guida ufficiale sugli agenti consiglia di non moltiplicarli senza motivo. Gli appunti arrivano negli inputs, così l'agente lavora su informazioni che gli dai tu invece di inventarle.

scheda_weekend.py
# /// script
# requires-python = ">=3.10,<3.14"
# dependencies = ["crewai[tools]==1.15.21"]
# ///
"""Scrive una presentazione controllata da un guardrail e ne ricava una scheda strutturata."""
import os
from typing import Any

from crewai import Agent, Crew, Process, Task, TaskOutput
from pydantic import BaseModel, Field

# Il modello si sceglie nel file .env (capitolo 7). Se MODEL manca, si usa quello scritto qui.
MODELLO = os.getenv("MODEL", "openai/gpt-4.1-mini")


# La forma dei dati che vogliamo alla fine: tre campi con nome e tipo.
class SchedaWeekend(BaseModel):
    titolo: str = Field(description="Un titolo breve per il weekend")
    luoghi: list[str] = Field(description="I luoghi da visitare citati negli appunti")
    giorni: int = Field(description="Quanti giorni dura il weekend")


# Guardrail a funzione: riceve il risultato del task e dice se va bene.
def massimo_80_parole(risultato: TaskOutput) -> tuple[bool, Any]:
    parole = len(risultato.raw.split())
    if parole > 80:
        # (False, messaggio): il messaggio torna all'agente, che riprova.
        return (False, f"Il testo ha {parole} parole: riscrivilo con al massimo 80 parole.")
    # (True, valore): il valore diventa il risultato del task.
    return (True, risultato.raw.strip())


redattore = Agent(
    role="Redattore di guide di viaggio",
    goal="Trasformare appunti di viaggio su {citta} in testi brevi e schede ordinate",
    backstory="Lavori in una piccola casa editrice: usi solo le informazioni che ricevi e non inventi nulla.",
    llm=MODELLO,
    verbose=True,
)

presentazione = Task(
    description=(
        "Scrivi la presentazione di un weekend di {giorni} giorni a {citta}. "
        "Usa solo questi appunti: {appunti}"
    ),
    expected_output="Un solo paragrafo in italiano, di massimo 80 parole.",
    agent=redattore,
    guardrail=massimo_80_parole,  # il controllo automatico
    guardrail_max_retries=3,      # tentativi extra concessi (3 è anche il valore predefinito)
)

scheda = Task(
    description="Ricava dagli appunti e dalla presentazione una scheda del weekend a {citta}.",
    expected_output="Una scheda in italiano con titolo, elenco dei luoghi e numero di giorni.",
    agent=redattore,
    output_pydantic=SchedaWeekend,  # la classe, senza parentesi: non SchedaWeekend()
)

crew = Crew(agents=[redattore], tasks=[presentazione, scheda], process=Process.sequential, verbose=True)

if __name__ == "__main__":
    risultato = crew.kickoff(
        inputs={
            "citta": "Arezzo",
            "giorni": "2",
            "appunti": "Piazza Grande; Basilica di San Francesco con gli affreschi di Piero della Francesca; "
            "Duomo; passeggiata sulle mura medicee.",
        }
    )
    print("\n=== PRESENTAZIONE (task 1) ===")
    print(risultato.tasks_output[0].raw)

    dati = risultato.pydantic  # l'oggetto SchedaWeekend creato dall'ultimo task
    if dati is None:
        print("Il modello non ha prodotto una scheda valida. Testo ricevuto:", risultato.raw)
    else:
        print("\n=== SCHEDA (task 2) ===")
        print("Titolo:", dati.titolo)
        print("Giorni:", dati.giorni)
        for luogo in dati.luoghi:
            print("-", luogo)

Pezzo per pezzo:

  • SchedaWeekend è il modulo da compilare, con tre campi.
  • massimo_80_parole è il guardrail a funzione visto sopra; nel task presentazione è agganciato con guardrail=massimo_80_parole.
  • Il segnaposto {appunti} porta nel task le informazioni vere. Il backstory chiede di non inventare: non è una garanzia, ma aiuta.
  • Il task scheda non ha bisogno di context: nel processo Processo sequenziale: I task si eseguono nell'ordine in cui li scrivi; il risultato di ciascuno passa come contesto ai successivi. È il processo predefinito. glossario riceve già il risultato della presentazione.
  • In fondo, risultato.tasks_output[0].raw è il testo del primo task, e risultato.pydantic la scheda. if dati is None controlla che la scheda sia arrivata davvero; for luogo in dati.luoghi ripete la riga rientrata sotto una volta per ogni luogo della lista.

Eseguirlo#

Si lancia come ogni script del corso:

Terminale
uv run --env-file .env scheda_weekend.py

Vedrai prima il lavoro sulla presentazione e un riquadro «Guardrail Check» con l'esito del controllo. Se il testo supera le 80 parole, compaiono un riquadro rosso «Guardrail Failed» e un avviso giallo con la parola «blocked», e l'agente riscrive; poi arriva la scheda, stampata campo per campo.

Ecco la parte finale della nostra esecuzione con qwen2.5:7b (output reale, senza i riquadri). La presentazione ha superato il guardrail al primo tentativo:

Output
=== PRESENTAZIONE (task 1) ===
Arezzo offre un weekend pieno di bellezze storiche. Piazza Grande affascina, Duomo e Basilica di San Francesco con gli splendidi affreschi di Piero della Francesca sono in bilico tra passato e presente. Non manca l'esperienza di camminare sulle antiche mura medicee che circondano la città, ricordando il suo potere passato.

=== SCHEDA (task 2) ===
Titolo: Weekend a Arezzo
Giorni: 2
- Piazza Grande
- Duomo
- Basilica di San Francesco
- Affreschi di Piero della Francesca
- Mura medicee

Nota due cose. I dati hanno la forma giusta: giorni è un numero e luoghi una lista. Ma la forma non garantisce il contenuto: gli affreschi sono finiti nella lista come se fossero un luogo a sé. Un guardrail o una descrizione più precisa del campo («solo luoghi fisici, non opere») avrebbero potuto evitarlo.

Prova tu: un secondo controllo sulla presentazione

Aggiungi un guardrail a funzione che rifiuta la presentazione se non cita «Piazza Grande», e usalo insieme a quello delle 80 parole. Una soluzione:

Python
def cita_piazza_grande(risultato: TaskOutput) -> tuple[bool, Any]:
    if "Piazza Grande" not in risultato.raw:
        return (False, "Nella presentazione manca Piazza Grande: citala.")
    return (True, risultato.raw.strip())


presentazione = Task(
    description=(
        "Scrivi la presentazione di un weekend di {giorni} giorni a {citta}. "
        "Usa solo questi appunti: {appunti}"
    ),
    expected_output="Un solo paragrafo in italiano, di massimo 80 parole.",
    agent=redattore,
    guardrails=[massimo_80_parole, cita_piazza_grande],  # al plurale: una lista di controlli, in ordine
)

"Piazza Grande" not in risultato.raw è vero quando quelle parole non compaiono nel testo. Con guardrails al plurale i due controlli vengono eseguiti uno dopo l'altro, e ognuno ha i suoi tentativi.

In breve
  • JSON scrive dati ordinati con graffe, chiavi tra virgolette e liste tra quadre; Pydantic descrive quella forma con una classe e la controlla.
  • output_pydantic=Classe (la classe, non un oggetto) fa restituire al task un oggetto con campi; output_json un dizionario. Mai tutti e due.
  • I campi si leggono con risultato.pydantic.campo, risultato.json_dict["campo"] o risultato["campo"]; controlla sempre che l'oggetto ci sia.
  • Un guardrail a funzione riceve il TaskOutput e restituisce (True, valore) oppure (False, messaggio); un guardrail a testo fa giudicare il modello.
  • guardrail_max_retries (predefinito 3) limita i tentativi; human_input=True fa approvare il risultato a una persona.

Nel prossimo capitolo lasci lo script singolo e passi al progetto ufficiale, quello che crea il comando crewai create.

Corso indipendente, non affiliato a CrewAI Inc. Contenuti verificati su CrewAI 1.15.21 il 15 settembre 2026.