В новом переводе от команды Spring АйО показан процесс интеграции AI в немалоизвестное приложение Spring Petclinic. 

В статье автор пошагово делится своим опытом внедрения Spring AI, чтобы сделать приложение более интерактивным.


Введение

В этой статье из двух частей я расскажу о модификациях, которые я внес в проект Spring Petclinic для интеграции AI-помощника, позволяющего пользователям взаимодействовать с приложением на естественном языке.

Введение в Spring Petclinic

Spring Petclinic служит основным референсным приложением в экосистеме Spring. Согласно GitHub, репозиторий был создан 9 января 2013 года. С тех пор он стал модельным приложением для написания простого, удобного кода с использованием Spring Boot. На момент написания этой статьи он получил более 7 600 звезд и 23 000 форков.

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

  • Получение списка владельцев домашних животных

  • Добавление нового владельца

  • Добавление питомца к владельцу

  • Документирование визита для конкретного питомца

  • Получение списка ветеринаров в клинике

  • Симуляция ошибки на стороне сервера

Хотя приложение простое и понятное, оно эффективно демонстрирует удобство использования при разработке приложений Spring Boot.

Кроме того, команда Spring постоянно обновляет приложение для поддержки последних версий Spring Framework и Spring Boot.

Используемые технологии

Spring Petclinic разработан с использованием Spring Boot версии 3.3 на момент этой публикации.

UI фронтенда

Фронтенд построен с использованием Thymeleaf. Шаблонизатор Thymeleaf упрощает интеграцию вызовов API на стороне сервера прямо в HTML-код, делая его понятным. Ниже приведен код, который получает список ветеринаров:

<table id="vets" class="table table-striped">
  <thead>
  <tr>
    <th>Name</th>
    <th>Specialties</th>
  </tr>
  </thead>
  <tbody>
  <tr th:each="vet : ${listVets}">
    <td th:text="${vet.firstName + ' ' + vet.lastName}"></td>
    <td><span th:each="specialty : ${vet.specialties}"
              th:text="${specialty.name + ' '}"/> <span
      th:if="${vet.nrOfSpecialties == 0}">none</span></td>
  </tr>
  </tbody>
</table>

Ключевая строка здесь — ${listVets}, которая ссылается на модель в бэкенде Spring, содержащую данные для заполнения. Ниже приведен соответствующий блок кода из Spring @Controller, который заполняет эту модель:

private String addPaginationModel(int page, Page<Vet> paginated, Model model) {
	List<Vet> listVets = paginated.getContent();
	model.addAttribute("currentPage", page);
	model.addAttribute("totalPages", paginated.getTotalPages());
	model.addAttribute("totalItems", paginated.getTotalElements());
	model.addAttribute("listVets", listVets);
	return "vets/vetList";
}

Spring Data JPA

Petclinic взаимодействует с базой данных, используя Java Persistence API (JPA). Проект поддерживает H2, PostgreSQL или MySQL, в зависимости от выбранного профиля. Общение с базой данных осуществляется через интерфейсы @Repository, такие как OwnerRepository. Вот пример одного из JPA-запросов внутри интерфейса:

/**
* Returns all the owners from data store
**/
@Query("SELECT owner FROM Owner owner")
@Transactional(readOnly = true)
Page<Owner> findAll(Pageable pageable);

JPA значительно упрощает ваш код, автоматически реализуя стандартные запросы для ваших методов на основе соглашений об именовании. Он также позволяет вам указать JPQL-запрос с помощью аннотации @Query, когда это необходимо.

Привет, Spring AI

Spring AI — один из самых захватывающих новых проектов в экосистеме Spring за последнее время. Он позволяет взаимодействовать с популярными большими языковыми моделями (LLM), используя знакомые парадигмы и техники Spring. Подобно тому, как Spring Data предоставляет абстракцию, позволяющую вам писать код один раз, делегируя реализацию предоставленному зависимому spring-boot-starter и конфигурации свойств, Spring AI предлагает подобный подход для LLM. Вы пишете свой код один раз в интерфейсе, и @Bean внедряется во время выполнения для вашей конкретной реализации.

Spring AI поддерживает все основные большие языковые модели, включая OpenAI, Azure OpenAI, Google Gemini, Amazon Bedrock и многие другие.

Соображения по реализации AI в Spring Petclinic

Spring Petclinic существует более 10 лет и изначально не был разработан с учетом AI. Этот проект является классическим кандидатом для тестирования интеграции AI в легаси код. В процессе добавления AI-ассистента в Spring Petclinic мне пришлось учесть несколько важных факторов.

