Gemini em Java com Vertex AI e LangChain4j

1. Introdução

Este codelab se concentra no modelo de linguagem grande (LLM) Gemini, hospedado na Vertex AI no Google Cloud. A Vertex AI é uma plataforma que engloba todos os produtos, serviços e modelos de machine learning no Google Cloud.

Você vai usar Java para interagir com a API Gemini usando a estrutura LangChain4j. Você vai conferir exemplos concretos para aproveitar o LLM para responder a perguntas, gerar ideias, extrair conteúdo estruturado e entidades, gerar respostas aprimoradas e fazer chamadas de função.

O que é a IA generativa?

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

A IA generativa usa modelos de linguagem grandes (LLMs) que podem realizar várias tarefas ao mesmo tempo e executar 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, ela aprende a gerar conteúdo semelhante ao criado por humanos.

Quais são os aplicativos comuns de IA generativa?

A IA generativa pode ser usada para:

  • Melhore as interações com os clientes por meio de experiências de pesquisa e chat aprimoradas.
  • Analise grandes quantidades de dados não estruturados por meio de interfaces de conversação e resumos.
  • Ajudar com tarefas repetitivas, como responder a solicitações de propostas, localizar o conteúdo de marketing em diferentes idiomas, verificar a conformidade dos contratos do cliente e muito mais.

Quais são as ofertas de IA generativa do Google Cloud?

Com a Vertex AI, é possível interagir, personalizar e incorporar modelos de base nos seus aplicativos sem precisar de experiência em ML. Você pode acessar modelos de base no Model Garden, ajustar modelos usando uma interface simples no Vertex AI Studio ou usar modelos em um notebook de ciência de dados.

A pesquisa e conversa da Vertex AI oferece aos desenvolvedores a maneira mais rápida de criar mecanismos de pesquisa e chatbots com tecnologia de IA generativa.

O Gemini para Google Cloud, com tecnologia de IA, é um colaborador com tecnologia de IA disponível no Google Cloud e em ambientes de desenvolvimento integrado para ajudar você a produzir mais em menos tempo. O Gemini Code Assist oferece preenchimento, geração e explicações de código, além de permitir 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: o modelo mais rápido e econômico, otimizado para tarefas de grande volume.
  • Gemini Pro: de tamanho médio, otimizado para escalonamento em várias tarefas.
  • Gemini Nano: o modelo mais eficiente, projetado 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 de linguagem tradicionais somente de texto.
  • Desempenho: o Gemini Ultra supera o estado da arte atual em muitos comparativos de mercado e foi o primeiro modelo a superar especialistas humanos no desafiador comparativo de mercado MMLU (Massive Multitask Language Understanding).
  • Flexibilidade: os diferentes tamanhos do Gemini permitem que ele seja adaptado para vários casos de uso, desde pesquisas em grande escala até 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 Java Vertex AI para Gemini.
  2. Framework LangChain4j.

Neste codelab, você vai usar o framework LangChain4j.

O que é o framework LangChain4j?

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

O projeto foi inspirado no projeto Python LangChain, mas com o objetivo de atender aos 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 para o Gemini de forma programática
  • Como transmitir respostas do Gemini
  • Como criar uma conversa entre um usuário e o Gemini
  • Como usar o Gemini em um contexto multimodal enviando texto e imagens
  • Como extrair informações estruturadas úteis de conteúdo não estruturado
  • Como manipular modelos de comando
  • Como fazer a classificação de texto, como a análise de sentimento
  • Como conversar com seus próprios documentos (Geração Aumentada de Recuperação)
  • Como estender seus chatbots com chamadas de função
  • Como usar o Gemma localmente com o Ollama e o 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 seu laptop, neste codelab usaremos 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 esta for a primeira vez que você iniciar o Cloud Shell, vai aparecer uma tela intermediária com a descrição dele. Se esse for o caso, clique em Continuar.

9c92662c6a846a5c.png

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

9f0e51b578fecce5.png

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

Depois de se conectar ao Cloud Shell, você vai notar que sua conta já está autenticada e que o projeto está configurado com seu ID do 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 comando a seguir no Cloud Shell para confirmar se 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 seus programas Java.

Ativar as APIs da Vertex AI

No console do Google Cloud, verifique se o nome do projeto aparece na parte de cima do console do Google Cloud. Se não estiver, clique em Selecionar um projeto para abrir o Seletor de projetos e escolha o projeto pretendido.

