Gemini in Java con Vertex AI e LangChain4j

1. Introduzione

Questo codelab si concentra sul modello linguistico di grandi dimensioni (LLM) Gemini, ospitato su Vertex AI su Google Cloud. Vertex AI è una piattaforma che comprende tutti i prodotti, i servizi e i modelli di machine learning su Google Cloud.

Utilizzerai Java per interagire con l'API Gemini utilizzando il framework LangChain4j. Esaminerai esempi concreti per sfruttare l'LLM per la risposta a domande, la generazione di idee, l'estrazione di entità e contenuti strutturati, la generazione basata sul recupero e le chiamate di funzione.

Che cos'è l'IA generativa?

Per IA generativa si intende l'utilizzo dell'intelligenza artificiale per la creazione di nuovi contenuti, come testo, immagini, musica, audio e video.

L'IA generativa sfrutta modelli linguistici di grandi dimensioni (LLM) in grado di eseguire più operazioni contemporaneamente e di eseguire operazioni pronte all'uso, come riepilogo, domande e risposte, classificazione e altro ancora. Con un addestramento minimo, i modelli di base possono essere adattati per casi d'uso mirati con pochissimi dati di esempio.

Come funziona l'IA generativa?

L'IA generativa utilizza un modello di machine learning (ML) per apprendere i pattern e le relazioni in un set di dati di contenuti creati dall'uomo. Quindi utilizza i pattern appresi per generare nuovi contenuti.

Il modo più comune per addestrare un modello di IA generativa consiste nell'utilizzare l'apprendimento supervisionato. Al modello viene assegnato un set di contenuti creati dall'uomo ed etichette corrispondenti. Poi, impara a generare contenuti simili a quelli creati dall'uomo.

Quali sono le applicazioni comuni dell'IA generativa?

L'IA generativa può essere utilizzata per:

  • Migliorare le interazioni con i clienti grazie a esperienze avanzate di chat e ricerca.
  • Esplorare enormi quantità di dati non strutturati attraverso interfacce di conversazione e riassunti.
  • Assistere nelle attività ripetitive come rispondere alle richieste di proposta, localizzare i contenuti di marketing in lingue diverse, verificare la conformità dei contratti con i clienti e altro ancora.

Quali sono le offerte di IA generativa di Google Cloud?

Con Vertex AI, puoi interagire con i modelli di base, personalizzarli e incorporarli nelle tue applicazioni con poca o nessuna competenza di machine learning. Puoi accedere ai modelli di base su Model Garden, ottimizzarli tramite una semplice UI su Vertex AI Studio o utilizzarli in un blocco note di data science.

Vertex AI Search and Conversation offre agli sviluppatori il modo più rapido per creare motori di ricerca e chatbot basati sull'IA generativa.

Basata su Gemini, Gemini per Google Cloud è un collaboratore basato sull'IA disponibile su Google Cloud e negli IDE per aiutarti a fare di più in meno tempo. Gemini Code Assist fornisce completamento del codice, generazione di codice, spiegazioni del codice e ti consente di chattare per porre domande tecniche.

Cos'è Gemini?

Gemini è una famiglia di modelli di IA generativa sviluppati da Google DeepMind progettati per casi d'uso multimodali. Multimodale significa che può elaborare e generare diversi tipi di contenuti, come testo, codice, immagini e audio.

b9913d011999e7c7.png

Gemini è disponibile in diverse varianti e dimensioni:

  • Gemini Ultra: la versione più grande e potente per attività complesse.
  • Gemini Flash: la soluzione più veloce e conveniente, ottimizzata per attività ad alto volume.
  • Gemini Pro: di medie dimensioni, ottimizzato per il ridimensionamento in base a varie attività.
  • Gemini Nano: il più efficiente, progettato per le attività sul dispositivo.

Funzionalità principali:

  • Multimodalità: la capacità di Gemini di comprendere e gestire più formati di informazioni rappresenta un passo significativo rispetto ai tradizionali modelli linguistici basati solo sul testo.
  • Prestazioni: Gemini Ultra supera le prestazioni attuali in molti benchmark ed è stato il primo modello a superare gli esperti umani nel difficile benchmark MMLU (Massive Multitask Language Understanding).
  • Flessibilità: le diverse dimensioni di Gemini lo rendono adattabile a vari casi d'uso, dalla ricerca su larga scala all'implementazione su dispositivi mobili.

Come faccio a interagire con Gemini su Vertex AI da Java?

Avete due opzioni:

  1. La libreria ufficiale dell'API Java di Vertex AI per Gemini.
  2. Framework LangChain4j.

In questo codelab utilizzerai il framework LangChain4j.

Che cos'è il framework LangChain4j?

Il framework LangChain4j è una libreria open source per l'integrazione di LLM nelle applicazioni Java, orchestrando vari componenti, come l'LLM stesso, ma anche altri strumenti come i database vettoriali (per le ricerche semantiche), i caricatori e gli splitter di documenti (per analizzare i documenti e apprendere da essi), gli analizzatori di output e altro ancora.

Il progetto è stato ispirato dal progetto Python LangChain, ma con l'obiettivo di supportare gli sviluppatori Java.

bb908ea1e6c96ac2.png

Cosa imparerai a fare

  • Come configurare un progetto Java per utilizzare Gemini e LangChain4j
  • Come inviare il primo prompt a Gemini tramite programmazione
  • Come mostrare progressivamente le risposte di Gemini
  • Come creare una conversazione tra un utente e Gemini
  • Come utilizzare Gemini in un contesto multimodale inviando sia testo che immagini
  • Come estrarre informazioni strutturate utili da contenuti non strutturati
  • Come manipolare i modelli di prompt
  • Come eseguire la classificazione del testo, ad esempio l'analisi del sentiment
  • Come chattare con i tuoi documenti (Retrieval Augmented Generation)
  • Come estendere i chatbot con le chiamate di funzione
  • Come utilizzare Gemma localmente con Ollama e TestContainers

Che cosa ti serve

  • Conoscenza del linguaggio di programmazione Java
  • Un progetto Google Cloud
  • Un browser, ad esempio Chrome o Firefox

2. Configurazione e requisiti

Configurazione dell'ambiente a tuo ritmo

  1. Accedi alla console Google Cloud e crea un nuovo progetto o riutilizzane uno esistente. Se non hai ancora un account Gmail o Google Workspace, devi crearne uno.

fbef9caa1602edd0.png

a99b7ace416376c4.png

5e3ff691252acf41.png

  • Il nome del progetto è il nome visualizzato per i partecipanti al progetto. Si tratta di una stringa di caratteri non utilizzata dalle API di Google. Puoi sempre aggiornarlo.
  • L'ID progetto è univoco per tutti i progetti Google Cloud ed è immutabile (non può essere modificato dopo essere stato impostato). La console Cloud genera automaticamente una stringa univoca; di solito non ti interessa quale sia. Nella maggior parte dei codelab, dovrai fare riferimento al tuo ID progetto (in genere identificato come PROJECT_ID). Se l'ID generato non ti piace, puoi generarne un altro casuale. In alternativa, puoi provare il tuo e vedere se è disponibile. Non può essere modificato dopo questo passaggio e rimane invariato per tutta la durata del progetto.
  • Per tua informazione, esiste un terzo valore, un Numero progetto, utilizzato da alcune API. Scopri di più su tutti e tre questi valori nella documentazione.
  1. Successivamente, dovrai abilitare la fatturazione nella console Cloud per utilizzare le API/risorse Cloud. La partecipazione a questo codelab non ha costi, o quasi. Per arrestare le risorse ed evitare di incorrere in fatturazione al termine di questo tutorial, puoi eliminare le risorse che hai creato o il progetto. I nuovi utenti di Google Cloud sono idonei al programma Prova senza costi di 300$.

Avvia Cloud Shell

Sebbene Google Cloud possa essere utilizzato da remoto dal tuo laptop, in questo codelab utilizzerai Cloud Shell, un ambiente a riga di comando in esecuzione nel cloud.

Attiva Cloud Shell

  1. Nella console Cloud, fai clic su Attiva Cloud Shell 853e55310c205094.png.

3c1dabeca90e44e5.png

Se è la prima volta che avvii Cloud Shell, viene visualizzata una schermata intermedia che descrive di cosa si tratta. Se viene visualizzata una schermata intermedia, fai clic su Continua.

9c92662c6a846a5c.png

Dovrebbero bastare pochi istanti per eseguire il provisioning e connettersi a Cloud Shell.

9f0e51b578fecce5.png

Questa macchina virtuale è caricata con tutti gli strumenti di sviluppo necessari. Offre una home directory permanente da 5 GB e viene eseguita in Google Cloud, migliorando notevolmente le prestazioni e l'autenticazione della rete. Gran parte, se non tutto, del lavoro in questo codelab può essere svolto con un browser.

