Gemini en Java avec Vertex AI et LangChain4j

1. Introduction

Cet atelier de programmation porte sur le grand modèle de langage (LLM, Large Language Model) Gemini, hébergé sur Vertex AI sur Google Cloud. Vertex AI est une plate-forme qui englobe tous les produits, services et modèles de machine learning de Google Cloud.

Vous utiliserez Java pour interagir avec l'API Gemini à l'aide du framework LangChain4j. Nous étudierons des exemples concrets qui vous permettront de tirer parti du LLM pour les questions-réponses, la génération d'idées, l'extraction d'entités et de contenus structurés, la génération augmentée de récupération et l'appel de fonction.

Qu'est-ce que l'IA générative ?

L'IA générative fait référence à l'utilisation de l'intelligence artificielle pour créer des contenus, comme du texte, des images, de la musique, de l'audio et des vidéos.

L'IA générative repose sur de grands modèles de langage (LLM) qui peuvent effectuer plusieurs tâches à la fois et effectuer des tâches prêtes à l'emploi telles que la synthèse, les questions/réponses, la classification, etc. Avec un entraînement minimal, les modèles de fondation peuvent être adaptés à des cas d'utilisation ciblés avec très peu de données d'exemples.

Comment fonctionne l'IA générative ?

L'IA générative s'appuie sur un modèle de machine learning (ML) pour apprendre les modèles et les relations d'un ensemble de données de contenus créés par l'humain. Elle utilise ensuite les schémas appris pour générer de nouveaux contenus.

La méthode la plus courante pour entraîner un modèle d'IA générative consiste à utiliser l'apprentissage supervisé. Le modèle reçoit un ensemble de contenus créés manuellement et des étiquettes correspondantes. Il apprend ensuite à générer du contenu semblable au contenu créé manuellement.

Quelles sont les applications d'IA générative courantes ?

Voici des exemple d'utilisations de l'IA générative :

  • Améliorez les interactions avec les clients grâce à des expériences de chat et de recherche améliorées.
  • Explorez de vastes quantités de données non structurées à l'aide d'interfaces de conversation et de synthèses.
  • Accomplissez les tâches répétitives, comme répondre à des appels d'offres, localiser le contenu marketing dans différentes langues, vérifier la conformité des contrats clients, etc.

Quelles sont les offres d'IA générative de Google Cloud ?

Avec Vertex AI, vous pouvez interagir avec des modèles de fondation, les personnaliser et les intégrer dans vos applications, même sans expertise en ML. Vous pouvez accéder aux modèles de fondation dans Model Garden, les régler via une interface utilisateur simple dans Vertex AI Studio ou les utiliser dans un notebook de data science.

Vertex AI Search and Conversation offre aux développeurs le moyen le plus rapide de créer des moteurs de recherche et des chatbots optimisés par l'IA générative.

Basé sur Gemini pour Google Cloud, Gemini pour Google Cloud est un outil de collaboration optimisé par l'IA, disponible sur Google Cloud et les IDE pour vous aider à gagner en efficacité et en rapidité. Gemini Code Assist propose des fonctionnalités de complétion et de génération de code, des explications sur le code et vous permet de discuter avec lui pour poser des questions techniques.

Qu'est-ce que Gemini ?

Gemini est une famille de modèles d'IA générative développés par Google DeepMind et conçus pour les cas d'utilisation multimodaux. Le terme "multimodal" permet de traiter et de générer différents types de contenus, tels que du texte, du code, des images et de l'audio.

b9913d011999e7c7.png

Gemini est disponible en plusieurs variantes et tailles:

  • Gemini Ultra: la version la plus grande et la plus performante pour des tâches complexes.
  • Gemini Flash: le plus rapide et le plus économique, optimisé pour les tâches volumineuses.
  • Gemini Pro: appareil de taille moyenne, optimisé pour s'adapter à diverses tâches.
  • Gemini Nano: le plus efficace, conçu pour les tâches sur l'appareil.

Principales fonctionnalités :

  • Multimodalité: la capacité de Gemini à comprendre et à gérer plusieurs formats d'informations représente un pas important par rapport aux modèles de langage traditionnels basés uniquement sur du texte.
  • Performances: Gemini Ultra surpasse la technologie de pointe actuelle sur de nombreux benchmarks et a été le premier à dépasser les experts humains pour le test MMLU (Massive Multitask Language Understanding) complexe.
  • Flexibilité: les différentes tailles de Gemini permettent de l'adapter à différents cas d'utilisation, de la recherche à grande échelle au déploiement sur des appareils mobiles.

Comment interagir avec Gemini sur Vertex AI à partir de Java ?

Vous avez deux possibilités :

  1. La bibliothèque officielle de l'API Java Vertex AI pour Gemini.
  2. avec le framework LangChain4j.

Dans cet atelier de programmation, vous allez utiliser le framework LangChain4j.

Qu'est-ce que le framework LangChain4j ?

Le framework LangChain4j est une bibliothèque Open Source qui permet d'intégrer les LLM à vos applications Java en orchestrant divers composants, tels que le LLM lui-même, mais aussi d'autres outils comme les bases de données vectorielles (pour les recherches sémantiques), les chargeurs et séparateurs de documents (pour analyser les documents et apprendre de ceux-ci), des analyseurs de sortie, etc.

Le projet s'est inspiré du projet Python LangChain, mais son objectif est de répondre aux besoins des développeurs Java.

bb908ea1e6c96ac2.png

Points abordés

  • Configurer un projet Java pour utiliser Gemini et LangChain4j
  • Envoyer votre première requête à Gemini de façon programmatique
  • Diffuser les réponses de Gemini en flux continu
  • Créer une conversation entre un utilisateur et Gemini
  • Utiliser Gemini dans un contexte multimodal en envoyant du texte et des images
  • Extraire des informations structurées utiles à partir de contenus non structurés
  • Comment manipuler les modèles de requête
  • Classification de texte (analyse des sentiments, par exemple)
  • Discuter avec vos propres documents (récupération de la génération augmentée)
  • Étendre vos chatbots avec l'appel de fonction
  • Utiliser Gemma localement avec Ollama et TestContainers

Ce dont vous aurez besoin

  • Connaissance du langage de programmation Java
  • Un projet Google Cloud
  • Un navigateur, tel que Chrome ou Firefox

2. Préparation

Configuration de l'environnement au rythme de chacun

  1. Connectez-vous à la console Google Cloud, puis créez un projet ou réutilisez un projet existant. Si vous n'avez pas encore de compte Gmail ou Google Workspace, vous devez en créer un.

fbef9caa1602edd0.png

a99b7ace416376c4.png

