Gemini em Java com Vertex AI e LangChain4j

1. Introdução

Este codelab tem como foco o Modelo de linguagem grande (LLM) do Gemini, hospedado na Vertex AI no Google Cloud. A Vertex AI é uma plataforma que abrange todos os produtos, serviços e modelos de machine learning no Google Cloud.

Você vai usar o Java para interagir com a API Gemini usando o framework LangChain4j. Você vai ver exemplos concretos para usar o LLM para respostas a perguntas, geração de ideias, extração de conteúdo estruturado e de entidades, geração aumentada de recuperação e chamada de função.

O que é IA generativa?

A IA generativa se refere ao uso da inteligência artificial para criar novos conteúdos, como textos, imagens, músicas, áudios e vídeos.

A IA generativa é alimentada por modelos de linguagem grandes (LLMs) que podem realizar várias tarefas ao mesmo tempo e realizar tarefas prontas para uso, como resumos, perguntas e respostas, classificação e muito mais. Com o mínimo de treinamento, os modelos de fundação podem ser adaptados para casos de uso específicos com poucos dados de exemplo.

Como funciona a IA generativa?

A IA generativa usa um modelo de machine learning (ML) para aprender os padrões e as relações em um conjunto de dados de conteúdo criado por humanos. Em seguida, ela usa os padrões aprendidos para gerar novo conteúdo.

A maneira mais comum de treinar um modelo de IA generativa é usar o aprendizado supervisionado. O modelo recebe um conjunto de conteúdo criado por humanos e rótulos correspondentes. Em seguida, ele aprende a gerar conteúdo semelhante ao criado por humanos.

Quais são as aplicações comuns da IA generativa?

A IA generativa pode ser usada para:

  • Melhore as interações com os clientes usando experiências aprimoradas de chat e pesquisa.
  • Use interfaces de conversa e resumos para analisar grandes quantidades de dados não estruturados.
  • Ajude com tarefas repetitivas, como responder a pedidos de propostas, localizar conteúdo de marketing para diferentes idiomas e verificar compliance de contratos de clientes e muito mais.

Quais ofertas de IA generativa o Google Cloud tem?

Com a Vertex AI, você pode interagir, personalizar e incorporar modelos de fundação nos seus aplicativos com pouca ou nenhuma experiência em ML. É possível acessar modelos de fundação no Model Garden, ajustar modelos com uma interface simples no Vertex AI Studio ou usar modelos em um notebook de ciência de dados.

O Vertex AI para Pesquisa e Conversação oferece aos desenvolvedores a maneira mais rápida de criar mecanismos de pesquisa e chatbots com tecnologia de IA generativa.

Com a tecnologia do Gemini, o Gemini para Google Cloud é um colaborador com tecnologia de IA disponível no Google Cloud e em ambientes de desenvolvimento integrado para ajudar você a fazer mais com mais rapidez. O Gemini Code Assist fornece preenchimento de código, geração de código, explicações de código e permite que você converse com ele para fazer perguntas técnicas.

O que é o Gemini?

Gemini é uma família de modelos de IA generativa desenvolvida pelo Google DeepMind, criada para casos de uso multimodais. Multimodal significa que ele pode processar e gerar diferentes tipos de conteúdo, como texto, código, imagens e áudio.

b9913d011999e7c7.png

O Gemini tem diferentes variações e tamanhos:

  • Gemini Ultra: é a versão maior e mais eficiente para tarefas complexas.
  • Gemini Flash: é mais rápido e econômico, otimizado para tarefas de grande volume.
  • Gemini Pro: médio, otimizado para escalonamento em várias tarefas.
  • Gemini Nano: o mais eficiente, criado para tarefas no dispositivo.

Principais recursos:

  • Multimodalidade: a capacidade do Gemini de entender e processar vários formatos de informação é um passo significativo além dos modelos tradicionais de linguagem apenas de texto.
  • Desempenho: o Gemini Ultra superou a tecnologia mais avançada atual em vários comparativos de mercado e foi o primeiro modelo a superar especialistas humanos no desafiador entendimento de linguagem multitarefa (MMLU, na sigla em inglês).
  • Flexibilidade: os diferentes tamanhos do Gemini o tornam adaptável para vários casos de uso, desde a pesquisa em grande escala até a implantação em dispositivos móveis.

Como interagir com o Gemini na Vertex AI usando Java?

Você tem duas opções:

  1. A biblioteca oficial da API Vertex AI Java para Gemini.
  2. LangChain4j.

Neste codelab, você usará o framework LangChain4j.

O que é o framework LangChain4j?

O framework LangChain4j (link em inglês) é uma biblioteca de código aberto para integrar LLMs aos seus aplicativos Java, orquestrando vários componentes, como o próprio LLM, além de outras ferramentas, como bancos de dados vetoriais (para pesquisas semânticas), carregadores de documentos e divisores (para analisar documentos e aprender com eles), analisadores de saída e muito mais.

Ele foi inspirado no projeto Python LangChain, mas com o objetivo de atender desenvolvedores Java.

bb908ea1e6c96ac2.png