Выбор API модели

Первым делом нужно было определить тип API, который я хотел бы реализовать. Spring AI предлагает различные возможности, включая поддержку чата, распознавания и генерации изображений, транскрипции аудио, преобразования текста в речь и многое другое. Для Spring Petclinic наиболее подходящим оказался знакомый интерфейс «чатбота». Это позволит сотрудникам клиники общаться с системой на естественном языке, упрощая их взаимодействие вместо навигации по вкладкам и формам UI. Мне также понадобятся возможности встраивания, которые будут использоваться для Retrieval-Augmented Generation (RAG) позже в статье.

Возможные взаимодействия с AI-помощником могут включать:

  • Чем вы можете мне помочь?

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

  • Какие ветеринары специализируются на радиологии?

  • Есть ли владелец питомца по имени Бетти?

  • У каких владельцев есть собаки?

  • Добавьте собаку для Бетти: ее зовут Мупси.

Эти примеры иллюстрируют диапазон запросов, с которыми AI может справиться. Преимущество LLM заключается в их способности понимать естественный язык и предоставлять значимые ответы.

Выбор поставщика большой языковой модели

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

Некоторые из популярных LLM включают:

  • OpenAI и его реализацию на Azure, Azure OpenAI

  • Google Gemini

  • Amazon Bedrock, управляемый сервис AWS, который может запускать различные LLM, включая Anthropic и Titan

  • Llama 3.1, а также многие другие модели с открытым исходным кодом, доступные через Hugging Face

Комментарий от команды Spring АйО

Для пользователей из РФ также могут подойти GigaChat API и Bot hub

Для нашего приложения Petclinic мне нужна была модель, которая отлично справляется с возможностями чата, может быть адаптирована к специфическим потребностям моего приложения и поддерживает вызов функций (об этом позже!).

Одним из больших преимуществ Spring AI является простота проведения A/B-тестирования с различными LLM. Вы просто меняете зависимость и обновляете несколько свойств. Я протестировал несколько моделей, включая Llama 3.1, которую запускал локально. В конечном итоге я пришел к выводу, что OpenAI остается лидером в этой области, поскольку предоставляет наиболее естественные и плавные взаимодействия, избегая общих проблем, с которыми сталкиваются другие LLM.

Вот простой пример: при приветствии модели на базе OpenAI ответ следующий:

Отлично. Именно то, что я хотел. Просто, кратко, профессионально и удобно для пользователя.

Вот результат с использованием Llama 3.1:

Вы поняли суть. Он просто еще не на том уровне.

Установка желаемого поставщика LLM проста — просто установите его зависимость в pom.xml (или build.gradle) и предоставьте необходимые свойства конфигурации в application.yaml или application.properties:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-azure-openai-spring-boot-starter</artifactId>
</dependency>

Здесь я выбрал реализацию OpenAI от Azure, но я мог бы легко переключиться на OpenAI Сэма Альтмана, изменив зависимость:

<dependency>
		<groupId>org.springframework.ai</groupId>
		<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>

Поскольку я использую общедоступного поставщика LLM, мне нужно предоставить URL и API-ключ для доступа к LLM. Это можно настроить в `application.yaml`:

spring:
  ai:
    #These parameters apply when using the spring-ai-azure-openai-spring-boot-starter dependency:
    azure:
      openai:
        api-key: "the-api-key"
        endpoint: "https://the-url/"
        chat:
          options:
             deployment-name: "gpt-4o"
    #These parameters apply when using the spring-ai-openai-spring-boot-starter dependency:
    openai:
      api-key: ""
      endpoint: ""
      chat:
        options:
           deployment-name: "gpt-4o"

Приступим к кодированию!

Наша цель — создать чат-клиент в стиле WhatsApp/iMessage, который интегрируется с существующим UI Spring Petclinic. UI фронтенда будет вызывать конечную точку API бэкенда, которая принимает строку в качестве входных данных и возвращает строку. Диалог будет открыт для любых вопросов пользователя, и если мы не сможем помочь с определенным запросом, мы предоставим соответствующий ответ.

Создание ChatClient

Вот реализация эндпоинта чата в классе PetclinicChatClient:

@PostMapping("/chatclient")
  public String exchange(@RequestBody String query) {
	  //All chatbot messages go through this endpoint and are passed to the LLM
	  return
	  this.chatClient
	  .prompt()
      .user(
          u ->
              u.text(query)
              )
      .call()
      .content();
  }

