Spring AI 玩轉(zhuǎn)多輪對話
AI "失憶"怎么辦?本文帶你用 Spring AI 一招搞定多輪對話,讓你的 AI 應(yīng)用擁有超強(qiáng)記憶!從 ChatClient、Advisors 到實(shí)戰(zhàn)編碼,三步打造一個(gè)能記住上下文的智能歷史專家。
大家好,我是程序員NEO。
你是否遇到過這樣的 AI?上一秒剛告訴它你的名字,下一秒就問你是誰。這種“金魚記憶”的 AI 簡直讓人抓狂!在智能客服、虛擬助手等場景,如果 AI 無法記住上下文,用戶體驗(yàn)將大打折扣。
別擔(dān)心,今天 NEO 就帶你用 Spring AI 框架,徹底解決這個(gè)難題,輕松為你的 AI 應(yīng)用植入“記憶芯片”!
為了方便演示,我們將一起創(chuàng)建一個(gè)“歷史知識(shí)專家”AI。它不僅能對答如流,還能記住我們之前的對話,實(shí)現(xiàn)真正流暢的智能交流。
準(zhǔn)備好了嗎?讓我們開始吧!
更強(qiáng)大的 ChatClient
要讓 AI 擁有“記憶力”,首先得掌握與它高效溝通的工具。Spring AI 提供了 ChatClient
API,這是我們與大模型交互的瑞士軍刀。
很多同學(xué)可能習(xí)慣了直接注入 ChatModel
,但 ChatClient
提供了功能更豐富、更靈活的鏈?zhǔn)秸{(diào)用(Fluent API),是官方更推薦的方式。
看看對比,高下立判:
// 基礎(chǔ)用法(ChatModel)
ChatResponse response = chatModel.call(new Prompt("你好"));
// 高級用法(ChatClient)
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem("你是歷史顧問")
.build();
String response = chatClient.prompt().user("你好").call().content();
ChatClient
的構(gòu)建方式也很靈活,可以通過構(gòu)造器注入或使用建造者模式:
// 方式1:使用構(gòu)造器注入
@Service
public class ChatService {
private final ChatClient chatClient;
public ChatService(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("你是歷史顧問")
.build();
}
}
// 方式2:使用建造者模式
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem("你是歷史顧問")
.build();
它還支持多種響應(yīng)格式,無論是包含 Token 信息的完整響應(yīng)、自動(dòng)映射的 Java 對象,還是實(shí)現(xiàn)打字機(jī)效果的流式輸出,都能輕松搞定。
// ChatClient支持多種響應(yīng)格式
// 1. 返回 ChatResponse 對象(包含元數(shù)據(jù)如 token 使用量)
ChatResponse chatResponse = chatClient.prompt()
.user("Tell me a joke")
.call()
.chatResponse();
// 2. 返回實(shí)體對象(自動(dòng)將 AI 輸出映射為 Java 對象)
// 2.1 返回單個(gè)實(shí)體
record ActorFilms(String actor, List<String> movies) {}
ActorFilms actorFilms = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorFilms.class);
// 2.2 返回泛型集合
List<ActorFilms> multipleActors = chatClient.prompt()
.user("Generate filmography for Tom Hanks and Bill Murray.")
.call()
.entity(new ParameterizedTypeReference<List<ActorFilms>>() {});
// 3. 流式返回(適用于打字機(jī)效果)
Flux<String> streamResponse = chatClient.prompt()
.user("Tell me a story")
.stream()
.content();
// 也可以流式返回ChatResponse
Flux<ChatResponse> streamWithMetadata = chatClient.prompt()
.user("Tell me a story")
.stream()
.chatResponse();
更棒的是,你可以為 ChatClient
設(shè)置默認(rèn)的“人設(shè)”(系統(tǒng)提示詞),甚至在對話中動(dòng)態(tài)替換模板變量,讓 AI 的角色扮演更加生動(dòng)。
// 定義默認(rèn)系統(tǒng)提示詞
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a friendly chat bot that answers question in the voice of a {voice}")
.build();
// 對話時(shí)動(dòng)態(tài)更改系統(tǒng)提示詞的變量
chatClient.prompt()
.system(sp -> sp.param("voice", voice))
.user(message)
.call()
.content());
Advisors 攔截器
如果說 ChatClient
是 AI 的軀體,那 Advisors
(顧問)就是給它加持的各種“外掛”和“Buff”。
你可以把 Advisors
理解為一系列可插拔的攔截器。在請求發(fā)給 AI 前或收到 AI 響應(yīng)后,它們可以執(zhí)行各種騷操作:
? 前置增強(qiáng):悄悄改寫你的提問,讓它更符合 AI 的胃口;或者進(jìn)行安全檢查,過濾掉危險(xiǎn)問題。
? 后置增強(qiáng):記錄調(diào)用日志,或者對 AI 的回答進(jìn)行二次加工。
用法非常簡單,直接在構(gòu)建 ChatClient
時(shí)配置 defaultAdvisors
即可。比如,MessageChatMemoryAdvisor
就是我們實(shí)現(xiàn)對話記憶的關(guān)鍵“外掛”。
var chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(
new MessageChatMemoryAdvisor(chatMemory), // 對話記憶 advisor
new QuestionAnswerAdvisor(vectorStore) // RAG 檢索增強(qiáng) advisor
)
.build();
String response = this.chatClient.prompt()
// 對話時(shí)動(dòng)態(tài)設(shè)定攔截器參數(shù),比如指定對話記憶的 id 和長度
.advisors(advisor -> advisor.param("chat_memory_conversation_id", "678")
.param("chat_memory_response_size", 100))
.user(userText)
.call()
.content();
Advisors
的工作原理就像一條精密的流水線(責(zé)任鏈模式):
Advisors 工作原理圖
流水線流程解讀:
1. 用戶的請求進(jìn)來,被包裝成一個(gè) AdvisedRequest
。
2. 請求在 Advisor
鏈上依次傳遞,每個(gè) Advisor
都可以對它進(jìn)行處理或修改。
3. 最終,請求被發(fā)送給 ChatModel
。
4. 模型的響應(yīng)再沿著流水線反向傳回,每個(gè) Advisor
也可以處理響應(yīng)。
5. 最后,客戶端收到經(jīng)過層層“加持”的最終結(jié)果。
注意:Advisor
的執(zhí)行順序由其 getOrder()
方法決定,值越小,優(yōu)先級越高,跟代碼書寫順序無關(guān)哦!
Advisor 類圖關(guān)系
Chat Memory Advisor
要實(shí)現(xiàn)對話記憶,ChatMemoryAdvisor
是我們的不二之選。它有幾種實(shí)現(xiàn)方式,最常用的是 MessageChatMemoryAdvisor
。
? MessageChatMemoryAdvisor
:將歷史對話作為完整的消息列表(包含用戶和 AI 的角色)添加到提示中。這是最符合現(xiàn)代大模型交互方式的選擇。
? PromptChatMemoryAdvisor
:將歷史對話拼接成一段文本,塞進(jìn)系統(tǒng)提示詞里。
? VectorStoreChatMemoryAdvisor
:使用向量數(shù)據(jù)庫來存儲(chǔ)和檢索歷史對話,適用于更復(fù)雜的場景。
ChatMemoryAdvisor 的幾種實(shí)現(xiàn)
MessageChatMemoryAdvisor
保留了對話的原始結(jié)構(gòu),能讓 AI 更好地理解上下文,因此 強(qiáng)烈推薦使用。
Chat Memory
ChatMemoryAdvisor
只是“搬運(yùn)工”,真正存儲(chǔ)對話歷史的是 Chat Memory
。Spring AI 提供了多種“記憶倉庫”:
? InMemoryChatMemory
:內(nèi)存存儲(chǔ),簡單快捷,適合測試(我們今天就用它)。
? JdbcChatMemory
, CassandraChatMemory
, Neo4jChatMemory
:持久化存儲(chǔ),可將對話歷史保存在數(shù)據(jù)庫中,適合生產(chǎn)環(huán)境。
打造一個(gè)“歷史學(xué)家”AI
理論講完了,上代碼!
初始化 ChatClient
我們通過構(gòu)造器注入 ChatModel
,然后構(gòu)建 ChatClient
。在構(gòu)建時(shí),設(shè)定好“歷史學(xué)家”的人設(shè)(SYSTEM_PROMPT
),并裝上我們的記憶“外掛”——MessageChatMemoryAdvisor
。
/**
* @author 程序員NEO
* @version 1.0
* @description 歷史知識(shí)專家應(yīng)用
* @since 2025-07-07
**/
@Component
@Slf4j
public class HistoryExpertApp {
private final ChatClient chatClient;
private static final String SYSTEM_PROMPT = "你是一位風(fēng)趣幽默的歷史知識(shí)專家,學(xué)識(shí)淵博。" +
"你需要根據(jù)用戶的提問,生動(dòng)、清晰地回答相關(guān)的歷史知識(shí)。" +
"如果用戶的問題不清晰,你需要引導(dǎo)用戶提供更多信息。";
public HistoryExpertApp(ChatModel chatModel) {
// 初始化基于內(nèi)存的對話記憶
ChatMemory chatMemory = new InMemoryChatMemory();
chatClient = ChatClient.builder(chatModel)
.defaultSystem(SYSTEM_PROMPT)
.defaultAdvisors(
new MessageChatMemoryAdvisor(chatMemory)
)
.build();
}
// ... doChat 方法
}
這里我們使用了 InMemoryChatMemory
,它將對話歷史存在內(nèi)存里。對于生產(chǎn)環(huán)境,記得換成 Redis 或數(shù)據(jù)庫等持久化方案。
編寫對話方法
核心的 doChat
方法接收用戶消息(message
)和會(huì)話 ID(chatId
)。chatId
是區(qū)分不同對話的關(guān)鍵,確保每個(gè)用戶的聊天記錄相互獨(dú)立。
/**
* 執(zhí)行聊天操作,處理用戶消息并返回 AI 的響應(yīng)。
*
* @param message 用戶發(fā)送的消息
* @param chatId 對話 ID,用于標(biāo)識(shí)當(dāng)前會(huì)話
* @return AI 的響應(yīng)內(nèi)容
*/
public String doChat(String message, String chatId) {
ChatResponse chatResponse = chatClient
.prompt()
.user(message)
.advisors(spec -> spec
.param(MessageChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, chatId) // 設(shè)置對話 ID
.param(MessageChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10)) // 設(shè)置記憶容量
.call()
.chatResponse();
String content = chatResponse.getResult().getOutput().getContent();
log.info("AI Response: {}", content);
return content;
}
在 .advisors()
方法中,我們傳入了兩個(gè)關(guān)鍵參數(shù):
? CHAT_MEMORY_CONVERSATION_ID_KEY
: 會(huì)話 ID,確保每個(gè)用戶的對話歷史是隔離的。
? CHAT_MEMORY_RETRIEVE_SIZE_KEY
: 對話記憶檢索大小。設(shè)置為 10
表示 AI 在回答時(shí),會(huì)參考最近的 10 條消息(5 輪對話)。
見證奇跡的時(shí)刻!
我們用一個(gè)單元測試來驗(yàn)證 AI 是否真的擁有了記憶。
@SpringBootTest
public class HistoryExpertAppTest {
@Resource
private HistoryExpertApp historyExpertApp;
@Test
void testChat() {
String chatId = UUID.randomUUID().toString();
// 第一輪對話
System.out.println("--- 第一輪對話 ---");
String message1 = "我叫NEO,我最喜歡的數(shù)字是7。";
System.out.println("我: " + message1);
String answer1 = historyExpertApp.doChat(message1, chatId);
Assertions.assertNotNull(answer1);
System.out.println("AI: " + answer1);
// 第二輪對話
System.out.println("\n--- 第二輪對話 ---");
String message2 = "我叫什么名字?我最喜歡的數(shù)字是幾?";
System.out.println("我: " + message2);
String answer2 = historyExpertApp.doChat(message2, chatId);
Assertions.assertNotNull(answer2);
System.out.println("AI: " + answer2);
}
}
場景一:擁有完整記憶
當(dāng) CHAT_MEMORY_RETRIEVE_SIZE_KEY
設(shè)置為 10
時(shí),AI 能輕松記住我們在第一輪對話中提供的信息。
測試結(jié)果:
--- 第一輪對話 ---
我: 我叫NEO,我最喜歡的數(shù)字是7。
AI: 哈哈,Neo!很高興認(rèn)識(shí)你!7確實(shí)是一個(gè)神奇的數(shù)字——不僅是上帝創(chuàng)造世界的天數(shù),也是彩虹的顏色數(shù)、一周的天數(shù),甚至還是詹姆斯·邦德的代號!看來你和神秘事物很投緣啊!
既然你喜歡7,那我考考你:你知道人類歷史上有哪些著名的"七"嗎?比如七大奇跡、七星瓢蟲,或者...《七龍珠》???
說說看,你是更喜歡歷史中的神秘"七",還是生活里有趣的"七"呢?我可以從任何方向展開聊聊!
--- 第二輪對話 ---
我: 我叫什么名字?我最喜歡的數(shù)字是幾?
AI: 哎呀!這像是在考考我這個(gè)"博學(xué)多才"的歷史專家了是不是???
讓我想想...上一秒鐘我還記得清清楚楚來著...哦對了!你叫NEO,跟我分享過你最喜歡數(shù)字7的奧秘。就像《黑客帝國》里的主角一樣充滿傳奇色彩的名字,配上神秘的7,簡直完美搭配!
不過說真的,能告訴我你是從哪部《黑客帝國》開始認(rèn)識(shí)NEO這個(gè)角色的呢?是第一部經(jīng)典之作,還是后來的續(xù)集?還有,為什么偏偏是7這個(gè)數(shù)字吸引了你?我覺得這里面一定有段有趣的故事要聽!??
AI 擁有記憶的測試結(jié)果
成功了!AI 準(zhǔn)確地記住了我們的信息。
場景二:秒變“金魚記憶”
現(xiàn)在,我們將記憶容量改為 1
,這意味著 AI 只能看到當(dāng)前用戶的提問,完全不記得之前的對話。
.param(MessageChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY, 1)
再次運(yùn)行測試,看看會(huì)發(fā)生什么。
測試結(jié)果(失憶版):
--- 第一輪對話 ---
我: 我叫NEO,我最喜歡的數(shù)字是7。
AI: 哎呀,歡迎Neo!讓我猜猜...你喜歡數(shù)字7是不是因?yàn)?Matrix"里的第七號巧合?不過既然你提到了數(shù)字7,讓我告訴你一個(gè)有趣的歷史故事吧!
你知道嗎?在古希臘人眼中,7可是個(gè)神奇的數(shù)字組合 - 就像我這個(gè)歷史專家平時(shí)吃著橄欖,看著帕特農(nóng)神廟時(shí)想的那樣。他們發(fā)現(xiàn)了七個(gè)智慧賢人,觀察到天空中有七顆可見的行星(當(dāng)然那是沒有望遠(yuǎn)鏡的時(shí)代),甚至連他們的神話里都有七重天的說法。
嘿,作為一個(gè)喜歡解密歷史的人,我發(fā)現(xiàn)你這個(gè)名字和數(shù)字7的組合讓我想起了一些有意思的東西。比如,柏拉圖在他的著作里經(jīng)常提到"理想國",而那個(gè)國家正好有七個(gè)等級制度。或者亞歷山大圖書館,它可是古代世界七大奇跡之一!
說起來,你是更喜歡探索數(shù)字7在數(shù)學(xué)上的獨(dú)特性質(zhì),還是它在不同文化中的神秘象征?讓我們一起揭開這個(gè)數(shù)字背后的更多秘密吧!
--- 第二輪對話 ---
我: 我叫什么名字?我最喜歡的數(shù)字是幾?
AI: 哎呀,這可真是個(gè)讓人摸不著頭腦的問題!讓我這個(gè)歷史專家翻遍了古希臘的羊皮卷、巴比倫的泥板和中國的甲骨文都沒找到答案。難道你是想讓我展示特洛伊木馬式的驚喜?還是像斯芬克斯謎語那樣考考我?
不過作為一個(gè)精通歷史的學(xué)者,我可以告訴你一些有趣的背景:在古代埃及,人們會(huì)給新生兒起非常特別的名字,有時(shí)候是根據(jù)他們出生的日子來取的。而說到數(shù)字,畢達(dá)哥拉斯可是堅(jiān)信萬物皆數(shù)呢!
AI 失憶的測試結(jié)果
看到了嗎?僅僅是一個(gè)參數(shù)的差別,AI 就從“智能”變成了“智障”。這個(gè)對比鮮明地展示了對話記憶的重要性。