iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0

前言:AI 失憶了?為什麼我們不能只把記憶存在 RAM 裡?

昨天我們成功讓 Spring Boot 發送單次請求給 Gemini API,並收到了回覆。但如果你接著問它:「我剛才問了你什麼?」,它肯定是一臉茫然。外部 LLM 本質上是「無狀態 (Stateless)」的服務。要讓 AI 擁有記憶,我們必須在每一次發送請求時,將過往的對話歷史 (Context) 一併打包發送。

很多時候會想把對話紀錄存在 Java 的變數(記憶體/RAM)裡。但未來若把應用程式部署到 Cloud Run 等雲端容器上,流量變大擴展出多台機器時,存在 RAM 裡的記憶就會完全錯亂。因此,我們這邊的做法會將對話歷史存放在外部雲端資料庫中。

觀念解說 1:架構選型!擁抱 Google Cloud 免費額度 (Free Tier)

既然決定要用雲端資料庫,預算絕對是考量重點。Google Cloud 的 Cloud SQL (MySQL) 雖然強大,但沒有永久免費額度。為了打造高 CP 值、甚至零成本的專案,我們選擇 Google Cloud 官方提供的另外一項神器:Cloud Firestore (NoSQL 資料庫)

Firestore 每天提供 1 GB 儲存空間、5 萬次讀取與 2 萬次寫入的永久免費額度!最棒的是,透過我們之前設定好的 ADC (Application Default Credentials),Spring Boot 完全不需要在設定檔裡寫任何資料庫的帳號密碼,就能自動取得管理員權限安全連線。

觀念解說 2:多輪對話的 JSON 結構 (contents 陣列)

在開始寫 Code 前,我們要知道 Gemini API 處理歷史紀錄的核心,在於 contents 陣列中角色 (role) 的交替:

  • user:代表使用者發送的訊息。
  • model:代表 Gemini 回覆的訊息。

一次包含歷史紀錄的請求結構必須像這樣交錯排列:

{
  "contents": [
    { "role": "user", "parts": [{ "text": "你好,我是 Takuya,我想挑戰人生第一場全馬!" }] },
    { "role": "model", "parts": [{ "text": "你好 Takuya!很高興認識你,挑戰全馬是個棒極了的目標!" }] },
    { "role": "user", "parts": [{ "text": "你還記得我是誰嗎?" }] }
  ]
}

動手實作 1:讓 Spring Boot 無縫接軌 Firestore

在 pom.xml 中引入 spring-cloud-Google Cloud-starter-data-firestore 後,就能利用 Spring Data 的風格,處理資料庫操作:

<dependency>
    <groupId>com.google.cloud</groupId>
    <artifactId>spring-cloud-Google Cloud-starter-data-firestore</artifactId>
</dependency>

同樣可以把程式碼交給 AI 來寫!我只需在 IDE 中給予 Prompt

「幫我用 Spring Data Firestore 寫一個 ChatHistory Entity 和依時間排序查詢最新對話的 Repository」

它就精準產出了以下的結構:

@Document(collectionName = "chat_history")
public class ChatHistory {
    
    @DocumentId
    private String id;
    private String sessionId; // 區分不同使用者或對話視窗 (如: marathon-takuya-01)
    private String role;      // user 或 model
    private String content;   // 訊息內容
    private Date createdAt;   // 建立時間
    
    // ... 省略 Getter, Setter 與 Constructor ...
}

接著建立 Repository 介面,不需要手寫複雜連線:

public interface ChatHistoryRepository extends FirestoreReactiveRepository<ChatHistory> {
    // 透過 Spring Data 命名規則,撈取該 Session 的歷史紀錄並依時間升冪排序
    Flux<ChatHistory> findBySessionIdOrderByCreatedAtAsc(String sessionId);
}

動手實作 2:Service 層的記憶組裝與滑動視窗 (Sliding Window)