O que você vai aprender

  • Como configurar um projeto Java para usar o Gemini e o LangChain4j.
  • Como enviar seu primeiro comando ao Gemini de forma programática
  • Como transmitir as respostas do Gemini por streaming
  • Como criar uma conversa entre um usuário e o Gemini
  • Como usar o Gemini em um contexto multimodal enviando textos e imagens
  • Como extrair informações estruturadas úteis de conteúdo não estruturado.
  • Como manipular modelos de comandos
  • Como fazer a classificação de textos, como análise de sentimento.
  • Como conversar com seus próprios documentos (Geração Aumentada de Recuperação)
  • Como ampliar os chatbots com chamadas de função
  • Como usar o Gemma localmente com Ollama e TestContainers

O que é necessário

  • Conhecimento da linguagem de programação Java.
  • um projeto do Google Cloud;
  • Um navegador, como o Chrome ou o Firefox

2. Configuração e requisitos

Configuração de ambiente autoguiada

  1. Faça login no Console do Google Cloud e crie um novo projeto ou reutilize um existente. Crie uma conta do Gmail ou do Google Workspace, se ainda não tiver uma.

fbef9caa1602edd0.png

a99b7ace416376c4.png

5e3ff691252acf41.png

  • O Nome do projeto é o nome de exibição para os participantes do projeto. É uma string de caracteres não usada pelas APIs do Google e pode ser atualizada quando você quiser.
  • O ID do projeto precisa ser exclusivo em todos os projetos do Google Cloud e não pode ser mudado após a definição. O console do Cloud gera automaticamente uma string exclusiva. Em geral, não importa o que seja. Na maioria dos codelabs, é necessário fazer referência ao ID do projeto, normalmente identificado como PROJECT_ID. Se você não gostar do ID gerado, crie outro aleatório. Se preferir, teste o seu e confira se ele está disponível. Ele não pode ser mudado após essa etapa e permanece durante o projeto.
  • Para sua informação, há um terceiro valor, um Número do projeto, que algumas APIs usam. Saiba mais sobre esses três valores na documentação.
  1. Em seguida, ative o faturamento no console do Cloud para usar os recursos/APIs do Cloud. A execução deste codelab não vai ser muito cara, se tiver algum custo. Para encerrar os recursos e evitar cobranças além deste tutorial, exclua os recursos criados ou exclua o projeto. Novos usuários do Google Cloud estão qualificados para o programa de US$ 300 de avaliação sem custos.

Inicie o Cloud Shell

Embora o Google Cloud possa ser operado remotamente em um laptop, neste codelab você vai usar o Cloud Shell, um ambiente de linha de comando executado no Cloud.

Ativar o Cloud Shell

  1. No Console do Cloud, clique em Ativar o Cloud Shell853e55310c205094.png.

3c1dabeca90e44e5.png.

Se você estiver iniciando o Cloud Shell pela primeira vez, verá uma tela intermediária com a descrição dele. Se uma tela intermediária for exibida, clique em Continuar.

9c92662c6a846a5c.png

Leva apenas alguns instantes para provisionar e se conectar ao Cloud Shell.

9f0e51b578fecce5.png

Essa máquina virtual tem todas as ferramentas de desenvolvimento necessárias. Ela oferece um diretório principal persistente de 5 GB, além de ser executada no Google Cloud. Isso aprimora o desempenho e a autenticação da rede. Grande parte do trabalho neste codelab, se não todo, pode ser feito em um navegador.

Depois de se conectar ao Cloud Shell, você verá sua autenticação e o projeto estará configurado com o ID do seu projeto.

  1. Execute o seguinte comando no Cloud Shell para confirmar se a conta está autenticada:
gcloud auth list

Resposta ao comando

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

To set the active account, run:
    $ gcloud config set account `ACCOUNT`
  1. Execute o seguinte comando no Cloud Shell para confirmar que o comando gcloud sabe sobre seu projeto:
gcloud config list project

Resposta ao comando

[core]
project = <PROJECT_ID>

Se o projeto não estiver configurado, configure-o usando este comando:

gcloud config set project <PROJECT_ID>

Resposta ao comando

Updated property [core/project].

3. Como preparar seu ambiente para desenvolvedores

Neste codelab, você vai usar o terminal e o editor do Cloud Shell para desenvolver programas em Java.

Ativar as APIs da Vertex AI

No console do Google Cloud, verifique se o nome do projeto é exibido na parte de cima do console do Google Cloud. Se não for, clique em Selecionar um projeto para abrir o Seletor de projetos e escolha o que você quer.

É possível ativar as APIs da Vertex AI na seção "Vertex AI" do console do Google Cloud ou no terminal do Cloud Shell.

Para ativar no console do Google Cloud, primeiro acesse a seção "Vertex AI" do menu do console do Google Cloud:

451976f1c8652341.png

Clique em Ativar todas as APIs recomendadas no painel da Vertex AI.

Isso vai ativar várias APIs, mas a mais importante para este codelab é a aiplatform.googleapis.com.

Também é possível ativar essa API no terminal do Cloud Shell com o seguinte comando:

gcloud services enable aiplatform.googleapis.com

Clone o repositório do GitHub

No terminal do Cloud Shell, clone o repositório deste codelab:

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

Para verificar se o projeto está pronto para ser executado, tente executar o programa "Hello World".

Verifique se você está na pasta de nível superior:

cd gemini-workshop-for-java-developers/ 

Crie o wrapper do Gradle:

gradle wrapper

Execute com gradlew:

./gradlew run

Você verá esta resposta:

..
> Task :app:run
Hello World!

Abrir e configurar o Cloud Editor

Abra o código com o editor de código do Cloud no Cloud Shell:

