领先一步
VMware提供培训和认证,以加速您的进步。
了解更多在这篇分两部分的博文中,我将讨论我对Spring Petclinic所做的修改,这些修改整合了一个AI助手,允许用户使用自然语言与应用程序交互。
Spring Petclinic 是 Spring 生态系统中的主要参考应用程序。根据 GitHub,该存储库创建于 2013 年 1 月 9 日。从那时起,它就成为使用 Spring Boot 编写简单易用的开发者友好型代码的示例应用程序。截至撰写本文时,它已获得超过 7600 个星标和 23000 个分支。
该应用程序模拟兽医诊所的管理系统。在应用程序中,用户可以执行以下几项活动:
列出宠物主人
添加新的主人
为主人添加宠物
记录特定宠物的访问记录
列出诊所的兽医
模拟服务器端错误
虽然该应用程序简单直接,但它有效地展示了开发 Spring Boot 应用程序的易用性。
此外,Spring 团队不断更新该应用程序以支持 Spring 框架和 Spring Boot 的最新版本。
Spring Petclinic 使用 Spring Boot 开发,截至本文发表时,具体版本为 3.3。
前端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";
}
Petclinic 使用 Java 持久性 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 生态系统中最近记忆中最令人兴奋的新项目之一。它允许使用熟悉的 Spring 范例和技术与流行的大型语言模型 (LLM) 进行交互。就像 Spring Data 提供了一个抽象层,允许您编写一次代码,并将实现委托给提供的spring-boot-starter
依赖项和属性配置一样,Spring AI 为 LLM 提供了类似的方法。您只需编写一次代码到接口,然后在运行时为您的特定实现注入一个@Bean
。
Spring AI 支持所有主要的大型语言模型,包括 OpenAI、Azure 的 OpenAI 实现、Google Gemini、Amazon Bedrock 和更多。
Spring Petclinic 已经有 10 多年的历史了,最初并非设计为支持 AI。它是测试将 AI 集成到“遗留”代码库中的经典案例。在着手为 Spring Petclinic 添加 AI 助手的挑战时,我必须考虑几个重要因素。
首先要考虑的是确定要实现的 API 类型。Spring AI 提供了各种功能,包括对聊天、图像识别和生成、音频转录、文本转语音等的支持。对于 Spring Petclinic,熟悉的“聊天机器人”接口最合理。这将允许诊所员工使用自然语言与系统通信,简化他们的交互,而不是在 UI 选项卡和表单之间导航。我还需要嵌入功能,这将在文章后面用于检索增强生成 (RAG)。
与 AI 助手的可能交互包括:
您如何帮助我?
请列出所有来我们诊所的主人。
哪些兽医擅长放射学?
有位名叫贝蒂的宠物主人吗?
哪些主人养狗?
为贝蒂添加一只狗;它的名字是 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 提供支持的模型问好时,响应如下:
完美。这正是我想要的。简单、简洁、专业且用户友好。
以下是使用 Llama3.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>
这里,我选择了 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 端点,该端点接受字符串作为输入并返回字符串作为输出。对话将对用户可能提出的任何问题开放,如果我们无法协助处理特定请求,我们将提供相应的回复。
以下是 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
下运行。另一个类PetclinicDisabledChatClient
在使用默认配置文件或任何其他配置文件时运行。此禁用配置文件仅返回一条消息,指示聊天不可用。
我们的实现主要将责任委托给 ChatClient
。但是我们如何创建 ChatClient
bean 本身呢?有一些可配置的选项可以影响用户体验。让我们逐一探讨它们,并检查它们对最终应用程序的影响。
这是一个基本的、未经修改的 ChatClient
bean 定义。
public PetclinicChatClient(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
在这里,我们只是根据依赖项中当前可用的 Spring AI 启动器,从构建器请求 ChatClient
的一个实例。虽然此设置有效,但我们的聊天客户端缺乏对 Petclinic 域或其服务的任何了解。
它当然很礼貌,但它缺乏对我们业务领域的任何理解。此外,它似乎患有严重的健忘症——它甚至不记得我上一条消息中的名字!
在我审查这篇文章时,我意识到我没有遵循我好友兼同事 Josh Long 的建议。我可能应该对我们新的 AI 统治者更客气一些!
您可能习惯了 ChatGPT 优秀的记忆力,这使得它感觉像是对话。然而,实际上,LLM API 完全是无状态的,不会保留您发送的任何过去的消息。这就是为什么 API 很快就忘记我的名字了。
您可能想知道 ChatGPT 如何保持对话上下文。答案很简单:ChatGPT 将过去的消息作为内容与每条新消息一起发送。每次您发送新消息时,它都会包含之前的对话,以便模型参考。虽然这看起来很浪费,但这只是系统的工作方式。这也是为什么更大的 token 窗口变得越来越重要的原因——用户期望重新访问几天前的对话,并从他们离开的地方继续。
让我们在我们的应用程序中实现类似的“聊天记忆”功能。幸运的是,Spring AI 提供了一个开箱即用的 Advisor 来帮助解决这个问题。您可以将顾问视为在调用 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;它应该了解兽医、主人、宠物和访问——仅此而已。
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 全力拥抱了它。
现在,回到我们的聊天机器人——让我们看看它的表现!
这是一个显著的改进!我们现在拥有一个针对我们领域进行微调的聊天机器人,它专注于我们的特定用例,记住最后 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 启用的功能。
在“所有者”选项卡中,我们可以按姓氏搜索所有者,或者简单地列出所有所有者。我们可以获取有关每个所有者的详细信息,包括他们的名字和姓氏,以及他们拥有的宠物及其类型。
该应用程序允许您通过提供系统规定的必需参数来添加新的主人。主人必须拥有名字、姓氏、地址和一个10位数的电话号码。
一个主人可以拥有多只宠物。宠物类型限于以下几种:猫、狗、蜥蜴、蛇、鸟或仓鼠。
“兽医”选项卡以分页视图显示可用的兽医及其专长。此选项卡目前没有搜索功能。虽然Spring Petclinic的`main`分支包含少量兽医信息,但在`spring-ai`分支中,我生成了数百个模拟兽医信息,以模拟处理大量数据的应用程序。稍后,我们将探讨如何使用检索增强生成(RAG)来管理此类大型数据集。
这些是我们可以在系统中执行的主要操作。我们已将我们的应用程序映射到其基本功能,我们希望OpenAI能够推断出与这些操作相对应的自然语言请求。
在上一节中,我们描述了四个不同的函数。现在,让我们通过指定特定的`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`配置文件中创建一个`@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。在我们简单的用例中,该函数只是使用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`来观察幕后发生了什么。
这里发生了什么?让我们回顾一下`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、要触发的顾问列表以及系统文本。
您可能想知道上面记录的请求中的函数在哪里。函数没有显式记录;它们封装在`AzureOpenAiChatOptions`的内容中。在调试模式下检查该对象会显示模型可用的函数列表。
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会话、消耗的令牌数量、响应完成方式等等。
这说明了系统端到端是如何工作的:它从您的浏览器开始,到达Spring后端,并涉及Spring AI和LLM之间的B2B ping-pong交互,直到将响应发送回进行初始调用的JavaScript。
现在,让我们回顾一下剩余的三个函数。
addPetToOwner
方法尤其有趣,因为它展示了模型函数调用的强大功能。
当用户想为主人添加宠物时,期望他们输入宠物类型ID是不现实的。相反,他们可能会说宠物是“狗”,而不是仅仅提供像“2”这样的数字ID。
为了帮助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和名称。
我还注意到,我并没有在我的业务实现中真正使用`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`需要将字符串“蜥蜴”映射到数据库中相应的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匹配。
如果您深入研究,您会发现事情变得更加令人印象深刻。`AddPetRequest`只将`ownerId`作为参数传递;我提供了主人的名字而不是他们的ID,而LLM设法自行确定了正确的映射。这表明LLM在调用`addPetToOwner`函数之前选择调用`listOwners`函数。通过添加一些断点,我们可以确认这种行为。最初,我们命中了检索主人的断点。
只有在返回并处理完所有者数据后,我们才会调用addPetToOwner
函数。
我的结论是:使用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位数的电话号码。如果缺少任何信息,模型将提示我们提供必要的详细信息,然后再继续添加所有者。
在请求必要的额外数据(例如地址、城市和电话号码)之前,模型没有创建新的所有者。但是,我不记得提供必需的姓氏。它还能工作吗?
我们发现模型中存在一个边缘情况:它似乎没有强制执行姓氏的要求,即使@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,使用了函数调用,并根据我们的特定需求改进了提示工程。
在第二部分,我们将深入研究检索增强生成 (RAG) 的强大功能,以将模型与大型的、特定领域的、太大而无法适应函数调用方法的数据集集成。