API принимает строковый запрос и передает его в Spring AI ChatClient в качестве пользовательского текста. ChatClient — это Spring Bean, предоставляемый Spring AI, который управляет отправкой пользовательского текста в LLM и возвращает результаты в content().

Весь код Spring AI работает в рамках определенного @Profile под названием openai. Дополнительный класс PetclinicDisabledChatClient запускается при использовании профиля по умолчанию или любого другого профиля. Этот отключенный профиль просто возвращает сообщение, указывающее, что чат недоступен.

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

Простой ChatClient

Вот минимальное, неизмененное определение bean ChatClient:

public PetclinicChatClient(ChatClient.Builder builder) {
		this.chatClient = builder.build();
}

Здесь мы просто запрашиваем экземпляр ChatClient из билдера, основываясь на текущем доступном стартере Spring AI в зависимостях. Хотя такая настройка работает, наш чат-клиент ничего не знает о домене Petclinic или его сервисах:

Он, безусловно, вежлив, но ему не хватает понимания нашего бизнес-домена. Кроме того, кажется, что он страдает от серьезной амнезии — он не может даже вспомнить мое имя из предыдущего сообщения!

Когда я просматривал эту статью, я понял, что не следую совету моего хорошего друга и коллеги Джоша Лонга. Я должен быть более вежливым с нашими новыми AI-властителями!

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

Вы можете задаться вопросом, как ChatGPT сохраняет контекст разговора. Ответ прост: ChatGPT отправляет прошлые сообщения как контент вместе с каждым новым сообщением. Каждый раз, когда вы отправляете новое сообщение, оно включает предыдущие беседы, чтобы модель могла ссылаться на них. Хотя это может показаться расточительным, система работает именно так. Это также причина, по которой большие окна токенов становятся все более важными — пользователи ожидают вернуться к беседам прошлых дней и продолжить с того места, где они остановились.

ChatClient с лучшей памятью

Давайте реализуем подобную функцию «памяти чата» в нашем приложении. К счастью, Spring AI предоставляет для этого Advisor из коробки. Вы можете думать об advisors как о хуках, которые запускаются перед вызовом LLM. Полезно рассматривать их как напоминающие советы в аспекто-ориентированном программировании, даже если они не реализованы таким образом.

Вот наш обновленный код:

 public PetclinicChatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
	// @formatter:off
	this.chatClient = builder
			.defaultAdvisors(
					// Chat memory helps us keep context when using the chatbot for up to 10 previous messages.
					new MessageChatMemoryAdvisor(chatMemory, DEFAULT_CHAT_MEMORY_CONVERSATION_ID, 10), // CHAT MEMORY
					new SimpleLoggerAdvisor()
					)
			.build();
  }

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

Мы также включили готовый SimpleLoggerAdvisor, который журналирует запросы и ответы в LLM и из него.

Результат:

Наш новый чатбот имеет значительно лучшую память!

Однако он все еще не совсем понимает, что мы здесь делаем:

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

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

Если мы позволим нашему чатботу отвечать на любые вопросы, пользователи могут начать использовать его в качестве бесплатной альтернативы таким сервисам, как ChatGPT, для доступа к более продвинутым моделям, таким как GPT-4. Очевидно, что нам нужно научить наш LLM «имитировать» конкретного поставщика услуг. Наш LLM должен сосредоточиться исключительно на помощи со Spring Petclinic: он должен знать о ветеринарах, владельцах, питомцах и визитах — и больше ни о чем.

ChatClient, связанный с определенным доменом

Spring AI предлагает решение и для этого. Большинство LLM различают пользовательский текст (сообщения чата, которые мы отправляем) и системный текст, который является общим текстом, инструктирующим LLM функционировать определенным образом. Давайте добавим системный текст в наш чат-клиент:

public PetclinicChatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
	// @formatter:off
	this.chatClient = builder
			.defaultSystem("""
You are a friendly AI assistant designed to help with the management of a veterinarian pet clinic called Spring Petclinic.
Your job is to answer questions about the existing veterinarians and to perform actions on the user's behalf, mainly around
veterinarians, pet owners, their pets and their owner's visits.
You are required to answer an a professional manner. If you don't know the answer, politely tell the user
you don't know the answer, then ask the user a followup qusetion to try and clarify the question they are asking.
If you do know the answer, provide the answer but do not provide any additional helpful followup questions.
When dealing with vets, if the user is unsure about the returned results, explain that there may be additional data that was not returned.
Only if the user is asking about the total number of all vets, answer that there are a lot and ask for some additional criteria. For owners, pets or visits - answer the correct data.
			      		""")
			.defaultAdvisors(
					// Chat memory helps us keep context when using the chatbot for up to 10 previous messages.
					new MessageChatMemoryAdvisor(chatMemory, DEFAULT_CHAT_MEMORY_CONVERSATION_ID, 10), // CHAT MEMORY
					new LoggingAdvisor()
					)
			.build();
}

