Gemini на Java с Vertex AI и LangChain4j

1. Введение

Данный практический семинар посвящен модели Gemini Large Language Model (LLM), размещенной на платформе Vertex AI в Google Cloud. Vertex AI — это платформа, объединяющая все продукты, сервисы и модели машинного обучения в Google Cloud.

Вы будете использовать Java для взаимодействия с API Gemini с помощью фреймворка LangChain4j . Вы изучите конкретные примеры, чтобы использовать возможности LLM для ответов на вопросы, генерации идей, извлечения сущностей и структурированного контента, расширенной генерации результатов поиска и вызова функций.

Что такое генеративный искусственный интеллект?

Генеративный ИИ — это использование искусственного интеллекта для создания нового контента, такого как текст, изображения, музыка, аудио и видео.

Генеративный ИИ основан на больших языковых моделях (LLM), которые могут выполнять многозадачные операции и осваивать готовые решения, такие как суммаризация, ответы на вопросы, классификация и многое другое. При минимальном обучении базовые модели могут быть адаптированы для целевых задач с очень небольшим объемом примеров данных.

Как работает генеративный искусственный интеллект?

Генеративный ИИ работает с помощью модели машинного обучения (ML), которая изучает закономерности и взаимосвязи в наборе данных, созданных людьми. Затем он использует изученные закономерности для генерации нового контента.

Наиболее распространенный способ обучения модели генеративного ИИ — это использование обучения с учителем. Модели предоставляется набор созданного человеком контента и соответствующие метки. Затем она учится генерировать контент, похожий на созданный человеком контент.

Какие существуют распространенные области применения генеративного искусственного интеллекта?

Генеративный ИИ может использоваться для:

  • Улучшите взаимодействие с клиентами за счет усовершенствованных функций чата и поиска.
  • Изучайте огромные массивы неструктурированных данных с помощью диалоговых интерфейсов и кратких обзоров.
  • Оказание помощи в выполнении повторяющихся задач, таких как ответы на запросы предложений, локализация маркетингового контента на разные языки, проверка договоров с клиентами на соответствие требованиям и многое другое.

Какие решения в области генеративного искусственного интеллекта предлагает Google Cloud?

С помощью Vertex AI вы можете взаимодействовать с базовыми моделями, настраивать их и встраивать в свои приложения, практически не имея знаний в области машинного обучения. Вы можете получить доступ к базовым моделям в Model Garden , настраивать модели через простой пользовательский интерфейс в Vertex AI Studio или использовать модели в блокноте для анализа данных.

Vertex AI Search and Conversation предлагает разработчикам самый быстрый способ создания поисковых систем и чат-ботов на основе генеративного искусственного интеллекта.

Gemini for Google Cloud — это инструмент для совместной работы на основе искусственного интеллекта, доступный в Google Cloud и IDE, который поможет вам быстрее и эффективнее выполнять больше задач. Gemini Code Assist предоставляет автозавершение кода, генерацию кода, пояснения к коду, а также позволяет общаться с ним и задавать технические вопросы.

Что такое Близнецы?

Gemini — это семейство генеративных моделей искусственного интеллекта, разработанных Google DeepMind и предназначенных для многомодальных сценариев использования. Многомодальный подход означает, что модель может обрабатывать и генерировать различные виды контента, такие как текст, код, изображения и аудио.

b9913d011999e7c7.png

Знак Близнецов бывает разных вариаций и размеров:

  • Gemini 2.0 Flash : Наши новейшие функции следующего поколения и улучшенные возможности.
  • Gemini 2.0 Flash-Lite : модель флэш-накопителя Gemini 2.0, оптимизированная для экономичности и низкой задержки.
  • Gemini 2.5 Pro : Наша самая передовая модель логического мышления на сегодняшний день.
  • Gemini 2.5 Flash : Продуманная модель, предлагающая всесторонние возможности. Она разработана для обеспечения баланса между ценой и производительностью.

Основные характеристики:

  • Мультимодальность : способность Gemini понимать и обрабатывать различные форматы информации является значительным шагом вперед по сравнению с традиционными языковыми моделями, работающими только с текстом.
  • Производительность : Gemini 2.5 Pro превосходит современные аналоги по многим показателям и стал первой моделью, которая превзошла экспертов в сложном тесте MMLU (Massive Multitask Language Understanding).
  • Гибкость : Различные размеры Gemini позволяют адаптировать его для различных сценариев использования, от крупномасштабных исследований до развертывания на мобильных устройствах.

Как можно взаимодействовать с Gemini в Vertex AI из Java?

У вас есть два варианта:

  1. Официальный Java API Vertex AI для библиотеки Gemini .
  2. Фреймворк LangChain4j .

В этом практическом занятии вы будете использовать фреймворк LangChain4j .

Что представляет собой фреймворк LangChain4j?

Фреймворк LangChain4j — это библиотека с открытым исходным кодом для интеграции LLM в ваши Java-приложения, которая объединяет различные компоненты, такие как сам LLM, а также другие инструменты, например, векторные базы данных (для семантического поиска), загрузчики и разделители документов (для анализа документов и обучения на их основе), парсеры выходных данных и многое другое.

Проект был вдохновлён проектом LangChain на Python, но с целью помочь Java-разработчикам.

bb908ea1e6c96ac2.png