5e3ff691252acf41.png

  • Le nom du projet est le nom à afficher pour les participants au projet. Il s'agit d'une chaîne de caractères non utilisée par les API Google. Vous pourrez toujours le modifier.
  • L'ID du projet est unique parmi tous les projets Google Cloud et non modifiable une fois défini. La console Cloud génère automatiquement une chaîne unique (en général, vous n'y accordez d'importance particulière). Dans la plupart des ateliers de programmation, vous devrez indiquer l'ID de votre projet (généralement identifié par PROJECT_ID). Si l'ID généré ne vous convient pas, vous pouvez en générer un autre de manière aléatoire. Vous pouvez également en spécifier un et voir s'il est disponible. Après cette étape, l'ID n'est plus modifiable et restera donc le même pour toute la durée du projet.
  • Pour information, il existe une troisième valeur (le numéro de projet) que certaines API utilisent. Pour en savoir plus sur ces trois valeurs, consultez la documentation.
  1. Vous devez ensuite activer la facturation dans la console Cloud pour utiliser les ressources/API Cloud. L'exécution de cet atelier de programmation est très peu coûteuse, voire sans frais. Pour désactiver les ressources et éviter ainsi que des frais ne vous soient facturés après ce tutoriel, vous pouvez supprimer le projet ou les ressources que vous avez créées. Les nouveaux utilisateurs de Google Cloud peuvent participer au programme d'essai sans frais pour bénéficier d'un crédit de 300 $.

Démarrer Cloud Shell

Bien que Google Cloud puisse être utilisé à distance depuis votre ordinateur portable, vous allez utiliser Cloud Shell dans cet atelier de programmation, un environnement de ligne de commande exécuté dans le cloud.

Activer Cloud Shell

  1. Dans Cloud Console, cliquez sur Activer Cloud Shell 853e55310c205094.png.

3c1dabeca90e44e5.png

Si vous démarrez Cloud Shell pour la première fois, un écran intermédiaire s'affiche. Si un écran intermédiaire vous a été présenté, cliquez sur Continuer.

9c92662c6a846a5c.png

Le provisionnement et la connexion à Cloud Shell ne devraient pas prendre plus de quelques minutes.

9f0e51b578fecce5.png

Cette machine virtuelle contient tous les outils de développement nécessaires. Elle comprend un répertoire d'accueil persistant de 5 Go et s'exécute dans Google Cloud, ce qui améliore considérablement les performances du réseau et l'authentification. Une grande partie, voire la totalité, de votre travail dans cet atelier de programmation peut être effectué dans un navigateur.

Une fois connecté à Cloud Shell, vous êtes authentifié et le projet est défini sur votre ID de projet.

  1. Exécutez la commande suivante dans Cloud Shell pour vérifier que vous êtes authentifié :
gcloud auth list

Résultat de la commande

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

To set the active account, run:
    $ gcloud config set account `ACCOUNT`
  1. Exécutez la commande suivante dans Cloud Shell pour vérifier que la commande gcloud connaît votre projet:
gcloud config list project

Résultat de la commande :

[core]
project = <PROJECT_ID>

Si vous obtenez un résultat différent, exécutez cette commande :

gcloud config set project <PROJECT_ID>

Résultat de la commande :

Updated property [core/project].

3. Préparer votre environnement de développement

Dans cet atelier de programmation, vous allez utiliser le terminal et l'éditeur Cloud Shell pour développer vos programmes Java.

Activer les API Vertex AI

Dans la console Google Cloud, assurez-vous que le nom de votre projet est affiché en haut de la console Google Cloud. Si ce n'est pas le cas, cliquez sur Sélectionner un projet pour ouvrir le sélecteur de projets, puis sélectionnez le projet souhaité.

Vous pouvez activer les API Vertex AI dans la section Vertex AI de la console Google Cloud ou depuis le terminal Cloud Shell.

Pour activer cette fonctionnalité depuis la console Google Cloud, accédez d'abord à la section "Vertex AI" du menu de la console Google Cloud:

451976f1c8652341.png

Cliquez sur Activer toutes les API recommandées dans le tableau de bord Vertex AI.

Cela activera plusieurs API, mais la plus importante pour cet atelier de programmation est aiplatform.googleapis.com.

Vous pouvez également activer cette API à partir du terminal Cloud Shell à l'aide de la commande suivante:

gcloud services enable aiplatform.googleapis.com

Clonez le dépôt GitHub.

Dans le terminal Cloud Shell, clonez le dépôt pour cet atelier de programmation:

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

Pour vérifier que le projet est prêt à être exécuté, vous pouvez essayer d'exécuter le programme "Hello World".

Placez-vous dans le dossier de premier niveau:

cd gemini-workshop-for-java-developers/ 

Créez le wrapper Gradle:

gradle wrapper

Exécutez avec gradlew:

./gradlew run

Vous devriez obtenir le résultat suivant :

..
> Task :app:run
Hello World!

Ouvrir et configurer l'éditeur Cloud

Ouvrez le code à l'aide de l'éditeur Cloud Code depuis Cloud Shell:

42908e11b28f4383.png

Dans l'éditeur Cloud Code, ouvrez le dossier source de l'atelier de programmation en sélectionnant File -> Open Folder, puis pointez vers le dossier source de l'atelier de programmation (par exemple, /home/username/gemini-workshop-for-java-developers/).

Installer Gradle pour Java

Pour que l'éditeur de code cloud fonctionne correctement avec Gradle, installez l'extension Gradle pour Java.

Tout d'abord, accédez à la section "Java Projects" (Projets Java) et appuyez sur le signe plus:

84d15639ac61c197.png

Sélectionner Gradle for Java:

34d6c4136a3cc9ff.png

Sélectionnez la version de Install Pre-Release:

3b044fb450cccb7.png

Une fois l'installation terminée, vous devriez voir les boutons Disable et Uninstall:

46410fe86d777f9c.png

Enfin, nettoyez l'espace de travail pour appliquer les nouveaux paramètres:

31e27e9bb61d975d.png

Vous serez invité à actualiser et à supprimer l'atelier. Choisissez Reload and delete:

d6303bc49e391dc.png

Si vous ouvrez l'un des fichiers (App.java, par exemple), l'éditeur devrait maintenant fonctionner correctement avec l'option de coloration syntaxique:

fed1b1b5de0dff58.png

Vous êtes maintenant prêt à exécuter des échantillons sur Gemini.

Configurer des variables d'environnement

Ouvrez un nouveau terminal dans l'éditeur Cloud Code en sélectionnant Terminal -> New Terminal. Configurez les deux variables d'environnement requises pour l'exécution des exemples de code:

  • PROJECT_ID : ID de votre projet Google Cloud
  • LOCATION : région dans laquelle le modèle Gemini est déployé