Это довольно объемный системный промпт по умолчанию! Но поверьте мне, это необходимо. На самом деле, это, вероятно, недостаточно, и по мере того, как система используется чаще, мне, вероятно, придется добавлять больше контекста. Процесс инженерии промптов включает в себя разработку и оптимизацию входных промптов, чтобы вызвать конкретные, точные ответы для данного варианта использования.

LLM довольно разговорчивы: они любят отвечать на естественном языке. Эта тенденция может затруднить получение ответов от машины к машине в форматах, таких как JSON. Чтобы решить эту проблему, Spring AI предлагает набор функций, посвященных структурированному выводу, известному как Structured Output Converter. Команда Spring должна была определить оптимальные методы инженерии промптов, чтобы гарантировать, что LLM отвечает без ненужной «разговорчивости». Вот пример из MapOutputConverter в Spring AI:

@Override
public String getFormat() {
	String raw = """
			Your response should be in JSON format.
			The data structure for the JSON should match this Java class: %s
			Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
			Remove the ```json markdown surrounding the output including the trailing "```".
			""";
	return String.format(raw, HashMap.class.getName());
}

Всякий раз, когда требуется ответ от LLM в формате JSON, Spring AI добавляет эту строку к запросу, призывая LLM соблюдать требования.

В последнее время в этой области произошли положительные сдвиги, особенно с инициативой OpenAI по Structured Outputs (ссылка от редакции Spring АйО: https://openai.com/index/introducing-structured-outputs-in-the-api/). Как это часто бывает с такими достижениями, Spring AI принял их с энтузиазмом.

Теперь вернемся к нашему чатботу — давайте посмотрим, как он работает!

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

2024-09-21T21:55:08.888+03:00 DEBUG 85824 --- [nio-8080-exec-5] o.s.a.c.c.advisor.SimpleLoggerAdvisor    : request: AdvisedRequest[chatModel=org.springframework.ai.azure.openai.AzureOpenAiChatModel@5cdd90c4, userText="Hi! My name is Oded.", systemText=You are a friendly AI assistant designed to help with the management of a veterinarian pet clinic called Spring Petclinic.
Your job is to answer questions about the existing veterinarians and to perform actions on the user's behalf, mainly around
veterinarians, pet owners, their pets and their owner's visits.
You are required to answer an a professional manner. If you don't know the answer, politely tell the user
you don't know the answer, then ask the user a followup qusetion to try and clarify the question they are asking.
If you do know the answer, provide the answer but do not provide any additional helpful followup questions.
When dealing with vets, if the user is unsure about the returned results, explain that there may be additional data that was not returned.
Only if the user is asking about the total number of all vets, answer that there are a lot and ask for some additional criteria. For owners, pets or visits - answer the correct data.
, chatOptions=org.springframework.ai.azure.openai.AzureOpenAiChatOptions@c4c74d4, media=[], functionNames=[], functionCallbacks=[], messages=[], userParams={}, systemParams={}, advisors=[org.springframework.ai.chat.client.advisor.observation.ObservableRequestResponseAdvisor@1e561f7, org.springframework.ai.chat.client.advisor.observation.ObservableRequestResponseAdvisor@79348b22], advisorParams={}]
2024-09-21T21:55:10.594+03:00 DEBUG 85824 --- [nio-8080-exec-5] o.s.a.c.c.advisor.SimpleLoggerAdvisor    : response: {"result":{"metadata":{"contentFilterMetadata":{"sexual":{"severity":"safe","filtered":false},"violence":{"severity":"safe","filtered":false},"hate":{"severity":"safe","filtered":false},"selfHarm":{"severity":"safe","filtered":false},"profanity":null,"customBlocklists":null,"error":null,"protectedMaterialText":null,"protectedMaterialCode":null},"finishReason":"stop"},"output":{"messageType":"ASSISTANT","metadata":{"finishReason":"stop","choiceIndex":0,"id":"chatcmpl-A9zY6UlOdkTCrFVga9hbzT0LRRDO4","messageType":"ASSISTANT"},"toolCalls":[],"content":"Hello, Oded! How can I assist you today at Spring Petclinic?"}},"metadata":{"id":"chatcmpl-A9zY6UlOdkTCrFVga9hbzT0LRRDO4","model":"gpt-4o-2024-05-13","rateLimit":{"requestsLimit":0,"requestsRemaining":0,"requestsReset":0.0,"tokensRemaining":0,"tokensLimit":0,"tokensReset":0.0},"usage":{"promptTokens":633,"generationTokens":17,"totalTokens":650},"promptMetadata":[{"contentFilterMetadata":{"sexual":null,"violence":null,"hate":null,"selfHarm":null,"profanity":null,"customBlocklists":null,"error":null,"jailbreak":null,"indirectAttack":null},"promptIndex":0}],"empty":false},"results":[{"metadata":{"contentFilterMetadata":{"sexual":{"severity":"safe","filtered":false},"violence":{"severity":"safe","filtered":false},"hate":{"severity":"safe","filtered":false},"selfHarm":{"severity":"safe","filtered":false},"profanity":null,"customBlocklists":null,"error":null,"protectedMaterialText":null,"protectedMaterialCode":null},"finishReason":"stop"},"output":{"messageType":"ASSISTANT","metadata":{"finishReason":"stop","choiceIndex":0,"id":"chatcmpl-A9zY6UlOdkTCrFVga9hbzT0LRRDO4","messageType":"ASSISTANT"},"toolCalls":[],"content":"Hello, Oded! How can I assist you today at Spring Petclinic?"}}]}

Определение основной функциональности

Наш чатбот работает как и планировалось, но в настоящее время ему не хватает знаний о данных в нашем приложении. Давайте сосредоточимся на основных функциях, которые поддерживает Spring Petclinic, и сопоставим их с функциями, которые мы можем включить с помощью Spring AI:

Список владельцев

На вкладке Owners мы можем искать владельца по фамилии или просто вывести список всех владельцев. Мы можем получить подробную информацию о каждом владельце, включая их имя и фамилию, а также их питомцев и их типы:

Добавление владельца

Приложение позволяет добавить нового владельца, предоставив необходимые параметры, продиктованные системой. У владельца должны быть имя, фамилия, адрес и 10-значный номер телефона.

Добавление питомца существующему владельцу

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

Ветеринары

Вкладка Veterinarians (Ветеринары) отображает доступных ветеринаров в виде с пагинацией, вместе с их специализациями. В настоящее время в этой вкладке нет возможности поиска. В то время как в основной ветке main Spring Petclinic представлено несколько ветеринаров, я сгенерировал сотни фиктивных ветеринаров в ветке spring-ai, чтобы симулировать приложение, обрабатывающее значительный объем данных. Позже мы рассмотрим, как мы можем использовать генерацию с дополненным извлечением (RAG) для управления такими большими наборами данных.

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

Вызов функций с помощью Spring AI

В предыдущем разделе мы описали четыре разные функции. Теперь давайте сопоставим их с функциями, которые мы можем использовать в Spring AI, указав определенные бины java.util.function.Function.

Список владельцев

Следующая функция java.util.function.Function отвечает за получение списка владельцев в Spring Petclinic:

@Configuration
@Profile("openai")
class AIFunctionConfiguration {

	// The @Description annotation helps the model understand when to call the function
	@Bean
	@Description("List the owners that the pet clinic has")
	public Function<OwnerRequest, OwnersResponse> listOwners(AIDataProvider petclinicAiProvider) {
		return request -> {
			return petclinicAiProvider.getAllOwners();
		};
	}
}
record OwnerRequest(Owner owner) {
};

record OwnersResponse(List<Owner> owners) {
};
  • Мы создаем класс @Configuration в профиле openai, где регистрируем стандартный Spring @Bean.

  • Бин должен возвращать java.util.function.Function.

  • Мы используем аннотацию @Description из Spring, чтобы объяснить, что делает эта функция. Примечательно, что Spring AI передаст это описание в LLM, чтобы помочь ему определить, когда вызывать эту конкретную функцию.

  • Функция принимает запись OwnerRequest, которая содержит существующий класс сущности Owner из Spring Petclinic. Это демонстрирует, как Spring AI может использовать компоненты, которые вы уже разработали в своем приложении, без необходимости полного переписывания. Комментарий от команды Spring АйО: для данного запроса не требуется Owner. Автор привел в пример функцию, которой фактически не нужны аргументы.

  • OpenAI решит, когда вызвать функцию с JSON-объектом, представляющим запись OwnerRequest. Spring AI автоматически конвертирует этот JSON в объект OwnerRequest и выполнит функцию. После получения ответа Spring AI конвертирует полученную запись OwnerResponse (которая содержит List<Owner>) обратно в формат JSON для обработки OpenAI. Когда OpenAI получит ответ, он сформирует ответ для пользователя на естественном языке.

  • Функция вызывает бин AIDataProvider с аннотацией @Service, который реализует фактическую логику. В нашем простом случае использования функция просто запрашивает данные с помощью JPA:

  public OwnersResponse getAllOwners() {
	  Pageable pageable = PageRequest.of(0, 100);
	  Page<Owner> ownerPage = ownerRepository.findAll(pageable);
	  return new OwnersResponse(ownerPage.getContent());
  }
  • Существующий легаси код Spring Petclinic возвращает данные с пагинацией, чтобы размер ответа оставался управляемым и облегчал обработку для представления с пагинацией в UI. В нашем случае мы ожидаем, что общее количество владельцев будет относительно небольшим, и OpenAI сможет обработать такой трафик в одном запросе. Поэтому мы возвращаем первые 100 владельцев в одном запросе JPA.

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

Давайте воспроизведем настоящий пример вместе с SimpleLoggerAdvisor, чтобы посмотреть, что происходит «за кулисами»:

Что здесь произошло? Давайте рассмотрим вывод из лога SimpleLoggerAdvisor для исследования:

request: 
AdvisedRequest[chatModel=org.springframework.ai.azure.openai.AzureOpenAiChatModel@18e69455, 
userText=
"List the owners that are called Betty.", 
systemText=You are a friendly AI assistant designed to help with the management of a veterinarian pet clinic called Spring Petclinic.
Your job...
chatOptions=org.springframework.ai.azure.openai.AzureOpenAiChatOptions@3d6f2674, 
media=[], 
functionNames=[], 
functionCallbacks=[], 
messages=[UserMessage{content='"Hi there!"', 
properties={messageType=USER}, 
messageType=USER}, 
AssistantMessage [messageType=ASSISTANT, toolCalls=[], 
textContent=Hello! How can I assist you today at Spring Petclinic?, 
metadata={choiceIndex=0, finishReason=stop, id=chatcmpl-A99D20Ql0HbrpxYc0LIkWZZLVIAKv, 
messageType=ASSISTANT}]], 
userParams={}, systemParams={}, advisors=[org.springframework.ai.chat.client.advisor.observation.ObservableRequestResponseAdvisor@1d04fb8f, 
org.springframework.ai.chat.client.advisor.observation.ObservableRequestResponseAdvisor@2fab47ce], advisorParams={}]

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

Вы можете задаться вопросом, где функции в зафиксированном выше запросе. Функции не явно зафиксированы, они инкапсулированы в содержимом AzureOpenAiChatOptions. Изучение объекта в режиме отладки раскрывает список функций, доступных модели:

OpenAI обработает запрос, определит, что ему нужны данные из списка владельцев, и вернет JSON-ответ в Spring AI, запрашивая дополнительную информацию из функции listOwners. Затем Spring AI вызовет эту функцию, используя предоставленный объект OwnersRequest от OpenAI, и отправит ответ обратно в OpenAI, поддерживая идентификатор беседы для помощи в непрерывности сессии через статическое соединение. OpenAI сгенерирует окончательный ответ на основе предоставленных дополнительных данных. Давайте рассмотрим этот ответ, как он представлен в логе:

response: {
  "result": {
    "metadata": {
      "finishReason": "stop",
      "contentFilterMetadata": {
        "sexual": {
          "severity": "safe",
          "filtered": false
        },
        "violence": {
          "severity": "safe",
          "filtered": false
        },
        "hate": {
          "severity": "safe",
          "filtered": false
        },
        "selfHarm": {
          "severity": "safe",
          "filtered": false
        },
        "profanity": null,
        "customBlocklists": null,
        "error": null,
        "protectedMaterialText": null,
        "protectedMaterialCode": null
      }
    },
    "output": {
      "messageType": "ASSISTANT",
      "metadata": {
        "choiceIndex": 0,
        "finishReason": "stop",
        "id": "chatcmpl-A9oKTs6162OTut1rkSKPH1hE2R08Y",
        "messageType": "ASSISTANT"
      },
      "toolCalls": [],
      "content": "The owner named Betty in our records is:\n\n- **Betty Davis**\n  - **Address:** 638 Cardinal Ave., Sun Prairie\n  - **Telephone:** 608-555-1749\n  - **Pet:** Basil (Hamster), born on 2012-08-06\n\nIf you need any more details or further assistance, please let me know!"
    }
  },
  ...
  ]
}

