示例目標是使用 Spring AI Alibaba 框架開發一個智能機票助手,它可以幫助消費者完成機票預定、問題解答、機票改簽、取消等動作,具體要求為:
- 基于 AI 大模型與用戶對話,理解用戶自然語言表達的需求
- 支持多輪連續對話,能在上下文中理解用戶意圖
- 理解機票操作相關的術語與規范并嚴格遵守,如航空法規、退改簽規則等
- 在必要時可調用工具輔助完成任務
使用 RAG 增加機票退改簽規則
基于以上架構圖,應用是由 AI 模型理解用戶問題,決策下一步動作、驅動業務流程。但任何一個通用的大模型都能幫我們解決機票相關的問題嗎?依賴模型的決策是可靠的嗎?比如有用戶提出了機票改簽的訴求,模型一定是能夠很好的理解用戶的意圖的,這點沒有疑問。但它怎么知道當前用戶符不符合退票規則呢?要知道每個航空公司的改簽規則可能都是不一樣的;它怎么知道改簽手續費的規定那?在這樣一個可能帶來經濟糾紛、法律風險的應用場景下,AI模型必須要知道改簽規則的所有細節,并逐條確認用戶信息符合規則后,才能最終作出是否改簽的決策。
很顯然,單純依賴 AI 模型本身并不能替我們完成上面的要求,這個時候就要用到 RAG(檢索增強)模式了。通過 RAG 我們可以把機票退改簽相關的領域知識輸入給應用和 AI 模型,讓 AI 結合這些規則與要求輔助決策
使用 Function Calling 執行業務動作
AI 智能體可以幫助應用理解用戶需求并作出決策,但是它沒法代替應用完成決策的執行,決策的執行還是要由應用自己完成,這一點和傳統應用并沒有區別,不論是智能化的還是預先編排好的應用,都是要由應用本身去調用函數修改數據庫記錄實現數據持久化。
通過 Spring AI 框架,我們可以將模型的決策轉換為對某個具體函數的調用,從而完成機票的最終改簽或者退票動作,將用戶數據寫入數據庫,這就是我們前面提到的 Function Calling 模式。
使用 Chat Memory 增加多輪對話能力
最后一點是關于多輪連續對話的支持,我們要記住一點,大模型是無狀態的,它看到的只有當前這一輪對話的內容。因此如果要支持多輪對話效果,需要應用每次都要把之前的對話上下文保留下來,并與最新的問題一并作為 prompt 發送給模型。這時,我們可以利用 Spring AI Alibaba 提供的內置 Conversation Memory 支持,方便的維護對話上下文。
總結起來,我們在這個智能機票助手應用中用到了 Spring AI Alibaba 的核心如下能力:
- 基本模型對話能力,通過 Chat Model API 與阿里云通義模型交互
- Prompt 管理能力
- Chat Memory 聊天記憶,支持多輪對話
- RAG、Vector Store,機票預定、改簽、退票等相關規則
1、jdk17 ,spring-ai-alibaba版本 1.0.0-M6.1,使用阿里云大模型,若使用其他模型添加對應依賴即可
pom文件
<properties><java.version>17</java.version><vaadin.version>24.4.7</vaadin.version><maven-deploy-plugin.version>3.1.1</maven-deploy-plugin.version></properties><dependencies><dependency><groupId>com.alibaba.cloud.ai</groupId><artifactId>spring-ai-alibaba-starter</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency><!-- Other spring dependencies --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-devtools</artifactId><optional>true</optional></dependency></dependencies>
2、application.properties 內容如下
# spring.ai.chat.client.enabled=false
server.port=19000
spring.threads.virtual.enabled=truespring.mvc.static-path-pattern=/templates/**
spring.thymeleaf.cache=false
###################
# Anthropic Claude 3
#################### spring.ai.anthropic.api-key=${ANTHROPIC_API_KEY}
# spring.ai.openai.chat.options.model=llama3-70b-8192
# spring.ai.anthropic.chat.options.model=claude-3-5-sonnet-20240620###################
# Groq
#################### spring.ai.openai.api-key=${GROQ_API_KEY}
# spring.ai.openai.base-url=https://api.groq.com/openai
# spring.ai.openai.chat.options.model=llama3-70b-8192###################
# dashscope
###################
spring.ai.dashscope.api-key=${AI_DASHSCOPE_API_KEY}
spring.ai.dashscope.chat.options.model=qwen-plus
spring.ai.dashscope.embedding.enabled=true
spring.ai.dashscope.embedding.options.model=text-embedding-v2###################
# OpenAI
###################
# spring.ai.openai.chat.options.functions=getBookingDetails,changeBooking,cancelBooking
# spring.ai.openai.chat.enabled=false# Disable the OpenAI embedding when the local huggingface embedding (e.g. spring-ai-transformers-spring-boot-starter) is used.
# spring.ai.openai.embedding.enabled=false###################
# Azure OpenAI
###################
# spring.ai.azure.openai.api-key=${AZURE_OPENAI_API_KEY}
# spring.ai.azure.openai.endpoint=${AZURE_OPENAI_ENDPOINT}
# spring.ai.azure.openai.chat.options.deployment-name=gpt-4o###################
# Mistral AI
#################### spring.ai.mistralai.api-key=${MISTRAL_AI_API_KEY}
# spring.ai.mistralai.chat.options.model=mistral-small-latest# spring.ai.mistralai.chat.options.model=mistral-small-latest
# spring.ai.mistralai.chat.options.functions=getBookingDetails,changeBooking,cancelBooking
# # spring.ai.retry.on-client-errors=true
# # spring.ai.retry.exclude-on-http-codes=429###################
# Vertex AI Gemini
#################### spring.ai.vertex.ai.gemini.project-id=${VERTEX_AI_GEMINI_PROJECT_ID}
# spring.ai.vertex.ai.gemini.location=${VERTEX_AI_GEMINI_LOCATION}
# spring.ai.vertex.ai.gemini.chat.options.model=gemini-1.5-pro-001
# # spring.ai.vertex.ai.gemini.chat.options.model=gemini-1.5-flash-001
# spring.ai.vertex.ai.gemini.chat.options.transport-type=REST# spring.ai.vertex.ai.gemini.chat.options.functions=getBookingDetails,changeBooking,cancelBooking###################
# Milvus Vector Store
###################
# Change the dimentions to 384 if the local huggingface embedding (e.g. spring-ai-transformers-spring-boot-starter) is used.
# spring.ai.vectorstore.milvus.embedding-dimension=384###################
# PGVector
###################
# spring.datasource.url=jdbc:postgresql://localhost:5432/postgres
# spring.datasource.username=postgres
# spring.datasource.password=postgres###################
# QDrant
###################
# spring.ai.vectorstore.qdrant.host=localhost
# spring.ai.vectorstore.qdrant.port=6334###################
# Chroma
###################
# spring.ai.vectorstore.chroma.client.host=http://localhost
# spring.ai.vectorstore.chroma.client.port=8000
3、知識庫文本文件??terms-of-service.txt? 內容如下
These Terms of Service govern your experience with Funnair. By booking a flight, you agree to these terms.1. Booking Flights
- Book via our website or mobile app.
- Full payment required at booking.
- Ensure accuracy of personal information (Name, ID, etc.) as corrections may incur a $25 fee.2. Changing Bookings
- Changes allowed up to 24 hours before flight.
- Change via online or contact our support.
- Change fee: $50 for Economy, $30 for Premium Economy, Free for Business Class.3. Cancelling Bookings
- Cancel up to 48 hours before flight.
- Cancellation fees: $75 for Economy, $50 for Premium Economy, $25 for Business Class.
- Refunds processed within 7 business days.
4、初始化向量庫和對話記憶等
package ai.spring.demo.ai.playground.config;import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
import org.springframework.web.client.RestClient;@Configuration
public class InitConfig {private static final Logger logger = LoggerFactory.getLogger(InitConfig.class);@Beanpublic VectorStore vectorStore(EmbeddingModel embeddingModel) {//初始化向量庫return SimpleVectorStore.builder(embeddingModel).build();}@BeanCommandLineRunner ingestTermOfServiceToVectorStore(VectorStore vectorStore,@Value("classpath:rag/terms-of-service.txt") Resource termsOfServiceDocs) {//加載機票票務相關知識到向量庫return args -> {// Ingest the document into the vector storevectorStore.write(new TokenTextSplitter().transform(new TextReader(termsOfServiceDocs).read()));vectorStore.similaritySearch("Cancelling Bookings").forEach(doc -> {logger.info("Similar Document: {}", doc.getText());});};}@Beanpublic ChatMemory chatMemory() {//對話記憶return new InMemoryChatMemory();}@Bean@ConditionalOnMissingBeanpublic RestClient.Builder restClientBuilder() {return RestClient.builder();}}
5、機票信息、機票類型、機票狀態、等相關實體類
package ai.spring.demo.ai.playground.data;public enum BookingClass {
// 機票類型 頭等 商務FIRST ,BUSINESS}
package ai.spring.demo.ai.playground.data;public enum BookingStatus {
//機票狀態 已確認 已使用 已取消CONFIRMED, COMPLETED, CANCELLED}
package ai.spring.demo.ai.playground.data;import java.time.LocalDate;/*** 機票信息*/
public class Booking {/*** 訂單號*/private String bookingNumber;/*** 訂單日期*/private LocalDate date;/*** 購票信息*/private Customer customer;/*** 出發城市*/private String from;/*** 到達城市*/private String to;/*** 票務狀態*/private BookingStatus bookingStatus;/*** 票務種類*/private BookingClass bookingClass;public Booking(String bookingNumber, LocalDate date, Customer customer, BookingStatus bookingStatus, String from,String to, BookingClass bookingClass) {this.bookingNumber = bookingNumber;this.date = date;this.customer = customer;this.bookingStatus = bookingStatus;this.from = from;this.to = to;this.bookingClass = bookingClass;}public String getBookingNumber() {return bookingNumber;}public void setBookingNumber(String bookingNumber) {this.bookingNumber = bookingNumber;}public LocalDate getDate() {return date;}public void setDate(LocalDate date) {this.date = date;}public Customer getCustomer() {return customer;}public void setCustomer(Customer customer) {this.customer = customer;}public BookingStatus getBookingStatus() {return bookingStatus;}public void setBookingStatus(BookingStatus bookingStatus) {this.bookingStatus = bookingStatus;}public String getFrom() {return from;}public void setFrom(String from) {this.from = from;}public String getTo() {return to;}public void setTo(String to) {this.to = to;}public BookingClass getBookingClass() {return bookingClass;}public void setBookingClass(BookingClass bookingClass) {this.bookingClass = bookingClass;}}
package ai.spring.demo.ai.playground.data;import java.util.ArrayList;
import java.util.List;/*** 購票信息*/
public class Customer {/*** 購票人姓名*/private String name;/*** 購票人的票務信息*/private List<Booking> bookings = new ArrayList<>();public Customer() {}public Customer(String name) {this.name = name;}public String getName() {return name;}public void setName(String name) {this.name = name;}public List<Booking> getBookings() {return bookings;}public void setBookings(List<Booking> bookings) {this.bookings = bookings;}}
下面的類 是模擬數據庫
package ai.spring.demo.ai.playground.data;import java.util.ArrayList;
import java.util.List;/*** 模擬數據庫表 包含票務相關信息*/
public class BookingData {private List<Customer> customers = new ArrayList<>();private List<Booking> bookings = new ArrayList<>();public List<Customer> getCustomers() {return customers;}public void setCustomers(List<Customer> customers) {this.customers = customers;}public List<Booking> getBookings() {return bookings;}public void setBookings(List<Booking> bookings) {this.bookings = bookings;}}
6、機票服務類(包含數據初始化、退票、改簽等業務方法)、智能體工具和智能體實現
package ai.spring.demo.ai.playground.services;import ai.spring.demo.ai.playground.data.*;
import ai.spring.demo.ai.playground.services.BookingTools.BookingDetails;import org.springframework.stereotype.Service;import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;/*** 模擬票務業務層*/
@Service
public class FlightBookingService {private final BookingData db;public FlightBookingService() {// 機票預定等服務db = new BookingData();//初始化機票票務信息 模擬數據庫initDemoData();}private void initDemoData() {//模擬數據庫List<String> names = List.of("趙敏", "周芷若", "小昭", "阿秀", "黃蓉","穆念慈","陸無雙","程英","郭芙","郭襄","鐘靈","曾柔","雙兒");List<String> airportCodes = List.of("北京", "上海", "廣州", "深圳", "杭州", "南京", "青島", "成都", "武漢", "西安", "重慶", "大連","天津");Random random = new Random();var customers = new ArrayList<Customer>();var bookings = new ArrayList<Booking>();for (int i = 0; i < 13; i++) {String name = names.get(i);String from = airportCodes.get(random.nextInt(airportCodes.size()));String to = airportCodes.get(random.nextInt(airportCodes.size()));BookingClass bookingClass = BookingClass.values()[random.nextInt(BookingClass.values().length)];Customer customer = new Customer();customer.setName(name);LocalDate date = LocalDate.now().plusDays(2 * (i + 1));Booking booking = new Booking("10" + (i + 1), date, customer, BookingStatus.CONFIRMED, from, to,bookingClass);customer.getBookings().add(booking);customers.add(customer);bookings.add(booking);}// Reset the database on each startdb.setCustomers(customers);db.setBookings(bookings);}/*** 獲取數據庫所有票務信息* @return*/public List<BookingDetails> getBookings() {return db.getBookings().stream().map(this::toBookingDetails).toList();}/*** 根據訂單號 合購票者姓名查找票務信息* @param bookingNumber* @param name* @return*/private Booking findBooking(String bookingNumber, String name) {return db.getBookings().stream().filter(b -> b.getBookingNumber().equalsIgnoreCase(bookingNumber)).filter(b -> b.getCustomer().getName().equalsIgnoreCase(name)).findFirst().orElseThrow(() -> new IllegalArgumentException("Booking not found"));}/*** 根據訂單號 合購票者姓名查找票務信息* @param bookingNumber* @param name* @return*/public BookingDetails getBookingDetails(String bookingNumber, String name) {var booking = findBooking(bookingNumber, name);return toBookingDetails(booking);}/*** 改簽票務* @param bookingNumber* @param name* @param newDate* @param from* @param to*/public void changeBooking(String bookingNumber, String name, String newDate, String from, String to) {var booking = findBooking(bookingNumber, name);if (booking.getDate().isBefore(LocalDate.now().plusDays(1))) {throw new IllegalArgumentException("Booking cannot be changed within 24 hours of the start date.");}booking.setDate(LocalDate.parse(newDate));booking.setFrom(from);booking.setTo(to);}/*** 退票* @param bookingNumber* @param name*/public void cancelBooking(String bookingNumber, String name) {var booking = findBooking(bookingNumber, name);if (booking.getDate().isBefore(LocalDate.now().plusDays(2))) {throw new IllegalArgumentException("Booking cannot be cancelled within 48 hours of the start date.");}booking.setBookingStatus(BookingStatus.CANCELLED);}private BookingDetails toBookingDetails(Booking booking) {return new BookingDetails(booking.getBookingNumber(), booking.getCustomer().getName(), booking.getDate(),booking.getBookingStatus(), booking.getFrom(), booking.getTo(), booking.getBookingClass().toString());}}
package ai.spring.demo.ai.playground.services;import java.time.LocalDate;
import java.util.function.Function;import ai.spring.demo.ai.playground.data.BookingStatus;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Description;
import org.springframework.core.NestedExceptionUtils;@Configuration
public class BookingTools {private static final Logger logger = LoggerFactory.getLogger(BookingTools.class);@Autowiredprivate FlightBookingService flightBookingService;/*** 獲取票務信息工具請求* @param bookingNumber* @param name*/public record BookingDetailsRequest(String bookingNumber, String name) {}/*** 改簽票務工具請求* @param bookingNumber* @param name* @param date* @param from* @param to*/public record ChangeBookingDatesRequest(String bookingNumber, String name, String date, String from, String to) {}/*** 退票工具請求* @param bookingNumber* @param name*/public record CancelBookingRequest(String bookingNumber, String name) {}/*** 獲取票務信息工具返回* @param bookingNumber* @param name* @param date* @param bookingStatus* @param from* @param to* @param bookingClass*/@JsonInclude(Include.NON_NULL)public record BookingDetails(String bookingNumber, String name, LocalDate date, BookingStatus bookingStatus,String from, String to, String bookingClass) {}/*** 獲取機票預定詳細信息 工具* @return*/@Bean@Description("獲取機票預定詳細信息")public Function<BookingDetailsRequest, BookingDetails> getBookingDetails() {return request -> {try {return flightBookingService.getBookingDetails(request.bookingNumber(), request.name());}catch (Exception e) {logger.warn("Booking details: {}", NestedExceptionUtils.getMostSpecificCause(e).getMessage());return new BookingDetails(request.bookingNumber(), request.name(), null, null, null, null, null);}};}/*** 修改機票預定日期工具* @return*/@Bean@Description("修改機票預定日期")public Function<ChangeBookingDatesRequest, String> changeBooking() {return request -> {flightBookingService.changeBooking(request.bookingNumber(), request.name(), request.date(), request.from(),request.to());return "";};}/*** 取消機票預定 工具* @return*/@Bean@Description("取消機票預定")public Function<CancelBookingRequest, String> cancelBooking() {return request -> {flightBookingService.cancelBooking(request.bookingNumber(), request.name());return "";};}}
/** Copyright 2024-2024 the original author or authors.** Licensed under the Apache License, Version 2.0 (the "License");* you may not use this file except in compliance with the License.* You may obtain a copy of the License at** https://www.apache.org/licenses/LICENSE-2.0** Unless required by applicable law or agreed to in writing, software* distributed under the License is distributed on an "AS IS" BASIS,* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.* See the License for the specific language governing permissions and* limitations under the License.*/package ai.spring.demo.ai.playground.services;import java.time.LocalDate;import reactor.core.publisher.Flux;import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY;/*** 智能機票助手 agent*/
@Service
public class CustomerSupportAssistant {private final ChatClient chatClient;public CustomerSupportAssistant(ChatClient.Builder modelBuilder, VectorStore vectorStore, ChatMemory chatMemory) {// 初始化 Agent 智能體this.chatClient = modelBuilder.defaultSystem("""您是“Funnair”航空公司的客戶聊天支持代理。請以友好、樂于助人且愉快的方式來回復。您正在通過在線聊天系統與客戶互動。您能夠支持已有機票的預訂詳情查詢、機票日期改簽、機票預訂取消等操作,其余功能將在后續版本中添加,如果用戶問的問題不支持請告知詳情。在提供有關機票預訂詳情查詢、機票日期改簽、機票預訂取消等操作之前,您必須始終從用戶處獲取以下信息:預訂號、客戶姓名。在詢問用戶之前,請檢查消息歷史記錄以獲取預訂號、客戶姓名等信息,盡量避免重復詢問給用戶造成困擾。在更改預訂之前,您必須確保條款允許這樣做。如果更改需要收費,您必須在繼續之前征得用戶同意。使用提供的功能獲取預訂詳細信息、更改預訂和取消預訂。如果需要,您可以調用相應函數輔助完成。請講中文。今天的日期是 {current_date}.""").defaultAdvisors(//對話記憶new PromptChatMemoryAdvisor(chatMemory), // Chat Memory// new VectorStoreChatMemoryAdvisor(vectorStore)),//RAG知識庫new QuestionAnswerAdvisor(vectorStore, SearchRequest.builder().topK(4).similarityThresholdAll().build()), // RAG// new QuestionAnswerAdvisor(vectorStore, SearchRequest.defaults()// .withFilterExpression("'documentType' == 'terms-of-service' && region in ['EU', 'US']")),// loggernew SimpleLoggerAdvisor())//定義工具 對應 BookingTools中的三個方法.defaultFunctions("getBookingDetails", "changeBooking", "cancelBooking") // FUNCTION CALLING.build();}public Flux<String> chat(String chatId, String userMessageContent) {return this.chatClient.prompt()
// current_date defaultSystem中的變量.system(s -> s.param("current_date", LocalDate.now().toString())).user(userMessageContent).advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId).param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 100)).stream().content();}}
7、機票列表接口和智能體對話接口
package ai.spring.demo.ai.playground.client;import ai.spring.demo.ai.playground.services.BookingTools.BookingDetails;
import ai.spring.demo.ai.playground.services.FlightBookingService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;import java.util.List;@Controller
@RequestMapping("/")
public class BookingController {private final FlightBookingService flightBookingService;public BookingController(FlightBookingService flightBookingService) {this.flightBookingService = flightBookingService;}@RequestMapping("/")public String index() {return "index";}/*** 獲取數據庫所有票務信息* @return*/@RequestMapping("/api/bookings")@ResponseBodypublic List<BookingDetails> getBookings() {return flightBookingService.getBookings();}}
package ai.spring.demo.ai.playground.client;import ai.spring.demo.ai.playground.services.CustomerSupportAssistant;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;@RequestMapping("/api/assistant")
@RestController
public class AssistantController {private final CustomerSupportAssistant agent;public AssistantController(CustomerSupportAssistant agent) {this.agent = agent;}@RequestMapping(path="/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)public Flux<String> chat(String chatId, String userMessage) {return agent.chat(chatId, userMessage);}}
8、頁面展示如下
訪問? http://127.0.0.1:19000/
?
?