LangChain 課程 – Memory:對話記憶
主題:自訂記憶配置
簡介
在使用 LLM(大型語言模型)建構聊天機器人或問答系統時,記憶(Memory) 扮演關鍵角色。沒有適當的記憶管理,模型每一次回覆只能看到最新的使用者輸入,無法利用先前的對話上下文,結果常常出現斷章取義或前後矛盾的回應。
LangChain 為開發者提供了多種記憶類型(ConversationBufferMemory、ConversationSummaryMemory、VectorStoreRetrieverMemory 等),並支援自訂記憶配置,讓你可以依照應用需求調整儲存方式、檢索策略與過期機制。本文將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步掌握如何在 LangChainJS 中打造彈性且效能友好的自訂記憶。
核心概念
1. 記憶的基本介面
在 LangChainJS 中,所有記憶類別皆實作 BaseMemory 介面,最核心的兩個方法是:
| 方法 | 說明 |
|---|---|
loadMemoryVariables(inputs: Record<string, any>) |
依據目前的對話輸入,返回需要注入 LLM Prompt 的變數(通常是 history 或 context)。 |
saveContext(inputs: Record<string, any>, outputs: Record<string, any>) |
把本次的使用者輸入與模型回覆寫入記憶庫。 |
只要遵守這兩個方法的簽名,就能自訂任何儲存機制(例如寫入資料庫、寫入檔案、或是推送到雲端快取)。
2. 為什麼要自訂?
| 場景 | 內建記憶的限制 | 自訂記憶的優勢 |
|---|---|---|
| 長對話 | ConversationBufferMemory 會把所有訊息全部保留,記憶體快速飆升。 |
可以加入截斷策略或摘要機制,只保留關鍵資訊。 |
| 多使用者 | 內建記憶是單例,無法分辨不同使用者的對話。 | 為每個使用者建立獨立的記憶容器(如 Redis、MongoDB)。 |
| 跨會話持久化 | 只在程式執行期間有效,重啟即遺失。 | 把記憶寫入永久儲存(例如 PostgreSQL、Firebase),讓對話可以跨會話延續。 |
| 向量檢索 | 只能做文字拼接,無法利用語義相似度檢索。 | 結合向量資料庫(如 Pinecone、Qdrant)做語意檢索,提升回覆相關性。 |
3. 自訂記憶類別的範本
以下是一個最小化的自訂記憶範例,我們以 Redis 為儲存後端,並加入 TTL(過期時間) 的概念:
// customRedisMemory.js
import { BaseMemory } from "langchain/memory";
import { createClient } from "redis";
class RedisMemory extends BaseMemory {
/** @type {ReturnType<typeof createClient>} */
client;
/** 記憶鍵的前綴,用於區分不同聊天機器人或使用者 */
keyPrefix;
/** 記憶的過期秒數,預設 1 天 */
ttl;
constructor({ url = "redis://localhost:6379", keyPrefix = "chat:", ttl = 86400 } = {}) {
super();
this.client = createClient({ url });
this.client.connect().catch(console.error);
this.keyPrefix = keyPrefix;
this.ttl = ttl;
}
/** 產生完整的 Redis 鍵 */
_makeKey(sessionId) {
return `${this.keyPrefix}${sessionId}`;
}
/** 從 Redis 讀取對話歷史,返回 { history: string } */
async loadMemoryVariables({ sessionId }) {
const key = this._makeKey(sessionId);
const history = (await this.client.get(key)) ?? "";
return { history };
}
/** 把本次的輸入與輸出寫入 Redis,並刷新 TTL */
async saveContext({ sessionId, input }, { output }) {
const key = this._makeKey(sessionId);
const prev = (await this.client.get(key)) ?? "";
const newEntry = `Human: ${input}\nAI: ${output}\n`;
const updated = prev + newEntry;
await this.client.set(key, updated, { EX: this.ttl });
}
/** 方便測試的清除方法 */
async clear(sessionId) {
await this.client.del(this._makeKey(sessionId));
}
}
export default RedisMemory;
重點:只要
loadMemoryVariables回傳的物件中,鍵名與 Prompt 模板裡使用的變數名稱相同(此例為history),LLM 就會自動把對話歷史注入 Prompt。
4. 把自訂記憶掛載到 Chain
import { ChatOpenAI } from "langchain/chat_models/openai";
import { ConversationChain } from "langchain/chains";
import RedisMemory from "./customRedisMemory.js";
const memory = new RedisMemory({
url: process.env.REDIS_URL,
keyPrefix: "mybot:",
ttl: 60 * 60 * 24 * 7, // 7 天
});
const model = new ChatOpenAI({ temperature: 0.7 });
const chain = new ConversationChain({
llm: model,
memory, // <-- 注入自訂記憶
// Prompt 中使用 {{history}} 變數
prompt: `以下是一段與使用者的對話,請保持語氣自然且貼近上下文。
{{history}}
Human: {{input}}
AI:`,
});
async function chat(sessionId, userMessage) {
const response = await chain.call({
sessionId, // 交給自訂記憶辨識不同使用者
input: userMessage,
});
console.log("AI:", response.response);
}
5. 進階範例:結合向量檢索的混合記憶
在需要語意檢索的情境(例如客服文件搜尋),單純的文字緩衝會產生大量冗餘資訊。我們可以在 loadMemoryVariables 中先從向量資料庫取回最相似的 3 條記錄,再與文字緩衝合併:
// hybridMemory.js
import { BaseMemory } from "langchain/memory";
import { PineconeClient } from "@pinecone-database/pinecone";
import { OpenAIEmbeddings } from "langchain/embeddings/openai";
class HybridMemory extends BaseMemory {
/** Pinecone 向量索引 */
pinecone;
/** 用於產生向量的 Embedding 模型 */
embeddings;
/** 文字緩衝(可自行替換成 RedisMemory 等) */
bufferMemory;
constructor({ pineconeIndex, bufferMemory, embeddings = new OpenAIEmbeddings() }) {
super();
this.pinecone = pineconeIndex;
this.embeddings = embeddings;
this.bufferMemory = bufferMemory;
}
async loadMemoryVariables({ sessionId, input }) {
// 1️⃣ 從文字緩衝取得完整歷史
const { history } = await this.bufferMemory.loadMemoryVariables({ sessionId });
// 2️⃣ 把當前使用者輸入向量化,搜尋相似記錄
const queryVector = await this.embeddings.embedQuery(input);
const matches = await this.pinecone.query({
vector: queryVector,
topK: 3,
includeMetadata: true,
});
// 3️⃣ 組合檢索結果
const retrieved = matches.matches
.map((m) => `相關文件: ${m.metadata?.text}`)
.join("\n");
// 4️⃣ 回傳給 Chain
return {
history,
retrieved, // 在 Prompt 中可使用 {{retrieved}}
};
}
async saveContext({ sessionId, input }, { output }) {
// 文字緩衝仍負責保存對話
await this.bufferMemory.saveContext({ sessionId, input }, { output });
// 同時把對話向量化寫入 Pinecone(方便未來檢索)
const vector = await this.embeddings.embedQuery(`${input}\n${output}`);
await this.pinecone.upsert({
vectors: [
{
id: `${sessionId}-${Date.now()}`,
values: vector,
metadata: { text: `${input}\n${output}` },
},
],
});
}
}
export default HybridMemory;
使用方式:
import { ChatOpenAI } from "langchain/chat_models/openai";
import { ConversationChain } from "langchain/chains";
import RedisMemory from "./customRedisMemory.js";
import HybridMemory from "./hybridMemory.js";
import { PineconeClient } from "@pinecone-database/pinecone";
const pinecone = new PineconeClient();
await pinecone.init({
environment: "us-west1-gcp",
apiKey: process.env.PINECONE_API_KEY,
});
const index = pinecone.Index("my-chat-index");
// 建立混合記憶(文字緩衝 + 向量檢索)
const hybrid = new HybridMemory({
pineconeIndex: index,
bufferMemory: new RedisMemory({ keyPrefix: "hybrid:", ttl: 30 * 24 * 60 * 60 }),
});
const model = new ChatOpenAI({ temperature: 0.6 });
const chain = new ConversationChain({
llm: model,
memory: hybrid,
prompt: `以下是與使用者的對話與相關文件,請先參考文件再回覆。
{{history}}
{{retrieved}}
Human: {{input}}
AI:`,
});
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 / 最佳實踐 |
|---|---|---|
| 記憶無限制累積 | 直接使用 ConversationBufferMemory 會導致 Prompt 超過模型 token 限制。 |
- 設定 截斷(只保留最近 N 條) - 使用 摘要記憶( ConversationSummaryMemory)或手動 slice。 |
| 多使用者共用同一記憶實例 | 若忘記傳入 sessionId,所有使用者的對話會混在一起。 |
為每個會話生成唯一 sessionId(UUID、JWT sub 等),並在 loadMemoryVariables/saveContext 內使用。 |
| 向量資料庫寫入延遲 | 每次回覆都同步寫入 Pinecone 可能拖慢回應速度。 | 使用 背景佇列(如 BullMQ)或 setTimeout 非同步寫入,確保主流程快速回應。 |
| TTL 設定過短 | 記憶過早過期會導致對話斷裂。 | 根據業務需求調整 TTL,或在使用者活躍時 刷新(EXPIRE)。 |
| 忘記序列化/反序列化 | 把 JavaScript 物件直接寫入 Redis 會變成 [object Object]。 |
使用 JSON.stringify / JSON.parse,或使用 RedisHash 結構儲存欄位。 |
| Prompt 變數名稱不一致 | loadMemoryVariables 回傳的 key 必須與 Prompt 中的 {{key}} 完全相符。 |
先寫測試,確保變數名稱匹配,否則模型會收到空字串。 |
最佳實踐清單:
- 明確設計 Session ID:使用
uuidv4()或 JWT 中的sub,保證唯一且可追蹤。 - 分層記憶:先用「短期緩衝」保存最近 5–10 條對話,再用「長期摘要」或「向量檢索」保存重要資訊。
- 避免同步阻塞:寫入外部儲存(Redis、資料庫、向量索引)盡量非同步,或在回傳前先
await重要的寫入(如更新 TTL)。 - 監控 Token 使用量:在每次組裝 Prompt 前,使用
tokenizer.encode(prompt).length檢查是否超過模型上限。 - 測試與回溯:在開發階段加入單元測試,驗證
saveContext、loadMemoryVariables的行為,並在生產環境保留 對話日誌(可加密)以便問題排查。
實際應用場景
| 場景 | 需求 | 記憶配置示例 |
|---|---|---|
| 客服聊天機器人 | 必須在同一會話內保持上下文,且能根據過往對話快速找出相關 FAQ。 | - RedisMemory 作為短期緩衝(TTL 24h)- VectorStoreRetrieverMemory(Pinecone)作為語意檢索- 每次回覆完畢同步寫入向量索引。 |
| 教育輔助問答 | 需要跨課程、跨日保存學生的提問與解答,並根據歷史表現提供個人化建議。 | - 使用 PostgreSQL(或 MongoDB)儲存對話紀錄,配合 ConversationSummaryMemory 每日產生摘要。- 在 Prompt 中加入 {{summary}} 讓模型參考過往學習概況。 |
| 多輪任務協調(如行程規劃) | 使用者會在同一次會話中提供多個資訊(日期、地點、偏好),需要在不同子任務間共享資訊。 | - 建立 鍵值映射記憶(如 KeyValueMemory)把「日期」→「2025/12/01」存入 Redis,其他子任務直接讀取。 |
| 開放式創意寫作 | 使用者希望 AI 記住先前的角色設定與世界觀,且可以隨時回顧。 | - ConversationBufferMemory + ConversationSummaryMemory 結合,定時產生「世界觀摘要」存入長期向量庫,讓未來的創作更具一致性。 |
| 金融風控聊天 | 必須符合合規要求,所有對話必須加密存檔且不可被修改。 | - 使用 加密的 MongoDB(AES)作為記憶儲存,saveContext 中加入簽名(HMAC)驗證,確保不可竄改。 |
總結
對話記憶是 LLM 應用的靈魂,自訂記憶配置 能讓開發者根據效能、成本、合規與使用者體驗等多重需求,靈活調整儲存與檢索策略。本文重點回顧如下:
- 了解
BaseMemory介面的兩個核心方法:loadMemoryVariables(讀)與saveContext(寫)。 - 依需求選擇或自訂記憶:Redis、資料庫、向量索引、摘要記憶等皆可自由組合。
- 實作範例:提供了 RedisMemory、HybridMemory(文字 + 向量)兩個完整範例,展示如何在
ConversationChain中掛載自訂記憶。 - 避免常見陷阱:記憶無限制累積、Session ID 混用、同步阻塞等,並提供最佳實踐清單。
- 應用場景:從客服、教育、任務協調到合規金融,皆能透過自訂記憶提升對話品質與業務價值。
掌握了自訂記憶的技巧後,你就能為任何 LangChain 應用打造 彈性、可擴展且符合商業需求 的對話體驗。祝你在開發旅程中玩得開心,寫出更聰明的聊天機器人! 🚀