Una volta eseguita la connessione a Cloud Shell, dovresti vedere che il tuo account è già autenticato e il progetto è già impostato sul tuo ID progetto.

  1. Esegui questo comando in Cloud Shell per verificare che l'account sia autenticato:
gcloud auth list

Output comando

 Credentialed Accounts
ACTIVE  ACCOUNT
*       <my_account>@<my_domain.com>

To set the active account, run:
    $ gcloud config set account `ACCOUNT`
  1. Esegui il seguente comando in Cloud Shell per verificare che il comando gcloud conosca il tuo progetto:
gcloud config list project

Output comando

[core]
project = <PROJECT_ID>

In caso contrario, puoi impostarlo con questo comando:

gcloud config set project <PROJECT_ID>

Output comando

Updated property [core/project].

3. Preparazione dell'ambiente di sviluppo

In questo codelab utilizzerai il terminale e l'editor di Cloud Shell per sviluppare i tuoi programmi Java.

Abilita le API Vertex AI

Nella console Google Cloud, assicurati che il nome del progetto sia visualizzato nella parte superiore della console Google Cloud. In caso contrario, fai clic su Seleziona un progetto per aprire il selettore di progetti e selezionare il progetto che ti interessa.

Puoi attivare le API Vertex AI dalla sezione Vertex AI della console Google Cloud o dal terminale Cloud Shell.

Per attivarla dalla console Google Cloud, vai prima alla sezione Vertex AI del menu della console Google Cloud:

451976f1c8652341.png

Fai clic su Abilita tutte le API consigliate nella dashboard di Vertex AI.

Verranno attivate diverse API, ma quella più importante per il codelab è aiplatform.googleapis.com.

In alternativa, puoi anche attivare questa API dal terminale Cloud Shell con il seguente comando:

gcloud services enable aiplatform.googleapis.com

Clona il repository GitHub

Nel terminale Cloud Shell, clona il repository per questo codelab:

git clone https://github.com/glaforge/gemini-workshop-for-java-developers.git

Per verificare che il progetto sia pronto per l'esecuzione, puoi provare a eseguire il programma "Hello World".

Assicurati di trovarti nella cartella di primo livello:

cd gemini-workshop-for-java-developers/ 

Crea il wrapper Gradle:

gradle wrapper

Esegui con gradlew:

./gradlew run

Dovresti vedere l'output seguente:

..
> Task :app:run
Hello World!

Aprire e configurare Cloud Editor

Apri il codice con Cloud Code Editor da Cloud Shell:

42908e11b28f4383.png

In Cloud Code Editor, apri la cartella di origine del codelab selezionando File -> Open Folder e indicando la cartella di origine del codelab (ad es. /home/username/gemini-workshop-for-java-developers/).

Configura le variabili di ambiente

Apri un nuovo terminale in Cloud Code Editor selezionando Terminal -> New Terminal. Configura due variabili di ambiente necessarie per l'esecuzione degli esempi di codice:

  • PROJECT_ID: l'ID del tuo progetto Google Cloud
  • LOCATION: la regione in cui è stato eseguito il deployment del modello Gemini

Esporta le variabili come segue:

export PROJECT_ID=$(gcloud config get-value project)
export LOCATION=us-central1

4. Prima chiamata al modello Gemini

Ora che il progetto è configurato correttamente, è il momento di chiamare l'API Gemini.

Dai un'occhiata a QA.java nella directory app/src/main/java/gemini/workshop:

package gemini.workshop;

import dev.langchain4j.model.vertexai.VertexAiGeminiChatModel;
import dev.langchain4j.model.chat.ChatLanguageModel;

public class QA {
    public static void main(String[] args) {
        ChatLanguageModel model = VertexAiGeminiChatModel.builder()
            .project(System.getenv("PROJECT_ID"))
            .location(System.getenv("LOCATION"))
            .modelName("gemini-1.5-flash-002")
            .build();

        System.out.println(model.generate("Why is the sky blue?"));
    }
}

In questo primo esempio, devi importare la classe VertexAiGeminiChatModel, che implementa l'interfaccia ChatModel.

Nel metodo main, configuri il modello linguistico della chat utilizzando il generatore per VertexAiGeminiChatModel e specifichi:

  • Progetto
  • Località
  • Nome del modello (gemini-1.5-flash-002).

Ora che il modello linguistico è pronto, puoi chiamare il metodo generate() e passare il prompt, la domanda o le istruzioni da inviare all'LLM. Qui fai una semplice domanda sul perché il cielo è blu.

Non esitare a modificare questo prompt per provare domande o attività diverse.

Esegui l'esempio nella cartella principale del codice sorgente:

./gradlew run -q -DjavaMainClass=gemini.workshop.QA

Dovresti visualizzare un output simile a questo:

The sky appears blue because of a phenomenon called Rayleigh scattering.
When sunlight enters the atmosphere, it is made up of a mixture of
different wavelengths of light, each with a different color. The
different wavelengths of light interact with the molecules and particles
in the atmosphere in different ways.

The shorter wavelengths of light, such as those corresponding to blue
and violet light, are more likely to be scattered in all directions by
these particles than the longer wavelengths of light, such as those
corresponding to red and orange light. This is because the shorter
wavelengths of light have a smaller wavelength and are able to bend
around the particles more easily.

As a result of Rayleigh scattering, the blue light from the sun is
scattered in all directions, and it is this scattered blue light that we
see when we look up at the sky. The blue light from the sun is not
actually scattered in a single direction, so the color of the sky can
vary depending on the position of the sun in the sky and the amount of
dust and water droplets in the atmosphere.

Congratulazioni, hai effettuato la tua prima chiamata a Gemini.

Risposta dinamica

Hai notato che la risposta è stata data in un'unica volta, dopo pochi secondi? È anche possibile ricevere la risposta progressivamente, grazie alla variante di risposta dinamica. La risposta in streaming, il modello restituisce la risposta pezzo per pezzo, man mano che diventa disponibile.

In questo codelab, utilizzeremo la risposta non in streaming, ma diamo un'occhiata alla risposta in streaming per capire come può essere eseguita.

In StreamQA.java nella directory app/src/main/java/gemini/workshop puoi vedere la risposta in streaming in azione:

package gemini.workshop;

import dev.langchain4j.model.chat.StreamingChatLanguageModel;
import dev.langchain4j.model.vertexai.VertexAiGeminiStreamingChatModel;

import static dev.langchain4j.model.LambdaStreamingResponseHandler.onNext;

public class StreamQA {
    public static void main(String[] args) {
        StreamingChatLanguageModel model = VertexAiGeminiStreamingChatModel.builder()
            .project(System.getenv("PROJECT_ID"))
            .location(System.getenv("LOCATION"))
            .modelName("gemini-1.5-flash-002")
            .maxOutputTokens(4000)
            .build();

        model.generate("Why is the sky blue?", onNext(System.out::println));
    }
}

Questa volta importiamo le varianti di classe di streaming VertexAiGeminiStreamingChatModel che implementano l'interfaccia StreamingChatLanguageModel. Dovrai anche importare in modo statico LambdaStreamingResponseHandler.onNext, un metodo di praticità che fornisce StreamingResponseHandler per creare un gestore di streaming con espressioni lambda Java.

Questa volta la firma del metodo generate() è leggermente diversa. Invece di restituire una stringa, il tipo di ritorno è void. Oltre al prompt, devi passare un gestore delle risposte in streaming. Qui, grazie all'importazione statica menzionata sopra, possiamo definire un'espressione lambda da passare al metodo onNext(). L'espressione lambda viene chiamata ogni volta che è disponibile un nuovo elemento della risposta, mentre quest'ultima viene chiamata solo in caso di errore.

Esegui:

./gradlew run -q -DjavaMainClass=gemini.workshop.StreamQA

Riceverai una risposta simile a quella del corso precedente, ma questa volta noterai che la risposta viene visualizzata progressivamente nella shell, anziché attendere la visualizzazione della risposta completa.

Configurazione aggiuntiva

Per la configurazione, abbiamo definito solo il progetto, la località e il nome del modello, ma esistono altri parametri che puoi specificare per il modello:

  • temperature(Float temp): per definire il livello di creatività della risposta (0 è poco creativo e spesso più fattuale, mentre 2 è per output più creativi)
  • topP(Float topP): per selezionare le parole possibili la cui probabilità totale corrisponde al numero in virgola mobile (compreso tra 0 e 1)
  • topK(Integer topK): per selezionare in modo casuale una parola da un numero massimo di parole probabili per il completamento del testo (da 1 a 40)
  • maxOutputTokens(Integer max): per specificare la lunghezza massima della risposta data dal modello (in genere, 4 token rappresentano circa 3 parole)
  • maxRetries(Integer retries): se hai superato la quota di richieste per periodo di tempo o se la piattaforma sta riscontrando un problema tecnico, puoi chiedere al modello di riprovare a effettuare la chiamata 3 volte