42908e11b28f4383.png

No Cloud Code Editor, abra a pasta de origem do codelab selecionando File -> Open Folder e aponte para a pasta de origem do codelab (por exemplo, /home/username/gemini-workshop-for-java-developers/).

Instalar o Gradle para Java

Para que o editor de código da nuvem funcione corretamente com o Gradle, instale a extensão Gradle para Java.

Primeiro, vá para a seção Projetos Java e pressione o sinal de adição:

84d15639ac61c197.png

Selecione Gradle for Java:

34d6c4136a3cc9ff.png

Selecione a versão do Install Pre-Release:

3b044fb450 acessob7.png

Depois de instalado, você verá os botões Disable e Uninstall:

46410fe86d777f9c.png

Por fim, limpe o espaço de trabalho para aplicar as novas configurações:

31e27e9bb61d975d.png

Isso vai solicitar que você atualize e exclua o workshop. Escolha Reload and delete:

d6303bc49e391dc.png

Se você abrir um dos arquivos, por exemplo, App.java, vai notar que o editor está funcionando corretamente com o destaque de sintaxe:

fed1b1b5de0dff58.png

Agora está tudo pronto para testar o Gemini!

Configurar as variáveis de ambiente.

Abra um novo terminal no Cloud Code Editor selecionando Terminal -> New Terminal. Configure duas variáveis de ambiente necessárias para executar os exemplos de código:

  • PROJECT_ID: ID do projeto do Google Cloud
  • LOCATION: a região onde o modelo do Gemini foi implantado

Exporte as variáveis da seguinte maneira:

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

4. Primeira chamada para o modelo do Gemini

Agora que o projeto está configurado corretamente, é hora de chamar a API Gemini.

Dê uma olhada em QA.java no diretório 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?"));
    }
}

Neste primeiro exemplo, você precisa importar a classe VertexAiGeminiChatModel, que implementa a interface ChatModel.

No método main, configure o modelo de linguagem do chat usando o builder da VertexAiGeminiChatModel e especifique:

  • Projeto
  • Local
  • Nome do modelo (gemini-1.5-flash-001).

Agora que o modelo de linguagem está pronto, chame o método generate() e transmita seu comando, sua pergunta ou instruções para enviar ao LLM. Aqui, você faz uma pergunta simples sobre o que torna o céu azul.

Sinta-se à vontade para alterar este comando para tentar perguntas ou tarefas diferentes.

Execute o exemplo na pasta raiz do código-fonte:

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

O resultado deve ser parecido com este:

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.

Parabéns! Você fez sua primeira chamada para o Gemini!

Resposta de streaming

Você notou que a resposta foi enviada de uma só vez, depois de alguns segundos? Graças à variante de resposta de streaming, também é possível receber a resposta progressivamente. Na resposta de streaming, o modelo retorna a resposta parte por parte, à medida que se torna disponível.

Neste codelab, vamos usar a resposta que não é de streaming, mas vamos conferir como fazer isso na resposta de streaming.

Em StreamQA.java, no diretório app/src/main/java/gemini/workshop, é possível conferir a resposta de streaming em ação:

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

Desta vez, importamos as variantes da classe de streaming VertexAiGeminiStreamingChatModel que implementa a interface StreamingChatLanguageModel. Você também precisa de um StreamingResponseHandler.

Desta vez, a assinatura do método generate() é um pouco diferente. Em vez de retornar uma string, o tipo de retorno é nulo. Além do prompt, você precisa passar um gerenciador de respostas de streaming. Aqui, você implementa a interface criando uma classe interna anônima, com dois métodos onNext(String text) e onError(Throwable error). O primeiro é chamado sempre que uma nova parte da resposta está disponível, enquanto o segundo é chamado apenas se ocorrer um erro.

Execute:

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

Você receberá uma resposta semelhante à da classe anterior, mas, desta vez, vai perceber que a resposta aparece progressivamente no shell, em vez de esperar a exibição da resposta completa.

Configuração extra

Para configuração, definimos apenas o projeto, o local e o nome do modelo, mas há outros parâmetros que você pode especificar para o modelo:

  • temperature(Float temp): para definir o nível de criatividade que você quer que a resposta seja. Zero significa que ela é baixa e muitas vezes mais factual, enquanto 1 é para resultados mais criativos.
  • topP(Float topP): para selecionar as palavras possíveis cuja probabilidade total se soma ao número de ponto flutuante (entre 0 e 1)
  • topK(Integer topK): para selecionar aleatoriamente uma palavra entre o número máximo de palavras prováveis para completar o texto (de 1 a 40).
  • maxOutputTokens(Integer max): para especificar o tamanho máximo da resposta dada pelo modelo (geralmente, quatro tokens representam aproximadamente três palavras)
  • maxRetries(Integer retries): caso você esteja ultrapassando a cota de solicitações por tempo ou se a plataforma estiver enfrentando algum problema técnico, faça com que o modelo repita a chamada três vezes.

Até agora, você fez uma única pergunta ao Gemini, mas você também pode participar de uma conversa com vários turnos. Isso é o que você vai conhecer na próxima seção.

5. Converse com o Gemini

Na etapa anterior, você fez apenas uma pergunta. Agora é hora de ter uma conversa real entre um usuário e o LLM. Cada pergunta e resposta pode se basear nas anteriores para formar uma discussão real.