Exportez les variables comme suit:

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

4. Premier appel au modèle Gemini

Maintenant que le projet est correctement configuré, il est temps d'appeler l'API Gemini.

Examinez QA.java dans le répertoire 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-001")
            .build();

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

Dans ce premier exemple, vous devez importer la classe VertexAiGeminiChatModel, qui implémente l'interface ChatModel.

Dans la méthode main, vous configurez le modèle de langage de chat à l'aide du compilateur pour VertexAiGeminiChatModel et spécifiez:

  • Projet
  • Lieu
  • Nom du modèle (gemini-1.5-flash-001).

Maintenant que le modèle de langage est prêt, vous pouvez appeler la méthode generate() et transmettre votre requête, votre question ou les instructions à envoyer au LLM. Ici, vous posez une question simple sur ce qui rend le ciel bleu.

N'hésitez pas à modifier cette invite pour essayer différentes questions ou tâches.

Exécutez l'exemple dans le dossier racine du code source:

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

Un résultat semblable à celui-ci doit s'afficher:

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.

Félicitations, vous avez passé votre premier appel à Gemini !

Réponse en streaming

Avez-vous remarqué que la réponse a été donnée en une seule fois, au bout de quelques secondes ? Il est également possible d'obtenir la réponse progressivement grâce à la variante de réponse en flux continu. Le modèle renvoie la réponse en flux continu, bloc par élément, dès qu'elle devient disponible.

Dans cet atelier de programmation, nous allons nous en tenir à la réponse qui n'est pas un flux, mais examinons la réponse de traitement par flux pour voir comment procéder.

Dans StreamQA.java, dans le répertoire app/src/main/java/gemini/workshop, vous pouvez voir la réponse de streaming en action:

package gemini.workshop;

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

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-001")
            .build();
        
        model.generate("Why is the sky blue?", new StreamingResponseHandler<>() {
            @Override
            public void onNext(String text) {
                System.out.println(text);
            }

            @Override
            public void onError(Throwable error) {
                error.printStackTrace();
            }
        });
    }
}

Cette fois, nous importons les variantes de classe de streaming VertexAiGeminiStreamingChatModel qui implémentent l'interface StreamingChatLanguageModel. Vous aurez également besoin d'un StreamingResponseHandler.

Cette fois, la signature de la méthode generate() est un peu différente. Au lieu de renvoyer une chaîne, le type renvoyé est nul. En plus de l'invite, vous devez transmettre un gestionnaire de réponses en streaming. Ici, vous implémenterez l'interface en créant une classe interne anonyme, avec deux méthodes onNext(String text) et onError(Throwable error). Le premier est appelé chaque fois qu'un nouvel élément de la réponse est disponible, tandis que le second n'est appelé qu'en cas d'erreur.

Exécutez la commande suivante :

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

Vous obtiendrez une réponse semblable à la classe précédente, mais cette fois, vous remarquerez que la réponse apparaît progressivement dans votre shell, au lieu d'attendre l'affichage de la réponse complète.

Configuration supplémentaire

Pour la configuration, nous n'avons défini que le projet, l'emplacement et le nom du modèle, mais vous pouvez spécifier d'autres paramètres pour le modèle:

  • temperature(Float temp) : pour définir le niveau de création souhaité pour la réponse (0 signifiant une création peu créative et souvent plus factuelle, 1 signifiant un plus grand nombre de créations)
  • topP(Float topP) : pour sélectionner les mots possibles dont la probabilité totale s'additionne à ce nombre à virgule flottante (entre 0 et 1)
  • topK(Integer topK) : permet de sélectionner aléatoirement un mot parmi un nombre maximal de mots probables pour la saisie semi-automatique du texte (de 1 à 40)
  • maxOutputTokens(Integer max) : pour spécifier la longueur maximale de la réponse donnée par le modèle (généralement, 4 jetons représentent environ 3 mots)
  • maxRetries(Integer retries) : si vous dépassez le quota de requêtes par temps ou si la plate-forme rencontre un problème technique, vous pouvez demander au modèle de relancer l'appel trois fois

Jusqu'à présent, vous avez posé une seule question à Gemini, mais vous pouvez également avoir une conversation multitour. C'est ce que vous allez découvrir dans la section suivante.

5. Discutez avec Gemini

À l'étape précédente, vous avez posé une seule question. Il est maintenant temps d'engager une véritable conversation entre un utilisateur et le LLM. Chaque question et chaque réponse peuvent s'appuyer sur les précédentes pour former une véritable discussion.

Examinez Conversation.java dans le dossier 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-001")
            .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));
        });
    }
}

Voici quelques nouvelles importations intéressantes dans cette classe:

  • MessageWindowChatMemory : classe qui vous aidera à gérer l'aspect multitour de la conversation et qui conserve en mémoire locale les questions et réponses précédentes
  • AiServices : classe qui associe le modèle de chat et la mémoire de chat.

Dans la méthode principale, vous allez configurer le modèle, la mémoire du chat et le service d'IA. Le modèle est configuré comme d'habitude avec les informations sur le projet, l'emplacement et le nom du modèle.

Pour la mémoire du chat, nous utilisons le compilateur de MessageWindowChatMemory afin de créer une mémoire qui conserve les 20 derniers messages échangés. Il s'agit d'une fenêtre glissante au-dessus de la conversation dont le contexte est conservé localement dans notre client de classe Java.

Vous créez ensuite le AI service qui associe le modèle de chat à la mémoire de chat.

Notez que le service d'IA utilise une interface ConversationService personnalisée que nous avons définie, que LangChain4j implémente, et qui accepte une requête String et renvoie une réponse String.

Il est maintenant temps de discuter avec Gemini. Tout d'abord, une simple salutation est envoyée, puis une première question sur la tour Eiffel pour savoir dans quel pays elle se trouve. Notez que la dernière phrase est liée à la réponse à la première question, puisque vous vous demandez combien d'habitants se trouvent dans le pays où se trouve la tour Eiffel, sans mentionner explicitement le pays donné dans la réponse précédente. Elle montre que les anciennes questions et réponses sont envoyées avec chaque requête.

Exécutez l'exemple:

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

Vous devriez voir trois réponses semblables aux suivantes:

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.

Vous pouvez poser des questions à un seul tour ou avoir des conversations multitours avec Gemini, mais jusqu'à présent, vous n'avez saisi que du texte. Qu'en est-il des images ? L'étape suivante consiste à explorer les images.

6. La multimodalité avec Gemini

Gemini est un modèle multimodal. Il accepte non seulement du texte en entrée, mais aussi des images, voire des vidéos. Dans cette section, vous allez découvrir un cas d'utilisation permettant de combiner du texte et des images.

