Key Findings
  • AI 模型本質上是無狀態的——每次 API 呼叫都是獨立的,不會記得上一句對話。ChatMemory 透過自動附加對話歷史解決了這個問題
  • Spring AI 的 Advisor 機制類似 AOP(面向切面程式設計),在 AI 呼叫前後自動注入邏輯——ChatMemory 只是其中一種 Advisor,還可以加日誌、安全過濾、RAG 等
  • MessageWindowChatMemory 使用滑動視窗策略,保留最近 N 條訊息——當對話超過視窗大小,最舊的訊息會被自動丟棄,有效控制 Token 成本
  • 透過 conversationId 實現多會話隔離——不同使用者的記憶互不干擾,是企業多租戶應用的基礎

為什麼 AI 沒有記憶?

在前面幾堂課的實作中,你可能已經注意到:每次呼叫 chatClient.prompt()...call() 都是一次全新的對話。AI 不記得你上一句說了什麼、不記得你叫什麼名字、不記得剛剛討論的主題。

這不是 Bug,而是 AI 模型的本質設計[3]

第 1 次呼叫: "我叫小明,我喜歡 Kotlin"  → AI: "你好小明!Kotlin 是很棒的語言..."
第 2 次呼叫: "我叫什麼名字?"            → AI: "抱歉,我不知道您的名字..."  😱

每次 API 呼叫都是獨立的 HTTP Request,AI 模型不會在伺服器端保存任何狀態。解決方案是:每次呼叫時,把之前的對話歷史一起送給 AI。這就是 ChatMemory 做的事。

Step 1:環境準備

開啟課程專案的 Lesson5/Lesson5_ChatMemory.ipynb

@file:DependsOn("org.springframework.ai:spring-ai-openai:1.0.0")
@file:DependsOn("org.springframework.ai:spring-ai-client-chat:1.0.0")
@file:DependsOn("org.slf4j:slf4j-simple:2.0.16")

import org.springframework.ai.openai.OpenAiChatModel
import org.springframework.ai.openai.api.OpenAiApi
import org.springframework.ai.chat.client.ChatClient

val apiKey = System.getenv("OPENAI_API_KEY")
    ?: error("請先設定 OPENAI_API_KEY(參考 Lesson 0)")

val openAiApi = OpenAiApi.builder().apiKey(apiKey).build()
val chatModel = OpenAiChatModel.builder().openAiApi(openAiApi).build()

println("✓ 環境準備完成")

Step 2:體驗「無記憶」的 AI

先用一般的 ChatClient 驗證 AI 確實沒有記憶:

val statelessClient = ChatClient.builder(chatModel).build()

// 第一次對話:自我介紹
val r1 = statelessClient.prompt()
    .user("你好!我叫小明,我最喜歡的程式語言是 Kotlin。")
    .call().content()
println("第 1 輪: $r1")

// 第二次對話:問 AI 是否記得
val r2 = statelessClient.prompt()
    .user("我叫什麼名字?我喜歡什麼程式語言?")
    .call().content()
println("第 2 輪: $r2")

預期輸出:

第 1 輪: 你好小明!Kotlin 確實是一門很棒的語言,簡潔又安全...
第 2 輪: 很抱歉,您還沒有告訴我您的名字和喜歡的程式語言...

AI 完全不記得你剛剛說過的話。接下來讓我們解決這個問題。

Step 3:認識 Advisor 機制

在加入 ChatMemory 之前,先理解 Spring AI 的 Advisor 架構[2]。Advisor 就像 Spring AOP 的 Interceptor——在 AI 呼叫的前後自動注入額外邏輯:

         你的程式碼
              │
              ▼
     ┌─── Advisor 鏈 ───┐
     │  📝 Memory Advisor │  ← 自動附加對話歷史
     │  📊 Log Advisor    │  ← 記錄請求/回應日誌
     │  🛡️ Safety Advisor │  ← 過濾敏感內容
     │  📚 RAG Advisor    │  ← 注入知識庫文件
     └───────────────────┘
              │
              ▼
         AI 模型呼叫
              │
              ▼
     ┌─── Advisor 鏈 ───┐
     │  (反向執行後處理) │
     └───────────────────┘
              │
              ▼
         回傳結果

ChatMemory Advisor 的工作就是:呼叫前自動把歷史訊息附加到 Prompt 中,呼叫後自動把新的對話存入記憶。

Step 4:啟用 ChatMemory

只需三步就能讓 AI 擁有記憶[1]

import org.springframework.ai.chat.memory.MessageWindowChatMemory
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor
import org.springframework.ai.chat.memory.ChatMemory