Dê uma olhada no Conversation.java na pasta 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));
        });
    }
}

Algumas importações interessantes nesta classe:

  • MessageWindowChatMemory: uma classe que ajuda a lidar com o aspecto de várias interações da conversa e mantém na memória local as perguntas e respostas anteriores.
  • AiServices: uma classe que vai unir o modelo e a memória do chat

No método principal, você vai configurar o modelo, a memória do chat e o serviço de IA. O modelo é configurado normalmente com as informações do projeto, do local e do nome do modelo.

Para a memória do chat, usamos o builder da MessageWindowChatMemory para criar uma memória que mantém as últimas 20 mensagens trocadas. É uma janela deslizante sobre a conversa cujo contexto é mantido localmente no nosso cliente de classe Java.

Em seguida, crie o AI service que vincula o modelo de chat à memória.

Observe como o serviço de IA usa uma interface ConversationService personalizada que definimos, implementada pelo LangChain4j e que usa uma consulta String e retorna uma resposta String.

Agora é hora de conversar com o Gemini. Primeiro, uma simples saudação é enviada e, em seguida, uma primeira pergunta sobre a Torre Eiffel para saber em que país ela pode ser encontrada. Observe que a última frase está relacionada à resposta da primeira pergunta, enquanto você se pergunta quantos habitantes estão no país onde a Torre Eiffel está situada, sem mencionar explicitamente o país dado na resposta anterior. Ele mostra que perguntas e respostas anteriores são enviadas com cada comando.

Execute o exemplo:

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

Você verá três respostas semelhantes a estas:

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.

Você pode fazer perguntas em uma única resposta ou conversar em vários turnos com o Gemini, mas, até o momento, apenas texto foi usado. E as imagens? Vamos explorar imagens na próxima etapa.

6. Multimodalidade com o Gemini

O Gemini é um modelo multimodal. Ele não apenas aceita texto como entrada, mas também imagens ou até mesmo vídeos como entrada. Nesta seção, você vai conhecer um caso de uso para misturar texto e imagens.

Você acha que o Gemini vai reconhecer esse gato?

af00516493ec9ade.png

Imagem de um gato na neve tirada da Wikipédiahttps://upload.wikimedia.org/wikipedia/commons/b/b6/Felis_catus-cat_on_snow.jpg

Dê uma olhada em Multimodal.java no diretório 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());
    }
}

Nas importações, notamos distinguir entre diferentes tipos de mensagens e conteúdos. Um UserMessage pode conter um objeto TextContent e um ImageContent. A multimodalidade está em jogo: misturar texto e imagens. O modelo retorna um Response que contém um AiMessage.

Em seguida, você extrai o AiMessage da resposta usando content() e, em seguida, o texto da mensagem graças a text().

Execute o exemplo:

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

O nome da imagem certamente deu uma dica sobre o conteúdo dela, mas a saída do Gemini é semelhante a esta:

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.

Misturar imagens e comandos de texto abre casos de uso interessantes. Você pode criar aplicativos que podem:

  • Reconhecer texto em imagens.
  • Verifique se uma imagem pode ser exibida.
  • Criar legendas de imagens.
  • Pesquise em um banco de dados de imagens com descrições em texto simples.

Além de extrair informações de imagens, também é possível extrair informações de textos não estruturados. Isso é o que você vai aprender na próxima seção.

7. Extraia informações estruturadas de texto não estruturado

Há muitas situações em que informações importantes são fornecidas em documentos de relatório, e-mails ou outros textos longos de forma não estruturada. O ideal é extrair os detalhes importantes contidos no texto não estruturado, na forma de objetos estruturados. Vamos ver como você pode fazer isso.

Digamos que você queira extrair o nome e a idade de uma pessoa com base na biografia ou descrição dela. Você pode instruir o LLM a extrair o JSON de texto não estruturado com um comando bem ajustado (geralmente chamado de engenharia de comando).

Dê uma olhada no ExtractData.java em 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
    }
}

Vamos dar uma olhada nas várias etapas nesse arquivo:

  • Um registro Person é definido para representar os detalhes que descrevem uma pessoa ( nome e idade).
  • A interface PersonExtractor é definida com um método que, considerando uma string de texto não estruturada, retorna uma instância de Person.
  • O extractPerson() recebe uma anotação @UserMessage, que associa um comando a ele. Esse é o comando que o modelo vai usar para extrair as informações e retornar os detalhes em um documento JSON, que vai ser analisado e desmarcado em uma instância Person.

Agora, vamos conferir o conteúdo do método main():

  • O modelo de chat é instanciado. Usamos temperature (muito baixo) de zero e topK de apenas um para garantir uma resposta muito determinista. Isso também ajuda o modelo a seguir melhor as instruções. Em particular, não queremos que o Gemini envolva a resposta JSON com marcação extra do Markdown.
  • Um objeto PersonExtractor é criado graças à classe AiServices do LangChain4j.
  • Em seguida, você pode simplesmente chamar Person person = extractor.extractPerson(...) para extrair os detalhes da pessoa do texto não estruturado e receber uma instância de Person com o nome e a idade.

Execute o exemplo:

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

Você verá esta resposta:

Anna
23

Sim, aqui é Anna, e eles têm 23 anos!