É possível ativar as APIs 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, acesse a seção Vertex AI do menu do console:

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 o codelab é a aiplatform.googleapis.com.

Como alternativa, você também pode 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 execução, 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

Execução 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/).

Configurar as variáveis de ambiente.

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

  • PROJECT_ID: o ID do seu projeto do Google Cloud
  • LOCAL: a região em que o modelo do Gemini é 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 Gemini

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

Confira 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-002")
            .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, você configura o modelo de linguagem de chat usando o criador de VertexAiGeminiChatModel e especifica:

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

Agora que o modelo de linguagem está pronto, você pode chamar o método generate() e transmitir o comando, a pergunta ou as instruções para enviar ao LLM. Aqui, você faz uma pergunta simples sobre o que deixa o céu azul.

Você pode mudar esse 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 será semelhante a 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 dada de uma vez, após alguns segundos? Também é possível receber a resposta progressivamente, graças à variante de resposta com streaming. Na resposta de streaming, o modelo retorna a resposta peça por peça, conforme fica disponível.

Neste codelab, vamos usar a resposta sem streaming, mas vamos conferir a resposta de streaming para saber como ela é feita.

Em StreamQA.java no diretório app/src/main/java/gemini/workshop, você pode conferir a resposta de streaming em ação:

package gemini.workshop;

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

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

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

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

Dessa vez, importamos as variantes da classe de streaming VertexAiGeminiStreamingChatModel, que implementa a interface StreamingChatLanguageModel. Você também vai precisar importar estáticamente LambdaStreamingResponseHandler.onNext, que é um método de conveniência que fornece StreamingResponseHandlers para criar um gerenciador de streaming com expressões lambda Java.

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 comando, é necessário transmitir um manipulador de resposta de streaming. Aqui, graças à importação estática mencionada acima, podemos definir uma expressão lambda que você transmite ao método onNext(). A expressão lambda é chamada sempre que uma nova parte da resposta está disponível, enquanto a segunda é chamada apenas se ocorrer um erro.

Execute:

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

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

Configuração extra

Para a configuração, definimos apenas o projeto, o local e o nome do modelo, mas há outros parâmetros que podem ser especificados para o modelo:

  • temperature(Float temp): define o nível de criatividade que você quer que a resposta tenha. 0 é pouco criativo e geralmente mais factual, enquanto 2 é para saídas mais criativas.
  • topP(Float topP): seleciona as palavras possíveis cuja probabilidade total soma-se a esse número de ponto flutuante (entre 0 e 1).
  • topK(Integer topK): seleciona aleatoriamente uma palavra de um número máximo de palavras prováveis para a conclusão de texto (de 1 a 40).
  • maxOutputTokens(Integer max): para especificar o comprimento máximo da resposta dada pelo modelo (geralmente, 4 tokens representam aproximadamente 3 palavras)
  • maxRetries(Integer retries): caso você esteja executando a solicitação além da cota por tempo ou a plataforma esteja enfrentando algum problema técnico, é possível fazer com que o modelo tente a chamada três vezes.

Até agora, você fez uma única pergunta ao Gemini, mas também é possível ter uma conversa com várias perguntas. É isso que você vai conhecer na próxima seção.

5. Conversar com o Gemini

Na etapa anterior, você fez uma única 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.

Observe 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-002")
            .build();

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

        interface ConversationService {
            String chat(String message);
        }

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

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

Há algumas importações novas interessantes nesta classe:

  • MessageWindowChatMemory: uma classe que ajuda a processar o aspecto de vários turnos da conversa e a manter as perguntas e respostas anteriores na memória local
  • AiServices: uma classe de abstração de nível mais alto que vincula o modelo de chat e a memória de chat

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

Para a memória de chat, usamos o builder de 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 cliente da classe Java.

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

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

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

Execute o exemplo:

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

Você vai receber 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 de uma única vez ou ter conversas com vários turnos com o Gemini, mas até agora, a entrada foi apenas de texto. E as imagens? Vamos analisar as imagens na próxima etapa.

6. Multimodalidade com o Gemini

O Gemini é um modelo multimodal. Ele aceita texto, imagens e até vídeos como entrada. Nesta seção, você vai conferir 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 Wikipediahttps://upload.wikimedia.org/wikipedia/commons/b/b6/Felis_catus-cat_on_snow.jpg

Confira 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-002")
            .build();

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

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

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

