AI 遇上 Spring Petclinic:使用 Spring AI 實現 AI 助手(第一部分)

工程 | Oded Shopen | 2024年9月26日 | ...

引言

在這篇分為兩部分的博文中,我將討論我對 Spring Petclinic 所做的修改,以便整合一個 AI 助手,允許使用者使用自然語言與應用程式進行互動。

Spring Petclinic 簡介

Spring Petclinic 是 Spring 生態系統中的主要參考應用。根據 GitHub 的資料,該倉庫創建於 2013 年 1 月 9 日。自那時起,它就成為了使用 Spring Boot 編寫簡單、開發者友好程式碼的典範應用。截至撰寫本文時,它已獲得了超過 7,600 顆星和 23,000 個分支。

e386a28e-e860-4cf7-a94c-aa7dad13abe3

該應用模擬了獸醫寵物診所的管理系統。在應用中,使用者可以執行多項活動

  • 列出寵物主人

  • 新增新主人

  • 為主人新增寵物

  • 記錄特定寵物的就診資訊

  • 列出診所的獸醫

  • 模擬伺服器端錯誤

儘管該應用簡單直接,但它有效地展示了開發 Spring Boot 應用的易用性。

此外,Spring 團隊持續更新該應用,以支援最新版本的 Spring Framework 和 Spring Boot。

使用的技術

Spring Petclinic 使用 Spring Boot 開發,截至本文釋出時,具體版本為 3.3。

前端 UI

前端 UI 使用 Thymeleaf 構建。Thymeleaf 的模板引擎方便了在 HTML 程式碼中進行無縫的後端 API 呼叫,使其易於理解。下面是用於檢索寵物主人列表的程式碼

<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 透過根據命名約定自動為你的方法實現預設查詢,顯著簡化了程式碼。它還允許你在需要時使用 @Query 註解指定 JPQL 查詢。

你好,Spring AI

Spring AI 是 Spring 生態系統中近期最令人興奮的新專案之一。它使用熟悉的 Spring 正規化和技術實現了與流行大型語言模型 (LLM) 的互動。就像 Spring Data 提供了一個抽象層,讓你只需編寫一次程式碼,將實現委託給提供的 spring-boot-starter 依賴和屬性配置一樣,Spring AI 為 LLM 提供了類似的方法。你只需為介面編寫一次程式碼,執行時會注入一個 @Bean 來提供你的特定實現。

Spring AI 支援所有主要的大型語言模型,包括 OpenAI、Azure 的 OpenAI 實現、Google Gemini、Amazon Bedrock 以及更多模型

在 Spring Petclinic 中實現 AI 的考慮因素

Spring Petclinic 已有十多年曆史,最初設計時並未考慮 AI。它是一個測試將 AI 整合到“遺留”程式碼庫中的典型候選專案。在應對為 Spring Petclinic 新增 AI 助手的挑戰時,我不得不考慮幾個重要因素。

選擇模型 API

首要考慮的是確定我要實現的 API 型別。Spring AI 提供了多種能力,包括支援聊天、影像識別和生成、音訊轉錄、文字轉語音等。對於 Spring Petclinic,一個熟悉的“聊天機器人”介面是最合適的。這將允許診所員工使用自然語言與系統交流,簡化他們的互動,而無需瀏覽 UI 選項卡和表單。我還需要嵌入能力,這將在本文後續部分用於檢索增強生成 (RAG)。

70d36ac5-3e2a-4dae-9ab7-94cecaf4f493

與 AI 助手的可能互動包括

  • 您能如何協助我?

  • 請列出去我們診所的主人。

  • 哪些獸醫專攻放射學?

  • 有叫 Betty 的寵物主人嗎?

  • 哪些主人養狗?

  • 為 Betty 新增一隻狗;它的名字叫 Moopsie。

這些示例說明了 AI 可以處理的查詢範圍。LLM 的優勢在於它們理解自然語言並提供有意義響應的能力。

選擇大型語言模型提供商

科技世界目前正經歷著大型語言模型 (LLM) 的淘金熱,幾乎每隔幾天就有新模型湧現,每個模型都提供了增強的能力、更大的上下文視窗以及改進推理等高階功能。

一些流行的 LLM 包括

  • OpenAI 及其基於 Azure 的服務,Azure OpenAI

  • Google Gemini

  • Amazon Bedrock,一項託管的 AWS 服務,可以執行各種 LLM,包括 Anthropic 和 Titan

  • Llama 3.1,以及透過Hugging Face 提供的許多其他開源 LLM

對於我們的 Petclinic 應用,我需要一個擅長聊天能力、可以根據我應用的特定需求進行定製並支援函式呼叫(稍後會詳細介紹!)的模型。