Com essa abordagem AiServices, você opera com objetos fortemente tipados. Você não está interagindo diretamente com o LLM. Em vez disso, você está trabalhando com classes concretas, como o registro Person para representar as informações pessoais extraídas, e tem um objeto PersonExtractor com um método extractPerson() que retorna uma instância Person. A noção de LLM é abstraída e, como desenvolvedor Java, você está apenas manipulando classes e objetos normais.

8. Estruturar comandos com modelos

Quando você interage com um LLM usando um conjunto comum de instruções ou perguntas, há uma parte do comando que nunca muda, enquanto outras contêm os dados. Por exemplo, se quiser criar receitas, use um comando como "Você é um chef talentoso, crie uma receita com os seguintes ingredientes: ..." e inclua os ingredientes no final do texto. É para isso que servem os modelos de prompt — semelhante às strings interpoladas em linguagens de programação. Um modelo de comando contém marcadores de posição que podem ser substituídos pelos dados certos para uma chamada específica para o LLM.

Mais especificamente, vamos estudar TemplatePrompt.java no diretório 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());
    }
}

Como de costume, você vai configurar o modelo VertexAiGeminiChatModel com um alto nível de criatividade com uma temperatura alta e também altos valores de topP e topK. Em seguida, crie um PromptTemplate com o método estático from(), transmitindo a string do comando, e use as variáveis de marcador de posição de chaves duplas: {{dish}} e {{ingredients}}.

Crie o comando final chamando apply(), que usa um mapa de pares de chave-valor que representam o nome do marcador e o valor da string para substituí-lo.

Por fim, você vai chamar o método generate() do modelo Gemini criando uma mensagem de usuário usando esse comando com a instrução prompt.toUserMessage().

Execute o exemplo:

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

A saída gerada será parecida com esta:

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

Você pode mudar os valores de dish e ingredients no mapa, ajustar a temperatura, topK e tokP e executar o código de novo. Isso permite observar o efeito da mudança desses parâmetros no LLM.

Os modelos de comando são uma boa maneira de ter instruções reutilizáveis e parametrizáveis para chamadas de LLM. É possível transmitir dados e personalizar comandos para diferentes valores fornecidos pelos usuários.

9. Classificação de texto com comandos few-shot

Os LLMs são bons em classificar textos em diferentes categorias. Você pode ajudar um LLM nessa tarefa fornecendo alguns exemplos de textos e as categorias associadas a eles. Essa abordagem é chamada de comandos de poucos shots.

Confira TextClassification.java no diretório app/src/main/java/gemini/workshop para fazer um tipo específico de classificação de texto: a análise de sentimento.

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

No método main(), crie o modelo de chat do Gemini como de costume, mas com um pequeno número máximo de token de saída, já que você quer apenas uma resposta curta: o texto é POSITIVE, NEGATIVE ou NEUTRAL.

Em seguida, você vai criar um modelo de comando reutilizável com a técnica de comandos few-shot, instruindo o modelo sobre alguns exemplos de entradas e saídas. Isso também ajuda o modelo a acompanhar a saída real. O Gemini não vai responder com uma frase completa. Em vez disso, ele vai responder com apenas uma palavra.

Aplique as variáveis com o método apply() para substituir o marcador {{text}} pelo parâmetro real ("I love strawberries") e transforme esse modelo em uma mensagem do usuário com toUserMessage().

Execute o exemplo:

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

Vai aparecer uma única palavra:

POSITIVE

Parece que amar morangos é um sentimento positivo!

10. Geração aumentada de recuperação

LLMs são treinados com uma grande quantidade de texto. No entanto, o conhecimento deles abrange apenas as informações que ele viu durante o treinamento. Se novas informações forem liberadas após a data de término do treinamento de modelo, esses detalhes não estarão disponíveis para o modelo. Assim, o modelo não será capaz de responder a perguntas sobre informações que ele não viu.

É por isso que abordagens como a Geração Aumentada de Recuperação (RAG, na sigla em inglês) ajudam a fornecer as informações extras que um LLM precisa saber para atender às solicitações dos usuários, para responder com informações mais atuais ou em informações particulares que não são acessíveis no momento do treinamento.

Vamos voltar às conversas. Desta vez, você poderá fazer perguntas sobre seus documentos. Você vai criar um chatbot capaz de recuperar informações relevantes de um banco de dados que contém seus documentos divididos em partes menores ("blocos"). Essas informações serão usadas pelo modelo para fundamentar as respostas, em vez de depender apenas do conhecimento contido no treinamento.

Na RAG, há duas fases:

  1. Fase de ingestão: os documentos são carregados na memória, divididos em blocos menores, e os embeddings vetoriais (uma representação multidimensional de alta vetores dos blocos) são calculados e armazenados em um banco de dados vetorial capaz de fazer pesquisas semânticas. Essa fase de ingestão normalmente é feita uma vez, quando é necessário adicionar novos documentos ao corpus.

cd07d33d20ffa1c8.png

  1. Fase de consulta: os usuários agora podem fazer perguntas sobre os documentos. A pergunta também será transformada em um vetor e comparada com todos os outros vetores do banco de dados. Os vetores mais semelhantes geralmente estão semanticamente relacionados e são retornados pelo banco de dados de vetores. Em seguida, o LLM recebe o contexto da conversa, os blocos de texto que correspondem aos vetores retornados pelo banco de dados, e ele é solicitado a fundamentar a resposta analisando esses blocos.

a1d2e2deb83c6d27.png

Preparar seus documentos

