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