Pensez-vous que Gemini reconnaîtra ce chat ?

af00516493ec9ade.png

Image d'un chat dans la neige, extraite de Wikipédiahttps://upload.wikimedia.org/wikipedia/commons/b/b6/Felis_catus-cat_on_snow.jpg

Examinez Multimodal.java dans le répertoire 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-001")
            .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());
    }
}

Dans les importations, nous remarquons la distinction entre les différents types de messages et contenus. Un UserMessage peut contenir à la fois un objet TextContent et un objet ImageContent. C'est là que la multimodalité est en jeu: le mélange de texte et d'images. Le modèle renvoie un Response contenant un AiMessage.

Vous récupérez ensuite AiMessage à partir de la réponse via content(), puis le texte du message à l'aide de text().

Exécutez l'exemple:

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

Le nom de l'image vous a certainement donné un indice sur ce qu'elle contenait, mais la sortie Gemini ressemble à ce qui suit:

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.

L'association d'images et de requêtes textuelles permet de découvrir des cas d'utilisation intéressants. Vous pouvez créer des applications pour:

  • Reconnaître du texte dans des images
  • Vérifiez si l'image peut s'afficher en toute sécurité.
  • Créez des légendes d'images.
  • Effectuez des recherches dans une base de données d'images contenant des descriptions en texte brut.

En plus d'extraire des informations à partir d'images, vous pouvez également extraire des informations d'un texte non structuré. C'est ce que vous allez apprendre dans la section suivante.

7. Extrayez les informations structurées d'un texte non structuré

Dans de nombreuses situations, des informations importantes sont fournies dans des rapports, dans des e-mails ou dans d'autres textes longs de manière non structurée. Dans l'idéal, vous aimeriez pouvoir extraire les informations clés contenues dans le texte non structuré, sous la forme d'objets structurés. Voyons comment vous pouvez le faire.

Supposons que vous souhaitiez extraire le nom et l'âge d'une personne à partir d'une biographie ou d'une description de cette personne. Vous pouvez demander au LLM d'extraire le code JSON d'un texte non structuré à l'aide d'une requête astucieusement affinée (ce procédé est communément appelé "ingénierie de requête").

Examinez ExtractData.java dans 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.UserMessage;

public class ExtractData {

    static record Person(String name, int age) {}

    interface PersonExtractor {
        @UserMessage("""
            Extract the name and age of the person described below.
            Return a JSON document with a "name" and an "age" property, \
            following this structure: {"name": "John Doe", "age": 34}
            Return only JSON, without any markdown markup surrounding it.
            Here is the document describing the person:
            ---
            {{it}}
            ---
            JSON:
            """)
        Person extractPerson(String text);
    }

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

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

        Person person = extractor.extractPerson("""
            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.    
            """
        );

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

Examinons les différentes étapes de ce fichier:

  • Un enregistrement Person est défini pour représenter les détails décrivant une personne ( nom et âge).
  • L'interface PersonExtractor est définie avec une méthode qui renvoie une instance Person à partir d'une chaîne de texte non structurée.
  • extractPerson() est annoté avec une annotation @UserMessage qui lui associe une requête. Il s'agit de l'invite que le modèle utilisera pour extraire les informations et les renvoyer sous la forme d'un document JSON, qui sera analysé pour vous et non assemblé dans une instance Person.

Examinons maintenant le contenu de la méthode main():

  • Le modèle de chat est instancié. Notez que nous utilisons une temperature très faible de zéro, et une topK de un seul, pour garantir une réponse très déterministe. Cela permet également au modèle de mieux suivre les instructions. En particulier, nous ne voulons pas que Gemini encapsule la réponse JSON avec un balisage Markdown supplémentaire.
  • Un objet PersonExtractor est créé grâce à la classe AiServices de LangChain4j.
  • Ensuite, il vous suffit d'appeler Person person = extractor.extractPerson(...) pour extraire les informations sur la personne du texte non structuré, puis de récupérer une instance Person avec le nom et l'âge.

Exécutez l'exemple:

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

Vous devriez obtenir le résultat suivant :

Anna
23

Oui, c'est Anna, ils ont 23 ans !

Cette approche AiServices vous permet d'utiliser des objets fortement typés. Vous n'interagissez pas directement avec le LLM. À la place, vous travaillez avec des classes concrètes, comme l'enregistrement Person pour représenter les informations personnelles extraites, et vous disposez d'un objet PersonExtractor avec une méthode extractPerson() qui renvoie une instance Person. Le concept de LLM a disparu. En tant que développeur Java, vous ne faites que manipuler des classes et des objets normaux.

8. Structurer les requêtes à l'aide de modèles de requêtes

Lorsque vous interagissez avec un LLM à l'aide d'un ensemble commun d'instructions ou de questions, une partie de cette requête ne change jamais, tandis que d'autres contiennent les données. Par exemple, si vous souhaitez créer des recettes, vous pouvez utiliser l'invite "Vous êtes un cuisinier talentueux, veuillez créer une recette avec les ingrédients suivants: ...", puis ajouter les ingrédients à la fin de ce texte. C'est à cela que servent les modèles d'invites. Ils sont semblables aux chaînes interpolées dans les langages de programmation. Un modèle de requête contient des espaces réservés que vous pouvez remplacer par les données appropriées pour un appel particulier au LLM.

Plus concrètement, nous allons étudier TemplatePrompt.java dans le répertoire 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-001")
            .maxOutputTokens(500)
            .temperature(0.8f)
            .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());
    }
}

Comme d'habitude, vous configurez le modèle VertexAiGeminiChatModel avec un haut niveau de créativité avec une température élevée et des valeurs topP et topK élevées. Ensuite, créez un PromptTemplate avec sa méthode statique from(), en transmettant la chaîne de notre requête, et en utilisant les variables d'espace réservé entre accolades doubles: {{dish}} et {{ingredients}}.

Pour créer l'invite finale, appelez apply() qui utilise un mappage de paires clé/valeur représentant le nom de l'espace réservé et la valeur de chaîne par laquelle le remplacer.

Enfin, vous appelez la méthode generate() du modèle Gemini en créant un message utilisateur à partir de cette requête, avec l'instruction prompt.toUserMessage().

Exécutez l'exemple:

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

Un résultat semblable aux lignes suivantes doit s'afficher:

**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.

N'hésitez pas à modifier les valeurs de dish et ingredients sur la carte, à ajuster la température, topK et tokP, puis à réexécuter le code. Vous pourrez ainsi observer l'effet de la modification de ces paramètres sur le LLM.

Les modèles de requêtes constituent un bon moyen d'obtenir des instructions réutilisables et paramétrables pour les appels LLM. Vous pouvez transmettre des données et personnaliser les requêtes en fonction des différentes valeurs fournies par vos utilisateurs.

