昨天我們成功讓 Spring Boot 發送單次請求給 Gemini API,並收到了回覆。但如果你接著問它:「我剛才問了你什麼?」,它肯定是一臉茫然。外部 LLM 本質上是「無狀態 (Stateless)」的服務。要讓 AI 擁有記憶,我們必須在每一次發送請求時,將過往的對話歷史 (Context) 一併打包發送。
很多時候會想把對話紀錄存在 Java 的變數(記憶體/RAM)裡。但未來若把應用程式部署到 Cloud Run 等雲端容器上,流量變大擴展出多台機器時,存在 RAM 裡的記憶就會完全錯亂。因此,我們這邊的做法會將對話歷史存放在外部雲端資料庫中。
既然決定要用雲端資料庫,預算絕對是考量重點。Google Cloud 的 Cloud SQL (MySQL) 雖然強大,但沒有永久免費額度。為了打造高 CP 值、甚至零成本的專案,我們選擇 Google Cloud 官方提供的另外一項神器:Cloud Firestore (NoSQL 資料庫)。
Firestore 每天提供 1 GB 儲存空間、5 萬次讀取與 2 萬次寫入的永久免費額度!最棒的是,透過我們之前設定好的 ADC (Application Default Credentials),Spring Boot 完全不需要在設定檔裡寫任何資料庫的帳號密碼,就能自動取得管理員權限安全連線。
在開始寫 Code 前,我們要知道 Gemini API 處理歷史紀錄的核心,在於 contents 陣列中角色 (role) 的交替:
user:代表使用者發送的訊息。model:代表 Gemini 回覆的訊息。一次包含歷史紀錄的請求結構必須像這樣交錯排列:
{
"contents": [
{ "role": "user", "parts": [{ "text": "你好,我是 Takuya,我想挑戰人生第一場全馬!" }] },
{ "role": "model", "parts": [{ "text": "你好 Takuya!很高興認識你,挑戰全馬是個棒極了的目標!" }] },
{ "role": "user", "parts": [{ "text": "你還記得我是誰嗎?" }] }
]
}
在 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);
}
對話紀錄不能無限追加!過長的歷史不僅浪費 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;
}
}
程式碼寫完了,我們該如何證明 AI 真的把我們的話記在腦海裡了?實測多輪對話最好的方式,就是連續發送兩個關聯的 Request。一樣可以請 AI 幫我們生成測試用的 curl 腳本或 Postman JSON Payload 並啟動你的 Spring Boot 應用程式後,打開終端機,我們來進行「兩回合」的靈魂對話測試:
sessionId 與特定提示詞的請求:curl -X POST http://localhost:8080/url \
-H "Content-Type: application/json" \
-d '{
"sessionId": "marathon-takuya-01",
"prompt": "你好,我是 Takuya,我四個月後的目標是完成人生初半馬!"
}'
AI 回覆: 你好,Takuya!這是一個非常棒的目標,四個月的時間準備初半馬是很充足的...
curl -X POST http://localhost:8080/chat \
-H "Content-Type: application/json" \
-d '{
"sessionId": "marathon-takuya-01",
"prompt": "考考你,你還記得我叫什麼名字?還有我的目標是什麼嗎?"
}'
AI 驚豔的回覆: 我當然記得!你叫 Takuya,你四個月後的目標是要完成人生的初半馬對吧?準備得怎麼樣了?
看到這個回覆,恭喜你!這代表我們實作的 Firestore 歷史讀取與組合邏輯完全正確。
除了看 API 回應,也要確認資料庫。打開 Google Cloud Console,進入 Firestore 的介面,你會看到 chat_history 這個 Collection 下,整齊地排列著剛剛你與 AI 對話的紀錄。這證明了我們的 Spring Boot 已經成功地將記憶永久保存到了雲端中!
在實作多輪對話與將 Spring Boot 整合 Google Cloud Cloud Firestore 的過程中,我們整理了幾個關於成本控制、系統效能與實戰報錯的關鍵避雷指南:
NOT_FOUNDNOT_FOUND: The database (default) does not exist for project kakeru-ai
(default)。安全性規則選擇 Production Mode (限定/生產模式),既安全又不會影響後端 Server Admin SDK 的讀寫存取。FAILED_PRECONDITION: The query requires an indexfindBySessionIdOrderByCreatedAtAsc 同時包含了條件過濾 (sessionId) 與 時間排序 (createdAt ASC)。Firestore 為了保證超高併發下的查詢效能,強制要求這類複合查詢必須先建立 複合索引 (Composite Index)。IsoChronology 報錯Unable to make private java.time.chrono.IsoChronology() accessible: module java.base does not "opens java.time.chrono"
LocalDateTime 欄位,Firestore SDK 在反序列化時會試圖透過反射實體化 JDK 內部類別而遭 JVM 攔截阻擋。java.util.Date(搭配 new Date()),即可完美避開 JVM 反射限制。今天我們透過 Google Cloud 生態系的優勢,利用免費額度的 Cloud Firestore 解決了無狀態伺服器的失憶問題,順利實作了滑動視窗機制,並克服了 Java 21 模組系統與 Cloud Firestore 複合索引的坑點!
現在,我們的 AI 已經具備了對話的連貫性。是時候讓它幫我們執行具體的專業任務了!
明天(Day 9),我們將進入 Prompt Engineering 實戰:描述我如何撰寫精準的系統提示詞,讓 AI 穩定產出 JSON 格式的「馬拉松訓練課表」,敬請期待!