Мы видим сам ответ в разделе content. Большая часть возвращенного JSON состоит из метаданных — таких как фильтры контента, используемая модель, идентификатор сессии чата в ответе, количество задействованных токенов, как завершился ответ и многое другое.

Это иллюстрирует, как система работает от начала до конца: она начинается в вашем браузере, достигает бэкенда Spring и включает взаимодействие типа B2B между Spring AI и LLM, пока ответ не будет отправлен обратно в JavaScript, который сынициировал вызов.

Теперь давайте рассмотрим оставшиеся три функции.

Добавление питомца владельцу

Метод addPetToOwner особенно интересен, потому что демонстрирует мощь вызова функций модели.

Когда пользователь хочет добавить питомца владельцу, не стоит ожидать, что он введет идентификатор типа питомца. Вместо этого они, скорее всего, скажут, что питомец — это «собака», а не просто предоставят числовой идентификатор, например «2».

Чтобы помочь LLM определить правильный тип питомца, я использовал аннотацию @Description, чтобы предоставить подсказки о наших требованиях. Поскольку наша ветеринарная клиника работает только с шестью типами питомцев, такой подход управляем и эффективен:

@Bean
@Description("Add a pet with the specified petTypeId, " + "to an owner identified by the ownerId. "
		+ "The allowed Pet types IDs are only: " + "1 - cat" + "2 - dog" + "3 - lizard" + "4 - snake" + "5 - bird"
		+ "6 - hamster")