9. Classification de texte avec une requête few-shot

Les LLM sont très efficaces pour classer du texte en différentes catégories. Vous pouvez aider un LLM à réaliser cette tâche en lui fournissant des exemples de textes et les catégories qui leur sont associées. Cette approche est souvent appelée requête few-shot.

Examinez TextClassification.java dans le répertoire app/src/main/java/gemini/workshop pour effectuer un type particulier de classification de texte: l'analyse des sentiments.

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;

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.Map;

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

        PromptTemplate promptTemplate = PromptTemplate.from("""
            Analyze the sentiment of the text below. Respond only with one word to describe the sentiment.

            INPUT: This is fantastic news!
            OUTPUT: POSITIVE

            INPUT: Pi is roughly equal to 3.14
            OUTPUT: NEUTRAL

            INPUT: I really disliked the pizza. Who would use pineapples as a pizza topping?
            OUTPUT: NEGATIVE

            INPUT: {{text}}
            OUTPUT: 
            """);

        Prompt prompt = promptTemplate.apply(
            Map.of("text", "I love strawberries!"));

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

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

Dans la méthode main(), vous créez le modèle de chat Gemini comme d'habitude, mais avec un petit nombre de jetons de sortie maximal, car vous ne souhaitez qu'une réponse courte: le texte est POSITIVE, NEGATIVE ou NEUTRAL.

Ensuite, vous créerez un modèle de requête réutilisable à l'aide de la technique de requête few-shot, en lui indiquant quelques exemples d'entrées et de sorties. Cela permet également au modèle de suivre le résultat réel. Gemini ne vous répondra pas par une phrase épaisse. À la place, il vous sera demandé de ne répondre qu'en un mot.

Appliquez les variables avec la méthode apply() pour remplacer l'espace réservé {{text}} par le paramètre réel ("I love strawberries"), puis transformez ce modèle en message utilisateur avec toUserMessage().

Exécutez l'exemple:

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

Un seul mot devrait s'afficher:

POSITIVE

Aimer les fraises semble être un sentiment positif !

10. Génération augmentée par récupération

Les LLM sont entraînés sur une grande quantité de texte. Cependant, leurs connaissances couvrent uniquement les informations qu'il a vues au cours de son entraînement. Si de nouvelles informations sont publiées après la date limite pour l'entraînement du modèle, elles ne seront pas disponibles pour le modèle. Ainsi, le modèle ne sera pas en mesure de répondre à des questions sur des informations qu'il n'a pas vues.

C'est pourquoi des approches telles que la génération augmentée de récupération (RAG) aident à fournir les informations supplémentaires qu'un LLM peut avoir besoin de connaître pour répondre aux demandes de ses utilisateurs, pour répondre avec des informations plus récentes ou sur des informations privées qui ne sont pas accessibles au moment de l'entraînement.

Revenons aux conversations. Cette fois, vous pourrez poser des questions sur vos documents. Vous allez créer un chatbot capable de récupérer des informations pertinentes à partir d'une base de données contenant vos documents divisés en fragments plus petits. Ces informations seront utilisées par le modèle pour baser ses réponses, au lieu de s'appuyer uniquement sur les connaissances contenues dans son entraînement.

Dans la RAG, il y a deux phases:

  1. Phase d'ingestion : les documents sont chargés en mémoire et divisés en fragments plus petits, et les représentations vectorielles continues (une représentation vectorielle multidimensionnelle élevée des fragments) sont calculées et stockées dans une base de données vectorielle capable d'effectuer des recherches sémantiques. Cette phase d'ingestion est généralement effectuée une fois, lorsque de nouveaux documents doivent être ajoutés au corpus de documents.

cd07d33d20ffa1c8.png

  1. Phase de requête : les utilisateurs peuvent maintenant poser des questions sur les documents. La question sera également transformée en vecteur et comparée à tous les autres vecteurs de la base de données. Les vecteurs les plus similaires sont généralement liés sémantiquement et sont renvoyés par la base de données vectorielle. Ensuite, le LLM reçoit le contexte de la conversation, c'est-à-dire les morceaux de texte qui correspondent aux vecteurs renvoyés par la base de données. Il est ensuite invité à baser sa réponse en examinant ces fragments.

a1d2e2deb83c6d27.png

Préparer vos documents

Pour cette nouvelle démonstration, vous poserez des questions sur l'étude "Vous n'avez besoin que d'attention". Il décrit l'architecture de réseau de neurones Transformer, mise au point par Google, qui est la façon dont tous les grands modèles de langage modernes sont implémentés aujourd'hui.

L'article est déjà téléchargé dans le fichier attention-is-all-you-need.pdf dans le dépôt.

Implémenter le chatbot

Voyons comment créer cette approche en deux phases: d'abord avec l'ingestion du document, puis l'heure de la requête lorsque les utilisateurs posent des questions sur le document.

Dans cet exemple, les deux phases sont implémentées dans la même classe. Normalement, il existe une application qui se charge de l'ingestion et une autre qui propose l'interface du chatbot à vos utilisateurs.

Dans cet exemple, nous allons également utiliser une base de données vectorielle en mémoire. Dans un scénario de production réel, les phases d'ingestion et d'interrogation seraient séparées dans deux applications distinctes, et les vecteurs seraient conservés dans une base de données autonome.

Ingestion de documents

La toute première étape de la phase d'ingestion de documents consiste à localiser le fichier PDF que nous avons déjà téléchargé et à préparer un fichier PdfParser pour le lire:

URL url = new URI("https://github.com/glaforge/gemini-workshop-for-java-developers/raw/main/attention-is-all-you-need.pdf").toURL();
ApachePdfBoxDocumentParser pdfParser = new ApachePdfBoxDocumentParser();
Document document = pdfParser.parse(url.openStream());

Au lieu de créer le modèle de langage de chat habituel, vous créez une instance d'un modèle de représentation vectorielle continue. Il s'agit d'un modèle particulier dont le rôle est de créer des représentations vectorielles d'éléments de texte (mots, phrases ou même paragraphes). Elle renvoie des vecteurs de nombres à virgule flottante au lieu de réponses textuelles.

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

Ensuite, vous aurez besoin de quelques cours pour collaborer afin de:

  • Chargez et scindez le document PDF en plusieurs morceaux.
  • Créer des représentations vectorielles continues pour tous ces fragments
InMemoryEmbeddingStore<TextSegment> embeddingStore = 
    new InMemoryEmbeddingStore<>();

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

Une instance de InMemoryEmbeddingStore, une base de données vectorielle en mémoire, est créée pour stocker les représentations vectorielles continues.

