本文 AI 產出,尚未審核

LangChain 課程 – Memory:對話記憶

主題:自訂記憶配置


簡介

在使用 LLM(大型語言模型)建構聊天機器人或問答系統時,記憶(Memory) 扮演關鍵角色。沒有適當的記憶管理,模型每一次回覆只能看到最新的使用者輸入,無法利用先前的對話上下文,結果常常出現斷章取義或前後矛盾的回應。

LangChain 為開發者提供了多種記憶類型(ConversationBufferMemoryConversationSummaryMemoryVectorStoreRetrieverMemory 等),並支援自訂記憶配置,讓你可以依照應用需求調整儲存方式、檢索策略與過期機制。本文將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步掌握如何在 LangChainJS 中打造彈性且效能友好的自訂記憶。


核心概念

1. 記憶的基本介面

在 LangChainJS 中,所有記憶類別皆實作 BaseMemory 介面,最核心的兩個方法是:

方法 說明
loadMemoryVariables(inputs: Record<string, any>) 依據目前的對話輸入,返回需要注入 LLM Prompt 的變數(通常是 historycontext)。
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}} 完全相符。 先寫測試,確保變數名稱匹配,否則模型會收到空字串。

最佳實踐清單

  1. 明確設計 Session ID:使用 uuidv4() 或 JWT 中的 sub,保證唯一且可追蹤。
  2. 分層記憶:先用「短期緩衝」保存最近 5–10 條對話,再用「長期摘要」或「向量檢索」保存重要資訊。
  3. 避免同步阻塞:寫入外部儲存(Redis、資料庫、向量索引)盡量非同步,或在回傳前先 await 重要的寫入(如更新 TTL)。
  4. 監控 Token 使用量:在每次組裝 Prompt 前,使用 tokenizer.encode(prompt).length 檢查是否超過模型上限。
  5. 測試與回溯:在開發階段加入單元測試,驗證 saveContextloadMemoryVariables 的行為,並在生產環境保留 對話日誌(可加密)以便問題排查。

實際應用場景

場景 需求 記憶配置示例
客服聊天機器人 必須在同一會話內保持上下文,且能根據過往對話快速找出相關 FAQ。 - RedisMemory 作為短期緩衝(TTL 24h)
- VectorStoreRetrieverMemory(Pinecone)作為語意檢索
- 每次回覆完畢同步寫入向量索引。
教育輔助問答 需要跨課程、跨日保存學生的提問與解答,並根據歷史表現提供個人化建議。 - 使用 PostgreSQL(或 MongoDB)儲存對話紀錄,配合 ConversationSummaryMemory 每日產生摘要。
- 在 Prompt 中加入 {{summary}} 讓模型參考過往學習概況。
多輪任務協調(如行程規劃) 使用者會在同一次會話中提供多個資訊(日期、地點、偏好),需要在不同子任務間共享資訊。 - 建立 鍵值映射記憶(如 KeyValueMemory)把「日期」→「2025/12/01」存入 Redis,其他子任務直接讀取。
開放式創意寫作 使用者希望 AI 記住先前的角色設定與世界觀,且可以隨時回顧。 - ConversationBufferMemory + ConversationSummaryMemory 結合,定時產生「世界觀摘要」存入長期向量庫,讓未來的創作更具一致性。
金融風控聊天 必須符合合規要求,所有對話必須加密存檔且不可被修改。 - 使用 加密的 MongoDB(AES)作為記憶儲存,saveContext 中加入簽名(HMAC)驗證,確保不可竄改。

總結

對話記憶是 LLM 應用的靈魂,自訂記憶配置 能讓開發者根據效能、成本、合規與使用者體驗等多重需求,靈活調整儲存與檢索策略。本文重點回顧如下:

  1. 了解 BaseMemory 介面的兩個核心方法loadMemoryVariables(讀)與 saveContext(寫)。
  2. 依需求選擇或自訂記憶:Redis、資料庫、向量索引、摘要記憶等皆可自由組合。
  3. 實作範例:提供了 RedisMemoryHybridMemory(文字 + 向量)兩個完整範例,展示如何在 ConversationChain 中掛載自訂記憶。
  4. 避免常見陷阱:記憶無限制累積、Session ID 混用、同步阻塞等,並提供最佳實踐清單。
  5. 應用場景:從客服、教育、任務協調到合規金融,皆能透過自訂記憶提升對話品質與業務價值。

掌握了自訂記憶的技巧後,你就能為任何 LangChain 應用打造 彈性、可擴展且符合商業需求 的對話體驗。祝你在開發旅程中玩得開心,寫出更聰明的聊天機器人! 🚀