Nas importações, distinguimos diferentes tipos de mensagens e conteúdos. Um UserMessage pode conter um objeto TextContent e um ImageContent. Isso é multimodalidade em ação: misturar texto e imagens. Não enviamos apenas uma solicitação de string simples, mas um objeto mais estruturado que representa uma mensagem do usuário, composta por uma parte de conteúdo de imagem e uma parte de conteúdo de texto. O modelo envia de volta um Response que contém um AiMessage.

Em seguida, você recupera o AiMessage da resposta usando content() e, depois, o texto da mensagem usando text().

Execute o exemplo:

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

O nome da imagem certamente deu uma dica do que ela continha, 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 novos casos de uso interessantes. Você pode criar aplicativos que:

  • Reconhece texto em imagens.
  • Verifique se uma imagem é segura para exibição.
  • 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, você também pode extrair informações de texto não estruturado. É isso que você vai aprender na próxima seção.

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

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

Digamos que você queira extrair o nome e a idade de uma pessoa com base em uma biografia, um currículo ou uma descrição dela. É possível instruir o LLM a extrair JSON de texto não estruturado com uma solicitação inteligente (comumente chamada de "engenharia de solicitação").

No exemplo abaixo, em vez de criar um comando que descreve a saída JSON, vamos usar um recurso poderoso do Gemini chamado saída estruturada, ou, às vezes, decodificação restrita, forçando o modelo a gerar apenas conteúdo JSON válido, seguindo um esquema JSON especificado.

Confira ExtractData.java no app/src/main/java/gemini/workshop:

package gemini.workshop;

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

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

public class ExtractData {

    record Person(String name, int age) { }

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

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

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

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

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

Vamos conferir as várias etapas deste 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, dada uma string de texto não estruturado, retorna uma instância de Person.
  • O extractPerson() é anotado com uma anotação @SystemMessage que associa um comando de instrução a ele. Esse é o comando que o modelo vai usar para orientar a extração das informações e retornar os detalhes na forma de um documento JSON, que será analisado para você e desempacotado em uma instância Person.

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

  • O modelo de chat é configurado e instanciado. Estamos usando dois novos métodos da classe de builder de modelos: responseMimeType() e responseSchema(). A primeira instrui o Gemini a gerar um JSON válido na saída. O segundo método define o esquema do objeto JSON que precisa ser retornado. Além disso, o segundo delegou para um método de conveniência que pode converter uma classe ou um registro Java em um esquema JSON adequado.
  • Um objeto PersonExtractor é criado graças à classe AiServices do LangChain4j.
  • Em seguida, basta chamar Person person = extractor.extractPerson(...) para extrair os detalhes da pessoa do texto não estruturado e receber uma instância 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 é a Anna, e ela tem 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. O conceito de LLM é abstrato, e, como desenvolvedor Java, você está apenas manipulando classes e objetos normais ao usar essa interface PersonExtractor.

8. Estruturar comandos com modelos de comando

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 partes contêm os dados. Por exemplo, se você quiser criar receitas, use um comando como "Você é um chef talentoso. Crie uma receita com os seguintes ingredientes: …" e anexe os ingredientes ao final do texto. É para isso que servem os modelos de comando, semelhantes à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 corretos para uma chamada específica ao 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-002")
            .maxOutputTokens(500)
            .temperature(1.0f)
            .topK(40)
            .topP(0.95f)
            .maxRetries(3)
            .build();

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

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

        Prompt prompt = promptTemplate.apply(variables);

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

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

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

Para criar o comando final, chame apply(), que recebe um mapa de pares de chave-valor que representam o nome do marcador de posição e o valor da string para substituí-lo.

Por fim, chame o método generate() do modelo Gemini criando uma mensagem do usuário com base nesse comando, usando a instrução prompt.toUserMessage().

Execute o exemplo:

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

Você vai ver um resultado gerado semelhante a este:

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

Mude os valores de dish e ingredients no mapa, ajuste a temperatura, topK e tokP e execute o código novamente. Assim, você vai poder 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 solicitações para diferentes valores fornecidos pelos usuários.

9. Classificação de texto com comando de poucos disparos

Os LLMs são muito 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 comando de poucos disparos (few-shot).

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

package gemini.workshop;

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

import java.util.List;

public class TextClassification {