對話紀錄不能無限追加!過長的歷史不僅浪費 API Token,也會拉長 AI 回應時間。我們必須實作「滑動視窗 (Sliding Window)」機制,限制僅傳送最新 10 筆歷史對話。

遇到這種需要處理 List 邊界值 (IndexOutOfBounds) 與資料重組的邏輯,同樣交給 AI 來處理。透過 Prompt

「請幫我在 Java 實作滑動視窗邏輯,撈取對話歷史後僅保留最新 10 筆,並將其與新的 user prompt 組合,轉換成 Gemini API 接受的 List 結構,最後雙雙存回資料庫。」

產出的程式碼:

@Service
public class GeminiServiceImpl implements GeminiService {

    private static final int MAX_WINDOW_SIZE = 10;
    private final ChatHistoryRepository chatHistoryRepository;
    // ... 省略其餘成員變數 ...

    @Override
    public String chat(String sessionId, String prompt) {
        if (sessionId == null || sessionId.isBlank()) {
            return generateText(prompt);
        }

        // 1. 讀取記憶:撈出過往對話歷史
        List<ChatHistory> historyList = chatHistoryRepository.findBySessionIdOrderByCreatedAtAsc(sessionId)
                .collectList()
                .blockOptional()
                .orElse(Collections.emptyList());

        // 2. 滑動視窗截斷 (Sliding Window):僅保留最新的 MAX_WINDOW_SIZE 筆
        int start = Math.max(0, historyList.size() - MAX_WINDOW_SIZE);
        List<ChatHistory> window = historyList.subList(start, historyList.size());

        // 3. 打包發送:組合對話歷史與新 prompt
        List<GeminiRequest.Content> contents = new ArrayList<>();
        for (ChatHistory item : window) {
            String role = "model".equalsIgnoreCase(item.getRole()) ? "model" : "user";
            contents.add(new GeminiRequest.Content(role, List.of(new GeminiRequest.Part(item.getContent()))));
        }
        contents.add(new GeminiRequest.Content("user", List.of(new GeminiRequest.Part(prompt))));

        // 發送給 Gemini API...
        String responseText = response != null ? response.extractText() : "";

        // 4. 寫入記憶:雙雙存回 Firestore 資料庫
        ChatHistory userHistory = new ChatHistory(null, sessionId, "user", prompt, new Date());
        ChatHistory modelHistory = new ChatHistory(null, sessionId, "model", responseText, new Date());
        chatHistoryRepository.saveAll(List.of(userHistory, modelHistory)).blockLast();

        return responseText;
    }
}

動手實作 3:驗證 AI 記憶力!

程式碼寫完了,我們該如何證明 AI 真的把我們的話記在腦海裡了?實測多輪對話最好的方式,就是連續發送兩個關聯的 Request。一樣可以請 AI 幫我們生成測試用的 curl 腳本或 Postman JSON Payload 並啟動你的 Spring Boot 應用程式後,打開終端機,我們來進行「兩回合」的靈魂對話測試:

回合 1:告訴 AI 你的專屬資訊,發送包含 sessionId 與特定提示詞的請求:

curl -X POST http://localhost:8080/url \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "marathon-takuya-01",
    "prompt": "你好,我是 Takuya,我四個月後的目標是完成人生初半馬!"
  }'

AI 回覆: 你好,Takuya!這是一個非常棒的目標,四個月的時間準備初半馬是很充足的...

回合 2:測試 AI 是否失憶。接著我們使用同一個 sessionId,刻意問它剛剛提過的資訊:

curl -X POST http://localhost:8080/chat \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "marathon-takuya-01",
    "prompt": "考考你,你還記得我叫什麼名字?還有我的目標是什麼嗎?"
  }'

AI 驚豔的回覆: 我當然記得!你叫 Takuya,你四個月後的目標是要完成人生的初半馬對吧?準備得怎麼樣了?
看到這個回覆,恭喜你!這代表我們實作的 Firestore 歷史讀取與組合邏輯完全正確。