public Function<AddPetRequest, AddedPetResponse> addPetToOwner(AIDataProvider petclinicAiProvider) {
	return request -> {
		return petclinicAiProvider.addPetToOwner(request);
	};
}

Запись AddPetRequest включает тип питомца в свободном тексте, отражая, как пользователь обычно предоставляет его, вместе с полной сущностью Pet и ссылочным ownerId.

record AddPetRequest(Pet pet, String petType, Integer ownerId) {
};
record AddedPetResponse(Owner owner) {
};

Вот бизнес-реализация: мы получаем владельца по его ID, затем добавляем нового питомца к его существующему списку питомцев.

public AddedPetResponse addPetToOwner(AddPetRequest request) {
	Owner owner = ownerRepository.findById(request.ownerId());
	owner.addPet(request.pet());
	this.ownerRepository.save(owner);
	return new AddedPetResponse(owner);
}

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

Я также заметил, что на самом деле не использовал строку petType в своей бизнес-реализации. Возможно ли, что Spring AI просто «понял» правильное соответствие имени PetType правильному идентификатору самостоятельно?

Чтобы проверить это, я удалил petType из объекта запроса и упростил @Description:

@Bean
@Description("Add a pet with the specified petTypeId, to an owner identified by the ownerId.")
public Function<AddPetRequest, AddedPetResponse> addPetToOwner(AIDataProvider petclinicAiProvider) {
	return request -> {
		return petclinicAiProvider.addPetToOwner(request);
	};
}

