iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
AI Engineering

30天拆Agent:從Repo看設計系列 第 20 篇

Day 20|外部 Memory Service 之後:ai-memory 為什麼把共享記憶做成 Git-backed Project Wiki?

  • 分享至 

  • xImage
  •  

上一篇看 Hindsight 時,已經看到一種做法:把 Memory 從 Agent Runtime 抽出來,放進獨立的 Memory Service。

今天看 akitaonrails/ai-memory,是它選擇把長期 Memory 做成一份人也能直接閱讀、修改,而且能用 Git 留下版本歷史的 Project Wiki。

例如航空客服 Project 累積一段時間後,Memory 可能真的就是:

flight/
├── decisions/
├── gotchas/
├── procedures/
├── concepts/
└── sessions/

1. ai-memory 先怎麼接進 Agent?

以 Claude Code 為例,ai-memory 會安裝兩條整合:

ai-memory install-mcp \
  --client claude-code \
  --apply

ai-memory install-hooks \
  --agent claude-code \
  --apply

兩者分工很簡單:

MCP
→ Agent 主動查 / 寫 Memory

Hook
→ Agent 工作時,自動把事件送進 ai-memory

例如使用者問:

之前這個 Project
有沒有碰過 cancellation code 的問題?

Agent 可以透過 MCP 呼叫:

memory_query

去查既有 Memory。

另一方面,Agent 工作過程中的:

SessionStart
UserPromptSubmit
PreToolUse
PostToolUse
SessionEnd

會透過 Hook 自動送進 ai-memory,不需要 Agent 額外決定「現在要記住這件事」。

所以一次 Session 可以簡化成:

User 問問題
    ↓
Agent 工作
    ↓
Prompt / Tool Call / Tool Result
    │
    ├── Hook → ai-memory 記錄工作過程
    │
    └── MCP  → Agent 查詢或寫入既有 Memory

install-hooks 會依 Agent 的 lifecycle API,建立事件對照。例如 Claude Code 的 PostToolUse 會接到 ai-memory 的 post-tool-use hook;Codex 也有自己的事件對照表。這些 mapping 寫在 render_shared.rs。

ai-memory 是把 Agent Runtime 原本提供的 Lifecycle Event 接進自己的 Memory pipeline,這些由 Hook 收進來、還沒整理成長期知識的工作紀錄,在 ai-memory 裡叫做 Observation。

例如:

PostToolUse

tool: query_ticket
result:
{
  "status_code": 3
}

這時它只是:

Agent 做過什麼的原始紀錄

還不是:

gotchas/cancellation-code.md

也就是:

Hook 先負責收集;後面的 Consolidation 才決定哪些 Observation 值得整理成長期 Memory。


2. 工作紀錄什麼時候才真的變成長期 Memory?

Hook 收到的 Observation 只是原始工作紀錄,不會每次都立刻呼叫 LLM。

只有在需要進行 Consolidation 時,ai-memory 才會把這些 Observation 交給 Consolidator 整理。

常見觸發方式包括:

觸發方式 什麼時候發生 是否需要額外設定
memory_consolidate 使用者要求整理 Memory,Agent 透過 MCP 呼叫這個 Tool 不需要改程式碼,但要有 MCP
PreCompact Agent Context 要進行壓縮前,由 Hook 觸發 Consolidation 需要安裝 Hook,並有 LLM Provider
SessionEnd Session 結束時自動整理 要另外開 AI_MEMORY_CONSOLIDATE_ON_SESSION_END=true,預設關閉

真正觸發 Consolidation 後,才會進入:

crates/ai-memory-consolidate/src/consolidator.rs

Session Observations
        ↓
Consolidator
        ↓
讀既有 Project Wiki
        ↓
呼叫設定的 LLM
        ↓
產生 Structured Memory
        ↓
寫回 Wiki

其中 consolidate_session(...) 就是負責把一段 Session 的 Observation 整理成長期 Memory 的主要入口。

所以可以把兩者的關係記成一句話:

觸發條件決定「什麼時候要整理」,Consolidator 負責「真的怎麼整理」。


3. Memory Extract 可以自訂,但有明確限制

LLM 整理 Session 時,可以把 Memory 分成什麼?