Finora hai fatto una sola domanda a Gemini, ma puoi anche avere una conversazione con più turni. Questo è ciò che esplorerai nella prossima sezione.

5. Prova Gemini

Nel passaggio precedente hai fatto una singola domanda. Ora è il momento di avere una conversazione reale tra un utente e l'LLM. Ogni domanda e risposta può basarsi sulle precedenti per formare una vera e propria discussione.

Dai un'occhiata a Conversation.java nella cartella app/src/main/java/gemini/workshop:

package gemini.workshop;

import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.vertexai.VertexAiGeminiChatModel;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.service.AiServices;

import java.util.List;

public class Conversation {
    public static void main(String[] args) {
        ChatLanguageModel model = VertexAiGeminiChatModel.builder()
            .project(System.getenv("PROJECT_ID"))
            .location(System.getenv("LOCATION"))
            .modelName("gemini-1.5-flash-002")
            .build();

        MessageWindowChatMemory chatMemory = MessageWindowChatMemory.builder()
            .maxMessages(20)
            .build();

        interface ConversationService {
            String chat(String message);
        }

        ConversationService conversation =
            AiServices.builder(ConversationService.class)
                .chatLanguageModel(model)
                .chatMemory(chatMemory)
                .build();

        List.of(
            "Hello!",
            "What is the country where the Eiffel tower is situated?",
            "How many inhabitants are there in that country?"
        ).forEach( message -> {
            System.out.println("\nUser: " + message);
            System.out.println("Gemini: " + conversation.chat(message));
        });
    }
}

Un paio di nuove importazioni interessanti in questo corso:

  • MessageWindowChatMemory: un'entità che ti aiuterà a gestire l'aspetto multi-turno della conversazione e a conservare nella memoria locale le domande e le risposte precedenti
  • AiServices: una classe di astrazione di livello superiore che collegherà il modello di chat e la memoria di chat

Nel metodo main, configurerai il modello, la memoria della chat e il servizio AI. Il modello viene configurato come di consueto con le informazioni sul progetto, sulla località e sul nome del modello.

Per il ricordo della chat, utilizziamo il generatore di MessageWindowChatMemory per creare un ricordo che conserva gli ultimi 20 messaggi scambiati. Si tratta di una finestra scorrevole sulla conversazione il cui contesto viene mantenuto localmente nella nostra classe client Java.

Poi crei il AI service che lega il modello di chat alla memoria di chat.

Nota come il servizio AI utilizzi un'interfaccia ConversationService personalizzata che abbiamo definito, implementata da LangChain4j, che riceve una query String e restituisce una risposta String.

Ora è il momento di parlare con Gemini. Innanzitutto viene inviato un semplice saluto, poi una prima domanda sulla Torre Eiffel per sapere in quale paese si trova. Tieni presente che l'ultima frase è correlata alla risposta alla prima domanda, in quanto ti chiedi quanti abitanti ci sono nel paese in cui si trova la Torre Eiffel, senza menzionare esplicitamente il paese indicato nella risposta precedente. Mostra che le domande e le risposte precedenti vengono inviate con ogni prompt.

Esegui l'esempio:

./gradlew run -q -DjavaMainClass=gemini.workshop.Conversation

Dovresti vedere tre risposte simili a queste:

User: Hello!
Gemini: Hi there! How can I assist you today?

User: What is the country where the Eiffel tower is situated?
Gemini: France

User: How many inhabitants are there in that country?
Gemini: As of 2023, the population of France is estimated to be around 67.8 million.

Puoi porre domande con una sola risposta o avere conversazioni con più turni con Gemini, ma finora l'input è stato solo di testo. E le immagini? Esploriamo le immagini nel passaggio successivo.

6. Multimodalità con Gemini

Gemini è un modello multimodale. Non solo accetta testo come input, ma anche immagini o persino video. In questa sezione viene mostrato un caso d'uso per la combinazione di testo e immagini.

Pensi che Gemini riconoscerà questo gatto?

af00516493ec9ade.png

Foto di un gatto nella neve presa da Wikipediahttps://upload.wikimedia.org/wikipedia/commons/b/b6/Felis_catus-cat_on_snow.jpg

Dai un'occhiata a Multimodal.java nella directory app/src/main/java/gemini/workshop:

package gemini.workshop;

import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.vertexai.VertexAiGeminiChatModel;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.model.output.Response;
import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.data.message.TextContent;

public class Multimodal {

    static final String CAT_IMAGE_URL =
        "https://upload.wikimedia.org/wikipedia/" +
        "commons/b/b6/Felis_catus-cat_on_snow.jpg";


    public static void main(String[] args) {
        ChatLanguageModel model = VertexAiGeminiChatModel.builder()
            .project(System.getenv("PROJECT_ID"))
            .location(System.getenv("LOCATION"))
            .modelName("gemini-1.5-flash-002")
            .build();

        UserMessage userMessage = UserMessage.from(
            ImageContent.from(CAT_IMAGE_URL),
            TextContent.from("Describe the picture")
        );

        Response<AiMessage> response = model.generate(userMessage);

        System.out.println(response.content().text());
    }
}

Nelle importazioni, tieni presente che distinguiamo tra diversi tipi di messaggi e contenuti. Un UserMessage può contenere sia un oggetto TextContent sia un oggetto ImageContent. Questa è la multimodalità in azione: mescolare testo e immagini. Non inviamo solo un semplice prompt di stringa, ma un oggetto più strutturato che rappresenta un messaggio dell'utente, composto da un componente di contenuti di immagini e da un componente di contenuti di testo. Il modello restituisce un Response contenente un AiMessage.

Recuperi quindi AiMessage dalla risposta tramite content() e il testo del messaggio grazie a text().

Esegui l'esempio:

./gradlew run -q -DjavaMainClass=gemini.workshop.Multimodal

Il nome dell'immagine ti ha sicuramente dato un'idea di cosa conteneva, ma l'output di Gemini è simile al seguente:

A cat with brown fur is walking in the snow. The cat has a white patch of fur on its chest and white paws. The cat is looking at the camera.

La combinazione di immagini e prompt di testo apre a casi d'uso interessanti. Puoi creare applicazioni che possono:

  • Riconoscere il testo nelle immagini.
  • Verificare se un'immagine è sicura da visualizzare.
  • Creare didascalie delle immagini.
  • Cerca in un database di immagini con descrizioni in testo normale.

Oltre a estrarre informazioni dalle immagini, puoi anche estrarre informazioni da testo non strutturato. È ciò che imparerai nella prossima sezione.

7. Estrare informazioni strutturate da testo non strutturato

In molte situazioni, le informazioni importanti vengono fornite in documenti di report, email o altri testi di formato lungo in modo non strutturato. Idealmente, vorresti essere in grado di estrarre i dettagli chiave contenuti nel testo non strutturato sotto forma di oggetti strutturati. Vediamo come si fa.

Supponiamo che tu voglia estrarre il nome e l'età di una persona, a partire da una biografia, un CV o una descrizione della persona. Puoi chiedere all'LLM di estrarre JSON da testo non strutturato con un prompt opportunamente modificato (questa tecnica è comunemente chiamata "prompt engineering").

Tuttavia, nell'esempio seguente, anziché creare un prompt che descriva l'output JSON, utilizzeremo una potente funzionalità di Gemini chiamata output strutturato o, a volte, decodifica vincolata, costringendo il modello a produrre solo contenuti JSON validi, seguendo uno schema JSON specificato.

Dai un'occhiata a ExtractData.java in app/src/main/java/gemini/workshop:

package gemini.workshop;

import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.vertexai.VertexAiGeminiChatModel;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.service.SystemMessage;

import static dev.langchain4j.model.vertexai.SchemaHelper.fromClass;

public class ExtractData {

    record Person(String name, int age) { }

    interface PersonExtractor {
        @SystemMessage("""
            Your role is to extract the name and age 
            of the person described in the biography.
            """)
        Person extractPerson(String biography);
    }

