長期記憶 — RAG#

CH06 中期記憶 解決「特定對話接續」,但跨對話的事實依然丟失:

session A: "我叫 Alex"
session B: "我叫什麼?"  → 模型不知道

解法是 RAG(Retrieval-Augmented Generation):抽出跨對話穩定的事實,存進記憶庫,需要時撈回來。 加上 RAG 之後,同一組 session 變這樣:

session A: "我叫 Alex"  → model 呼叫 remember → 存進記憶庫

... 之後任何 session ...

session B: "我叫什麼?" → model 呼叫 recall  → "你叫 Alex"

7.1 整體設計#

兩個工具#

RAG 在 minimal-agent 裡是兩個工具:

工具何時做什麼
rememberuser 說出值得記的事把事實存進記憶庫
recallmodel 想撈跨 session 事實撈出語意最相關的記憶

實作上就是 CH02 那份工具清單再多兩個分支。 Model 根據工具的 description 判斷什麼時候應該呼叫哪個工具:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
    "name": "remember",
    "description": "Save a fact, preference, or note to long-term memory "
                   "that persists across sessions. Use this when the user "
                   "shares something worth remembering — their name, "
                   "preferences, project context.",
    "input_schema": {
        "type": "object",
        "properties": {"text": {"type": "string"}},
        "required": ["text"],
    },
},
{
    "name": "recall",
    "description": "Search long-term memory via semantic similarity. "
                   "Use this whenever the user might be referencing "
                   "something stored from a previous session.",
    "input_schema": {
        "type": "object",
        "properties": {"query": {"type": "string"}},
        "required": ["query"],
    },
},

RAG 工作流程#

整個流程涉及四個零件:Embedding + Voyage AI 負責字串 → 向量;JSONL 負責持久化;Cosine 算相似度。 7.2 ~ 7.5 各自展開。

整個 round 涉及四個角色(User、Executor、Model、Voyage AI):

%%{init: {'sequence': {'noteAlign': 'left'}}}%%
sequenceDiagram
    participant User
    box AI Agent
        participant Executor
    end
    participant Model
    participant Voyage as Voyage AI

    User->>Executor: 訊息
    Executor->>Model: messages + tools (remember / recall)
    Note over Model: 看 description<br/>決定叫哪個
    alt remember
        Model-->>Executor: tool_use: remember(text)
        Executor->>Voyage: embed(text, input_type="document")
        Voyage-->>Executor: vector
        Note over Executor: append 一行到 store.jsonl
        Executor->>Model: tool_result: "Remembered"
    else recall
        Model-->>Executor: tool_use: recall(text)
        Executor->>Voyage: embed(text, input_type="query")
        Voyage-->>Executor: query_vec
        Note over Executor: cosine 比對 + 取 top-K
        Executor->>Model: tool_result: 相關記憶
    end
    Model-->>User: 答案

7.2 為什麼需要 Embedding#

關鍵字搜尋有限制:

存的:    "User name is Alex"
查的:    "what does the user call themselves"
關鍵字命中:0  ← 連 "user" 都沒重疊到嗎?實際情況更慘

Embedding 把句子映射成 向量(vector) — 也就是高維語意空間裡的座標。 語意接近的句子(「user’s name」、「what’s their name」、「他叫什麼」)在這個空間裡會落在很近的位置,即使字面上完全不重疊。

Text                        Action       Vector
"User name is Alex"         ──embed──▶  [0.12, -0.45, 0.81, ...]
"What's the user's name?"   ──embed──▶  [0.10, -0.43, 0.79, ...]   ← 語意接近
"How's the weather today?"  ──embed──▶  [0.85,  0.02, -0.31,...]   ← 天差地別

7.3 Voyage AI 的角色#

Voyage AI 是 純粹的 text → vector 翻譯機:你給它 text,它把每段 text 透過 embedding 算成 vector 回傳 — Voyage 端不存任何資料。 拿到的 vector 你自己存到本地(本章用 JSONL,見 7.4)。

text  ──Voyage embed──▶  vector  ──▶  本地存(7.4 JSONL)