Para esta nova demonstração, você vai fazer perguntas sobre o artigo de pesquisa "Attention is all you need". Ele descreve a arquitetura de rede neural de transformador, criada pelo Google, que é como todos os modelos de linguagem grandes modernos são implementados atualmente.

O documento já foi salvo no arquivo attention-is-all-you-need.pdf no repositório.

Implementar o chatbot

Vamos conferir como criar a abordagem de duas fases: primeiro com a ingestão de documentos e depois o tempo de consulta em que os usuários fazem perguntas sobre o documento.

Neste exemplo, as duas fases são implementadas na mesma classe. Normalmente, você tem um aplicativo que cuida da ingestão e outro que oferece a interface do bot de bate-papo para os usuários.

Além disso, neste exemplo usaremos um banco de dados de vetores na memória. Em um cenário de produção real, as fases de ingestão e consulta seriam separadas em dois aplicativos distintos, e os vetores são mantidos em um banco de dados independente.

Ingestão de documentos

A primeira etapa da fase de ingestão de documentos é localizar o arquivo PDF que já foi salvo e preparar um PdfParser para leitura:

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

Em vez de criar o modelo de linguagem de chat comum, crie uma instância de um modelo de incorporação. Esse é um modelo específico cuja função é criar representações vetoriais de textos (palavras, frases ou mesmo parágrafos). Ela retorna vetores de números de ponto flutuante, em vez de retornar respostas de texto.

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

Agora você vai precisar que algumas turmas colaborem juntas para:

  • Carregar e dividir o documento PDF em partes.
  • Criar embeddings de vetores para todos esses blocos.
InMemoryEmbeddingStore<TextSegment> embeddingStore = 
    new InMemoryEmbeddingStore<>();

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

Uma instância do InMemoryEmbeddingStore, um banco de dados de vetores na memória, é criada para armazenar os embeddings do vetor.

O documento é dividido em partes graças à classe DocumentSplitters. Ele vai dividir o texto do arquivo PDF em trechos de 500 caracteres, com uma sobreposição de 100 caracteres (com o fragmento seguinte, para evitar o corte de palavras ou frases, em pedaços).

O ingestão da loja vincula o divisor de documentos, o modelo de embedding para calcular os vetores e o banco de dados de vetores na memória. Em seguida, o método ingest() vai fazer a ingestão.

Agora, a primeira fase terminou, o documento foi transformado em blocos de texto com seus embeddings vetoriais associados e armazenado no banco de dados de vetores.

Como fazer perguntas

É hora de se preparar para fazer perguntas! Crie um modelo de chat para iniciar a conversa:

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

Você também precisa de uma classe de recuperação para vincular o banco de dados de vetores (na variável embeddingStore) ao modelo de embedding. A função dele é consultar o banco de dados de vetores para encontrar vetores semelhantes no banco de dados. Para isso, ele calcula um embedding vetorial para a consulta do usuário:

EmbeddingStoreContentRetriever retriever =
    new EmbeddingStoreContentRetriever(embeddingStore, embeddingModel);

Fora do método principal, crie uma interface que represente um assistente especialista em LLM, que é uma interface que a classe AiServices implementará para você interagir com o modelo:

interface LlmExpert {
    String ask(String question);
}

Agora, você pode configurar um novo serviço de IA:

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

Esse serviço é vinculado:

  • O modelo de linguagem do chat que você configurou anteriormente.
  • Uma recordação do chat para acompanhar a conversa.
  • O recuperador compara uma consulta de embedding de vetor com os vetores no banco de dados.
  • Um modelo de comando informa explicitamente que o modelo de chat precisa responder com base nas informações fornecidas, ou seja, nos trechos relevantes da documentação com uma incorporação de vetor semelhante ao vetor da pergunta do usuário.
.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())

Agora você já pode fazer perguntas!

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

O código-fonte completo está em RAG.java no diretório app/src/main/java/gemini/workshop:

Execute o exemplo:

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

Na saída, você verá as respostas para as suas perguntas:

=== 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. Chamadas de função

Também há situações em que você quer que um LLM tenha acesso a sistemas externos, como uma API remota da Web que recupera informações ou tem uma ação, ou serviços que executam algum tipo de computação. Exemplo:

APIs da Web remota:

  • Rastrear e atualizar pedidos dos clientes.
  • Encontre ou crie um tíquete em um Issue Tracker.
  • Busque dados em tempo real, como cotações de ações ou medições de sensores de IoT.
  • Envie um e-mail.

Ferramentas de computação:

  • Uma calculadora para problemas matemáticos mais avançados.
  • Interpretação de código para execução de código quando LLMs precisam de lógica de raciocínio.
  • Converter solicitações de linguagem natural em consultas SQL para que um LLM possa consultar um banco de dados.

A chamada de função é a capacidade do modelo de solicitar que uma ou mais chamadas de função sejam feitas em nome dele. Assim, ele pode responder adequadamente ao comando do usuário com mais dados atualizados.

Dado um comando específico de um usuário e o conhecimento das funções existentes que podem ser relevantes para esse contexto, um LLM pode responder com uma solicitação de chamada de função. O aplicativo que integra o LLM pode chamar a função e responder ao LLM com uma resposta, que o interpreta respondendo com uma resposta textual.

Quatro etapas da chamada de função

Vamos dar uma olhada em um exemplo de chamada de função: conseguir informações sobre a previsão do tempo.