這裡 ai-memory 沒有完全交給 Prompt 自由決定。

它先固定了一組 PageKind:

crates/ai-memory-consolidate/src/types.rs

pub enum PageKind {
    Rule,
    Decision,
    Gotcha,
    Procedure,
    Fact,
}
Kind 意義
Fact 一般值得留下的事實
Decision Project 已經做出的決策
Gotcha 已知陷阱、容易踩到的坑
Procedure 可以重複執行的流程
Rule Project 應長期遵守的規則

例如剛才的航空 API:

status_code=3
容易被開發者誤認成 customer cancellation

LLM Consolidation 可以判斷:kind = gotcha

最後形成像:gotchas/cancellation-code.md

# Cancellation code 3

在 provider X 中,`status_code=3`
代表 flight cancellation。

不能直接套用一般 customer-initiated
change flow。

這個 PageKind 不是 Prompt 裡隨便寫的文字,而是 Rust enum,也會進 Structured Output Schema。

因此使用者不能只設定:

memory_types = [
  "fact",
  "preference",
  "customer_profile"
]

就讓系統多出:

Preference
CustomerProfile

這是 ai-memory 對 Memory Extraction 的第一個限制:

可以調整整理內容,但不能只靠設定任意新增 Memory Kind。


那 Preference 要怎麼存?

ai-memory 有一種特殊 Wiki Page:_slots/,適合放少量、之後 Session 經常需要知道的 Context。

例如:

使用者偏好:
技術文章不要中英混雜,
Code 名稱除外。

可以做成:_slots/writing-preference.md

Markdown frontmatter 寫:

---
slot_kind: invariant
---

ai-memory 原始碼對 Slot 的分類是:

pub enum SlotKind {
    Invariant,
    State,
}

兩種意思很簡單:

Invariant
→ 比較穩定的背景 / Preference
→ 不應該隨便被覆寫

State
→ 目前工作狀態
→ 本來就會一直變

例如:

---
slot_kind: state
---

目前正在修改 Day 20。
下一步要確認 Git / SQLite。

所以PageKind和SlotKind回答的是不同問題。

設計 回答的問題
PageKind 這份長期 Knowledge 是 Fact、Decision、Gotcha 還是 Procedure?
SlotKind 這個少量 Context 是穩定資訊,還是目前工作狀態?

實際使用 Slot 時,改的是 Markdown 的:

slot_kind: invariant

或:

slot_kind: state

那 _prompts/consolidation.md 又是什麼?

它不是 Memory,它是在告訴 Consolidator:

這個 Project 整理 Memory 時,什麼東西比較重要?

例如航空客服可以建立:

_prompts/consolidation.md

內容:

優先保留:

- 航空公司 API 的特殊 mapping
- 已確認的票規
- User 長期偏好
- 曾經造成錯誤的 API 行為

忽略:

- package install log
- 一次性的 debug output
- routine command output

Consolidation 時,ai-memory 會把這段文字放進 Prompt,影響 LLM 選擇與整理資訊的方式,程式裡直接定義:

pub const PROJECT_INSTRUCTIONS_PATH: &str =
    "_prompts/consolidation.md";

但是它只是整理偏好,它不能把:PageKind = Fact / Decision / Gotcha / Procedure / Rule,改成:PageKind = Fact / Preference / CustomerProfile