Что вы узнаете

  • Как настроить Java-проект для использования Gemini и LangChain4j
  • Как программно отправить первое приглашение в Gemini
  • Как транслировать ответы от Близнецов
  • Как создать диалог между пользователем и Gemini
  • Как использовать Gemini в мультимодальном контексте, отправляя одновременно текст и изображения.
  • Как извлечь полезную структурированную информацию из неструктурированного контента
  • Как управлять шаблонами подсказок
  • Как проводить классификацию текста, например, анализ тональности.
  • Как общаться со своими документами в чате (дополненная генерация данных)
  • Как расширить функциональность чат-ботов с помощью вызова функций
  • Как использовать Gemma локально с Ollama и TestContainers

Что вам понадобится

  • Знание языка программирования Java.
  • Проект Google Cloud
  • Браузер, например Chrome или Firefox.

2. Настройка и требования

Настройка среды для самостоятельного обучения

  1. Войдите в консоль Google Cloud и создайте новый проект или используйте существующий. Если у вас еще нет учетной записи Gmail или Google Workspace, вам необходимо ее создать .

fbef9caa1602edd0.png

a99b7ace416376c4.png

5e3ff691252acf41.png

  • Название проекта — это отображаемое имя участников данного проекта. Это строка символов, не используемая API Google. Вы всегда можете его изменить.
  • Идентификатор проекта уникален для всех проектов Google Cloud и является неизменяемым (его нельзя изменить после установки). Консоль Cloud автоматически генерирует уникальную строку; обычно вам неважно, какая она. В большинстве практических заданий вам потребуется указать идентификатор вашего проекта (обычно обозначается как PROJECT_ID ). Если сгенерированный идентификатор вас не устраивает, вы можете сгенерировать другой случайный идентификатор. В качестве альтернативы вы можете попробовать свой собственный и посмотреть, доступен ли он. После этого шага его нельзя изменить, и он сохраняется на протяжении всего проекта.
  • К вашему сведению, существует третье значение — номер проекта , которое используется некоторыми API. Подробнее обо всех трех значениях можно узнать в документации .
  1. Далее вам потребуется включить оплату в консоли Cloud для использования ресурсов/API Cloud. Выполнение этого практического задания не потребует больших затрат, если вообще потребует. Чтобы отключить ресурсы и избежать дополнительных расходов после завершения этого урока, вы можете удалить созданные ресурсы или удалить проект. Новые пользователи Google Cloud имеют право на бесплатную пробную версию стоимостью 300 долларов США .

Запустить Cloud Shell

Хотя Google Cloud можно управлять удаленно с ноутбука, в этом практическом занятии вы будете использовать Cloud Shell — среду командной строки, работающую в облаке.

Активировать Cloud Shell

  1. В консоли Cloud нажмите «Активировать Cloud Shell» . 853e55310c205094.png .

3c1dabeca90e44e5.png

Если вы запускаете Cloud Shell впервые, вам будет показан промежуточный экран с описанием его возможностей. Если вам был показан промежуточный экран, нажмите «Продолжить» .

9c92662c6a846a5c.png

Подготовка и подключение к Cloud Shell займут всего несколько минут.

9f0e51b578fecce5.png

Эта виртуальная машина оснащена всеми необходимыми инструментами разработки. Она предоставляет постоянный домашний каталог объемом 5 ГБ и работает в облаке Google, что значительно повышает производительность сети и аутентификацию. Большая часть, если не вся, ваша работа в этом практическом задании может быть выполнена с помощью браузера.

После подключения к Cloud Shell вы увидите, что прошли аутентификацию и что проект настроен на ваш идентификатор проекта.

  1. Выполните следующую команду в Cloud Shell, чтобы подтвердить свою аутентификацию:
gcloud auth list

вывод команды

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

To set the active account, run:
    $ gcloud config set account `ACCOUNT`
  1. Выполните следующую команду в Cloud Shell, чтобы убедиться, что команда gcloud знает о вашем проекте:
gcloud config list project

вывод команды

[core]
project = <PROJECT_ID>

Если это не так, вы можете установить это с помощью следующей команды:

gcloud config set project <PROJECT_ID>

вывод команды

Updated property [core/project].

3. Подготовка среды разработки

В этом практическом занятии вы будете использовать терминал Cloud Shell и редактор Cloud Shell для разработки своих программ на Java.

Включите API Vertex AI

В консоли Google Cloud убедитесь, что название вашего проекта отображается в верхней части окна . Если это не так, нажмите «Выбрать проект» , чтобы открыть окно выбора проекта , и выберите нужный проект.

Вы можете включить API Vertex AI либо в разделе Vertex AI в консоли Google Cloud, либо через терминал Cloud Shell.

Чтобы включить эту функцию через консоль Google Cloud, сначала перейдите в раздел Vertex AI в меню консоли Google Cloud:

451976f1c8652341.png

На панели управления Vertex AI нажмите «Включить все рекомендуемые API» .

Это позволит использовать несколько API, но наиболее важным для практического занятия является aiplatform.googleapis.com .

В качестве альтернативы, вы также можете включить этот API из терминала Cloud Shell с помощью следующей команды:

gcloud services enable aiplatform.googleapis.com

Клонируйте репозиторий Github.

В терминале Cloud Shell клонируйте репозиторий для этого практического задания:

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