Se você perguntar ao Gemini ou a qualquer outro LLM sobre o clima em Paris, ele vai responder dizendo que não há informações sobre a previsão do tempo. Para que o LLM tenha acesso aos dados meteorológicos em tempo real, defina algumas funções que ele pode usar.

Confira o diagrama a seguir:

31e0c2aba5e6f21c.png

1️⃣ Primeiro, um usuário pergunta sobre o clima em Paris. O app de chatbot sabe que há uma ou mais funções disponíveis para ajudar o LLM a realizar a consulta. O chatbot envia o comando inicial e a lista de funções que podem ser chamadas. Aqui, uma função chamada getWeather(), que usa um parâmetro de string para o local.

8863be53a73c4a70.png

Como o LLM não sabe sobre a previsão do tempo, em vez de responder por mensagem de texto, ele envia uma solicitação de execução de função. O chatbot precisa chamar a função getWeather() com "Paris" como parâmetro de localização.

d1367cc69c07b14d.png

2️⃣ O chatbot invoca essa função em nome do LLM e recupera a resposta da função. Aqui, imaginamos que a resposta seja {"forecast": "sunny"}.

73a5f2ed19f47d8.png

3️⃣ O app de chatbot envia a resposta JSON de volta ao LLM.

20832cb1ee6fbfeb.png

4️⃣ O LLM analisa a resposta JSON, interpreta essa informação e depois responde com um texto dizendo que o tempo está ensolarado em Paris.

Cada etapa como código

Primeiro, configure o modelo do Gemini como de costume:

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

Você especifica uma especificação de ferramenta que descreve a função que pode ser chamada:

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

O nome da função é definido, bem como o nome e o tipo do parâmetro, mas observe que tanto a função quanto os parâmetros são descritos. As descrições são muito importantes e ajudam o LLM a realmente entender o que uma função pode fazer e, assim, julgar se essa função precisa ser chamada no contexto da conversa.

Vamos começar a primeira etapa, enviando a pergunta inicial sobre o clima em 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);

Na etapa 2, passamos a ferramenta que gostaríamos que o modelo usasse, e o modelo responde com uma solicitação de execução muito:

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

Etapa 3. Neste ponto, sabemos qual função o LLM gostaria que fosse chamada. No código, não estamos fazendo uma chamada real para uma API externa, apenas retornamos uma previsão do tempo hipotética diretamente:

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

Na etapa 4, o LLM aprende sobre o resultado da execução da função e pode sintetizar uma resposta textual:

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

O resultado é:

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.

Você pode ver na saída acima da solicitação de execução da ferramenta, bem como a resposta.

O código-fonte completo está em FunctionCalling.java no diretório app/src/main/java/gemini/workshop:

Execute o exemplo:

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

O resultado será semelhante a este:

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. O LangChain4j processa a chamada de função.

Na etapa anterior, você viu como as interações de pergunta/resposta de texto normais e de solicitação/resposta de função são intercaladas. Com isso, você forneceu a resposta da função solicitada diretamente, sem chamar uma função real.

No entanto, o LangChain4j também oferece uma abstração de nível superior que pode processar as chamadas de função de forma transparente para você, enquanto lida com a conversa normalmente.

Chamada de função única

Vamos dar uma conferida em FunctionCallingAssistant.java, parte por parte.

Primeiro, crie um registro que representará a estrutura de dados de resposta da função:

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

A resposta contém informações sobre o local, a previsão e a temperatura.

Em seguida, crie uma classe que contenha a função real que você quer disponibilizar para o modelo:

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

Observe que essa classe contém uma única função, mas é anotada com a anotação @Tool, que corresponde à descrição da função que o modelo pode solicitar para chamar.

Os parâmetros da função (apenas um aqui) também são anotados, mas com esta breve anotação @P, que também fornece uma descrição do parâmetro. É possível adicionar quantas funções quiser para disponibilizá-las ao modelo em cenários mais complexos.

Nesta aula, você retorna algumas respostas automáticas, mas se quiser chamar um serviço externo real de previsão do tempo, isso está no corpo do método em que você faria a chamada para esse serviço.

Como vimos quando você criou um ToolSpecification na abordagem anterior, é importante documentar o que uma função faz e descrever a que os parâmetros correspondem. Isso ajuda o modelo a entender como e quando essa função pode ser usada.

Em seguida, o LangChain4j permite fornecer uma interface que corresponda ao contrato que você quer usar para interagir com o modelo. Essa é uma interface simples que recebe uma string que representa a mensagem do usuário e retorna uma string correspondente à resposta do modelo:

interface WeatherAssistant {
    String chat(String userMessage);
}

Também é possível usar assinaturas mais complexas que envolvam o UserMessage do LangChain4j (para uma mensagem do usuário) ou AiMessage (para uma resposta de modelo) ou até mesmo um TokenStream, se você quiser lidar com situações mais avançadas, já que esses objetos mais complicados também contêm informações extras, como o número de tokens consumidos etc. Mas, para simplificar, vamos apenas usar string na entrada e string na saída.

Vamos finalizar com o método main(), que une todas as partes:

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

Como de costume, você configura o modelo de chat do Gemini. Em seguida, instancia seu serviço de previsão do tempo que contém a "função" que o modelo nos pedirá para chamar.