Spring AI 的一大優勢在於可以輕鬆地與各種 LLM 進行 A/B 測試。你只需更改依賴並更新一些屬性即可。我測試了幾種模型,包括我在本地執行的 Llama 3.1。最終,我得出結論,OpenAI 在這一領域仍然處於領先地位,因為它提供了最自然流暢的互動,同時避免了其他 LLM 常見的陷阱。

這裡有一個基本示例:當向由 OpenAI 驅動的模型打招呼時,回覆如下

096c6e75-74b6-44e1-924f-1935027aa7ef

完美。正是我想要的。簡單、簡潔、專業且使用者友好。

這是使用 Llama3.1 的結果

215a8acd-337e-4c63-b875-803f13daf146

你明白我的意思了。它還沒達到那個水平。

設定所需的 LLM 提供商非常簡單——只需在 pom.xml(或 build.gradle)中設定其依賴,並在 application.yamlapplication.properties 中提供必要的配置屬性即可

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

在這裡,我選擇了 Azure 對 OpenAI 的實現,但透過更改依賴,我可以輕鬆切換到 Sam Altman 的 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 風格的聊天客戶端,它與 Spring Petclinic 現有的 UI 整合。前端 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 Bean。ChatClient 是 Spring AI 提供的 Spring Bean,負責將使用者文字傳送到 LLM 並返回 content() 中的結果。

所有的 Spring AI 程式碼都在一個名為 openai 的特定 @Profile 下執行。當使用預設 profile 或任何其他 profile 時,會執行另一個類 PetclinicDisabledChatClient。這個停用 profile 只會返回一條訊息,表示聊天不可用。

我們的實現主要將職責委託給 ChatClient。但是我們如何建立 ChatClient Bean 本身呢?有一些可配置的選項可以影響使用者體驗。讓我們逐一探討它們,並檢查它們對最終應用的影響

一個簡單的 ChatClient

以下是一個最基本的、未修改的 ChatClient Bean 定義

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

雖然這個設定有效,但我們的聊天客戶端缺乏對 Petclinic 領域或其服務的任何瞭解

0e879adf-ce3a-460a-a5d2-fcbfcf0af91e

54d23cf7-34bb-400c-9260-fb2d684df98d

它確實很有禮貌,但缺乏對我們業務領域的理解。此外,它似乎患有嚴重的失憶症——甚至記不住上條訊息中我的名字!

在我回顧這篇文章時,我意識到我沒有聽取我的好友兼同事 Josh Long 的建議。我大概應該對我們新的 AI 主宰者們更禮貌一些!

你可能習慣了 ChatGPT 出色的記憶力,這讓它感覺很像對話。然而,實際上,LLM API 完全是無狀態的,不保留你傳送的任何歷史訊息。這就是為什麼 API 這麼快就忘記了我的名字。

你可能會好奇 ChatGPT 如何保持對話上下文。答案很簡單:ChatGPT 在傳送每條新訊息時,都會將過去的訊息作為內容一起傳送。每當你傳送一條新訊息,它都會包含之前的對話供模型參考。雖然這可能看起來浪費,但系統就是這樣運作的。這也是為什麼更大的 token 視窗變得越來越重要的原因——使用者期望能夠重訪幾天前的對話,並從上次中斷的地方繼續。

一個記憶力更好的 ChatClient

讓我們在應用中實現類似的“聊天記憶”功能。幸運的是,Spring AI 提供了一個開箱即用的 Advisor 來幫助解決這個問題。你可以將 Advisor 視為在呼叫 LLM 之前執行的鉤子。將其視為類似於面向切面程式設計中的 Advice 會有所幫助,即使它們的實現方式並非如此。

這是我們更新後的程式碼

 	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 之間請求和響應。

結果

b551498e-bf8e-4d3a-8d77-9ff66d929dfc

我們的新聊天機器人記憶力明顯更好!

然而,對於我們到底在做什麼,仍然不太清楚

ade2c93a-5ef1-4e3f-ab1b-b3589ce332a5

對於一個通用的世界知識型 LLM 來說,這個響應還不錯。然而,我們的診所領域性很強,有特定的用例。此外,我們的聊天機器人應該只專注於協助我們診所的事宜。例如,它不應該嘗試回答這樣的問題

6537a0ba-20a8-47f5-a021-15c4c91f4840

如果我們允許聊天機器人回答任何問題,使用者可能會開始將其用作 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 提供了一套專門用於結構化輸出的功能集,稱為結構化輸出轉換器。Spring 團隊不得不確定最佳的提示工程技術,以確保 LLM 回覆時不會出現不必要的“閒聊”。以下是 Spring AI 的 MapOutputConverter Bean 中的一個示例

	@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 的結構化輸出倡議。正如這類進展常常發生的那樣,Spring AI 全心全意地接受了它