Чтобы убедиться в готовности проекта к запуску, вы можете попробовать запустить программу "Hello World".

Убедитесь, что вы находитесь в папке верхнего уровня:

cd gemini-workshop-for-java-developers/ 

Создайте обертку Gradle:

gradle wrapper

Запуск с помощью gradlew :

./gradlew run

Вы должны увидеть следующий результат:

..
> Task :app:run
Hello World!

Откройте и настройте Cloud Editor.

Откройте код с помощью редактора кода Cloud Code Editor из Cloud Shell:

42908e11b28f4383.png

В облачном редакторе кода откройте папку с исходным кодом codelab, выбрав File -> Open Folder и указав путь к папке с исходным кодом codelab (например, /home/username/gemini-workshop-for-java-developers/ ).

Настройка переменных среды

Откройте новый терминал в Cloud Code Editor, выбрав Terminal -> New Terminal . Настройте две переменные среды, необходимые для запуска примеров кода:

  • PROJECT_ID — Идентификатор вашего проекта в Google Cloud
  • МЕСТОПОЛОЖЕНИЕ — Регион, где развернута модель Gemini.

Экспортируйте переменные следующим образом:

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

4. Первый звонок к модели Близнецов

Теперь, когда проект должным образом настроен, пришло время обратиться к API Gemini.

Взгляните на файл QA.java в каталоге 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-2.0-flash")
            .build();

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

В первом примере вам необходимо импортировать класс VertexAiGeminiChatModel , который реализует интерфейс ChatModel .

В main методе вы настраиваете языковую модель чата, используя построитель для VertexAiGeminiChatModel , и указываете следующее:

  • Проект
  • Расположение
  • Название модели ( gemini-2.0-flash ).

Теперь, когда языковая модель готова, вы можете вызвать метод generate() и передать свой запрос, вопрос или инструкции для отправки в языковую модель. Здесь вы задаете простой вопрос о том, что делает небо голубым.

Вы можете изменить это задание, чтобы попробовать другие вопросы или задачи.

Запустите пример в корневой папке исходного кода:

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

Вы должны увидеть результат, похожий на этот:

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.

Поздравляем, вы сделали свой первый звонок в компанию Gemini!

Ответ потокового вещания

Вы заметили, что ответ был получен сразу, через несколько секунд? Также возможно получать ответ постепенно, благодаря варианту потокового ответа. При потоковом ответе модель возвращает ответ по частям, по мере его поступления.

В этом практическом занятии мы будем рассматривать ответ без потоковой передачи, но давайте посмотрим на ответ с потоковой передачей, чтобы понять, как это можно сделать.

В StreamQA.java расположенном в директории app/src/main/java/gemini/workshop вы можете увидеть потоковую передачу ответа в действии:

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-2.0-flash")
            .maxOutputTokens(4000)
            .build();

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

На этот раз мы импортируем вариант класса потоковой обработки VertexAiGeminiStreamingChatModel , который реализует интерфейс StreamingChatLanguageModel . Вам также потребуется статически импортировать LambdaStreamingResponseHandler.onNext — удобный метод, предоставляющий обработчики StreamingResponseHandler для создания обработчика потоковой обработки с использованием лямбда-выражений Java.

На этот раз сигнатура метода generate() немного отличается. Вместо строки возвращается тип void. В дополнение к приглашению необходимо передать обработчик потокового ответа. Здесь, благодаря упомянутому выше статическому импорту, мы можем определить лямбда-выражение, которое передается методу onNext() . Лямбда-выражение вызывается каждый раз, когда становится доступна новая часть ответа, в то время как последний вызывается только при возникновении ошибки.

Бегать:

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

Вы получите ответ, похожий на тот, что был на предыдущем занятии, но на этот раз вы заметите, что ответ появляется в вашей командной оболочке постепенно, а не после отображения полного ответа.

Дополнительная конфигурация

Для настройки мы определили только проект, местоположение и название модели, но вы можете указать и другие параметры для модели:

  • temperature(Float temp) — определяет, насколько креативным вы хотите получить ответ (0 означает низкую креативность и, как правило, более фактологический ответ, а 2 — более креативный).
  • topP(Float topP) — выбирает возможные слова, сумма вероятностей которых равна этому числу с плавающей запятой (от 0 до 1).
  • topK(Integer topK) — случайный выбор слова из максимального числа вероятных слов для завершения текста (от 1 до 40).
  • maxOutputTokens(Integer max) — указывает максимальную длину ответа, предоставляемого моделью (как правило, 4 токена соответствуют примерно 3 словам).
  • maxRetries(Integer retries) — если вы превышаете квоту запросов за раз или платформа сталкивается с какой-либо технической проблемой, вы можете настроить модель на повторную попытку вызова 3 раза.

До сих пор вы задавали Близнецам один вопрос, но вы также можете вести многоходовую беседу. Именно это вы изучите в следующем разделе.

5. Пообщайтесь с Близнецами

На предыдущем шаге вы задали один вопрос. Теперь пришло время для настоящего диалога между пользователем и LLM. Каждый вопрос и ответ могут основываться на предыдущих, формируя полноценную дискуссию.

Посмотрите файл Conversation.java в папке 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-2.0-flash")
            .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));
        });
    }
}