Agora, use a classe AiServices novamente para vincular o modelo de chat, a memória do chat e a ferramenta (ou seja, o serviço de previsão do tempo com a função correspondente). AiServices retorna um objeto que implementa a interface WeatherAssistant definida por você. Só falta chamar o método chat() desse assistente. Ao invocá-la, você verá apenas as respostas de texto, mas as solicitações de chamada de função e as respostas dela não serão visíveis pelo desenvolvedor, e essas solicitações serão tratadas de forma automática e transparente. Se o Gemini achar que uma função precisa ser chamada, ele vai responder com a solicitação da chamada de função, e o LangChain4j vai chamar a função local por você.

Execute o exemplo:

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

O resultado será semelhante a este:

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

Este foi um exemplo de uma única função.

Várias chamadas de função

Você também pode ter várias funções e deixar o LangChain4j lidar com várias chamadas de função em seu nome. Confira MultiFunctionCallingAssistant.java para um exemplo de várias funções.

Ele tem uma função para converter moedas:

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

Outra função para receber o valor de uma ação:

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

Outra função para aplicar uma porcentagem a um determinado valor:

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

Em seguida, você pode combinar todas essas funções com uma classe MultiTools e fazer perguntas como "Qual é 10% do preço da ação AAPL convertido de USD para 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?"));
}

Execute-o da seguinte maneira:

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

Você vai encontrar as várias funções chamadas:

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.

Para agentes

A chamada de função é um ótimo mecanismo de extensão para modelos de linguagem grandes como o Gemini. Ele nos permite criar sistemas mais complexos, geralmente chamados de "agentes" ou "assistentes de IA". Esses agentes podem interagir com o mundo externo por meio de APIs externas e com serviços que podem ter efeitos colaterais no ambiente externo (como enviar e-mails, criar tíquetes etc.)

Ao criar agentes tão poderosos, faça isso com responsabilidade. Considere a interação humana antes de realizar ações automáticas. É importante ter em mente a segurança ao projetar agentes com tecnologia LLM que interagem com o mundo externo.

13. Como executar o Gemma com Ollama e TestContainers

Até agora, usamos o Gemini, mas também temos o Gemma, modelo irmãozinho.

O Gemma é uma família de modelos abertos, leves e de última geração, criados com a mesma pesquisa e tecnologia usada para criar os modelos do Gemini. O Gemma está disponível em duas variações Gemma1 e Gemma2, cada uma com vários tamanhos. O Gemma1 está disponível em dois tamanhos: 2B e 7B. O Gemma2 está disponível em dois tamanhos: 9B e 27B. Os pesos deles estão disponíveis sem custo financeiro e, com tamanhos pequenos, você pode executá-los por conta própria, até mesmo no seu laptop ou no Cloud Shell.

Como usar o Gemma?

Há muitas maneiras de executar o Gemma: na nuvem, usando a Vertex AI com um clique de botão, ou o GKE com algumas GPUs, mas também é possível executá-lo localmente.

Uma boa opção para executar o Gemma localmente é com a Ollama, uma ferramenta que permite executar pequenos modelos, como Llama 2, Mistral e muitos outros na sua máquina local. É semelhante ao Docker, mas para LLMs.

Instale o Ollama seguindo as instruções do seu sistema operacional.

Se estiver usando um ambiente Linux, primeiro ative o Ollama depois de instalá-lo.

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

Depois de instalado localmente, execute comandos para extrair um modelo:

ollama pull gemma:2b

Aguarde o modelo ser extraído. Isso pode levar alguns instantes.

Execute o modelo:

ollama run gemma:2b

Agora, é possível interagir com o modelo:

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

Para sair do prompt, pressione Ctrl+D

Como executar o Gemma em Ollama em TestContainers

Em vez de instalar e executar o Ollama localmente, você pode usá-lo dentro de um contêiner, processado por TestContainers.

TestContainers não é útil apenas para testes, mas também pode ser usado para executar contêineres. Também existe um OllamaContainer específico para você aproveitar.

Este é o panorama completo:

2382c05a48708dfd.png

Implementação

Vamos dar uma conferida em GemmaWithOllamaContainer.java, parte por parte.

Primeiro, você precisa criar um contêiner Ollama derivado que extrai o modelo do Gemma. Essa imagem já existe em uma execução anterior ou será criada. Se a imagem já existir, basta informar ao TestContainers que você quer substituir a imagem padrão do Ollama pela variante com tecnologia 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"));
    }
}

Em seguida, você cria e inicia um contêiner de teste do Ollama e, em seguida, cria um modelo de chat do Ollama apontando para o endereço e a porta do contêiner com o modelo que quer usar. Por fim, basta invocar model.generate(yourPrompt) como de costume:

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

Execute-o da seguinte maneira:

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

A primeira execução levará um tempo para criar e executar o contêiner, mas, depois disso, você verá o Gemma respondendo:

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.

O Gemma está em execução no Cloud Shell.

14. Parabéns

Parabéns! Você criou seu primeiro aplicativo de chat de IA generativa em Java usando o LangChain4j e a API Gemini. Você descobriu ao longo do caminho que modelos de linguagem grande multimodais são bastante poderosos e capazes de lidar com várias tarefas, como perguntas/respostas, até mesmo na sua própria documentação, extração de dados, interação com APIs externas e muito mais.

Qual é a próxima etapa?

Chegou a hora de aprimorar seus aplicativos com integrações avançadas de LLMs.

Leia mais

Documentos de referência