最終驗證:前往 Google Cloud Firestore

除了看 API 回應,也要確認資料庫。打開 Google Cloud Console,進入 Firestore 的介面,你會看到 chat_history 這個 Collection 下,整齊地排列著剛剛你與 AI 對話的紀錄。這證明了我們的 Spring Boot 已經成功地將記憶永久保存到了雲端中!

踩坑與避雷指南

在實作多輪對話與將 Spring Boot 整合 Google Cloud Cloud Firestore 的過程中,我們整理了幾個關於成本控制、系統效能與實戰報錯的關鍵避雷指南:

1. Context 膨脹與滑動視窗 (Sliding Window) 觀念

  • 問題:對話紀錄盡量不能無限追加!過長的對話歷史不僅會浪費 API 的 Token 成本,也會導致 LLM 的回應速度變得極為緩慢。
  • 解法:實作「滑動視窗 (Sliding Window)」機制。在將歷史紀錄傳給 LLM 之前,於 Java 後端對撈出來的對話歷史進行截斷(例如僅保留最新的 10 筆/5 組對話)。這樣能確保系統既具備短期記憶,又不會將 API 額度燒光。

2. Google Cloud 尚未開啟 Firestore 或 Database 報錯 NOT_FOUND

  • 症狀NOT_FOUND: The database (default) does not exist for project kakeru-ai
  • 原因:新建的 Google Cloud 專案預設沒有啟用 Firestore 服務。
  • 解法:至 Google Cloud Console 搜尋 Firestore 點擊啟用API並建立資料庫。選擇 Firestore 原生模式 (Native Mode),資料庫 ID 保持預設的 (default)。安全性規則選擇 Production Mode (限定/生產模式),既安全又不會影響後端 Server Admin SDK 的讀寫存取。

3. 查詢時報錯 FAILED_PRECONDITION: The query requires an index

  • 症狀:傳送請求後拋出需要建立 Index 的錯誤與一長串官方網址。
  • 原因:Spring Data 的 findBySessionIdOrderByCreatedAtAsc 同時包含了條件過濾 (sessionId) 與 時間排序 (createdAt ASC)。Firestore 為了保證超高併發下的查詢效能,強制要求這類複合查詢必須先建立 複合索引 (Composite Index)
  • 解法:直接點擊 Console 回傳的一鍵建置連結,點擊 「建立索引 (Create Index)」,等待 1~2 分鐘建立完畢即可正常查詢。

4. Java 17/21 模組系統反射限制 IsoChronology 報錯

  • 症狀Unable to make private java.time.chrono.IsoChronology() accessible: module java.base does not "opens java.time.chrono"
  • 原因:Java 17/21 啟用了強封裝模組系統 (JPMS),若 Entity 使用 LocalDateTime 欄位,Firestore SDK 在反序列化時會試圖透過反射實體化 JDK 內部類別而遭 JVM 攔截阻擋。
  • 解法:將 Entity 的時間欄位改用 Firestore 原生完全支援的 java.util.Date(搭配 new Date()),即可完美避開 JVM 反射限制。

今日總結與明日預告

今天我們透過 Google Cloud 生態系的優勢,利用免費額度的 Cloud Firestore 解決了無狀態伺服器的失憶問題,順利實作了滑動視窗機制,並克服了 Java 21 模組系統與 Cloud Firestore 複合索引的坑點!

現在,我們的 AI 已經具備了對話的連貫性。是時候讓它幫我們執行具體的專業任務了!

明天(Day 9),我們將進入 Prompt Engineering 實戰:描述我如何撰寫精準的系統提示詞,讓 AI 穩定產出 JSON 格式的「馬拉松訓練課表」,敬請期待!


上一篇
Day 7 |「教練,我想變強!」在後端注入靈魂,實作 Gemini API 核心串接
系列文
單鐵的人生如履薄冰!AI 教練 APP 30天開發旅程,你說能走到最後嗎?8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言