Questo articolo ti insegnerà a strutturare la risposta di un LLM come GPT-5 oppure modelli open source usando Pydantic per validare il tuo output.

Si tratta di un tema molto rilevante data l'affidabilità non sempre elevatissima dei sistemi anche più commerciali in giro, come proprio GPT. Infatti, l'esigenza di poter estrarre informazioni strutturate in formato JSON, ad esempio, si rivela fondamentale per compiti di data mining, dove dal formato non strutturato (come un testo libero) si vanno ad estrarre informazioni puntuali.

Il contenuto proposto sarà valido sia per modelli closed-source come GPT di OpenAI o Anthropic che per modelli open source.

🎙️
Leggendo questo articolo imparerai

- cosa è e come definire uno modello dati
- come fare in modo che il tuo LLM rispetti il formato di output attraverso regole di validazione

Perché occorre un output strutturato?

Certamente gli LLM come GPT-5 riescono a fornire enorme valore anche senza strutturare la loro risposta secondo uno schema. Resta importante però, soprattutto per i programmatori e coloro che lavorano con i dati, che un possibile schema di risposta possa essere rispettato se è quella la volontà dell'utente.

Ma perché non chiedere al LLM di strutturare l'output direttamente in prompt?

Ad esempio

Dato in input una città italiana, fornisci in output un JSON con questo formato

{
  "città": [citta],
  "cap": [cap],
  "regione": [regione]
}

...

>> Input: "Rovereto"

Nulla di male.