    public static void main(String[] args) {
        ChatLanguageModel model = VertexAiGeminiChatModel.builder()
            .project(System.getenv("PROJECT_ID"))
            .location(System.getenv("LOCATION"))
            .modelName("gemini-1.5-flash-002")
            .responseMimeType("application/json")
            .responseSchema(fromClass(Person.class))
            .build();

        PersonExtractor extractor = AiServices.create(PersonExtractor.class, model);

        String bio = """
            Anna is a 23 year old artist based in Brooklyn, New York. She was born and 
            raised in the suburbs of Chicago, where she developed a love for art at a 
            young age. She attended the School of the Art Institute of Chicago, where 
            she studied painting and drawing. After graduating, she moved to New York 
            City to pursue her art career. Anna's work is inspired by her personal 
            experiences and observations of the world around her. She often uses bright 
            colors and bold lines to create vibrant and energetic paintings. Her work 
            has been exhibited in galleries and museums in New York City and Chicago.    
            """;
        Person person = extractor.extractPerson(bio);

        System.out.println(person.name());  // Anna
        System.out.println(person.age());   // 23
    }
}

Diamo un'occhiata ai vari passaggi di questo file:

  • Un record Person è definito per rappresentare i dettagli che descrivono una persona (nome ed età).
  • L'interfaccia PersonExtractor è definita con un metodo che, data una stringa di testo non strutturato, restituisce un'istanza Person.
  • Il extractPerson() è annotato con un'annotazione @SystemMessage che associa un prompt di istruzioni. Questo è il prompt che il modello utilizzerà per guidare l'estrazione delle informazioni e restituire i dettagli sotto forma di un documento JSON, che verrà analizzato per te e destrutturato in un'istanza Person.

Ora esaminiamo i contenuti del metodo main():

  • Il modello di chat è configurato e istanziato. Utilizziamo due nuovi metodi della classe ModelBuilder: responseMimeType() e responseSchema(). Il primo indica a Gemini di generare JSON valido in output. Il secondo metodo definisce lo schema dell'oggetto JSON da restituire. Inoltre, quest'ultimo delega a un metodo di utilità in grado di convertire una classe o un record Java in uno schema JSON appropriato.
  • Un oggetto PersonExtractor viene creato grazie alla classe AiServices di LangChain4j.
  • Poi, puoi semplicemente chiamare Person person = extractor.extractPerson(...) per estrarre i dettagli della persona dal testo non strutturato e ottenere un'istanza Person con il nome e l'età.

Esegui l'esempio:

./gradlew run -q -DjavaMainClass=gemini.workshop.ExtractData

Dovresti vedere l'output seguente:

Anna
23

Sì, sono Anna e ho 23 anni.

Con questo approccio AiServices operi con oggetti fortemente tipizzati. Non interagisci direttamente con l'LLM. Lavori invece con classi concrete, come il record Person per rappresentare le informazioni personali estratte, e hai un oggetto PersonExtractor con un metodo extractPerson() che restituisce un'istanza Person. Il concetto di LLM viene rimosso e, come sviluppatore Java, quando utilizzi questa interfaccia PersonExtractor, manipoli solo classi e oggetti normali.

8. Strutturare i prompt con i modelli di prompt

Quando interagisci con un LLM utilizzando un insieme comune di istruzioni o domande, c'è una parte del prompt che non cambia mai, mentre le altre parti contengono i dati. Ad esempio, se vuoi creare ricette, potresti utilizzare un prompt come "Sei uno chef di talento, crea una ricetta con i seguenti ingredienti: …" e poi aggiungere gli ingredienti alla fine del testo. A questo servono i modelli di prompt, simili alle stringhe interpolate nei linguaggi di programmazione. Un modello di prompt contiene segnaposto che puoi sostituire con i dati corretti per una determinata chiamata all'LLM.

Nello specifico, esaminiamo TemplatePrompt.java nella directory app/src/main/java/gemini/workshop:

package gemini.workshop;

import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.vertexai.VertexAiGeminiChatModel;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.model.input.Prompt;
import dev.langchain4j.model.input.PromptTemplate;
import dev.langchain4j.model.output.Response;

import java.util.HashMap;
import java.util.Map;

public class TemplatePrompt {
    public static void main(String[] args) {
        ChatLanguageModel model = VertexAiGeminiChatModel.builder()
            .project(System.getenv("PROJECT_ID"))
            .location(System.getenv("LOCATION"))
            .modelName("gemini-1.5-flash-002")
            .maxOutputTokens(500)
            .temperature(1.0f)
            .topK(40)
            .topP(0.95f)
            .maxRetries(3)
            .build();

        PromptTemplate promptTemplate = PromptTemplate.from("""
            You're a friendly chef with a lot of cooking experience.
            Create a recipe for a {{dish}} with the following ingredients: \
            {{ingredients}}, and give it a name.
            """
        );

        Map<String, Object> variables = new HashMap<>();
        variables.put("dish", "dessert");
        variables.put("ingredients", "strawberries, chocolate, and whipped cream");

        Prompt prompt = promptTemplate.apply(variables);

        Response<AiMessage> response = model.generate(prompt.toUserMessage());

        System.out.println(response.content().text());
    }
}

Come di consueto, configuri il modello VertexAiGeminiChatModel con un alto livello di creatività e una temperatura elevata, nonché valori elevati di topP e topK. Poi crei un PromptTemplate con il relativo metodo statico from(), passando la stringa del nostro prompt e utilizzi le variabili segnaposto tra parentesi graffe doppie: {{dish}} e {{ingredients}}.

Puoi creare il prompt finale chiamando apply(), che accetta una mappa di coppie chiave/valore che rappresentano il nome del segnaposto e il valore della stringa da sostituire.

Infine, chiami il metodo generate() del modello Gemini creando un messaggio dell'utente da quel prompt, con l'istruzione prompt.toUserMessage().

Esegui l'esempio:

./gradlew run -q -DjavaMainClass=gemini.workshop.TemplatePrompt

Dovresti visualizzare un output generato simile a questo:

**Strawberry Shortcake**

Ingredients:

* 1 pint strawberries, hulled and sliced
* 1/2 cup sugar
* 1/4 cup cornstarch
* 1/4 cup water
* 1 tablespoon lemon juice
* 1/2 cup heavy cream, whipped
* 1/4 cup confectioners' sugar
* 1/4 teaspoon vanilla extract
* 6 graham cracker squares, crushed

Instructions:

1. In a medium saucepan, combine the strawberries, sugar, cornstarch, 
water, and lemon juice. Bring to a boil over medium heat, stirring 
constantly. Reduce heat and simmer for 5 minutes, or until the sauce has 
thickened.
2. Remove from heat and let cool slightly.
3. In a large bowl, combine the whipped cream, confectioners' sugar, and 
vanilla extract. Beat until soft peaks form.
4. To assemble the shortcakes, place a graham cracker square on each of 
6 dessert plates. Top with a scoop of whipped cream, then a spoonful of 
strawberry sauce. Repeat layers, ending with a graham cracker square.
5. Serve immediately.

**Tips:**

* For a more elegant presentation, you can use fresh strawberries 
instead of sliced strawberries.
* If you don't have time to make your own whipped cream, you can use 
store-bought whipped cream.

Non esitare a modificare i valori di dish e ingredients nella mappa e a modificare la temperatura, topK e tokP ed eseguire di nuovo il codice. In questo modo potrai osservare l'effetto della modifica di questi parametri sull'LLM.

I modelli di prompt sono un buon modo per avere istruzioni riutilizzabili e parametrizzabili per le chiamate LLM. Puoi trasmettere dati e personalizzare i prompt per valori diversi, forniti dagli utenti.

9. Classificazione del testo con prompt few-shot

Gli LLM sono piuttosto bravi a classificare il testo in diverse categorie. Puoi aiutare un modello LLM in questa attività fornendo alcuni esempi di testi e le relative categorie associate. Questo approccio è spesso chiamato prompt few-shot.

Apriamo TextClassification.java nella directory app/src/main/java/gemini/workshop per eseguire un particolare tipo di classificazione del testo: l'analisi del sentiment.

package gemini.workshop;

import com.google.cloud.vertexai.api.Schema;
import com.google.cloud.vertexai.api.Type;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.vertexai.VertexAiGeminiChatModel;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.service.SystemMessage;

import java.util.List;

public class TextClassification {

    enum Sentiment { POSITIVE, NEUTRAL, NEGATIVE }