В этом классе появилось несколько новых интересных импортных предметов:

  • MessageWindowChatMemory — класс, который поможет обрабатывать многоходовый характер диалога и хранить в локальной памяти предыдущие вопросы и ответы.
  • AiServices — это класс абстракции более высокого уровня, который свяжет модель чата и память чата.

В основном методе вы будете настраивать модель, память чата и службу ИИ. Модель настраивается как обычно, с указанием проекта, местоположения и названия модели.

Для хранения данных чата мы используем конструктор класса MessageWindowChatMemory , который создает память, содержащую последние 20 сообщений. Это скользящее окно над беседой, контекст которой хранится локально в нашем Java-классе-клиенте.

Затем вы создаете AI service , которая связывает модель чата с памятью чата.

Обратите внимание, как сервис ИИ использует пользовательский интерфейс ConversationService , который мы определили и который реализует LangChain4j. Этот интерфейс принимает String запрос и возвращает String ответ.

Теперь пришло время поговорить с Близнецами. Сначала отправляется простое приветствие, затем первый вопрос об Эйфелевой башне, чтобы узнать, в какой стране она находится. Обратите внимание, что последнее предложение связано с ответом на первый вопрос, поскольку вы интересуетесь, сколько жителей проживает в стране, где расположена Эйфелева башня, не упоминая при этом страну, указанную в предыдущем ответе. Это показывает, что предыдущие вопросы и ответы отправляются с каждым запросом.

Запустите пример:

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

Вы должны увидеть три ответа, похожих на эти:

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.

С Близнецами можно задавать вопросы в один ход или вести многоходовые беседы, но до сих пор ввод данных осуществлялся только в текстовом формате. А как насчет изображений? Давайте рассмотрим изображения на следующем шаге.

6. Мультимодальность с Gemini

Gemini — это мультимодальная модель. Она принимает на вход не только текст, но и изображения, а также видео. В этом разделе вы увидите пример использования смешанных данных — текста и изображений.

Как вы думаете, узнают ли Близнецы этого кота?

af00516493ec9ade.png

Фотография кошки в снегу взята из Википедии.

Посмотрите файл Multimodal.java в каталоге 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-2.0-flash")
            .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());
    }
}

В импортах обратите внимание, что мы различаем разные типы сообщений и содержимого. Объект UserMessage может содержать как объект TextContent , так и объект ImageContent . Это пример мультимодальности: смешение текста и изображений. Мы отправляем не просто строку запроса, а более структурированный объект, представляющий собой сообщение пользователя, состоящее из фрагмента изображения и фрагмента текста. Модель отправляет обратно Response , содержащий объект AiMessage .

Затем вы получаете AiMessage из ответа с помощью content() , а затем текст сообщения благодаря методу text() .

Запустите пример:

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

Название изображения, безусловно, намекало на его содержимое, но результаты работы Gemini выглядят примерно так:

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.

Сочетание изображений и текстовых подсказок открывает интересные возможности применения. Вы можете создавать приложения, которые могут:

  • Распознавайте текст на картинках.
  • Проверьте, безопасно ли отображать изображение.
  • Создавайте подписи к изображениям.
  • Поиск по базе данных изображений с описаниями в виде простого текста.

Помимо извлечения информации из изображений, вы также можете извлекать информацию из неструктурированного текста. Об этом вы узнаете в следующем разделе.

7. Извлечение структурированной информации из неструктурированного текста.

Во многих случаях важная информация представлена ​​в отчетах, электронных письмах или других длинных текстах в неструктурированном виде. В идеале хотелось бы иметь возможность извлекать ключевые детали из неструктурированного текста в виде структурированных объектов. Давайте посмотрим, как это можно сделать.

Допустим, вам нужно извлечь имя и возраст человека, имея биографию, резюме или описание этого человека. Вы можете указать LLM извлекать JSON из неструктурированного текста с помощью грамотно настроенного запроса (это обычно называется «инженерным управлением запросом» ).

Однако в приведенном ниже примере вместо создания запроса, описывающего вывод в формате JSON, мы воспользуемся мощной функцией Gemini, называемой структурированным выводом , или иногда генерацией с ограничениями, которая заставляет модель выводить только допустимое содержимое JSON, соответствующее указанной схеме JSON .

Посмотрите файл ExtractData.java в 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-2.0-flash")
            .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
    }
}

Давайте рассмотрим различные этапы, описанные в этом файле:

  • Запись Person предназначена для представления подробных сведений о человеке (имя и возраст).
  • Интерфейс PersonExtractor определен методом, который, получив на вход неструктурированную текстовую строку, возвращает экземпляр Person .
  • Функция extractPerson() аннотирована аннотацией @SystemMessage , которая связывает с ней подсказку. Именно эту подсказку модель будет использовать для извлечения информации и возврата данных в виде JSON-документа, который будет проанализирован и десериализован в экземпляр Person .

Теперь давайте посмотрим на содержимое метода main() :

  • Модель чата настроена и создана. Мы используем два новых метода класса построителя модели: responseMimeType() и responseSchema() . Первый указывает Gemini генерировать корректный JSON в выходных данных. Второй метод определяет схему объекта JSON, который должен быть возвращен. Кроме того, последний делегирует вызов вспомогательному методу, который может преобразовать Java-класс или запись в правильную схему JSON.
  • Объект PersonExtractor создается благодаря классу AiServices из библиотеки LangChain4j.
  • Затем вы можете просто вызвать метод Person person = extractor.extractPerson(...) чтобы извлечь данные о человеке из неструктурированного текста и получить в ответ экземпляр Person с именем и возрастом.