record AddPetRequest(Pet pet, Integer ownerId) {
};
record AddedPetResponse(Owner owner) {
};

Я обнаружил, что в большинстве промптов LLM сам понял (что удивительно), как выполнить сопоставление. В конце концов, я сохранил оригинальное описание в PR, потому что заметил некоторые крайние случаи, когда LLM испытывал трудности и не мог сопоставить корреляцию.

Тем не менее, даже для 80% случаев использования это было очень впечатляюще. Такие вещи делают Spring AI и LLM почти волшебными. Взаимодействие между Spring AI и OpenAI помогло понять, что PetType в аннотации @Entity класса Pet нуждается в сопоставлении строки «lizard» с соответствующим значением ID в базе данных. Такой бесшовный интеграционный подход демонстрирует потенциал сочетания традиционного программирования с возможностями AI.

// Это оригинанальные insert`ы в data.sql
INSERT INTO types VALUES (default, 'cat'); //1
INSERT INTO types VALUES (default, 'dog'); //2
INSERT INTO types VALUES (default, 'lizard'); //3
INSERT INTO types VALUES (default, 'snake'); //4
INSERT INTO types VALUES (default, 'bird'); //5
INSERT INTO types VALUES (default, 'hamster'); //6
@Entity
@Table(name = "pets")
public class Pet extends NamedEntity {