    public static void main(String[] args) {
        ChatLanguageModel model = VertexAiGeminiChatModel.builder()
            .project(System.getenv("PROJECT_ID"))
            .location(System.getenv("LOCATION"))
            .modelName("gemini-1.5-flash-002")
            .maxOutputTokens(10)
            .maxRetries(3)
            .responseSchema(Schema.newBuilder()
                .setType(Type.STRING)
                .addAllEnum(List.of("POSITIVE", "NEUTRAL", "NEGATIVE"))
                .build())
            .build();


        interface SentimentAnalysis {
            @SystemMessage("""
                Analyze the sentiment of the text below.
                Respond only with one word to describe the sentiment.
                """)
            Sentiment analyze(String text);
        }

        MessageWindowChatMemory memory = MessageWindowChatMemory.withMaxMessages(10);
        memory.add(UserMessage.from("This is fantastic news!"));
        memory.add(AiMessage.from(Sentiment.POSITIVE.name()));

        memory.add(UserMessage.from("Pi is roughly equal to 3.14"));
        memory.add(AiMessage.from(Sentiment.NEUTRAL.name()));

        memory.add(UserMessage.from("I really disliked the pizza. Who would use pineapples as a pizza topping?"));
        memory.add(AiMessage.from(Sentiment.NEGATIVE.name()));

        SentimentAnalysis sentimentAnalysis =
            AiServices.builder(SentimentAnalysis.class)
                .chatLanguageModel(model)
                .chatMemory(memory)
                .build();

        System.out.println(sentimentAnalysis.analyze("I love strawberries!"));
    }
}

Un enum Sentiment elenca i diversi valori per un'opinione: negativa, neutra o positiva.

Nel metodo main(), crei il modello di chat di Gemini come al solito, ma con un numero ridotto di token di output massimo, poiché vuoi solo una risposta breve: il testo è POSITIVE, NEGATIVE o NEUTRAL. Per limitare il modello a restituire solo questi valori, in modo esclusivo, puoi sfruttare il supporto dell'output strutturato che hai scoperto nella sezione sull'estrazione dei dati. Per questo motivo viene utilizzato il metodo responseSchema(). Questa volta, non utilizzerai il comodo metodo di SchemaHelper per dedurre la definizione dello schema, ma utilizzerai il generatore Schema per capire come si presenta la definizione dello schema.

Una volta configurato il modello, crei un'interfaccia SentimentAnalysis che AiServices di LangChain4j implementerà per te utilizzando l'LLM. Questa interfaccia contiene un metodo: analyze(). Prende il testo da analizzare in input e restituisce un valore dell'enum Sentiment. Pertanto, manipoli solo un oggetto fortemente tipizzato che rappresenta la classe di sentimento riconosciuta.

Poi, per fornire gli "esempi con pochi scatti" per indurre il modello a svolgere il proprio lavoro di classificazione, crei una memoria di chat per trasmettere coppie di messaggi degli utenti e risposte dell'IA che rappresentano il testo e il relativo sentiment.

Uniamo tutto con il metodo AiServices.builder(), passando la nostra interfaccia SentimentAnalysis, il modello da utilizzare e la memoria della chat con gli esempi con pochi esempi. Infine, chiama il metodo analyze() con il testo da analizzare.

Esegui l'esempio:

./gradlew run -q -DjavaMainClass=gemini.workshop.TextClassification

Dovresti vedere una singola parola:

POSITIVE

Sembra che amare le fragole sia un sentimento positivo.

10. Retrieval-Augmented Generation

Gli LLM vengono addestrati con una grande quantità di testo. Tuttavia, le sue conoscenze riguardano solo le informazioni che ha visto durante l'addestramento. Se vengono rilasciate nuove informazioni dopo la data di interruzione dell'addestramento del modello, questi dettagli non saranno disponibili per il modello. Di conseguenza, il modello non sarà in grado di rispondere a domande su informazioni che non ha visto.

Ecco perché approcci come la generazione basata sul recupero (RAG) che verranno trattati in questa sezione contribuiscono a fornire le informazioni aggiuntive che un modello LLM potrebbe dover conoscere per soddisfare le richieste dei suoi utenti, per rispondere con informazioni che potrebbero essere più attuali o su informazioni private non accessibili al momento dell'addestramento.

Torniamo alle conversazioni. Questa volta potrai porre domande sui tuoi documenti. Creerai un chatbot in grado di recuperare informazioni pertinenti da un database contenente i tuoi documenti suddivisi in pezzi più piccoli ("chunk"). Queste informazioni verranno utilizzate dal modello per basare le sue risposte, anziché fare affidamento esclusivamente sulle conoscenze contenute nell'addestramento.

Il RAG prevede due fasi:

  1. Fase di importazione: i documenti vengono caricati in memoria, suddivisi in chunk più piccoli e gli embedding vettoriali (una rappresentazione vettoriale altamente multidimensionale dei chunk) vengono calcolati e archiviati in un database vettoriale in grado di eseguire ricerche semantiche. Questa fase di importazione viene in genere eseguita una sola volta, quando è necessario aggiungere nuovi documenti al corpus di documenti.

cd07d33d20ffa1c8.png

  1. Fase di query: ora gli utenti possono porre domande sui documenti. Anche la domanda verrà trasformata in un vettore e confrontata con tutti gli altri vettori del database. I vettori più simili sono generalmente correlati semanticamente e vengono restituiti dal database di vettori. Poi, all'LLM viene fornito il contesto della conversazione, i blocchi di testo corrispondenti ai vettori restituiti dal database e gli viene chiesto di basare la sua risposta su questi blocchi.

a1d2e2deb83c6d27.png

Preparare i documenti

Per questo nuovo esempio, porrai domande su un modello di auto fittizio di un produttore di auto fittizio: l'auto Cymbal Starlight. L'idea è che un documento su un'auto fittizia non debba far parte della conoscenza del modello. Pertanto, se Gemini è in grado di rispondere correttamente alle domande su questa auto, significa che l'approccio RAG funziona: è in grado di eseguire ricerche nel documento.

Implementare il chatbot

Scopri come creare l'approccio in due fasi: prima con l'importazione dei documenti e poi al momento della query (chiamata anche "fase di recupero") quando gli utenti pongono domande sul documento.

In questo esempio, entrambe le fasi sono implementate nella stessa classe. In genere, hai un'applicazione che si occupa dell'importazione e un'altra che offre l'interfaccia del chatbot agli utenti.

Inoltre, in questo esempio utilizzeremo un database di vettori in memoria. In uno scenario di produzione reale, le fasi di importazione e di query vengono separate in due applicazioni distinte e i vettori vengono mantenuti in un database autonomo.

Importazione dei documenti

Il primo passaggio della fase di importazione dei documenti consiste nel individuare il file PDF relativo alla nostra auto fittizia e preparare un PdfParser per leggerlo:

URL url = new URI("https://raw.githubusercontent.com/meteatamel/genai-beyond-basics/main/samples/grounding/vertexai-search/cymbal-starlight-2024.pdf").toURL();
ApachePdfBoxDocumentParser pdfParser = new ApachePdfBoxDocumentParser();
Document document = pdfParser.parse(url.openStream());

Anziché creare prima il solito modello linguistico di chat, crea un'istanza di un modello di embedding. Si tratta di un modello particolare il cui ruolo è creare rappresentazioni vettoriali di parti di testo (parole, frasi o anche paragrafi). Restituisce vettori di numeri in virgola mobile anziché risposte di testo.

VertexAiEmbeddingModel embeddingModel = VertexAiEmbeddingModel.builder()
    .endpoint(System.getenv("LOCATION") + "-aiplatform.googleapis.com:443")
    .project(System.getenv("PROJECT_ID"))
    .location(System.getenv("LOCATION"))
    .publisher("google")
    .modelName("text-embedding-005")
    .maxRetries(3)
    .build();

A questo punto, avrai bisogno di alcuni corsi per collaborare insieme a:

  • Carica e suddividi il documento PDF in blocchi.
  • Crea embedding di vettori per tutti questi chunk.
InMemoryEmbeddingStore<TextSegment> embeddingStore = 
    new InMemoryEmbeddingStore<>();

EmbeddingStoreIngestor storeIngestor = EmbeddingStoreIngestor.builder()
    .documentSplitter(DocumentSplitters.recursive(500, 100))
    .embeddingModel(embeddingModel)
    .embeddingStore(embeddingStore)
    .build();
storeIngestor.ingest(document);

Viene creata un'istanza di InMemoryEmbeddingStore, un database vettoriale in memoria, per archiviare gli incorporamenti vettoriali.

Il documento è suddiviso in chunk grazie alla classe DocumentSplitters. Il testo del file PDF verrà suddiviso in snippet di 500 caratteri, con una sovrapposizione di 100 caratteri (con il chunk successivo, per evitare di tagliare parole o frasi a pezzetti).

L'importatore dell'archivio collega lo splitter dei documenti, il modello di embedding per calcolare i vettori e il database di vettori in memoria. A questo punto, il metodo ingest() si occuperà dell'importazione.

Ora che la prima fase è terminata, il documento è stato trasformato in blocchi di testo con i relativi incorporamenti di vettori associati e archiviato nel database di vettori.

Porre domande

È ora di prepararsi a porre domande. Crea un modello di chat per avviare la conversazione:

