Ian Chou's Blog

多輪 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

核心概念:

  1. 前端 localStorage 存完整對話歷史
  2. 每次送 API 時,只取最近 k 則訊息(例如 6 則 = 3 輪)
  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 就能自動存對話。

但如果你用的是 OpenRouterOllama 等其他 API,就沒有這個功能,需要自己實作。

服務 自動存儲 備註
OpenAI Responses API store=True 最省事
Grok chat.append() 自動管理
Gemini start_chat() Session 內自動
OpenRouter ❌ 手動 需自己實作
DeepSeek ❌ 手動 每次要傳完整歷史

總結

  1. 短對話:Sliding Window(最近 6 則)就夠用
  2. 中長對話:加上摘要機制
  3. 跨 session 記憶:對話向量化

對於本地 Blog 搜索這種場景,Sliding Window + localStorage 是最佳解!


相關連結