Questo articolo ti insegnerà a strutturare la risposta di un LLM come GPT-4 oppure llama3 usando delle librerie di validazione in Python.

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.

Useremo diverse librerie, come Pydantic e Instructor. Il contenuto proposto sarà valido sia per modelli closed-source come GPT di OpenAI o Anthropic che per modelli open source come Llama3.

🎙️
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
- come usare le librerie Instructor e Pydantic

Buona lettura!

Perché occorre un output strutturato?

Certamente gli LLM come GPT-4 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.

Partendo da una particolare versione di GPT-3.5, OpenAI ha aggiunto il parametro response_format nella sua API di completions - questo permette all'utente di definire diverse chiavi, come json_object, per guidare il modello verso una risposta più consona al prompt inserito.

Ecco un esempio

from openai import OpenAI
client = OpenAI()

response = client.chat.completions.create(
  model="gpt-3.5-turbo-0125",
  response_format={ "type": "json_object" },
  messages=[
    {"role": "system", "content": "You are a helpful assistant designed to output JSON."},
    {"role": "user", "content": "Who won the world series in 2020?"}
  ]
)
print(response.choices[0].message.content)

>>> "content": "{\"winner\": \"Los Angeles Dodgers\"}"`

Tale logica però non sempre funziona. Infatti OpenAI, nella sua documentazione, suggerisce di scrivere la parola "JSON" nel prompt proprio per guidare GPT nella generazione dello stesso. È un suggerimento talmente importante che siamo obbligati a scrivere farlo nel momento in cui usiamo response_format={ "type": "json_object" }.

Perché è difficile per gli LLM produrre output JSON coerenti?

Questo perché gli LLM sono di fatto delle macchine che restituiscono il prossimo token con più probabilità di seguire il precedente dato un prompt in input. Di fatto, è difficile incontrare in "natura" questo pattern a meno che il modello non sia stato espressamente guidato in fase di training a vedere e comprendere questi formati.

La modalità JSON dei più recenti LLM non garantisce che l'output corrisponda a uno schema specifico, ma solo che sia valido e venga analizzato senza errori.

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-4 oppure Llama3, come accennato.

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, Instructor, 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
  • Instructor: è di fatto un wrapper intorno a Pydantic specializzato per lavorare con gli LLM ed è la libreria che permetterà di creare le logiche di validazione
  • 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

pip install pydantic instructor 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 Pydnatic 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 Instructor 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 Instructor

Qui creermo la logica principale della validazione e strutturazione del JSON grazie ad Instructor. Utilizza una interfaccia simile a quella che fornisce OpenAI per chiamare GPT via API.

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

from openai import OpenAI
import instructor

def query_gpt(prompt: str) -> list:
    client = instructor.from_openai(OpenAI(api_key="..."))
    resp = client.chat.completions.create(
        model="gpt-3.5-turbo",
        response_model=SoccerDataset,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": prompt},
        ],
    )
    return resp.model_dump_json(indent=4)

Ricordiamoci di passare la nostra chiave API di OpenAI al client appena creato. Useremo GPT-3.5-Turbo, passando come response_model proprio SoccerDataset. Ovviamente sarebbe possibile usare "gpt-4o" se volessimo usare il modello più potente al momento della scrittura di questo articolo.

💡
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
import instructor

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) -> list:
    client = instructor.from_openai(OpenAI())
    resp = client.chat.completions.create(
        model="gpt-3.5-turbo",
        response_model=SoccerDataset,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": prompt},
        ],
    )
    return resp.model_dump_json(indent=4)

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-3.5-Turbo ha seguito alla perfezione il nostro prompt e Instructor 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 modelli open source con Instructor

Abbiamo visto come usare GPT in Instructor per avere un output JSON strutturato. 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!

Creiamo una nuova funzione chiamata query_llama.

def query_llama(prompt: str) -> list:
    client = instructor.from_openai(
    OpenAI(
            base_url="http://localhost:11434/v1",
            api_key="ollama",  # valore richiesto, ma non influente
        ),
        mode=instructor.Mode.JSON,
    )
    resp = client.chat.completions.create(
        model="llama3",
        messages=[
            {
                "role": "system",
                "content": system_prompt
            },
            {
                "role": "user",
                "content": prompt
            }
        ],
        response_model=SoccerDataset,
    )
    return resp.model_dump_json(indent=4)

Ci sono delle differenze con il codice di GPT. Vediamole.

  • ollama viene chiamato attraverso la stessa interfaccia di GPT, cambiando però il puntatore del url di base (base_url) e la chiave API, che è richiesta ma non serve per il corretto funzionamento (non chiedetemi perché)
  • bisogna esplicitare la modalità JSON attraverso il parametro mode

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, Instructor 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.