Запустите пример:

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

Вы должны увидеть следующий результат:

Anna
23

Да, это Анна, и им по 23 года!

При использовании подхода AiServices вы работаете со строго типизированными объектами. Вы не взаимодействуете напрямую с LLM. Вместо этого вы работаете с конкретными классами, такими как запись Person , представляющая извлеченную личную информацию, и у вас есть объект PersonExtractor с методом extractPerson() , который возвращает экземпляр Person . Понятие LLM абстрагировано, и как Java-разработчик, вы просто манипулируете обычными классами и объектами, используя интерфейс PersonExtractor .

8. Структурируйте вопросы с помощью шаблонов вопросов.

При взаимодействии с LLM с использованием стандартного набора инструкций или вопросов, часть запроса остается неизменной, в то время как другие части содержат данные. Например, если вы хотите создать рецепты, вы можете использовать запрос типа «Вы талантливый повар, пожалуйста, создайте рецепт со следующими ингредиентами: ...», а затем добавить ингредиенты в конец этого текста. Именно для этого и существуют шаблоны запросов — подобно интерполированным строкам в языках программирования. Шаблон запроса содержит заполнители, которые вы можете заменить соответствующими данными для конкретного вызова LLM.

Рассмотрим подробнее файл TemplatePrompt.java в директории 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-2.0-flash")
            .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());
    }
}

Как обычно, вы настраиваете модель VertexAiGeminiChatModel , задавая высокий уровень креативности, высокую температуру, а также высокие значения topP и topK. Затем вы создаете PromptTemplate с помощью статического метода from() , передавая строку нашего запроса, и используете переменные-заполнители в двойных фигурных скобках: и .

Итоговое приглашение создается путем вызова функции apply() , которая принимает карту пар ключ/значение, представляющих имя заполнителя и строковое значение, которым его нужно заменить.

Наконец, вы вызываете метод generate() модели Gemini, создавая сообщение пользователя из этой подсказки с помощью инструкции prompt.toUserMessage() .

Запустите пример:

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

В результате вы должны увидеть сгенерированный файл, похожий на этот:

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

Вы можете изменить значения параметров dish и ingredients на карте, а также подкорректировать температуру, topK и tokP и повторно запустить код. Это позволит вам увидеть влияние изменения этих параметров на LLM.

Шаблоны подсказок — это хороший способ иметь многократно используемые и параметризуемые инструкции для вызовов LLM. Вы можете передавать данные и настраивать подсказки для различных значений, предоставленных вашими пользователями.

9. Классификация текста с использованием подсказок на основе небольшого количества примеров.

Магистранты довольно хорошо умеют классифицировать тексты по различным категориям. Вы можете помочь им в этом, предоставив несколько примеров текстов и соответствующих им категорий. Такой подход часто называют методом «нескольких подсказок» .

Давайте откроем TextClassification.java в директории app/src/main/java/gemini/workshop , чтобы выполнить определенный тип классификации текста: анализ тональности.

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-2.0-flash")
            .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!"));
    }
}

Перечисление Sentiment содержит список различных значений для выражения тональности: отрицательное, нейтральное или положительное.

В методе main() вы создаете модель чата Gemini, как обычно, но с небольшим максимальным количеством выходных токенов, поскольку вам нужен только короткий ответ: текст POSITIVE , NEGATIVE или NEUTRAL . А чтобы ограничить модель возвратом только этих значений, вы можете воспользоваться поддержкой структурированного вывода, которую вы обнаружили в разделе извлечения данных. Именно поэтому используется метод responseSchema() . На этот раз вы не используете удобный метод из SchemaHelper для определения схемы, а воспользуетесь построителем Schema , чтобы понять, как выглядит определение схемы.

После настройки модели вы создаете интерфейс SentimentAnalysis , который AiServices из LangChain4j реализует для вас с помощью LLM. Этот интерфейс содержит один метод: analyze() . Он принимает текст для анализа на входе и возвращает значение перечисления Sentiment . Таким образом, вы работаете только с строго типизированным объектом, представляющим класс распознаваемого настроения.

Затем, чтобы предоставить "несколько примеров" для стимулирования модели к классификации, вы создаете память чата, в которую передаете пары пользовательских сообщений и ответов ИИ, представляющих текст и связанное с ним настроение.

Давайте объединим все воедино с помощью метода AiServices.builder() , передав наш интерфейс SentimentAnalysis , используемую модель и память чата с примерами для анализа. Наконец, вызовем метод analyze() с текстом для анализа.

Запустите пример:

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

Вы должны увидеть одно слово:

POSITIVE

Похоже, любовь к клубнике — это позитивное чувство!

10. Генерация с расширенными возможностями поиска

Модели LLM обучаются на большом объеме текста. Однако их знания охватывают только ту информацию, которую они видели во время обучения. Если после даты окончания обучения модели появляется новая информация, эти данные становятся недоступны модели. Таким образом, модель не сможет отвечать на вопросы, касающиеся информации, которую она не видела.

