多輪 RAG 對話記憶:從 Sliding Window 到 localStorage 的實作指南
問題:AI 忘記了我在聊什麼
你有沒有遇過這種情況?
你:「這篇 Blog 講了哪些部署方式?」
AI:「文章提到了 Fly.io、Vercel、Cloudflare 三種...」
你:「那第一種的優點是什麼?」
AI:「❌ 我不知道你說的『第一種』是什麼」
每次問完,AI 就失憶了。這是因為預設的 API 調用是無狀態的——每次請求都是獨立的,模型不會記得上一輪對話。
2025 年主流解法
處理多輪對話記憶,現在主流做法已經不是「把整個 chat history 都丟給模型」,而是選擇性記憶:
| 策略 | 適用場景 | 複雜度 |
|---|---|---|
| Sliding Window | 短對話(3-5 輪) | ⭐ 簡單 |
| 對話摘要 | 中長對話 | ⭐⭐ 中等 |
| 對話向量化 RAG | 長期記憶、跨 session | ⭐⭐⭐ 複雜 |
| 結構化狀態機 | Agent / Workflow | ⭐⭐⭐⭐ 進階 |
對於大多數 Blog 搜索、技術問答場景,Sliding Window 就夠用了。
Sliding Window 原理
graph LR
A[完整對話歷史
20 則訊息] --> B[Sliding Window
取最近 6 則]
B --> C[送給 LLM]
C --> D[AI 回應]
D --> A
核心概念:
- 前端 localStorage 存完整對話歷史
- 每次送 API 時,只取最近 k 則訊息(例如 6 則 = 3 輪)
- LLM 只看到最近的上下文,足以理解「第一種」指的是什麼
實作步驟
1. 後端:接收 history 參數
把 /api/chat 從 GET 改成 POST,接收對話歷史:
from pydantic import BaseModel
class ChatMessage(BaseModel):
role: str # "user" or "assistant"
content: str
class ChatRequest(BaseModel):
q: str
history: list[ChatMessage] = []
MAX_HISTORY = 6 # Sliding window size
@app.post("/api/chat")
async def chat(request: ChatRequest):
q = request.q
history = request.history[-MAX_HISTORY:] # 只取最近 k 則
# 組合 messages
messages = [{"role": "system", "content": system_prompt}]
for msg in history:
messages.append({"role": msg.role, "content": msg.content})
# 加入當前問題 + RAG context
messages.append({"role": "user", "content": f"Context:\n{context}\n\nQuestion: {q}"})
# 呼叫 LLM...
2. 前端:localStorage 管理
const HISTORY_KEY = 'blog_chat_history';
function loadHistory() {
return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]');
}
function saveHistory(history) {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
}
3. 送出請求時附帶歷史
async function startChat(query) {
const history = loadHistory();
// 先把 user message 存起來
history.push({ role: 'user', content: query });
saveHistory(history);
// 送 POST 請求
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
q: query,
history: history.slice(0, -1) // 不包含剛加的這則
})
});
// 串流讀取回應...
let fullResponse = '';
const reader = response.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
fullResponse += new TextDecoder().decode(value);
}
// 存 AI 回應
history.push({ role: 'assistant', content: fullResponse });
saveHistory(history);
}
4. 渲染對話氣泡
function renderChatHistory() {
const history = loadHistory();
chatContainer.innerHTML = history.map(msg => {
if (msg.role === 'assistant') {
return `<div class="bubble assistant">${marked.parse(msg.content)}</div>`;
} else {
return `<div class="bubble user">${escapeHtml(msg.content)}</div>`;
}
}).join('');
}
進階:什麼時候需要更複雜的策略?
對話摘要
當對話超過 10 輪,可以把舊對話摘要成一段話:
summary = summarize(old_messages) # 用 LLM 生成摘要
messages = [
{"role": "system", "content": system_prompt},
{"role": "system", "content": f"對話摘要:{summary}"},
*recent_messages[-6:],
current_question
]
對話向量化
把每輪對話也存進 LanceDB:
# 存對話
db.insert({
"content": f"User: {q}\nAI: {response}",
"embedding": embed(q),
"timestamp": now()
})
# 查詢時,同時搜索文章 + 歷史對話
article_results = article_table.search(query_embedding).limit(5)
history_results = history_table.search(query_embedding).limit(3)
為什麼不用 OpenAI 的 store=True?
OpenAI 在 2025 年推出了 Responses API,一行 store=True 就能自動存對話。
但如果你用的是 OpenRouter、Ollama 等其他 API,就沒有這個功能,需要自己實作。
| 服務 | 自動存儲 | 備註 |
|---|---|---|
| OpenAI Responses API | ✅ store=True |
最省事 |
| Grok | ✅ chat.append() |
自動管理 |
| Gemini | ✅ start_chat() |
Session 內自動 |
| OpenRouter | ❌ 手動 | 需自己實作 |
| DeepSeek | ❌ 手動 | 每次要傳完整歷史 |
總結
- 短對話:Sliding Window(最近 6 則)就夠用
- 中長對話:加上摘要機制
- 跨 session 記憶:對話向量化
對於本地 Blog 搜索這種場景,Sliding Window + localStorage 是最佳解!