現在,回到我們的聊天機器人——讓我們看看它的表現如何!

22543742-6c1a-426e-aa23-3a16181e3e7e

bcea015e-8707-4ddc-adeb-a534e0d28725

4f92d3bf-f1d9-44d1-8d7b-c8c7f3a39775

這是一個顯著的改進!我們現在擁有一個針對我們的領域進行調優、專注於我們的特定用例、記住最近 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 啟用的函式上

列出主人

在“主人”選項卡中,我們可以按姓氏搜尋主人或簡單地列出所有主人。我們可以獲取每個主人的詳細資訊,包括他們的名字和姓氏,以及他們擁有的寵物及其型別

ed1a033e-6389-47a5-8c9b-d1889bd6e9de

新增主人

該應用允許您透過提供系統要求的必要引數來新增新主人。主人必須有名字、姓氏、地址和 10 位電話號碼。

6c6f580d-7cbf-4055-8a2d-e4f43e5f6c1f

為現有主人新增寵物

一個主人可以擁有多隻寵物。寵物型別僅限於以下幾種:貓、狗、蜥蜴、蛇、鳥或倉鼠。

6f6e20ca-28b8-4093-8e4a-31c6f7ffd032

獸醫

“獸醫”選項卡以分頁檢視顯示可用的獸醫及其專長。此選項卡目前沒有搜尋功能。Spring Petclinic 的 main 分支包含少量獸醫,而我在 spring-ai 分支中生成了數百名模擬獸醫,以模擬一個處理大量資料的應用程式。稍後,我們將探討如何使用檢索增強生成 (RAG) 來管理此類大型資料集。

d055326a-93a4-4c17-a14f-b7a66ed7beea

這些是我們在系統中可以執行的主要操作。我們將應用對映到其基本功能,並希望 OpenAI 能夠根據這些操作推斷出自然語言的請求。

使用 Spring AI 進行函式呼叫

在上一節中,我們描述了四個不同的函式。現在,讓我們透過指定特定的 java.util.function.Function Bean,將它們對映到我們可以與 Spring AI 一起使用的函式。

列出主人

以下 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) {
};
  • 我們在 openai profile 中建立了一個 @Configuration 類,並在其中註冊了一個標準的 Spring @Bean

  • 該 Bean 必須返回一個 java.util.function.Function

  • 我們使用 Spring 的 @Description 註解來解釋此函式的功能。值得注意的是,Spring AI 會將此描述傳遞給 LLM,以幫助它確定何時呼叫此特定函式。

  • 該函式接受一個 OwnerRequest 記錄,該記錄包含現有的 Spring Petclinic Owner 實體類。這表明 Spring AI 如何利用您已在應用中開發的元件,而無需進行完整的重寫。

  • OpenAI 將決定何時使用代表 OwnerRequest 記錄的 JSON 物件呼叫該函式。Spring AI 將自動將此 JSON 轉換為 OwnerRequest 物件並執行該函式。返回響應後,Spring AI 會將結果 OwnerResponse 記錄(其中包含一個 List<Owner>)轉換回 JSON 格式供 OpenAI 處理。當 OpenAI 收到響應後,它將用自然語言為使用者生成回覆。

  • 該函式呼叫一個 AIDataProvider @Service Bean,該 Bean 實現了實際的業務邏輯。在我們簡單的用例中,該函式僅使用 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 應該能夠在單個請求中處理此類流量。因此,我們在單個 JPA 請求中返回前 100 個主人。

    您可能認為這種方法並非最優,在實際應用中確實如此。如果資料量很大,這種方法效率會很低——系統中的主人很可能超過 100 個。對於這種情況,我們需要實現不同的模式,正如我們將在 listVets 函式中探討的那樣。然而,對於我們的演示用例,我們可以假設系統中包含少於 100 個主人。

讓我們結合 SimpleLoggerAdvisor 使用一個真實的例子,觀察幕後發生的情況

f5ee0306-5463-4dc6-96de-908b7af37d9e

這裡發生了什麼?讓我們檢視 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 的有趣資料,包括使用者文字、歷史訊息、代表當前聊天會話的 ID、要觸發的 advisor 列表以及系統文字。

您可能想知道上面的日誌請求中的函式在哪裡。函式並未明確記錄;它們封裝在 AzureOpenAiChatOptions 的內容中。在除錯模式下檢查該物件會顯示模型可用的函式列表

f2ebab0c-aef1-49ba-ad64-c5cd79d8583e