Tale logica però non sempre funziona e manca di validazione. Come possiamo ad esser certi che il modello risponda nel formato giusto (ad esempio che il CAP sia sempre un numero, e che la regione sia sempre una regione d'Italia) e seguendo regole chiare?

Chiedere all LLM quindi non garantisce che l'output corrisponda a uno schema specifico.

Resta quindi importante poter validare quello che c'è dentro a questi output e sollevare eccezioni ed errori qualora non fossero consistenti con il nostro modello dati.

Caso d'uso

In questo articolo vedremo l'esempio di estrarre informazioni in JSON partendo da una semplice domanda ad un LLM come GPT-5.

La domanda può essere qualunque, ma chiederemo al modello domande relative ai vincitori dei mondiali di calcio nel tempo.

In particolare vogliamo estrarre

  • Data della finale
  • Nazione ospite del torneo
  • Squadra vincitore
  • Top marcatori

Non ci preoccuperemo di validare l'esattezza dei dati, ma solo di adattare la risposta testuale del LLM allo schema che vedremo ora.

Nell'articolo vedremo questo esempio e forse ne esploreremo anche degli altri.

Le dipendenze

Vediamo ora le dipendenze da installare per eseguire questo tutorial.

Ovviamente, dando per scontato che abbiamo già un ambiente di sviluppo attivo, andiamo ad installare Pydantic, client OpenAI e ollama.

  • Pydantic: è la libreria di validazione e definizione modelli dati più famosa e usata dalla community grazie alla sua facilità d'uso, efficienza e rilevanza nella data science
  • OpenAI: il famoso client per interrogare GPT e gli altri modelli di OpenAI
  • ollama: interfaccia molto conveniente agli LLM open source come llama3

Nel nostro ambiente di sviluppo, lanciamo il comando per iniziare

uv add pydantic openai ollama

Poiché vogliamo anche provare i modelli open source, il prossimo step è installare ollama a livello di sistema. Puoi imparare come installare ed usare ollama leggendo questo articolo dedicato

Come usare LLM in locale con ollama e Python
Questo articolo ti guiderà attraverso l’utilizzo di ollama, uno strumento da linea di comando che permette il download, l’esplorazione e l’utilizzo di Large Language Models (LLM) sul tuo PC in locale, che sia Windows, Mac o Linux, con supporto GPU.

Ora possiamo concentrarci allo sviluppo.

Definizione di un modello dati

Un modello dati è uno schema logico da seguire per strutturare dati. Si usano in moltissimi contesti, dalla definizione di tabelle in database alla validazione di dati input.

Ho già affrontato un po' il discorso di modelli dati usando Pydantic nella data science e machine learning nel post di seguito 👇

Migliorare i propri modelli di dati con Pydantic
Pydantic è una libreria Python che ci consente di strutturare e convalidare i dati in modo efficiente. Applicazioni in Python e nel contesto del Machine Learning

Iniziamo creando i modelli dati Pydantic

from pydantic import BaseModel, Field
from typing import List
import datetime

class SoccerData(BaseModel):
    date_of_final: datetime.date = Field(..., description="Date of the final event")
    hosting_country: str = Field(..., description="The nation hosting the tournament")
    winner: str = Field(..., description="The soccer team that won the final cup")
    top_scorers: list = Field(
        ..., description="A list of the top 3 scorers of the tournament"
    )

class SoccerDataset(BaseModel):
    reports: List[SoccerData] = []

In questo script stiamo importando la classe BaseModel e Field da Pydantic e usandole per creare un modello dati. Stiamo di fatto costruendo la struttura che deve avere il nostro risultato finale.

Pydantic richiede che dichiariamo il tipo di dato che entra nel modello. Abbiamo datetime.date che, ad esempio, forza il campo date ad essere una data e non una stringa. Allo stesso tempo, il campo top_scorers dovrà essere per forza una lista, altrimenti Pydantic ritornerà un errore di validazione.

Infine, creiamo un modello dati che raccoglie molteplici istanze del modello SoccerData. Questo è chiamato SoccerDataset e verrà usato da OpenAI o ollama per validare la presenza di più report, non di uno solo.

Creazione del prompt di sistema

Molto semplicemente, scriveremo in inglese quello che modello deve fare sottolineando l'intento e la struttura del risultato fornendo degli esempi.

system_prompt = """You are an expert sports journalist. You'll be asked to create a small report on who won the soccer world cups in specific years.\
 You'll report the date of the tournament's final, the top 3 scorers of the entire tournament, the winning team, and the nation hosting the tournament.
 Return a JSON object with the following fields: date_of_final, hosting_country, winner, top_scorers.\
 
 If multiple years are inputted, separate the reports with a comma.\
 
 Here's an example
 [
    {
        "date_of_final": "1966",
        "hosting_country": "England",
        "winner": "England",
        "top_scorers": ["Player A", "Player B", "Player C"]
    },
    {
        "date_of_final": ...
        "hosting_country": ...
        "winner": ...
        "top_scorers": ...
    },

]

Here's the years you'll need to report on:

 """

Questo prompt verrà usato come system prompt e ci permetterà semplicemente di passare gli anni di interesse separati da una virgola.

Creazione del codice per output strutturati

Per prima cosa useremo OpenAI in una funzione chiamata query_gpt che ci permette di parametrizzare il nostro prompt

def query_gpt(prompt: str) -> SoccerDataset:
  completion = client.chat.completions.parse(
      model="gpt-4o-2024-08-06",
      messages=[
          {"role": "system", "content": system_prompt},
          {"role": "user", "content": prompt},
      ],
      response_format=SoccerDataset,
  )
  return completion.choices[0].message.parsed

Ricordiamoci di passare la nostra chiave API di OpenAI al client appena creato oppure di creare un file .env nella cartella di progetto e di caricarla al suo interno nel formato OPENAI_API_KEY=sk-... .

Useremo GPT-4o, passando come response_format proprio SoccerDataset.

💡
Non usiamo SoccerData, ma SoccerDataset.

Se usassimo il primo, l'LLM restituirebbe sempre e solo un singolo risultato.

Mettiamo tutto insieme e lanciamo il software passando come content nel prompt dell'utente gli anni 2010, 2014 e 2018 come input dalla quale vogliamo generare il report strutturato.

from openai import OpenAI

from typing import List
from pydantic import BaseModel, Field
import datetime


class SoccerData(BaseModel):
    date_of_final: datetime.date = Field(..., description="Date of the final event")
    hosting_country: str = Field(..., description="The nation hosting the tournament")
    winner: str = Field(..., description="The soccer team that won the final cup")
    top_scorers: list = Field(
        ..., description="A list of the top 3 scorers of the tournament"
    )


class SoccerDataset(BaseModel):
    reports: List[SoccerData] = []


system_prompt = """You are an expert sports journalist. You'll be asked to create a small report on who won the soccer world cups in specific years.\
 You'll report the date of the tournament's final, the top 3 scorers of the entire tournament, the winning team, and the nation hosting the tournament.
 Return a JSON object with the following fields: date_of_final, hosting_country, winner, top_scorers.\
 
 If the query is invalid, return an empty report.\
 
 If multiple years are inputted, separate the reports with a comma.\
 
 Here's an example
 [
    {
        "date_of_final": "1966",
        "hosting_country": "England",
        "winner": "England",
        "top_scorers": ["Player A", "Player B", "Player C"]
    },
    {
        "date_of_final": ...
        "hosting_country": ...
        "winner": ...
        "top_scorers": ...
    },

]

Here's the years you'll need to report on:

 """

def query_gpt(prompt: str) -> SoccerDataset:
  completion = client.chat.completions.parse(
      model="gpt-4o-2024-08-06",
      messages=[
          {"role": "system", "content": system_prompt},
          {"role": "user", "content": prompt},
      ],
      response_format=SoccerDataset,
  )
  return completion.choices[0].message.parsed

if __name__ == "__main__":
  resp = query_gpt("2010, 2014, 2018")
  print(resp)

Il risultato è il seguente

{
    "reports": [
        {
            "date_of_final": "2010-07-11",
            "hosting_country": "South Africa",
            "winner": "Spain",
            "top_scorers": [
                "Thomas Müller",
                "David Villa",
                "Wesley Sneijder"
            ]
        },
        {
            "date_of_final": "2014-07-13",
            "hosting_country": "Brazil",
            "winner": "Germany",
            "top_scorers": [
                "James Rodríguez",
                "Thomas Müller",
                "Neymar"
            ]
        },
        {
            "date_of_final": "2018-07-15",
            "hosting_country": "Russia",
            "winner": "France",
            "top_scorers": [
                "Harry Kane",
                "Antoine Griezmann",
                "Romelu Lukaku"
            ]
        }
    ]
}

Fantastico. GPT-4o ha seguito il nostro prompt e Pydantic ha validato i campi creando una struttura coerente col modello dati. Infatti, l'output non è una stringa, come tipicamente restituirebbe un LLM come GPT, ma una lista di dizionari Python.

Proviamo ora ad inserire un input che non ha senso.

if __name__ == "__main__":
    print(query_gpt("ciao, come stai?"))

>>> 
{
    "reports": []
}

LLM restituisce correttamente un report vuoto, perché così gli abbiamo chiesto di gestire le query invalide via system prompt.

Usare output strutturato con modelli open source

Vediamo ora come usare ollama per usare modelli open source come llama3.

💡
Ricordati che è richiesto scaricare llama3 via ollama per poter usarlo.

Utilizza il comando ollama pull llama3 per scaricarlo!

Prenderemo spunto dal blog post dedicato di Ollama.

Structured outputs · Ollama Blog
Ollama now supports structured outputs making it possible to constrain a model’s output to a specific format defined by a JSON schema. The Ollama Python and JavaScript libraries have been updated to support structured outputs.

Creiamo una nuova funzione chiamata query_llama.

from ollama import chat

def query_llama(prompt: str) -> SoccerDataset:
  response = chat(
    messages=[
            {
                "role": "system",
                "content": system_prompt
            },
            {
                "role": "user",
                "content": prompt
            }
    ],
    model='llama3.1',
    format=SoccerDataset.model_json_schema(),
  )
  return SoccerDataset.model_validate_json(response.message.content)

Eseguiamo la nuova funzione

if __name__ == "__main__":
    print(query_llama("2010, 2014, 2018"))

Ecco i risultati

{
    "reports": [
        {
            "date_of_final": "2010-07-11",
            "hosting_country": "South Africa",
            "winner": "Spain",
            "top_scorers": [
                "Thomas Müller",
                "Wolfram Toloi",
                "Landon Donovan"
            ]
        },
        {
            "date_of_final": "2014-07-13",
            "hosting_country": "Brazil",
            "winner": "Germany",
            "top_scorers": [
                "James Rodríguez",
                "Miroslav Klose",
                "Thomas Müller"
            ]
        },
        {
            "date_of_final": "2018-07-15",
            "hosting_country": "Russia",
            "winner": "France",
            "top_scorers": [
                "Harry Kane",
                "Kylian Mbappé",
                "Antoine Griezmann"
            ]
        }
    ]
}

Abbiamo una lista con JSON corretti! Tutto questo in locale con llama3.

Come ho accennato prima, la validazione avviene per la struttura, non per il contenuto. Infatti, il contenuto è diverso da quello generato da GPT.

Vediamo come i marcatori siano diversi. Forse è possibile avere la lista corretta andando ad iterare sul prompt, specificando bene quali siano i marcatori che vogliamo ricevere.

Conclusione

Abbiamo visto come usare Pydantic e ollama per guidare l'output di un LLM ad un formato strutturato, come il JSON.

Ricorda che il modello viene proprio guidato in questo processo, e quindi non è deterministico. Ci saranno casi in cui il JSON non verrà rispettato proprio per la natura non deterministica degli LLM.