Le document est divisé en fragments grâce à la classe DocumentSplitters. Il va diviser le texte du fichier PDF en extraits de 500 caractères, avec un chevauchement de 100 caractères (avec le morceau suivant, pour éviter de couper des mots ou des phrases, en petits morceaux).

L'ingérateur du magasin associe le séparateur de documents, le modèle de représentation vectorielle continue pour calculer les vecteurs et la base de données vectorielle en mémoire. La méthode ingest() se charge ensuite de l'ingestion.

Maintenant, la première phase est terminée. Le document a été transformé en morceaux de texte avec les représentations vectorielles continues associées, puis stocké dans la base de données vectorielle.

Poser des questions

Il est temps de vous préparer à poser des questions ! Créez un modèle de chat pour démarrer la conversation:

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

Vous avez également besoin d'une classe "Retriever" pour associer la base de données vectorielle (dans la variable embeddingStore) au modèle de représentation vectorielle continue. Son rôle est d'interroger la base de données vectorielles en calculant une représentation vectorielle continue pour la requête de l'utilisateur, afin de trouver des vecteurs similaires dans la base de données:

EmbeddingStoreContentRetriever retriever =
    new EmbeddingStoreContentRetriever(embeddingStore, embeddingModel);

En dehors de la méthode principale, créez une interface qui représente un assistant expert LLM. Il s'agit d'une interface que la classe AiServices implémentera pour que vous puissiez interagir avec le modèle:

interface LlmExpert {
    String ask(String question);
}

À ce stade, vous pouvez configurer un nouveau service d'IA:

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

Ce service est associé aux éléments suivants:

  • Le modèle de langage de chat que vous avez configuré précédemment.
  • Une mémoire de chat pour garder une trace de la conversation
  • Le extracteur compare une requête de représentation vectorielle continue de vecteur aux vecteurs de la base de données.
  • Un modèle de requête indique explicitement que le modèle de chat doit répondre en se basant sur les informations fournies (c'est-à-dire sur les extraits pertinents de la documentation dont la représentation vectorielle continue de vecteur est semblable au vecteur de la question de l'utilisateur).