// 1. 建立記憶體(滑動視窗,保留最近 20 條訊息)
val chatMemory = MessageWindowChatMemory.builder()
    .maxMessages(20)
    .build()

// 2. 建立記憶 Advisor
val memoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory).build()

// 3. 建立帶記憶的 ChatClient
val memoryClient = ChatClient.builder(chatModel)
    .defaultAdvisors(memoryAdvisor)
    .build()

println("✓ ChatMemory 已啟用(視窗大小: 20)")

現在重新測試:

val m1 = memoryClient.prompt()
    .user("你好!我叫小明,我最喜歡的程式語言是 Kotlin。")
    .call().content()
println("第 1 輪: $m1")

val m2 = memoryClient.prompt()
    .user("我叫什麼名字?我喜歡什麼程式語言?")
    .call().content()
println("第 2 輪: $m2")

預期輸出:

第 1 輪: 你好小明!Kotlin 是一門非常出色的程式語言...
第 2 輪: 你叫小明,你最喜歡的程式語言是 Kotlin! 🎉

AI 現在記得你了!讓我們繼續更多輪對話:

val m3 = memoryClient.prompt()
    .user("除了 Kotlin,我最近也在學 Spring AI。")
    .call().content()
println("第 3 輪: $m3")

val m4 = memoryClient.prompt()
    .user("幫我總結一下到目前為止你知道關於我的所有資訊。")
    .call().content()
println("第 4 輪: $m4")

預期輸出:

第 4 輪: 根據我們的對話,以下是我知道的關於你的資訊:
1. 你叫小明
2. 你最喜歡的程式語言是 Kotlin
3. 你最近也在學習 Spring AI
你正在建立一個很棒的技術棧!

Step 5:多會話隔離——conversationId

在企業應用中,多個使用者同時使用同一個 AI 服務,每個使用者的記憶必須隔離。透過 conversationId 實現:

// Alice 的對話
val alice1 = memoryClient.prompt()
    .user("我叫 Alice,我是 Java 開發者。")
    .advisors { it.param(ChatMemory.CONVERSATION_ID, "alice-session") }
    .call().content()
println("Alice: $alice1")

// Bob 的對話
val bob1 = memoryClient.prompt()
    .user("我叫 Bob,我專精 Python 和機器學習。")
    .advisors { it.param(ChatMemory.CONVERSATION_ID, "bob-session") }
    .call().content()
println("Bob: $bob1")

// 驗證隔離:問 Alice 的 AI 關於 Bob 的事
val alice2 = memoryClient.prompt()
    .user("我叫什麼名字?我會什麼程式語言?你知道 Bob 嗎?")
    .advisors { it.param(ChatMemory.CONVERSATION_ID, "alice-session") }
    .call().content()
println("Alice 的 AI: $alice2")

預期輸出:

Alice 的 AI: 你叫 Alice,你是 Java 開發者。
至於 Bob,在我們的對話中你沒有提到過這個人,我不認識他。

Alice 和 Bob 的記憶完全隔離——Alice 的 AI 不知道 Bob 的存在,反之亦然。這是多租戶 AI 應用的基礎架構。

Step 6:滑動視窗實驗——記憶會過期

MessageWindowChatMemory 使用滑動視窗策略:只保留最近 N 條訊息,超出的自動丟棄。這是控制 Token 成本的關鍵機制。

用一個小視窗來觀察記憶過期的行為:

// 建立一個很小的記憶視窗(只保留 10 條訊息)
val smallMemory = MessageWindowChatMemory.builder()
    .maxMessages(10)
    .build()

val smallClient = ChatClient.builder(chatModel)
    .defaultAdvisors(MessageChatMemoryAdvisor.builder(smallMemory).build())
    .build()

// 第 1 輪:告訴 AI 名字
smallClient.prompt().user("記住:我的名字是「測試員 Alpha」。").call()

// 第 2~8 輪:大量對話把第 1 輪擠出視窗
for (i in 2..8) {
    smallClient.prompt().user("這是第 $i 輪的隨機對話,請簡短回答。").call()
}

// 第 9 輪:測試 AI 是否還記得名字
val recall = smallClient.prompt()
    .user("我之前告訴你我的名字是什麼?")
    .call().content()
println("回憶測試: $recall")

當對話輪數超過視窗大小,最早的訊息(包含名字)已被丟棄,AI 將無法回答。

視窗大小的選擇建議:

場景建議大小說明
簡單問答 / FAQ5 ~ 10對話短、上下文需求低
一般聊天助手15 ~ 20平衡記憶與成本
複雜技術討論30 ~ 50需要較多上下文
深度分析 / 程式碼審查100+成本較高,但需要完整歷史
成本考量: 視窗越大,每次 API 呼叫附帶的歷史訊息越多,消耗的 Token 也越多。20 條訊息的歷史可能佔用 2,000~5,000 Token,這在每次呼叫中都會計費。選擇合適的視窗大小是體驗與成本的平衡

Step 7:記憶儲存後端

目前使用的 InMemoryChatMemory 把對話存在記憶體中——應用重啟就消失了。正式環境需要持久化儲存[1]

儲存方式適用場景特點
InMemoryChatMemory開發 / 測試預設,重啟即消失
JdbcChatMemory正式環境(關聯式資料庫)PostgreSQL / MySQL,易於查詢與管理
CassandraChatMemory大規模分散式系統高可用、高擴展
Neo4jChatMemory需要關係圖譜的場景圖資料庫,適合社交/推薦

切換儲存後端只需替換一行程式碼——這就是 Spring AI 抽象層的威力:

// 開發環境:記憶體儲存
val devMemory = MessageWindowChatMemory.builder()
    .maxMessages(20)
    .build()

// 正式環境:JDBC 持久化(以 PostgreSQL 為例)
// val prodMemory = MessageWindowChatMemory.builder()
//     .chatMemoryRepository(JdbcChatMemoryRepository(jdbcTemplate))
//     .maxMessages(20)
//     .build()

// ChatClient 的使用方式完全不變!
val client = ChatClient.builder(chatModel)
    .defaultAdvisors(MessageChatMemoryAdvisor.builder(devMemory).build())
    .build()

Step 8:實戰——帶記憶的學習助手

結合本課所有概念,打造一個能記住學生姓名、學習進度的 Spring AI 學習助手:

val learningMemory = MessageWindowChatMemory.builder()
    .maxMessages(30)
    .build()

val learningClient = ChatClient.builder(chatModel)
    .defaultSystem("""你是「Spring AI 學習助手」,一位耐心且專業的程式設計老師。
        你會記住學生的名字、學習進度和偏好。
        回答時使用繁體中文,適當使用程式碼範例。
        當學生問到之前討論過的概念時,引用之前的對話內容。""")
    .defaultAdvisors(MessageChatMemoryAdvisor.builder(learningMemory).build())
    .build()

fun chat(sessionId: String, message: String): String {
    return learningClient.prompt()
        .user(message)
        .advisors { it.param(ChatMemory.CONVERSATION_ID, sessionId) }
        .call().content() ?: "(無回應)"
}

// 模擬學習對話
val sid = "student-001"
println(chat(sid, "嗨!我叫 Alex,我剛學完 Lesson 4 的 Function Calling。"))
println()
println(chat(sid, "我對 @Tool 的 description 怎麼寫比較好還不太確定。"))
println()
println(chat(sid, "可以幫我總結一下我目前的學習狀況嗎?"))

預期輸出(第三輪):

當然,Alex!根據我們的對話,你目前的學習狀況:

📚 已完成進度:到 Lesson 4(Function Calling)
🔍 目前疑問:@Tool 的 description 撰寫技巧
💡 建議下一步:Lesson 5(ChatMemory)你正在學了!

關於你的 @Tool description 問題,記住三個原則:
1. 寫給 AI 看,不是給人看
2. 包含功能說明 + 回傳內容 + 參數格式
3. 具體 > 抽象(「根據訂單編號查詢」比「查詢資料」好)

本課重點回顧

概念重點
AI 無狀態每次 API 呼叫獨立,不會自動記住對話
AdvisorAOP 風格的中介層,AI 呼叫前後自動注入邏輯
MessageWindowChatMemory滑動視窗策略,保留最近 N 條訊息
MessageChatMemoryAdvisor將記憶自動注入每次 AI 呼叫
conversationId多會話隔離,不同使用者記憶互不干擾
視窗大小5~10(簡單)、15~20(一般)、30~50(複雜)
持久化儲存JDBC / Cassandra / Neo4j,切換只需一行

下一步:Lesson 6 — RAG 基礎

有了記憶後,AI 可以記住對話脈絡。但如果要讓 AI 存取企業專屬知識(產品文件、SOP、技術規格),就需要 RAG(檢索增強生成)。下一堂課將介紹:

  • 向量嵌入(Embedding) — 把文字轉換為數學向量
  • 向量資料庫 — 高效的語意搜尋引擎
  • RAG Pipeline — 從文件匯入到智能問答的完整流程

掌握記憶,繼續前進!

AI 有記憶了,下一步讓它讀懂你的企業文件——RAG 檢索增強生成。

前往 Lesson 6:RAG 基礎 →