OpenAI 將處理請求,確定需要從主人列表中獲取資料,並向 Spring AI 返回一個 JSON 回覆,請求從 listOwners 函式獲取額外資訊。然後,Spring AI 將使用 OpenAI 提供的 OwnersRequest 物件呼叫該函式,並將響應傳送回 OpenAI,同時保持會話 ID 以協助無狀態連線上的會話連續性。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 大部分由元資料組成——例如內容過濾器、正在使用的模型、響應中的聊天 ID 會話、消耗的 token 數量、響應如何完成等等。

這說明了系統如何端到端地運作:它從您的瀏覽器開始,到達 Spring 後端,並在 Spring AI 和 LLM 之間進行 B2B 乒乓互動,直到響應被髮送回發起初始呼叫的 JavaScript。

現在,讓我們回顧剩下的三個函式。

為主人新增寵物

addPetToOwner 方法尤其有趣,因為它展示了模型的函式呼叫能力。

當用戶想為主人新增寵物時,期望他們輸入寵物型別 ID 是不現實的。相反,他們很可能會說寵物是“狗”,而不是簡單地提供一個數字 ID,比如“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 實體已經預填充了正確的寵物型別 ID 和名稱。

787f442b-2b02-4499-9516-223d09c77128

我還注意到在我的業務實現中並沒有真正使用 petType 字串。Spring AI 是否可能僅僅“自己弄清楚”了 PetType 名稱到正確 ID 的對映關係?

為了測試這一點,我從我的請求物件中移除了 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 之間的互動設法理解了 Pet@Entity 中的 PetType 需要將字串“lizard”對映到其在資料庫中對應的 ID 值。這種無縫整合展示了將傳統程式設計與 AI 能力結合的潛力。

// These are the original insert queries in 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 識別出我將“hamster”拼寫成了“hamstr”,糾正了請求,併成功地將其與正確的寵物 ID 匹配。

612140cf-0bfd-441e-b481-984a1814a840

如果您深入研究,會發現事情變得更加令人印象深刻。AddPetRequest 只將 ownerId 作為引數傳遞;我提供了所有者的名字而不是他們的 ID,而 LLM 設法自行確定了正確的對映。這表明 LLM 在呼叫 addPetToOwner 函式之前選擇了呼叫 listOwners 函式。透過新增一些斷點,我們可以證實這種行為。最初,我們命中了檢索所有者的斷點。

73e60339-4472-4fb9-bb7d-d23fb4680faf

只有在所有者資料返回並處理後,我們才會呼叫 addPetToOwner 函式

4c3ae801-b1f8-4477-924f-3e4d10c137df

我的結論是:使用 Spring AI 時,從簡單開始。提供您知道必需的基本資料,並使用簡短、簡潔的 bean 描述。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());
	}

在這裡,我們指導模型確保 OwnerRequest 中的 Owner 在新增之前滿足某些驗證條件。具體來說,所有者必須包括名字、姓氏、地址和 10 位電話號碼。如果缺少任何這些資訊,模型將提示我們提供必要的詳細資訊,然後才能繼續新增所有者。

7d3d4244-dd6d-47b0-a620-833e7ad1601d

在請求必要的附加資料(例如地址、城市和電話號碼)之前,模型沒有建立新的所有者。然而,我不記得提供了必需的姓氏。它還能工作嗎?

429dca17-1dbd-4444-8282-b629a6320423

我們發現了模型中的一個邊緣情況:即使 `@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);
		};
	}

透過在描述中新增提示“as two separate words”,模型更清楚地瞭解了我們的期望,從而能夠正確地強制要求姓氏。

a3f4d7c3-5b7c-46eb-9b0a-f828f338b40c

26e59430-d5ab-4cf0-86a7-f49fc51016ee

ff606400-d784-47cb-a34a-0319849ec0fe

下一步

在本文的第一部分,我們探討了如何利用 Spring AI 處理大型語言模型。我們構建了一個自定義的 ChatClient,使用了函式呼叫(Function Calling),並針對我們的特定需求改進了提示工程(Prompt Engineering)。

第二部分,我們將深入探討檢索增強生成(Retrieval-Augmented Generation - RAG)的強大之處,以便將模型與大型的、特定領域的資料集整合,這些資料集對於函式呼叫(Function Calling)方法來說過於龐大。

獲取 Spring 新聞通訊

訂閱 Spring 新聞通訊,保持聯絡

訂閱

領先一步

VMware 提供培訓和認證,助您加速前行。

瞭解更多

獲取支援

Tanzu Spring 在一項簡單的訂閱中提供對 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位制檔案。

瞭解更多

近期活動

檢視 Spring 社群的所有近期活動。

檢視全部