Именно поэтому такие подходы, как генерация с расширенным поиском (Retrieval Augmented Generation, RAG) , которые будут рассмотрены в этом разделе, помогают предоставить дополнительную информацию, которая может потребоваться модулю LLM для выполнения запросов пользователей, предоставления более актуальной информации или конфиденциальных данных, недоступных во время обучения.

Вернемся к диалогам. На этот раз вы сможете задавать вопросы о своих документах. Вы создадите чат-бота, способного извлекать необходимую информацию из базы данных, содержащей ваши документы, разбитые на более мелкие части («фрагменты»), и эта информация будет использоваться моделью для обоснования ответов, вместо того чтобы полагаться исключительно на знания, полученные в ходе обучения.

В рамках программы RAG выделяют две фазы:

  1. Этап загрузки — документы загружаются в память, разбиваются на более мелкие фрагменты, вычисляются векторные представления (многомерное векторное представление этих фрагментов), которые сохраняются в векторной базе данных, способной выполнять семантический поиск. Этот этап загрузки обычно выполняется один раз, когда необходимо добавить новые документы в корпус документов.

cd07d33d20ffa1c8.png

  1. Этап запроса — Теперь пользователи могут задавать вопросы о документах. Вопрос также будет преобразован в вектор и сравнен со всеми остальными векторами в базе данных. Наиболее похожие векторы обычно семантически связаны и возвращаются базой данных векторов. Затем LLM получает контекст разговора, фрагменты текста, соответствующие векторам, возвращаемым базой данных, и ему предлагается обосновать свой ответ, проанализировав эти фрагменты.

a1d2e2deb83c6d27.png

Подготовьте документы.

В этом новом примере вы будете задавать вопросы о вымышленной модели автомобиля от также вымышленного производителя: автомобиль Cymbal Starlight! Идея заключается в том, что документ о вымышленном автомобиле не должен быть частью знаний о модели. Поэтому, если Gemini сможет правильно ответить на вопросы об этом автомобиле, это будет означать, что подход RAG работает: он способен выполнить поиск по вашему документу.

Внедрить чат-бота

Давайте рассмотрим, как построить двухэтапный подход: сначала процесс загрузки документа, а затем этап запроса (также называемый «этапом поиска»), когда пользователи задают вопросы о документе.

В этом примере обе фазы реализованы в одном классе. Обычно у вас есть одно приложение, которое занимается сбором данных, и другое приложение, которое предоставляет пользователям интерфейс чат-бота.

Также в этом примере мы будем использовать векторную базу данных, хранящуюся в оперативной памяти. В реальных производственных условиях этапы загрузки и обработки запросов будут разделены на два отдельных приложения, а векторы будут храниться в автономной базе данных.

Загрузка документов

Первым шагом на этапе загрузки документа является поиск PDF-файла о нашем вымышленном автомобиле и подготовка PdfParser для его чтения:

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

Вместо того чтобы сначала создавать обычную языковую модель чата, вы создаете экземпляр модели встраивания . Это особая модель, задача которой — создавать векторные представления фрагментов текста (слов, предложений или даже абзацев). Она возвращает векторы чисел с плавающей запятой, а не текстовые ответы.

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

Next, you will need a few classes to collaborate together to:

  • Load and split the PDF document in chunks.
  • Create vector embeddings for all of these chunks.
InMemoryEmbeddingStore<TextSegment> embeddingStore = 
    new InMemoryEmbeddingStore<>();

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

An instance of InMemoryEmbeddingStore , an in-memory vector database, is created to store the vector embeddings.

The document is split in chunks thanks to the DocumentSplitters class. It is going to split the text of the PDF file into snippets of 500 characters, with an overlap of 100 characters (with the following chunk, to avoid cutting words or sentences, in bits and pieces).

The store ingestor links the document splitter, the embedding model to calculate the vectors, and the in-memory vector database. Then, the ingest() method will take care of doing the ingestion.

Now, the first phase is over, the document has been transformed into text chunks with their associated vector embeddings, and stored in the vector database.

Задавая вопросы

It's time to get ready to ask questions! Create a chat model to start the conversation:

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

You also need a retriever class to link the vector database (in the embeddingStore variable) with the embedding model. Its job is to query the vector database by computing a vector embedding for the user's query, to find similar vectors in the database:

EmbeddingStoreContentRetriever retriever =
    new EmbeddingStoreContentRetriever(embeddingStore, embeddingModel);

Create an interface that represents a car expert assistant, that's an interface that the AiServices class will implement for you to interact with the model:

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

The CarExpert interface returns a string response wrapped in LangChain4j's Result class. Why use this wrapper? Because not only it will give you the answer, but it will also let you examine the chunks from the database that have been returned by the content retriever. That way, you can display the sources of the document(s) that are used to ground the final answer to the user.

At this point, you can configure a new AI service:

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

This service binds together:

  • The chat language model that you configured earlier.
  • A chat memory to keep track of the conversation.
  • The retriever compares a vector embedding query to the vectors in the database.