.retrievalAugmentor(DefaultRetrievalAugmentor.builder()
    .contentInjector(DefaultContentInjector.builder()
        .promptTemplate(PromptTemplate.from("""
            You are an expert in large language models,\s
            you excel at explaining simply and clearly questions about LLMs.

            Here is the question: {{userMessage}}

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

Vous êtes enfin prêt à poser vos questions !

List.of(
    "What neural network architecture can be used for language models?",
    "What are the different components of a transformer neural network?",
    "What is attention in large language models?",
    "What is the name of the process that transforms text into vectors?"
).forEach(query ->
    System.out.printf("%n=== %s === %n%n %s %n%n", query, expert.ask(query)));
);

Le code source complet se trouve dans RAG.java, dans le répertoire app/src/main/java/gemini/workshop:

Exécutez l'exemple:

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

Dans le résultat, vous devriez voir les réponses à vos questions:

=== What neural network architecture can be used for language models? === 

 Transformer architecture 


=== What are the different components of a transformer neural network? === 

 The different components of a transformer neural network are:

1. Encoder: The encoder takes the input sequence and converts it into a 
sequence of hidden states. Each hidden state represents the context of 
the corresponding input token.
2. Decoder: The decoder takes the hidden states from the encoder and 
uses them to generate the output sequence. Each output token is 
generated by attending to the hidden states and then using a 
feed-forward network to predict the token's probability distribution.
3. Attention mechanism: The attention mechanism allows the decoder to 
attend to the hidden states from the encoder when generating each output 
token. This allows the decoder to take into account the context of the 
input sequence when generating the output sequence.
4. Positional encoding: Positional encoding is a technique used to 
inject positional information into the input sequence. This is important 
because the transformer neural network does not have any inherent sense 
of the order of the tokens in the input sequence.
5. Feed-forward network: The feed-forward network is a type of neural 
network that is used to predict the probability distribution of each 
output token. The feed-forward network takes the hidden state from the 
decoder as input and outputs a vector of probabilities. 


=== What is attention in large language models? === 

Attention in large language models is a mechanism that allows the model 
to focus on specific parts of the input sequence when generating the 
output sequence. This is important because it allows the model to take 
into account the context of the input sequence when generating each output token.

Attention is implemented using a function that takes two sequences as 
input: a query sequence and a key-value sequence. The query sequence is 
typically the hidden state from the previous decoder layer, and the 
key-value sequence is typically the sequence of hidden states from the 
encoder. The attention function computes a weighted sum of the values in 
the key-value sequence, where the weights are determined by the 
similarity between the query and the keys.

The output of the attention function is a vector of context vectors, 
which are then used as input to the feed-forward network in the decoder. 
The feed-forward network then predicts the probability distribution of 
the next output token.

Attention is a powerful mechanism that allows large language models to 
generate text that is both coherent and informative. It is one of the 
key factors that has contributed to the recent success of large language 
models in a wide range of natural language processing tasks. 


=== What is the name of the process that transforms text into vectors? === 

The process of transforming text into vectors is called **word embedding**.

Word embedding is a technique used in natural language processing (NLP) 
to represent words as vectors of real numbers. Each word is assigned a 
unique vector, which captures its meaning and semantic relationships 
with other words. Word embeddings are used in a variety of NLP tasks, 
such as machine translation, text classification, and question 
answering.

There are a number of different word embedding techniques, but one of 
the most common is the **skip-gram** model. The skip-gram model is a 
neural network that is trained to predict the surrounding words of a 
given word. By learning to predict the surrounding words, the skip-gram 
model learns to capture the meaning and semantic relationships of words.

Once a word embedding model has been trained, it can be used to 
transform text into vectors. To do this, each word in the text is 
converted to its corresponding vector. The vectors for all of the words 
in the text are then concatenated to form a single vector, which 
represents the entire text.

Text vectors can be used in a variety of NLP tasks. For example, text 
vectors can be used to train machine translation models, text 
classification models, and question answering models. Text vectors can 
also be used to perform tasks such as text summarization and text 
clustering. 

11. Appel de fonction

Dans certains cas, vous pouvez également souhaiter qu'un LLM ait accès à des systèmes externes, comme une API Web distante qui récupère des informations ou qui a une action, ou encore des services qui effectuent des calculs. Exemple :

API Web distantes:

  • Suivez et mettez à jour les commandes des clients.
  • Recherchez ou créez une demande dans l'outil de suivi des problèmes.
  • Récupérez des données en temps réel, comme les cours de la bourse ou les mesures de capteurs IoT.
  • Envoyer un e-mail.

Outils de calcul:

  • Une calculatrice pour des problèmes mathématiques plus complexes.
  • Interprétation du code pour exécuter du code lorsque les LLM ont besoin d'une logique de raisonnement.
  • convertir des requêtes en langage naturel en requêtes SQL pour qu'un LLM puisse interroger une base de données ;

L'appel de fonction permet au modèle de demander un ou plusieurs appels de fonction en son nom, afin de répondre correctement à l'invite d'un utilisateur avec des données plus récentes.

À partir d'une requête spécifique d'un utilisateur et de la connaissance des fonctions existantes pouvant être pertinentes dans ce contexte, un LLM peut répondre par une requête d'appel de fonction. L'application intégrant le LLM peut alors appeler la fonction, puis répondre au LLM avec une réponse. Le LLM interprète ensuite le LLM en fournissant une réponse textuelle.

Quatre étapes de l'appel de fonction

Examinons un exemple d'appel de fonction: obtenir des informations sur les prévisions météorologiques.

Si vous interrogez Gemini ou tout autre LLM sur la météo à Paris, il répond en disant qu'il n'a aucune information sur les prévisions météorologiques. Si vous voulez que le LLM ait accès en temps réel aux données météorologiques, vous devez définir certaines fonctions qu'il peut utiliser.

Examinez le schéma suivant:

31e0c2aba5e6f21c.png

1️⃣ Un utilisateur demande d'abord la météo à Paris. L'application de chatbot sait qu'une ou plusieurs fonctions sont à sa disposition pour aider le LLM à traiter la requête. Le chatbot envoie à la fois la requête initiale, ainsi que la liste des fonctions pouvant être appelées. Ici, une fonction appelée getWeather() qui utilise un paramètre de chaîne pour l'emplacement.

8863be53a73c4a70.png

Comme le LLM ne connaît pas les prévisions météorologiques, il renvoie une requête d'exécution de fonction au lieu de répondre par message. Le chatbot doit appeler la fonction getWeather() avec "Paris" comme paramètre d'emplacement.

d1367cc69c07b14d.png

2️⃣ Le chatbot appelle cette fonction au nom du LLM et récupère la réponse de la fonction. Ici, nous imaginons que la réponse est {"forecast": "sunny"}.

73a5f2ed19f47d8.png

3️⃣ L'application chatbot renvoie la réponse JSON au LLM.

20832cb1ee6fbfeb.png

4️⃣ Le LLM examine la réponse JSON, interprète ces informations, puis répond avec le texte indiquant qu'il fait beau à Paris.

Chaque étape sous forme de code

Commencez par configurer le modèle Gemini comme d'habitude:

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

Vous spécifiez une spécification d'outil qui décrit la fonction pouvant être appelée:

ToolSpecification weatherToolSpec = ToolSpecification.builder()
    .name("getWeatherForecast")
    .description("Get the weather forecast for a location")
    .addParameter("location", JsonSchemaProperty.STRING,
        JsonSchemaProperty.description("the location to get the weather forecast for"))
    .build();

Le nom de la fonction est défini, ainsi que le nom et le type du paramètre. Notez toutefois que la fonction et les paramètres reçoivent une description. Les descriptions sont très importantes. Elles aident le LLM à bien comprendre ce qu'une fonction est capable de faire. Elles déterminent donc si cette fonction doit être appelée dans le contexte de la conversation.

Pour commencer, envoyez votre première question sur la météo à Paris:

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);

À l'étape 2, nous transmettons l'outil que nous souhaitons que le modèle utilise, et celui-ci renvoie une requête "trop d'exécution" :

// 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());

Étape 3 : À ce stade, nous savons quelle fonction le LLM doit appeler. Dans le code, nous n'appelons pas réellement une API externe, nous renvoyons simplement une prévision météo hypothétique directement:

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

À l'étape 4, le LLM apprend le résultat de l'exécution de la fonction et peut ensuite synthétiser une réponse textuelle:

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

Voici le résultat :

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.

Vous pouvez voir la sortie au-dessus de la requête d'exécution de l'outil, ainsi que la réponse.

Le code source complet se trouve dans FunctionCalling.java, dans le répertoire app/src/main/java/gemini/workshop:

Exécutez l'exemple:

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

Un résultat semblable aux lignes suivantes doit s'afficher :

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 gère l'appel de fonction

À l'étape précédente, vous avez vu comment sont entrelacées les questions/réponses textuelles normales et les interactions requête/réponse de fonction. Entre les deux, vous avez fourni directement la réponse de la fonction demandée, sans appeler de fonction réelle.

Cependant, LangChain4j propose également une abstraction de niveau supérieur qui peut gérer les appels de fonction de manière transparente pour vous, tout en gérant la conversation comme d'habitude.

Appel de fonction unique

Examinons FunctionCallingAssistant.java, élément par élément.

Tout d'abord, créez un enregistrement qui représentera la structure des données de réponse de la fonction:

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

La réponse contient des informations sur le lieu, les prévisions et la température.

Vous créez ensuite une classe contenant la fonction que vous souhaitez mettre à la disposition du modèle:

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);
        }
    }
}

Notez que cette classe contient une seule fonction, mais elle est annotée avec @Tool, qui correspond à la description de la fonction que le modèle peut demander à appeler.

Les paramètres de la fonction (un seul ici) sont également annotés, mais avec cette annotation courte @P, qui donne également une description du paramètre. Pour des scénarios plus complexes, vous pouvez ajouter autant de fonctions que vous le souhaitez afin de les mettre à la disposition du modèle.

Dans cette classe, vous allez renvoyer des réponses standardisées, mais si vous souhaitez appeler un véritable service de prévisions météo externe, vous devez appeler ce service dans le corps de la méthode.

Comme nous l'avons vu lorsque vous avez créé un ToolSpecification dans l'approche précédente, il est important de documenter le rôle d'une fonction et de décrire à quoi correspondent les paramètres. Cela aide le modèle à comprendre comment et quand cette fonction peut être utilisée.

Ensuite, LangChain4j vous permet de fournir une interface qui correspond au contrat que vous souhaitez utiliser pour interagir avec le modèle. Ici, il s'agit d'une interface simple qui reçoit une chaîne représentant le message de l'utilisateur et renvoie une chaîne correspondant à la réponse du modèle:

interface WeatherAssistant {
    String chat(String userMessage);
}

Il est également possible d'utiliser des signatures plus complexes impliquant le UserMessage (pour un message utilisateur) ou AiMessage (pour une réponse du modèle) de LangChain4j, ou même un TokenStream, si vous souhaitez gérer des situations plus avancées, car ces objets plus complexes contiennent également des informations supplémentaires telles que le nombre de jetons consommés. Pour des raisons de simplicité, nous n'acceptons que chaîne en entrée et chaîne en sortie.

Terminons par la méthode main(), qui relie tous les éléments:

public static void main(String[] args) {
    ChatLanguageModel model = VertexAiGeminiChatModel.builder()
        .project(System.getenv("PROJECT_ID"))
        .location(System.getenv("LOCATION"))
        .modelName("gemini-1.5-flash-001")
        .maxOutputTokens(100)
        .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?"));
}

Vous configurez ensuite le modèle de chat Gemini comme d'habitude. Instanciez ensuite votre service de prévisions météo contenant la "fonction" que le modèle nous demandera d'appeler.

Vous allez maintenant utiliser à nouveau la classe AiServices pour lier le modèle de chat, la mémoire du chat et l'outil (le service de prévisions météo avec sa fonction). AiServices renvoie un objet qui implémente l'interface WeatherAssistant que vous avez définie. Il ne vous reste plus qu'à appeler la méthode chat() de cet assistant. Lorsque vous l'appelez, vous ne verrez que les réponses textuelles, mais les requêtes d'appel de fonction et les réponses aux appels de fonction ne seront pas visibles par le développeur, et ces requêtes seront traitées automatiquement et de manière transparente. Si Gemini pense qu'une fonction doit être appelée, il répond avec la requête d'appel de fonction, et LangChain4j se charge d'appeler la fonction locale en votre nom.

Exécutez l'exemple:

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

Un résultat semblable aux lignes suivantes doit s'afficher :

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

Ceci était un exemple de fonction unique.

Appels de fonction multiples

Vous pouvez également avoir plusieurs fonctions et laisser LangChain4j gérer plusieurs appels de fonction en votre nom. Consultez MultiFunctionCallingAssistant.java pour découvrir un exemple de plusieurs fonctions.

Il dispose d'une fonction pour convertir les devises:

@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;
}

Autre fonction permettant d'obtenir la valeur d'une action:

@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;
}

Autre fonction permettant d'appliquer un pourcentage à un montant donné:

@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;
}

Vous pouvez ensuite combiner toutes ces fonctions avec une classe MultiTools et poser des questions telles que "Quel est 10% du cours de l'action AAPL converti de USD en 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-001")
        .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?"));
}

Exécutez-le comme suit:

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

Vous devriez voir les différentes fonctions appelées:

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.

Vers les agents

L'appel de fonction est un excellent mécanisme d'extension pour les grands modèles de langage comme Gemini. Elle nous permet de créer des systèmes plus complexes, souvent appelés "agents" ou "assistants IA". Ces agents peuvent interagir avec le monde externe via des API externes et avec des services pouvant avoir des effets secondaires sur l'environnement externe (comme l'envoi d'e-mails, la création de tickets, etc.).

Lorsque vous créez des agents aussi puissants, vous devez le faire de manière responsable. Nous vous conseillons d'adopter une approche human-in-the-loop (avec intervention humaine) avant d'effectuer des actions automatiques. Il est important de garder la sécurité à l'esprit lorsque vous concevez des agents basés sur des LLM qui interagissent avec le monde extérieur.

13. Exécuter Gemma avec Ollama et TestContainers

Jusqu'à présent, nous avons utilisé Gemini, mais il existe aussi Gemma, son petit modèle associé.

Gemma est une famille de modèles ouverts légers et de pointe, élaborés à partir des recherches et des technologies utilisées pour créer les modèles Gemini. Gemma est disponible en deux variantes, Gemma1 et Gemma2, chacune avec différentes tailles. Gemma1 est disponible en deux tailles: 2B et 7B. Gemma2 est disponible en deux tailles: 9B et 27B. Leurs pondérations sont disponibles librement, et leur petite taille vous permet de les exécuter vous-même, même sur votre ordinateur portable ou dans Cloud Shell.

Comment exécuter Gemma ?

Il existe de nombreuses façons d'exécuter Gemma: dans le cloud, via Vertex AI en un seul clic, ou GKE avec certains GPU, mais vous pouvez également l'exécuter en local.

Une bonne option pour exécuter Gemma localement est d'utiliser Ollama, un outil qui vous permet d'exécuter de petits modèles, tels que Llama 2, Mistral et bien d'autres sur votre machine locale. Il est semblable à Docker, mais pour les LLM.

Installez Ollama en suivant les instructions correspondant à votre système d'exploitation.

Si vous utilisez un environnement Linux, vous devez d'abord activer Ollama après l'avoir installé.

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

Une fois le modèle installé en local, vous pouvez exécuter des commandes pour l'extraire:

ollama pull gemma:2b

Attendez que le modèle soit extrait. Cela peut prendre un certain temps.

Exécutez le modèle:

ollama run gemma:2b

Vous pouvez maintenant interagir avec le modèle:

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

Pour quitter l'invite, appuyez sur Ctrl+D.

Exécuter Gemma dans Ollama sur TestContainers

Au lieu d'avoir à installer et exécuter Ollama localement, vous pouvez l'utiliser dans un conteneur géré par TestContainers.

TestContainers est non seulement utile pour les tests, mais aussi pour exécuter des conteneurs. Vous pouvez même profiter d'un OllamaContainer spécifique !

Voici la vue d'ensemble:

2382c05a48708dfd.png

Implémentation

Examinons GemmaWithOllamaContainer.java, élément par élément.

Tout d'abord, vous devez créer un conteneur Ollama dérivé qui extrait le modèle Gemma. Cette image existe déjà à partir d'une exécution précédente ou va être créée. Si l'image existe déjà, il vous suffit d'indiquer à TestContainers que vous souhaitez remplacer l'image Ollama par défaut par votre variante alimentée par 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"));
    }
}

Ensuite, vous allez créer et démarrer un conteneur de test Ollama, puis créer un modèle de chat Ollama, en pointant vers l'adresse et le port du conteneur avec le modèle que vous souhaitez utiliser. Enfin, appelez simplement model.generate(yourPrompt) comme d'habitude:

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);
}

Exécutez-le comme suit:

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

La première exécution prendra un certain temps pour créer et exécuter le conteneur, mais une fois l'opération terminée, Gemma devrait répondre:

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 est en cours d'exécution dans Cloud Shell.

14. Félicitations

Félicitations, vous avez créé votre première application de chat par IA générative en Java à l'aide de LangChain4j et de l'API Gemini. Vous avez découvert en cours de route que les grands modèles de langage multimodaux sont assez puissants et capables de gérer diverses tâches comme les questions/réponses, même avec votre propre documentation, l'extraction de données, l'interaction avec des API externes, etc.

Et ensuite ?

À vous d'améliorer vos applications grâce à de puissantes intégrations LLM !

Complément d'informations

Documents de référence