東西 做什麼 使用者怎麼改
PageKind 限制 LLM 最後輸出的主要 Memory 類別 不能靠一般 config 新增
_slots/*.md 保存穩定 Preference 或目前 State 編輯 Markdown + slot_kind
_prompts/consolidation.md 告訴 LLM 整理時要重視 / 忽略什麼 直接編輯 Markdown

所以如果需求是:「航空客服特別重視票規與 API 踩坑。」,寫_prompts/consolidation.md就可以。


4. Memory 寫成 Markdown 後,Git 與 SQLite 分別做什麼?

Consolidation 最後會呼叫 Wiki::write_page(),把整理好的 Memory 寫進 Project Wiki。例如:

workspace = customer-service
project   = flight
path      = gotchas/cancellation-code.md

實際會產生:

wiki/
└── customer-service/
    └── flight/
        └── gotchas/
            └── cancellation-code.md

這份 Markdown 才是 Memory 的 Source of Truth;Git 負責留下它的修改歷史,SQLite 則負責讓 Agent 快速搜尋這些 Markdown。

Git:留下 Memory 的版本歷史

<data_dir>/wiki/ 本身就是一個真正的 Git Repository,Wiki::new() 會透過:

GitAdapter::open_or_init(&root)

初始化 Git,而 commit_all() 負責建立 Commit。ai-memory 使用 Rust git2 / libgit2 操作 Git,不是每次 shell out 執行 git commit,但產生的仍是一般 Git 歷史。

例如 Memory 一開始是:

status_code=3 代表 flight cancellation

後來修正成:

只有 provider X 的 status_code=3
才代表 flight cancellation

這兩個版本會留在 Git history,因此可以直接:

cd <data_dir>/wiki

git log
git diff
git show <commit>

查看 Memory 什麼時候被修改、前後差了什麼。

SQLite:替 Markdown 建搜尋索引

如果 Wiki 已經有幾千份 Markdown,Agent 查:

之前有沒有碰過 flight cancellation mapping?

不需要逐一打開所有 .md。

ai-memory 會同步維護:

<data_dir>/db/memory.sqlite

SQLite 裡保存搜尋需要的索引,例如 FTS5 全文搜尋、Entity、Link,以及有設定 Embedding Provider 時的 Vector 資訊。memory_query 先透過這些 Index 找到相關 Page,例如:

gotchas/cancellation-code.md

再讀真正的 Markdown。

SQLite 的更新也有兩條路:

ai-memory 自己寫 Memory
→ Wiki::write_page()
→ Markdown + SQLite 一起更新

使用者直接修改 .md
→ Watcher 偵測變更
→ 更新 SQLite

所以即使直接用 VS Code、Obsidian 或 vim 修改 Markdown,watcher.rs 仍會把變更同步回搜尋索引;如果 SQLite 損壞,也能重新掃描 Markdown,透過 Reindex 重建。

整條流程可以縮成:

Observation
    ↓
Consolidator + LLM
    ↓
Wiki::write_page()
    ↓
gotchas/cancellation-code.md
    │
    ├── Git
    │   → 留版本歷史
    │
    └── SQLite
        → 建搜尋索引
        → memory_query 找回這份 Markdown

所以 Git-backed Markdown Wiki + derived SQLite index 的意思很直接:Markdown 是真正要保留的 Memory,Git 記錄它怎麼改過,SQLite 則讓 Agent 能快速找到它。


5. 之後另一個 Agent 怎麼找到這份 Memory?

現在才需要回到「跨 Agent」。

假設早上是 Claude Code 發現:gotchas/cancellation-code.md,下午換 Codex。

Codex 不需要知道 Claude Code 的原始 Session State,它只要透過 MCP 呼叫:memory_query查:

flight cancellation
status code

ai-memory 使用 SQLite Index 找到候選 Page,再回到真正的 Markdown Wiki。

概念是:

Codex
   │
   │ memory_query
   ▼
SQLite Search Index
   │
   │ 找到
   ▼
gotchas/cancellation-code.md
   │
   ▼
回傳 Project Knowledge

所以兩個 Agent 真正共享的是外面的:Project Wiki

這也解釋了為什麼 ai-memory 可以接不同 Runtime。

共同介面是:MCP → 查 / 寫 Memory

而自動 Capture 的部分則依 Agent 使用它自己的 integration。

例如:

Claude Code
→ Lifecycle Hook

Codex
→ Lifecycle Hook

OpenClaw
→ Generated TypeScript Plugin

OpenClaw 的 integration 實作在:

crates/ai-memory-cli/src/commands/openclaw_plugin.rs

所以不是所有 Agent 都硬套同一份 Hook Script。

不同 Runtime 各自接到同一個 ai-memory Server,最後讀寫同一套 Project Wiki。


References


上一篇
Day 19|Codex、Claude、OpenClaw 都有 Memory,為什麼還需要 Hindsight?
下一篇
Day 21|TeamAI CLI:不同 Agent,怎麼共用同一套 Skill、Memory 與工作方式?
系列文
30天拆Agent:從Repo看設計 共 23 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言