.retrievalAugmentor(DefaultRetrievalAugmentor.builder()
    .contentInjector(DefaultContentInjector.builder()
        .promptTemplate(PromptTemplate.from("""
            You are an expert in car automotive, and you answer concisely.

            Here is the question: {{userMessage}}

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

You're finally ready to ask your questions!

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

The full source code is in RAG.java in app/src/main/java/gemini/workshop directory.

Run the sample:

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

In the output, you should see answers to your questions:

=== 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. Function calling

There are situations where you would like an LLM to have access to external systems, like a remote web API that retrieves information or have an action, or services that perform some kind of computation. For example:

Remote web APIs:

  • Track and update customer orders.
  • Find or create a ticket in an issue tracker.
  • Fetch real time data like stock quotes or IoT sensor measurements.
  • Send an email.

Computation tools:

  • A calculator for more advanced math problems.
  • Code interpretation for running code when LLMs need reasoning logic.
  • Convert natural language requests into SQL queries so that an LLM can query a database.

Function calling (sometimes called tools, or tool use) is the ability for the model to request one or more function calls to be made on its behalf, so it can properly answer a user's prompt with fresher data.

Given a particular prompt from a user, and the knowledge of existing functions that can be relevant to that context, an LLM can reply with a function call request. The application integrating the LLM can then call the function on its behalf, and then reply back to the LLM with a response, and the LLM then interprets back by replying with a textual answer.

Four steps of function calling

Let's have a look at an example of function calling: getting information about the weather forecast.

If you ask Gemini or any other LLM about the weather in Paris, they would reply by saying that it has no information about the current weather forecast. If you want the LLM to have real time access to the weather data, you need to define some functions it can request to be used.

Take a look at the following diagram:

31e0c2aba5e6f21c.png

1️⃣ First, a user asks about the weather in Paris. The chatbot app (using LangChain4j) knows there are one or more functions that are at its disposal to help the LLM fulfill the query. The chatbot both sends the initial prompt, as well as the list of functions that can be called. Here, a function called getWeather() which takes a string parameter for the location.

8863be53a73c4a70.png

As the LLM doesn't know about weather forecasts, instead of replying via text, it sends back a function execution request. The chatbot must call the getWeather() function with "Paris" as location parameter.

2️⃣ The chatbot invokes that function on behalf of the LLM, retrieves the function response. Here, we imagine that the response is {"forecast": "sunny"} .

d1367cc69c07b14d.png

3️⃣ The chatbot app sends the JSON response back to the LLM.

73a5f2ed19f47d8.png

4️⃣ The LLM looks at the JSON response, interprets that information, and eventually replies back with the text that the weather is sunny in Paris.

20832cb1ee6fbfeb.png

Each step as code

First, you'll configure the Gemini model as usual:

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

You define a tool specification that describes the function that can be called:

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

The name of the function is defined, as well as the name and type of the parameter, but notice that both the function and the parameters are given descriptions. Descriptions are very important and help the LLM really understand what a function can do, and thus judge whether this function needs to be called in the context of the conversation.

Let's start step #1, by sending the initial question about the weather in 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);

In step #2, we pass the tool we'd like the model to use, and the model replies with a too execution request:

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

Step #3. At this point, we know what function the LLM would like us to call. In the code, we're not making a real call to an external API, we just return an hypothetical weather forecast directly:

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

And in step #4, the LLM learns about the function execution result, and can then synthesize a textual response:

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

The full source code is in FunctionCalling.java in app/src/main/java/gemini/workshop directory.

Run the sample:

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

You should see an output similar to the following:

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.

You can see in the output above the tool execution request, as well as the answer.

12. LangChain4j handles function calling

In the previous step, you saw how the normal text question/answer and function request/response interactions are interleaved, and in between, you provided the requested function response directly, without calling a real function.

However, LangChain4j also offers a higher-level abstraction that can handle the function calls transparently for you, while handling the conversation as usual.

Single function call

Let's have a look at FunctionCallingAssistant.java , piece by piece.

First, you create a record that will represent the function's response data structure:

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

The response contains information about the location, the forecast, and the temperature.

Then you create a class that contains the actual function you want to make available to the model:

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

Note that this class contains a single function, but it is annotated with the @Tool annotation which corresponds to the description of the function the model can request to call.

The parameters of the function (a single one here) is also annotated, but with this short @P annotation, which also gives a description of the parameter. You could add as many functions as you wanted, to make them available to the model, for more complex scenarios.

In this class, you return some canned responses, but if you wanted to call a real external weather forecast service, this is in the body of that method that you would make the call to that service.

As we saw when you created a ToolSpecification in the previous approach, it's important to document what a function does, and describe what the parameters correspond to. This helps the model understand how and when this function can be used.

Next, LangChain4j lets you provide an interface that corresponds to the contract you want to use to interact with the model. Here, it's a simple interface that takes in a string representing the user message, and returns a string corresponding to the model's response:

interface WeatherAssistant {
    String chat(String userMessage);
}

It is also possible to use more complex signatures that involve LangChain4j's UserMessage (for a user message) or AiMessage (for a model response), or even a TokenStream , if you want to handle more advanced situations, as those more complicated objects also contain extra information such as the number of tokens consumed, etc. But for simplicity sake, we'll just take string in input, and string in output.

Let's finish with the main() method that ties all the pieces together:

public static void main(String[] args) {
    ChatLanguageModel model = VertexAiGeminiChatModel.builder()
        .project(System.getenv("PROJECT_ID"))
        .location(System.getenv("LOCATION"))
        .modelName("gemini-2.0-flash")
        .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?"));
    System.out.println(assistant.chat("Is it warmer in London or in Paris?"));
}

As usual, you configure the Gemini chat model. Then you instantiate your weather forecast service that contains the "function" that the model will request us to call.

Now, you use the AiServices class again to bind the chat model, the chat memory, and the tool (ie. the weather forecast service with its function). AiServices returns an object that implements your WeatherAssistant interface you defined. The only thing left is to call the chat() method of that assistant. When invoking it, you will only see the text responses, but the function call requests and the function call responses will not be visible from the developer, and those requests will be handled automatically and transparently. If Gemini thinks a function should be called, it'll reply with the function call request, and LangChain4j will take care of calling the local function on your behalf.

Run the sample:

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

You should see an output similar to the following:

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

It is warmer in Paris (20 degrees) than in London (15 degrees).

This was an example of a single function.

Multiple function calls

You can also have multiple functions and let LangChain4j handle multiple function calls on your behalf. Take a look at MultiFunctionCallingAssistant.java for a multiple function example.

It has a function to convert currencies:

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

Another function to get the value of a stock:

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

Another function to apply a percentage to a given amount:

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

You can then combine all these functions and a MultiTools class and ask questions like "What is 10% of AAPL stock price converted from USD to EUR?""

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

Run it as follows:

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

And you should see the multiple functions called:

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.

Towards Agents

Function calling is a great extension mechanism for large language models like Gemini. It enables us to build more complex systems often called "agents" or "AI assistants". These agents can interact with the external world via external APIs and with services that can have side effects on the external environment (like sending emails, creating tickets, etc.)

When creating such powerful agents, you should do so responsibly. You should consider a human-in-the-loop before making automatic actions. It's important to keep safety in mind when designing LLM-powered agents that interact with the external world.

13. Running Gemma with Ollama and TestContainers

So far, we've been using Gemini but there's also Gemma , its little sister model.

Gemma is a family of lightweight, state-of-the-art open models built from the same research and technology used to create the Gemini models. The latest Gemma model is Gemma3 available in four sizes: 1B ( text-only ), 4B, 12B and 27B. Their weights are freely available, and their small sizes means you can run it on your own, even on your laptop or in Cloud Shell.

How do you run Gemma?

There are many ways to run Gemma: in the cloud, via Vertex AI with a click of a button, or GKE with some GPUs, but you can also run it locally.

One good option to run Gemma locally is with Ollama , a tool that lets you run small models, like Llama, Mistral, and many others on your local machine. It's similar to Docker but for LLMs.

Install Ollama following the instruction for your Operating System.

If you are using a Linux environment you will need to enable Ollama first after installing it.

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

Once installed locally, you can run commands to pull a model:

ollama pull gemma3:1b

Wait for the model to be pulled. This can take some time.

Run the model:

ollama run gemma3:1b

Now, you can interact with the model:

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

To exit the prompt press Ctrl+D

Running Gemma in Ollama on TestContainers

Instead of having to install and run Ollama locally, you can use Ollama within a container, handled by TestContainers .

TestContainers is not only useful for testing, but you can also use it for executing containers. There's even a specific OllamaContainer you can take advantage of!

Here's the whole picture:

2382c05a48708dfd.png

Выполнение

Let's have a look at GemmaWithOllamaContainer.java , piece by piece.

First, you need to create a derived Ollama container that pulls in the Gemma model. This image either already exists from a previous run or it will be created. If the image already exists, you're just going to tell TestContainers that you want to substitute the default Ollama image with your Gemma-powered variant:

    private static final String TC_OLLAMA_GEMMA3 = "tc-ollama-gemma3-1b";
    public static final String GEMMA_3 = "gemma3:1b";

    // Creating an Ollama container with Gemma 3 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_GEMMA3)
            .exec();

        if (listImagesCmd.isEmpty()) {
            System.out.println("Creating a new Ollama container with Gemma 3 image...");
            OllamaContainer ollama = new OllamaContainer("ollama/ollama:0.7.1");
            System.out.println("Starting Ollama...");
            ollama.start();
            System.out.println("Pulling model...");
            ollama.execInContainer("ollama", "pull", GEMMA_3);
            System.out.println("Committing to image...");
            ollama.commitToImage(TC_OLLAMA_GEMMA3);
            return ollama;
        }

        System.out.println("Ollama image substitution...");
        // Substitute the default Ollama image with our Gemma variant
        return new OllamaContainer(
            DockerImageName.parse(TC_OLLAMA_GEMMA3)
                .asCompatibleSubstituteFor("ollama/ollama"));
    }

Next, you create and start an Ollama test container and then create an Ollama chat model, by pointing at the address and port of the container with the model you want to use. Finally, you just invoke model.generate(yourPrompt) as usual:

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_3)
        .build();

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

    System.out.println(response);
}

Run it as follows:

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

The first run will take a while to create and run the container but once done, you should see Gemma responding:

INFO: Container ollama/ollama:0.7.1 started in PT7.228339916S
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.

You have Gemma running in Cloud Shell!

14. Congratulations

Congratulations, you've successfully built your first Generative AI chat application in Java using LangChain4j and the Gemini API! You discovered along the way that multimodal large language models are pretty powerful and capable of handling various tasks like question/answering, even on your own documentation, data extraction, interacting with external APIs, and more.

Что дальше?

It's your turn to enhance your applications with powerful LLM integrations!

Дополнительная информация

Справочная документация