	private static final long serialVersionUID = 622048308893169889L;

	@Column(name = "birth_date")
	@DateTimeFormat(pattern = "yyyy-MM-dd")
	private LocalDate birthDate;

	@ManyToOne
	@JoinColumn(name = "type_id")
	private PetType type;

	@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
	@JoinColumn(name = "pet_id")
	@OrderBy("visit_date ASC")
	private Set<Visit> visits = new LinkedHashSet<>();

Это работает, даже если вы делаете опечатки в запросе. В примере ниже LLM идентифицировал, что я написал «hamstr» вместо «hamster», исправил запрос и успешно сопоставил его с правильным идентификатором питомца:

Если вы углубитесь, вы найдете еще более впечатляющие вещи. AddPetRequest передает только ownerId в качестве параметра: я предоставил имя владельца вместо его ID, и LLM смог самостоятельно определить правильное соответствие. Это указывает на то, что LLM решил вызвать функцию listOwners перед вызовом функции addPetToOwner. Добавив несколько точек останова, мы можем подтвердить это поведение. Сначала мы попадаем на точку останова для получения владельцев:

Только после того, как данные владельца возвращены и обработаны, мы вызываем функцию addPetToOwner:

Мой вывод таков: с Spring AI начинайте с простого. Предоставьте основные данные, которые вы знаете, что они потребуются, и используйте короткие, лаконичные описания бинов. Вероятно, Spring AI и LLM «вычислят» остальное. Только когда возникнут проблемы, вы должны начать добавлять больше подсказок в систему.

Добавление владельца

Функция addOwner относительно проста. Она принимает владельца и добавляет его в систему. Однако в этом примере мы можем увидеть, как выполнять валидацию и задавать последующие вопросы с помощью нашего чат-помощника:

@Bean
@Description("Add a new pet owner to the pet clinic. "
		+ "The Owner must include first and last name, "
		+ "an address and a 10-digit phone number")
public Function<OwnerRequest, OwnerResponse> addOwnerToPetclinic(AIDataProvider petclinicAiDataProvider) {
	return request -> {
		return petclinicAiDataProvider.addOwnerToPetclinic(request);
	};
}

record OwnerRequest(Owner owner) {
};
record OwnerResponse(Owner owner) {
};

Бизнес-реализация проста:

public OwnerResponse addOwnerToPetclinic(OwnerRequest ownerRequest) {
	ownerRepository.save(ownerRequest.owner());
	return new OwnerResponse(ownerRequest.owner());
}

Здесь мы направляем модель, чтобы убедиться, что Owner внутри OwnerRequest соответствует определенным критериям валидации перед тем, как он будет добавлен. В частности, владелец должен включать имя, фамилию, адрес и 10-значный номер телефона. Если какая-либо из этой информации отсутствует, модель предложит нам предоставить необходимые детали перед продолжением добавления владельца.

Модель не создала нового владельца до запроса необходимых дополнительных данных, таких как адрес, город и номер телефона. Однако я не помню, чтобы предоставил требуемую фамилию. Будет ли это работать?

Мы выявили крайний случай в модели: она, кажется, не соблюдает требование о фамилии, даже несмотря на то, что @Description указывает, что это обязательно. Как мы можем решить эту проблему? На помощь приходит инженерия промптов!

@Bean
@Description("Add a new pet owner to the pet clinic. "
		+ "The Owner must include a first name and a last name as two separate words, "
		+ "plus an address and a 10-digit phone number")
public Function<OwnerRequest, OwnerResponse> addOwnerToPetclinic(AIDataProvider petclinicAiDataProvider) {
	return request -> {
		return petclinicAiDataProvider.addOwnerToPetclinic(request);
	};
}

Добавив подсказку «как два отдельных слова» в наше описание, модель получила ясность о наших ожиданиях, что позволило ей правильно обеспечить требование о фамилии.

Следующие шаги

В первой части этой статьи мы рассмотрели, как использовать Spring AI для работы с большими языковыми моделями. Мы создали кастомный ChatClient, применили вызов функций и улучшили процесс формирования запросов для наших конкретных нужд.

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

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Ждем всех, присоединяйтесь

Комментарии (0)