    enum Sentiment { POSITIVE, NEUTRAL, NEGATIVE }

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


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

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

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

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

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

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

Um tipo enumerado Sentiment lista os diferentes valores de um sentimento: negativo, neutro ou positivo.

No método main(), você cria o modelo de chat do Gemini como de costume, mas com um número máximo de tokens de saída pequeno, já que só quer uma resposta curta: o texto é POSITIVE, NEGATIVE ou NEUTRAL. Para restringir o modelo a retornar apenas esses valores, você pode aproveitar o suporte à saída estruturada que descobriu na seção de extração de dados. É por isso que o método responseSchema() é usado. Desta vez, você não vai usar o método conveniente de SchemaHelper para inferir a definição do esquema, mas vai usar o builder Schema para entender como a definição do esquema é exibida.

Depois que o modelo estiver configurado, crie uma interface SentimentAnalysis que o AiServices do LangChain4j vai implementar para você usando o LLM. Essa interface contém um método: analyze(). Ele pega o texto para análise na entrada e retorna um valor de tipo enumerado Sentiment. Você está manipulando apenas um objeto com tipo forte que representa a classe de sentimento reconhecida.

Em seguida, para dar os "exemplos de poucas fotos" e estimular o modelo a fazer a classificação, crie uma memória de chat para transmitir pares de mensagens do usuário e respostas de IA que representem o texto e o sentimento associado a ele.

Vamos vincular tudo ao método AiServices.builder() transmitindo nossa interface SentimentAnalysis, o modelo a ser usado e a memória de chat com os exemplos de poucas fotos. Por fim, chame o método analyze() com o texto a ser analisado.

Execute o exemplo:

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

Você vai ver uma única palavra:

POSITIVE

Parece que amar morangos é um sentimento positivo.

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

Os LLMs são treinados com uma grande quantidade de texto. No entanto, o conhecimento dele abrange apenas as informações que ele teve acesso durante o treinamento. Se novas informações forem lançadas após a data limite do treinamento do modelo, esses detalhes não estarão disponíveis para o modelo. Assim, o modelo não vai conseguir responder a perguntas sobre informações que ele não conhece.

É por isso que abordagens como a geração aumentada de recuperação (RAG), que será abordada nesta seção, ajudam a fornecer as informações extras que um LLM pode precisar para atender às solicitações dos usuários, responder com informações mais atuais ou sobre informações particulares que não são acessíveis no momento do treinamento.

Vamos voltar para as conversas. Dessa vez, você poderá fazer perguntas sobre seus documentos. Você vai criar um chatbot capaz de recuperar informações relevantes de um banco de dados com seus documentos divididos em partes menores ("fragmentos"). 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 partes menores, e os embeddings vetoriais (uma representação vetorial altamente multidimensional dos blocos) são calculados e armazenados em um banco de dados vetorial capaz de fazer pesquisas semânticas. Essa fase de transferência normalmente é feita uma vez, quando novos documentos precisam ser adicionados ao corpus de documentos.

cd07d33d20ffa1c8.png

  1. Fase de consulta: agora os usuários podem fazer perguntas sobre os documentos. A pergunta também será transformada em um vetor e comparada com todos os outros vetores no banco de dados. Os vetores mais semelhantes geralmente são relacionados semanticamente e 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 é solicitado a fundamentar a resposta analisando esses blocos.

a1d2e2deb83c6d27.png

Preparar os documentos

Neste novo exemplo, você vai fazer perguntas sobre um modelo fictício de um fabricante de carros também fictício: o Cymbal Starlight. A ideia é que um documento sobre um carro fictício não faça parte do conhecimento do modelo. Se o Gemini conseguir responder corretamente às perguntas sobre esse carro, significa que a abordagem RAG funciona: ele consegue pesquisar no documento.

Implementar o chatbot

Vamos explorar como criar a abordagem de duas fases: primeiro com a ingestão de documentos e depois o tempo de consulta (também chamado de "fase de recuperação") quando 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 transferência e outro que oferece a interface do chatbot aos usuários.

Além disso, neste exemplo, vamos usar 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 seriam mantidos em um banco de dados independente.

Ingestão de documentos

A primeira etapa da fase de transferência de documentos é localizar o arquivo PDF sobre nosso carro fictício e preparar um PdfParser para lê-lo:

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

Em vez de criar o modelo de linguagem de chat usual primeiro, você cria uma instância de um modelo de incorporação. Esse é um modelo específico que tem a função de criar representações vetoriais de partes de texto (palavras, frases ou até parágrafos). Ele retorna vetores de números de ponto flutuante, em vez de 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("text-embedding-005")
    .maxRetries(3)
    .build();

Em seguida, você vai precisar de algumas classes para colaborar e:

  • Carregue e divida o documento PDF em blocos.
  • Crie embeddings de vetor 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 de InMemoryEmbeddingStore, um banco de dados de vetores na memória, é criada para armazenar os embeddings de vetor.

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

O ingestor de armazenamento 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 cuidar da transferência.

Agora que a primeira fase acabou, o documento foi transformado em blocos de texto com as embeddings de vetor associadas 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-002")
        .maxOutputTokens(1000)
        .build();

Você também precisa de uma classe retriever para vincular o banco de dados de vetores (na variável embeddingStore) ao modelo de inserção. A tarefa dele é consultar o banco de dados de vetores calculando um embedding vetorial para a consulta do usuário, a fim de encontrar vetores semelhantes no banco de dados:

EmbeddingStoreContentRetriever retriever =
    new EmbeddingStoreContentRetriever(embeddingStore, embeddingModel);

Crie uma interface que represente um assistente de especialistas em carros, que é uma interface que a classe AiServices vai implementar para você interagir com o modelo:

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

A interface CarExpert retorna uma resposta de string encapsulada na classe Result do LangChain4j. Por que usar esse wrapper? Porque, além de fornecer a resposta, ele também permite examinar os blocos do banco de dados que foram retornados pelo extrator de conteúdo. Assim, você pode mostrar as fontes dos documentos usados para fundamentar a resposta final ao usuário.

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

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

Esse serviço vincula:

  • O modelo de linguagem de chat que você configurou anteriormente.
  • Uma memória de chat para acompanhar a conversa.
  • O retriever compara uma consulta de embedding vetorial com os vetores no banco de dados.
.retrievalAugmentor(DefaultRetrievalAugmentor.builder()
    .contentInjector(DefaultContentInjector.builder()
        .promptTemplate(PromptTemplate.from("""
            You are an expert in car automotive, and you answer concisely.

            Here is the question: {{userMessage}}

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

Agora você está pronto para fazer suas perguntas.

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

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ê vai encontrar as respostas para suas perguntas:

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

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

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

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

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

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

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

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

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

11. Chamadas de função

Há situações em que você quer que um LLM tenha acesso a sistemas externos, como uma API da Web remota que extrai informações ou realiza uma ação, ou serviços que executam algum tipo de cálculo. Exemplo:

APIs da Web remotas:

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

Ferramentas de computação:

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

A chamada de função (às vezes chamada de ferramentas ou uso de ferramentas) é a capacidade do modelo de solicitar que uma ou mais chamadas de função sejam feitas em nome dele, para que ele possa responder adequadamente ao comando do usuário com dados mais recentes.

Com base em um comando específico de um usuário e no conhecimento de funções 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 em nome dele e responder com uma resposta. O LLM interpreta a resposta com uma resposta textual.

Quatro etapas de chamada de função

Vamos conferir um exemplo de chamada de função: como receber 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 que não tem informações sobre a previsão do tempo atual. Se você quiser que o LLM tenha acesso em tempo real aos dados meteorológicos, defina algumas funções que ele pode solicitar para uso.

Confira o diagrama a seguir:

31e0c2aba5e6f21c.png

1️⃣ Primeiro, um usuário pergunta sobre a previsão do tempo em Paris. O app de chatbot (usando o LangChain4j) sabe que há uma ou mais funções à disposição para ajudar o LLM a atender à consulta. O chatbot envia a solicitação 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 previsões do tempo, em vez de responder por 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 local.

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 é {"forecast": "sunny"}.

73a5f2ed19f47d8.png

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

20832cb1ee6fbfeb.png

4️⃣ O LLM analisa a resposta JSON, interpreta essas informações e, por fim, responde com o texto de 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-002")
    .maxOutputTokens(100)
    .build();

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

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

O nome da função e o nome e o tipo do parâmetro são definidos, mas a função e os parâmetros têm descrições. As descrições são muito importantes e ajudam o LLM a entender o que uma função pode fazer e, assim, julgar se ela 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, transmitimos a ferramenta que queremos que o modelo use, e o modelo responde com uma solicitação de execução:

// 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. Nesse ponto, sabemos qual função o LLM quer que chamemos. 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());

A resposta é:

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.

Na saída acima, você pode conferir a solicitação de execução da ferramenta e 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 normais de pergunta/resposta de texto e solicitação/resposta de função são intercaladas e, entre elas, você forneceu a resposta de função solicitada diretamente, sem chamar uma função real.

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

Chamada de função única

Vamos analisar FunctionCallingAssistant.java, peça por peça.

Primeiro, você cria um registro que vai 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 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);
        }
    }
}

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 (um único aqui) também são anotados, mas com essa breve anotação @P, que também fornece uma descrição do parâmetro. Você pode adicionar quantas funções quiser para disponibilizá-las ao modelo em cenários mais complexos.

Nesta classe, você retorna algumas respostas prontas, mas se você quiser chamar um serviço de previsão do tempo externo real, isso será feito no corpo desse método.

Como vimos quando você criou uma 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 corresponde ao contrato que você quer usar para interagir com o modelo. Aqui, é 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 envolvem UserMessage do LangChain4j (para uma mensagem do usuário) ou AiMessage (para uma resposta do modelo) ou até mesmo 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 usar apenas strings na entrada e na saída.

Vamos terminar 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-pro-002")
        .build();

    WeatherForecastService weatherForecastService = new WeatherForecastService();

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

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

Como de costume, você configura o modelo de chat do Gemini. Em seguida, você instancia o serviço de previsão do tempo que contém a "função" que o modelo vai solicitar que você chame.

Agora, use a classe AiServices novamente para vincular o modelo de chat, a memória de chat e a ferramenta (ou seja, o serviço de previsão do tempo com a função). AiServices retorna um objeto que implementa a interface WeatherAssistant definida. Só falta chamar o método chat() desse assistente. Ao invocar, você só vai ver as respostas de texto, mas as solicitações e respostas de chamada de função não vão aparecer para o desenvolvedor e serão processadas de forma automática e transparente. Se o Gemini achar que uma função precisa ser chamada, ele vai responder com a solicitação de chamada de função, e o LangChain4j vai cuidar de chamar a função local em seu nome.

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.

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

Várias chamadas de função

Você também pode ter várias funções e permitir que o LangChain4j processe várias chamadas de função. Confira MultiFunctionCallingAssistant.java para conferir 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;
}

Você pode combinar todas essas funções e uma classe MultiTools e fazer perguntas como "Qual é o valor de 10% do preço das ações da 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-002")
        .maxOutputTokens(100)
        .build();

    MultiTools multiTools = new MultiTools();

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

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

Execute da seguinte maneira:

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

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

Em direção aos agentes

A chamada de função é um ótimo mecanismo de extensão para modelos de linguagem grandes, como o Gemini. Isso nos permite criar sistemas mais complexos, muitas vezes 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 envio de e-mails, criação de tíquetes etc.).

Ao criar agentes tão poderosos, faça isso de forma responsável. Considere a intervenção humana antes de realizar ações automáticas. É importante considerar a segurança ao projetar agentes com tecnologia de LLM que interagem com o mundo externo.

13. Como executar o Gemma com o Ollama e o TestContainers

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

O Gemma é uma família de modelos abertos leves e de última geração criados com a mesma pesquisa e tecnologia usadas na criação dos 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 estão disponíveis sem custo financeiro, e os tamanhos pequenos permitem que você execute o modelo por conta própria, mesmo no laptop ou no Cloud Shell.

Como executar o Gemma?

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

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

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

Se você estiver usando um ambiente Linux, será necessário ativar o Ollama após a instalação.

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

Depois de instalar localmente, é possível executar 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, você pode 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 no Ollama no TestContainers

Em vez de instalar e executar o Ollama localmente, você pode usar o Ollama em um contêiner, processado pelo TestContainers.

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

Confira o panorama completo:

2382c05a48708dfd.png

Implementação

Vamos analisar GemmaWithOllamaContainer.java, peça por peça.

Primeiro, você precisa criar um contêiner derivado do Ollama que extraia o modelo Gemma. Essa imagem já existe de uma execução anterior ou será criada. Se a imagem já existir, você vai informar ao TestContainers que quer substituir a imagem padrão do Ollama pela variante com o 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, crie e inicie um contêiner de teste do Ollama e, depois, um modelo de chat do Ollama, apontando para o endereço e a porta do contêiner com o modelo que você quer usar. Por fim, invoque model.generate(yourPrompt) normalmente:

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 da seguinte maneira:

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

A primeira execução vai levar um tempo para criar e executar o contêiner, mas, quando terminar, o Gemma vai responder:

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 app de chat de IA generativa em Java usando o LangChain4j e a API Gemini. Você descobriu ao longo do caminho que os modelos de linguagem grandes multimodais são muito 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?

Agora é sua vez de melhorar seus aplicativos com integrações de LLM.

Leia mais

Documentos de referência