ChatLanguageModel model = VertexAiGeminiChatModel.builder()
        .project(System.getenv("PROJECT_ID"))
        .location(System.getenv("LOCATION"))
        .modelName("gemini-1.5-flash-002")
        .maxOutputTokens(1000)
        .build();

È necessaria anche una classe di recupero per collegare il database di vettori (nella variabile embeddingStore) al modello di embedding. Il suo compito è eseguire query sul database vettoriale calcolando un embedding vettoriale per la query dell'utente, in modo da trovare vettori simili nel database:

EmbeddingStoreContentRetriever retriever =
    new EmbeddingStoreContentRetriever(embeddingStore, embeddingModel);

Crea un'interfaccia che rappresenti un assistente esperto di auto, ovvero un'interfaccia che la classe AiServices implementerà per consentirti di interagire con il modello:

interface CarExpert {
    Result<String> ask(String question);
}

L'interfaccia CarExpert restituisce una risposta di stringa racchiusa nella classe Result di LangChain4j. Perché utilizzare questo wrapper? Perché non solo ti fornirà la risposta, ma ti consentirà anche di esaminare i chunk del database restituiti dal recupero dei contenuti. In questo modo, puoi mostrare all'utente le fonti dei documenti utilizzati per fondare la risposta finale.

A questo punto, puoi configurare un nuovo servizio AI:

CarExpert expert = AiServices.builder(CarExpert.class)
    .chatLanguageModel(model)
    .chatMemory(MessageWindowChatMemory.withMaxMessages(10))
    .contentRetriever(retriever)
    .build();

Questo servizio unisce:

  • Il modello di lingua chat che hai configurato in precedenza.
  • Un ricordo della chat per tenere traccia della conversazione.
  • Il retriever confronta una query di embedding vettoriale con i vettori nel database.
.retrievalAugmentor(DefaultRetrievalAugmentor.builder()
    .contentInjector(DefaultContentInjector.builder()
        .promptTemplate(PromptTemplate.from("""
            You are an expert in car automotive, and you answer concisely.

            Here is the question: {{userMessage}}

            Answer using the following information:
            {{contents}}
the following information:
            {{contents}}
            """))
        .build())
    .contentRetriever(retriever)
    .build())

Finalmente puoi porre le tue domande.

List.of(
    "What is the cargo capacity of Cymbal Starlight?",
    "What's the emergency roadside assistance phone number?",
    "Are there some special kits available on that car?"
).forEach(query -> {
    Result<String> response = expert.ask(query);
    System.out.printf("%n=== %s === %n%n %s %n%n", query, response.content());
    System.out.println("SOURCE: " + response.sources().getFirst().textSegment().text());
});

Il codice sorgente completo si trova in RAG.java nella directory app/src/main/java/gemini/workshop.

Esegui l'esempio:

./gradlew -q run -DjavaMainClass=gemini.workshop.RAG

Nell'output dovresti vedere le risposte alle tue domande:

=== What is the cargo capacity of Cymbal Starlight? === 

 The Cymbal Starlight 2024 has a cargo capacity of 13.5 cubic feet.
 

SOURCE: Cargo
The Cymbal Starlight 2024 has a cargo capacity of 13.5 cubic feet. The cargo area is located in the trunk of
the vehicle.
To access the cargo area, open the trunk lid using the trunk release lever located in the driver's footwell.
When loading cargo into the trunk, be sure to distribute the weight evenly. Do not overload the trunk, as this
could affect the vehicle's handling and stability.
Luggage

=== What's the emergency roadside assistance phone number? === 

The emergency roadside assistance phone number is 1-800-555-1212.
 

SOURCE: Chapter 18: Emergencies
Roadside Assistance
If you experience a roadside emergency, such as a flat tire or a dead battery, you can call roadside
assistance for help. Roadside assistance is available 24 hours a day, 7 days a week.
To call roadside assistance, dial the following number:
1-800-555-1212
When you call roadside assistance, be prepared to provide the following information:
Your name and contact information
Your vehicle's make, model, and year
Your vehicle's location

=== Are there some special kits available on that car? === 

 Yes, the Cymbal Starlight comes with a tire repair kit.
 

SOURCE: Lane keeping assist:  This feature helps to keep you in your lane by gently steering the vehicle back
into the lane if you start to drift.
Adaptive cruise control:  This feature automatically adjusts your speed to maintain a safe following
distance from the vehicle in front of you.
Forward collision warning:  This feature warns you if you are approaching another vehicle too
quickly.
Automatic emergency braking:  This feature can automatically apply the brakes to avoid a collision.

11. Chiamata di funzione

In alcune situazioni potresti volere che un modello LLM abbia accesso a sistemi esterni, ad esempio un'API web remota che recupera informazioni o esegue un'azione oppure servizi che eseguono un qualche tipo di calcolo. Ad esempio:

API web remote:

  • Monitorare e aggiornare gli ordini dei clienti.
  • Trova o crea un ticket in un tracker dei problemi.
  • Recupera dati in tempo reale come quotazioni di borsa o misurazioni di sensori IoT.
  • Invia un'email.

Strumenti di calcolo:

  • Una calcolatrice per problemi di matematica più avanzati.
  • Interpretazione del codice per l'esecuzione del codice quando gli LLM richiedono una logica di ragionamento.
  • Converti le richieste in linguaggio naturale in query SQL in modo che un LLM possa eseguire query su un database.

Le chiamate di funzioni (a volte chiamate strumenti o utilizzo di strumenti) sono la capacità del modello di richiedere l'esecuzione di una o più chiamate di funzioni per suo conto, in modo da poter rispondere correttamente alla richiesta di un utente con dati più recenti.

Dato un determinato prompt di un utente e la conoscenza delle funzioni esistenti che possono essere pertinenti a quel contesto, un LLM può rispondere con una richiesta di chiamata di funzione. L'applicazione che integra l'LLM può quindi chiamare la funzione per suo conto e poi rispondere all'LLM con una risposta, che l'LLM interpreta rispondendo con una risposta testuale.

Quattro passaggi della chiamata di funzione

Vediamo un esempio di chiamata di funzione: l'ottenimento di informazioni sulle previsioni meteo.

Se chiedi a Gemini o a qualsiasi altro LLM il meteo a Parigi, ti risponderà di non avere informazioni sulle previsioni meteo attuali. Se vuoi che l'LLM abbia accesso in tempo reale ai dati meteo, devi definire alcune funzioni che può richiedere di utilizzare.

Dai un'occhiata al seguente diagramma:

31e0c2aba5e6f21c.png

1️⃣ Innanzitutto, un utente chiede informazioni sul meteo a Parigi. L'app di chatbot (che utilizza LangChain4j) sa che ha a disposizione una o più funzioni per aiutare l'LLM a soddisfare la query. Il chatbot invia sia il prompt iniziale sia l'elenco delle funzioni che possono essere chiamate. Qui, una funzione chiamata getWeather() che accetta un parametro di stringa per la posizione.

8863be53a73c4a70.png

Poiché l'LLM non conosce le previsioni meteo, anziché rispondere via messaggio, invia una richiesta di esecuzione di funzione. Il chatbot deve chiamare la funzione getWeather() con "Paris" come parametro posizione.

d1367cc69c07b14d.png

2️⃣ Il chatbot richiama la funzione per conto dell'LLM e recupera la risposta della funzione. Qui supponiamo che la risposta sia {"forecast": "sunny"}.

73a5f2ed19f47d8.png

3️⃣ L'app di chatbot restituisce la risposta JSON all'LLM.

20832cb1ee6fbfeb.png

4️⃣ L'LLM esamina la risposta JSON, interpreta le informazioni e alla fine risponde con il testo che indica che a Parigi è soleggiato.

Ogni passaggio come codice

Innanzitutto, configura il modello Gemini come di consueto:

ChatLanguageModel model = VertexAiGeminiChatModel.builder()
    .project(System.getenv("PROJECT_ID"))
    .location(System.getenv("LOCATION"))
    .modelName("gemini-1.5-flash-002")
    .maxOutputTokens(100)
    .build();

Definisci una specifica dello strumento che descrive la funzione che può essere chiamata:

ToolSpecification weatherToolSpec = ToolSpecification.builder()
    .name("getWeather")
    .description("Get the weather forecast for a given location or city")
    .parameters(JsonObjectSchema.builder()
        .addStringProperty(
            "location", 
            "the location or city to get the weather forecast for")
        .build())
    .build();

Il nome della funzione è definito, così come il nome e il tipo del parametro, ma tieni presente che sia la funzione che i parametri hanno una descrizione. Le descrizioni sono molto importanti e aiutano l'LLM a capire davvero cosa può fare una funzione e quindi a giudicare se questa funzione deve essere chiamata nel contesto della conversazione.