Anthropic 自己沒有 embedding API、官方推薦 Voyage:

  • 申請快、免費 50M tokens / 月
  • voyage-3-lite 速度快、品質夠
  • 包很輕(pip install voyageai

為什麼存 vector 而不是即時算#

方案每次 recall 的 API call 數
存 vector(本章)1 次(只 embed query)
即時算N+1 次(embed query + 重新 embed 每筆記憶)

幾百筆的記憶就讓「即時算」貴上幾百倍、慢上幾百倍。 embedding 算一次存著用 是 RAG 的標準做法 — 也是為什麼 7.1 序列圖裡 recall 分支只 embed query、不重新 embed 每筆記憶。

為什麼存入跟查詢要分開 embed#

Voyage(以及多數現代 embedding model)支援 asymmetric retrieval:存入時用 input_type="document"、查詢時用 input_type="query"。 模型內部對這兩種做不同優化(query 通常較短、語意較開放;document 較長、結構較穩定)。 7.1 序列圖的兩個分支用不同 input_type 就是這個原因。


7.4 儲存格式:JSONL#

從 Voyage 拿到的 vector(JSON 裡放在 embedding 欄位 — 業界慣稱)連同原文、id、時間戳打包成一行 JSON,append 到 memory/store.jsonl — 每筆記憶一行:

memory/store.jsonl
{"id": "abc12345", "text": "User name is Alex", "embedding": [...], "tags": [], "created": ...}
{"id": "def67890", "text": "Prefers Python over...", "embedding": [...], ...}
{"id": "ghi54321", "text": "Working on minimal-agent project", ...}
  • append-only:每次 remember 加一行,從不改舊資料
  • 可肉眼 inspect:純文字,可 grep、cat、git diff
  • 零依賴:不需要資料庫、向量索引、ANN library
  • 載入快:啟動時讀整個檔案到記憶體 list

幾百筆記憶用 linear scan + cosine 全掃一次 < 10ms。要到幾百萬筆才需要 FAISS / Chroma / pgvector。


7.5 為什麼需要 Cosine#

recall 把 query text 傳給 Voyage 後拿回 query vector(7.1 序列圖 recall 分支的第一步),但 vector 本身只是個高維座標 — 要拿它跟 store.jsonl 裡每筆記憶 的 vector 比一輪、挑出語意最接近的前 K 筆(top-K)才有用。 「比相似度」就是 cosine 出場的地方

兩向量的「相似度」用夾角衡量:

夾角語意cos
完全相同1.0
90°無關0.0
180°完全相反-1.0

cos 在 [0°, 180°] 單調遞減:值越大越像。 完美的排序信號 — 而且只要點積 + 範數,計算便宜,高維向量比快。

為什麼不是 Euclidean distance? 對 normalize 過的向量(多數 embedding model 都會 normalize),cosine 跟 Euclidean 排序結果等價(cos 越大 ⟺ 距離越小)。 但 Euclidean 要開平方根、cosine 不用 — 算幾百萬筆時這個常數差就有感。

為什麼只回 top-K 不全部回#

cosine 算完之後手上有每筆記憶 的相似度,但不會把全部塞給 model — 按相似度排序、只回前 K 筆(K 是預設值、例如 3 或 5)。 兩個原因:

  • 省 token — 把幾十、上百筆記憶全塞 context 會擠走真正有用的訊息
  • 避免雜訊稀釋 — 排到後段的記憶可能根本不相關,model 看到反而干擾判斷

也有實作改用「相似度門檻」(例如只回 cosine ≥ 0.7 的)取代固定 K — trade-off 是低相關度查詢可能一筆都回不出來,model 拿到空陣列要自己處理。 minimal-agent 選固定 K,對幾百筆規模夠用。


7.6 設計取捨:Tool-Driven vs Auto-Injection#

兩種 RAG 模式:

模式做法優缺
Auto-Injection每次 user message 之前,自動 recall 注入 system context不會漏;但每次都消耗 token,且模型沒得選
Tool-Drivenremember / recall 當工具暴露,模型自己決定何時呼叫可觀察、省 token、模型有判斷力;但偶爾會漏

我們選 Tool-Driven。理由:

  • 省 token — 用不到的時候不浪費
  • 可觀察 — 每次決策是顯式 tool_use,能看見軌跡
  • 保留控制權 — 模型判斷「值不值得存」、「該不該查」

讓 tool-driven 真的能 work 的關鍵是 description 寫得精準(範例見 7.1)— 模型靠那段話判斷何時叫。


7.7 跨 session 場景#

兩個 session 看 remember / recall 怎麼讓事實跨對話存活:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
session A:
  $ python minimal_agent.py
  you> 我叫 Alex,正在用 Python 寫 minimal-agent
    [model 呼叫 remember("Alex, building minimal-agent in Python")]
              embed → 一行寫進 memory/store.jsonl
  claude> 好的。

  ... 對話結束 / 程式關閉 ...

session B(一個月後,全新對話、messages 空的):
  $ python minimal_agent.py
  you> 我之前用什麼語言寫 agent?
    [model 呼叫 recall("user's programming language")]
              embed query → cosine 比對 store.jsonl
              top-K: [{"text": "Alex, building minimal-agent in Python", ...}]
  claude> 你用 Python。

關鍵:session B 是完全新的對話messages list 看不到 session A 的歷史,但 model 透過 recall 從 JSONL 撈出跨 session 的事實,依然答得出來 — 這就是長期記憶在做的事。


階段檢查點#

到這裡你應該理解:

  • 長期記憶 = 跨對話的事實 — 名字、偏好、決策⋯⋯不綁特定 session
  • embedding 算一次存著用 — 記憶 vector 永遠不重算,retrieval 階段只 embed query
  • JSONL append-only 就夠 — 純文字、可 inspect、零依賴,幾百萬筆才換 FAISS / Chroma
  • Tool-driven 不是 Auto-injection — 選 tool 讓 model 自己決定何時叫,省 token、可觀察

三層記憶組起來#

CH05 短期記憶 到本章,三層記憶的全貌:

維度短期記憶(CH05)中期記憶(CH06)長期記憶 RAG(CH07)
儲存位置self.messages(記憶體)sessions/<name>.jsonmemory/store.jsonl + embedding
生命週期程式跑著的時候跨 session(手動 save / load)永久
容量受 context window 限制~ context window幾乎無限(語意檢索)
用途當前對話的脈絡接續一段未完成的對話事實、偏好、跨對話的知識

層之間的轉換靠這兩個機制:

  • 短期 → 中期:使用者打 /save <name> 把當前 messages list 寫到磁碟
  • 任意 → 長期:模型呼叫 remember(text) 工具把該記住的事實塞進 RAG store

每層內部的零件:

短期(CH05):context window 管理
  ├── token 預算追蹤 + 自動 trim
  └── /compact 壓縮歷史

中期(CH06):對話持久化
  ├── /save → sessions/<name>.json
  ├── /load → 載回 messages list
  └── --resume → CLI 捷徑

長期(CH07):RAG
  ├── remember → embed + JSONL append
  ├── recall   → embed + cosine + top-K
  └── Voyage AI + JSONL append-only store

三層各司其職、不互搶責任:

  • 短期 — 治當下對話太長超出 context window
  • 中期 — 治單一對話跨 session 接續
  • 長期 — 治知識跨任何對話累積

加總起來:agent 同時知道「現在講到哪、上次聊到哪、用戶長期是誰」。


RAG 延伸方向#

本章用 Voyage + JSONL + linear scan 做出 minimum viable RAG。 若要往更大規模 / 更精緻的方向走:

  • Auto-RAG — 不靠 model 決定,每次 user message 之前自動 recall 注入 system context(7.6 tool-driven 的反向)
  • 替換 embedding modelvoyage-3-lite 換成 voyage-3 提升品質、或改本地 embedding model 省 API 成本
  • ANN library — 幾百萬筆規模時 linear scan 太慢,改用 FAISS / Chroma / pgvector 加 index
  • 記憶整理 — 重複合併、舊記憶過期、分類 permanent / temporary
  • 多用戶隔離 — 多人共用 agent 時記憶按 user_id 分開存

參考資源#