Iniziamo con il passaggio 1, inviando la domanda iniziale sul meteo a Parigi:

List<ChatMessage> allMessages = new ArrayList<>();

// 1) Ask the question about the weather
UserMessage weatherQuestion = UserMessage.from("What is the weather in Paris?");
allMessages.add(weatherQuestion);

Nel passaggio 2, passiamo lo strumento che vogliamo che il modello utilizzi e il modello risponde con una richiesta di esecuzione dello strumento:

// 2) The model replies with a function call request
Response<AiMessage> messageResponse = model.generate(allMessages, weatherToolSpec);
ToolExecutionRequest toolExecutionRequest = messageResponse.content().toolExecutionRequests().getFirst();
System.out.println("Tool execution request: " + toolExecutionRequest);
allMessages.add(messageResponse.content());

Passaggio 3. A questo punto, sappiamo quale funzione l'LLM vorrebbe che chiamassimo. Nel codice, non viene effettuata una chiamata reale a un'API esterna, ma viene restituita direttamente una previsione meteo ipotetica:

// 3) We send back the result of the function call
ToolExecutionResultMessage toolExecResMsg = ToolExecutionResultMessage.from(toolExecutionRequest,
    "{\"location\":\"Paris\",\"forecast\":\"sunny\", \"temperature\": 20}");
allMessages.add(toolExecResMsg);

Nel passaggio 4, l'LLM apprende il risultato dell'esecuzione della funzione e può quindi sintetizzare una risposta testuale:

// 4) The model answers with a sentence describing the weather
Response<AiMessage> weatherResponse = model.generate(allMessages);
System.out.println("Answer: " + weatherResponse.content().text());

L'output è:

Tool execution request: ToolExecutionRequest { id = null, name = "getWeatherForecast", arguments = "{"location":"Paris"}" }
Answer:  The weather in Paris is sunny with a temperature of 20 degrees Celsius.

Nell'output sopra la richiesta di esecuzione dello strumento puoi vedere la risposta.

Il codice sorgente completo si trova in FunctionCalling.java nella directory app/src/main/java/gemini/workshop:

Esegui l'esempio:

./gradlew run -q -DjavaMainClass=gemini.workshop.FunctionCalling

Dovresti vedere un output simile al seguente:

Tool execution request: ToolExecutionRequest { id = null, name = "getWeatherForecast", arguments = "{"location":"Paris"}" }
Answer:  The weather in Paris is sunny with a temperature of 20 degrees Celsius.

12. LangChain4j gestisce la chiamata di funzioni

Nel passaggio precedente hai visto come le normali interazioni di testo domanda/risposta e richiesta/risposta della funzione sono interlacciate e, nel mezzo, hai fornito direttamente la risposta della funzione richiesta, senza chiamare una funzione reale.

Tuttavia, LangChain4j offre anche un'astrazione di livello superiore che può gestire le chiamate di funzione in modo trasparente, gestendo al contempo la conversazione come di consueto.

Chiamata di una singola funzione

Diamo un'occhiata a FunctionCallingAssistant.java, pezzo per pezzo.

Per prima cosa, crea un record che rappresenti la struttura dei dati della risposta della funzione:

record WeatherForecast(String location, String forecast, int temperature) {}

La risposta contiene informazioni sulla posizione, sulla previsione e sulla temperatura.

Poi crea un corso che contenga la funzione effettiva che vuoi rendere disponibile al modello:

static class WeatherForecastService {
    @Tool("Get the weather forecast for a location")
    WeatherForecast getForecast(@P("Location to get the forecast for") String location) {
        if (location.equals("Paris")) {
            return new WeatherForecast("Paris", "Sunny", 20);
        } else if (location.equals("London")) {
            return new WeatherForecast("London", "Rainy", 15);
        } else {
            return new WeatherForecast("Unknown", "Unknown", 0);
        }
    }
}

Tieni presente che questo tipo contiene una singola funzione, ma è annotato con l'annotazione @Tool che corrisponde alla descrizione della funzione che il modello può richiedere di chiamare.

Anche i parametri della funzione (qui uno solo) sono annotati, ma con questa breve annotazione @P, che fornisce anche una descrizione del parametro. Puoi aggiungere tutte le funzioni che vuoi per renderle disponibili al modello per scenari più complessi.

In questo metodo restituisci alcune risposte predefinite, ma se volessi chiamare un vero servizio esterno di previsioni meteo, è nel corpo di questo metodo che effettuerai la chiamata al servizio.

Come abbiamo visto quando hai creato un ToolSpecification nell'approccio precedente, è importante documentare cosa fa una funzione e descrivere a cosa corrispondono i parametri. In questo modo, il modello comprende come e quando può essere utilizzata questa funzione.

Successivamente, LangChain4j ti consente di fornire un'interfaccia corrispondente al contratto che vuoi utilizzare per interagire con il modello. In questo caso, si tratta di un'interfaccia semplice che riceve una stringa che rappresenta il messaggio dell'utente e restituisce una stringa corrispondente alla risposta del modello:

interface WeatherAssistant {
    String chat(String userMessage);
}

È anche possibile utilizzare firme più complesse che coinvolgono UserMessage (per un messaggio utente) o AiMessage (per una risposta del modello) di LangChain4j o anche TokenStream, se vuoi gestire situazioni più avanzate, poiché questi oggetti più complicati contengono anche informazioni aggiuntive come il numero di token consumati e così via. Tuttavia, per semplicità, prenderemo solo stringa in input e stringa in output.

Concludiamo con il metodo main() che lega tutti i componenti:

public static void main(String[] args) {
    ChatLanguageModel model = VertexAiGeminiChatModel.builder()
        .project(System.getenv("PROJECT_ID"))
        .location(System.getenv("LOCATION"))
        .modelName("gemini-1.5-pro-002")
        .build();

    WeatherForecastService weatherForecastService = new WeatherForecastService();

    WeatherAssistant assistant = AiServices.builder(WeatherAssistant.class)
        .chatLanguageModel(model)
        .chatMemory(MessageWindowChatMemory.withMaxMessages(10))
        .tools(weatherForecastService)
        .build();

    System.out.println(assistant.chat("What is the weather in Paris?"));
}

Come di consueto, configuri il modello di chat di Gemini. Poi esegui l'inizializzazione del servizio di previsioni meteo che contiene la "funzione" che il modello ci chiederà di chiamare.

Ora utilizza di nuovo la classe AiServices per associare il modello di chat, la memoria della chat e lo strumento (ad es. il servizio di previsioni meteo con la relativa funzione). AiServices restituisce un oggetto che implementa l'interfaccia WeatherAssistant che hai definito. Non resta che chiamare il metodo chat() dell'assistente. Quando la richiami, vedrai solo le risposte di testo, ma le richieste di chiamata di funzione e le risposte di chiamata di funzione non saranno visibili allo sviluppatore e queste richieste verranno gestite automaticamente e in modo trasparente. Se Gemini ritiene che debba essere chiamata una funzione, risponderà con la richiesta di chiamata della funzione e LangChain4j si occuperà di chiamare la funzione locale per tuo conto.

Esegui l'esempio:

./gradlew run -q -DjavaMainClass=gemini.workshop.FunctionCallingAssistant

Dovresti vedere un output simile al seguente:

OK. The weather in Paris is sunny with a temperature of 20 degrees.

Questo è un esempio di una singola funzione.

Più chiamate di funzioni

Puoi anche avere più funzioni e lasciare che LangChain4j gestisca per tuo conto più chiamate di funzioni. Dai un'occhiata a MultiFunctionCallingAssistant.java per un esempio di più funzioni.

Ha una funzione per convertire le valute:

@Tool("Convert amounts between two currencies")
double convertCurrency(
    @P("Currency to convert from") String fromCurrency,
    @P("Currency to convert to") String toCurrency,
    @P("Amount to convert") double amount) {

    double result = amount;

    if (fromCurrency.equals("USD") && toCurrency.equals("EUR")) {
        result = amount * 0.93;
    } else if (fromCurrency.equals("USD") && toCurrency.equals("GBP")) {
        result = amount * 0.79;
    }

    System.out.println(
        "convertCurrency(fromCurrency = " + fromCurrency +
            ", toCurrency = " + toCurrency +
            ", amount = " + amount + ") == " + result);

    return result;
}

Un'altra funzione per ottenere il valore di un'azione:

@Tool("Get the current value of a stock in US dollars")
double getStockPrice(@P("Stock symbol") String symbol) {
    double result = 170.0 + 10 * new Random().nextDouble();

    System.out.println("getStockPrice(symbol = " + symbol + ") == " + result);

    return result;
}

Un'altra funzione per applicare una percentuale a un determinato importo:

@Tool("Apply a percentage to a given amount")
double applyPercentage(@P("Initial amount") double amount, @P("Percentage between 0-100 to apply") double percentage) {
    double result = amount * (percentage / 100);

    System.out.println("applyPercentage(amount = " + amount + ", percentage = " + percentage + ") == " + result);

    return result;
}

Puoi quindi combinare tutte queste funzioni e una classe MultiTools e porre domande come "Qual è il 10% del prezzo delle azioni AAPL convertito da USD in EUR?"

public static void main(String[] args) {
    ChatLanguageModel model = VertexAiGeminiChatModel.builder()
        .project(System.getenv("PROJECT_ID"))
        .location(System.getenv("LOCATION"))
        .modelName("gemini-1.5-flash-002")
        .maxOutputTokens(100)
        .build();

    MultiTools multiTools = new MultiTools();

    MultiToolsAssistant assistant = AiServices.builder(MultiToolsAssistant.class)
        .chatLanguageModel(model)
        .chatMemory(withMaxMessages(10))
        .tools(multiTools)
        .build();

    System.out.println(assistant.chat(
        "What is 10% of the AAPL stock price converted from USD to EUR?"));
}

Eseguilo come segue:

./gradlew run -q -DjavaMainClass=gemini.workshop.MultiFunctionCallingAssistant

Dovresti vedere le più funzioni chiamate:

getStockPrice(symbol = AAPL) == 172.8022224055534
convertCurrency(fromCurrency = USD, toCurrency = EUR, amount = 172.8022224055534) == 160.70606683716468
applyPercentage(amount = 160.70606683716468, percentage = 10.0) == 16.07060668371647
10% of the AAPL stock price converted from USD to EUR is 16.07060668371647 EUR.

Verso gli agenti

Le chiamate di funzione sono un ottimo meccanismo di estensione per i modelli linguistici di grandi dimensioni come Gemini. Ci consente di creare sistemi più complessi, spesso chiamati "agenti" o "assistenti IA". Questi agenti possono interagire con il mondo esterno tramite API esterne e con servizi che possono avere effetti collaterali sull'ambiente esterno (come l'invio di email, la creazione di ticket e così via).

Quando crei agenti così potenti, devi farlo in modo responsabile. Prima di intraprendere azioni automatiche, ti consigliamo di prendere in considerazione l'utilizzo di un sistema human-in-the-loop. È importante tenere presente la sicurezza quando si progettano agenti basati su LLM che interagiscono con il mondo esterno.

13. Eseguire Gemma con Ollama e TestContainers

Finora abbiamo utilizzato Gemini, ma esiste anche Gemma, il suo modello fratello minore.

Gemma è una famiglia di modelli aperti leggeri e all'avanguardia creati sulla base della stessa ricerca e tecnologia utilizzata per creare i modelli Gemini. Gemma è disponibile in due varianti, Gemma1 e Gemma2, ciascuna con varie dimensioni. Gemma1 è disponibile in due dimensioni: 2B e 7B. Gemma2 è disponibile in due dimensioni: 9B e 27B. I pesi sono disponibili senza costi e le dimensioni ridotte ti consentono di eseguirli autonomamente, anche sul tuo laptop o in Cloud Shell.

Come gestisci Gemma?

Esistono molti modi per eseguire Gemma: nel cloud, tramite Vertex AI con un solo clic o GKE con alcune GPU, ma puoi anche eseguirlo localmente.

Un'ottima opzione per eseguire Gemma localmente è Ollama, uno strumento che ti consente di eseguire piccoli modelli, come Llama 2, Mistral e molti altri, sulla tua macchina locale. È simile a Docker, ma per gli LLM.

Installa Ollama seguendo le istruzioni per il tuo sistema operativo.

Se utilizzi un ambiente Linux, dovrai prima attivare Ollama dopo averlo installato.

ollama serve > /dev/null 2>&1 & 

Una volta installato localmente, puoi eseguire comandi per estrarre un modello:

ollama pull gemma:2b

Attendi il recupero del modello. L'operazione può richiedere un po' di tempo.

Esegui il modello:

ollama run gemma:2b

Ora puoi interagire con il modello:

>>> Hello!
Hello! It's nice to hear from you. What can I do for you today?

Per uscire dal prompt, premi Ctrl+D

Eseguire Gemma in Ollama su TestContainers

Anziché dover installare ed eseguire Ollama in locale, puoi utilizzarlo all'interno di un contenitore gestito da TestContainers.

TestContainers non è utile solo per i test, ma puoi utilizzarlo anche per l'esecuzione dei container. Esiste anche un'opzione specifica OllamaContainer che puoi sfruttare.

Ecco il quadro completo:

2382c05a48708dfd.png

Implementazione

Diamo un'occhiata a GemmaWithOllamaContainer.java, pezzo per pezzo.

Innanzitutto, devi creare un contenitore Ollama derivato che importi il modello Gemma. Questa immagine esiste già da un'esecuzione precedente o verrà creata. Se l'immagine esiste già, dovrai solo dire a TestContainers che vuoi sostituire l'immagine Ollama predefinita con la tua variante basata su Gemma:

private static final String TC_OLLAMA_GEMMA_2_B = "tc-ollama-gemma-2b";

// Creating an Ollama container with Gemma 2B if it doesn't exist.
private static OllamaContainer createGemmaOllamaContainer() throws IOException, InterruptedException {

    // Check if the custom Gemma Ollama image exists already
    List<Image> listImagesCmd = DockerClientFactory.lazyClient()
        .listImagesCmd()
        .withImageNameFilter(TC_OLLAMA_GEMMA_2_B)
        .exec();

    if (listImagesCmd.isEmpty()) {
        System.out.println("Creating a new Ollama container with Gemma 2B image...");
        OllamaContainer ollama = new OllamaContainer("ollama/ollama:0.1.26");
        ollama.start();
        ollama.execInContainer("ollama", "pull", "gemma:2b");
        ollama.commitToImage(TC_OLLAMA_GEMMA_2_B);
        return ollama;
    } else {
        System.out.println("Using existing Ollama container with Gemma 2B image...");
        // Substitute the default Ollama image with our Gemma variant
        return new OllamaContainer(
            DockerImageName.parse(TC_OLLAMA_GEMMA_2_B)
                .asCompatibleSubstituteFor("ollama/ollama"));
    }
}

Successivamente, crea e avvia un contenitore di test Ollama e poi un modello di chat Ollama, indicando l'indirizzo e la porta del contenitore con il modello che vuoi utilizzare. Infine, invoca model.generate(yourPrompt) come di consueto:

public static void main(String[] args) throws IOException, InterruptedException {
    OllamaContainer ollama = createGemmaOllamaContainer();
    ollama.start();

    ChatLanguageModel model = OllamaChatModel.builder()
        .baseUrl(String.format("http://%s:%d", ollama.getHost(), ollama.getFirstMappedPort()))
        .modelName("gemma:2b")
        .build();

    String response = model.generate("Why is the sky blue?");

    System.out.println(response);
}

Eseguilo come segue:

./gradlew run -q -DjavaMainClass=gemini.workshop.GemmaWithOllamaContainer

La prima esecuzione richiederà un po' di tempo per creare ed eseguire il contenitore, ma al termine dovresti vedere Gemma rispondere:

INFO: Container ollama/ollama:0.1.26 started in PT2.827064047S
The sky appears blue due to Rayleigh scattering. Rayleigh scattering is a phenomenon that occurs when sunlight interacts with molecules in the Earth's atmosphere.

* **Scattering particles:** The main scattering particles in the atmosphere are molecules of nitrogen (N2) and oxygen (O2).
* **Wavelength of light:** Blue light has a shorter wavelength than other colors of light, such as red and yellow.
* **Scattering process:** When blue light interacts with these molecules, it is scattered in all directions.
* **Human eyes:** Our eyes are more sensitive to blue light than other colors, so we perceive the sky as blue.

This scattering process results in a blue appearance for the sky, even though the sun is actually emitting light of all colors.

In addition to Rayleigh scattering, other atmospheric factors can also influence the color of the sky, such as dust particles, aerosols, and clouds.

Gemma è in esecuzione in Cloud Shell.

14. Complimenti

Congratulazioni, hai creato la tua prima applicazione di chat di IA generativa in Java utilizzando LangChain4j e l'API Gemini. Nel corso del percorso hai scoperto che i modelli linguistici di grandi dimensioni multimodali sono piuttosto potenti e in grado di gestire varie attività come domande/risposte, anche sulla tua documentazione, estrazione di dati, interazione con API esterne e altro ancora.

Passaggi successivi

Ora tocca a te migliorare le tue applicazioni con potenti integrazioni LLM.

Letture aggiuntive

Documentazione di riferimento