zeroclaw 的 Memory 是 agent 的長期記憶——它記錄對話歷史、使用者偏好、決策紀錄,並在每次對話前把相關記憶召回注入給 LLM。這篇記錄它如何用一個 trait 統一四種後端(SQLite、Markdown、Lucid、Postgres),以及它最有趣的設計:混合式語意搜尋、Embedding LRU 快取、還有讓 agent 在冷啟動時從 Markdown 文字檔重建「靈魂」的機制。
Memory Trait:七個方法,全部必填
跟 Channel 和 Provider 的設計不同——後兩者都有預設實作——Memory trait 的七個方法全部是必填的:
#[async_trait]
pub trait Memory: Send + Sync {
fn name(&self) -> &str;
async fn store(
&self,
key: &str,
content: &str,
category: MemoryCategory,
session_id: Option<&str>,
) -> anyhow::Result<()>;
async fn recall(
&self,
query: &str,
limit: usize,
session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>>;
async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>>;
async fn list(
&self,
category: Option<&MemoryCategory>,
session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>>;
async fn forget(&self, key: &str) -> anyhow::Result<bool>;
async fn count(&self) -> anyhow::Result<usize>;
async fn health_check(&self) -> bool;
}這反映了「每種後端的搜尋策略都根本不同」——SQLite 用 FTS5 + 向量搜尋,Markdown 用關鍵字比對,Postgres 用 ILIKE——沒有一個合理的預設實作能套用在所有後端,所以索性不提供。
核心資料型別
pub struct MemoryEntry {
pub id: String,
pub key: String,
pub content: String,
pub category: MemoryCategory,
pub timestamp: String,
pub session_id: Option<String>,
pub score: Option<f64>, // 只有 recall() 會填,get()/list() 都是 None
}
pub enum MemoryCategory {
Core, // 長期事實、偏好、決策
Daily, // 每日工作日誌
Conversation, // 對話上下文
Custom(String), // 開放式擴充點
}score 的設計很有趣:它是 recall() 的副產品,代表這筆記憶和查詢的相關度。get() 和 list() 都是精確查找,沒有「相關度」的概念,所以這個欄位在那兩個情境下永遠是 None。Custom(String) 則是一個逃生艙,讓使用者可以自訂任意分類而不需要修改 enum。
四個後端,一個介面
zeroclaw 目前有四個實作(加上一個 no-op):
| 後端 | 搜尋方式 | 特性 |
|---|---|---|
SqliteMemory |
FTS5 + 向量 (cosine) | 推薦預設,單一 .db 檔 |
MarkdownMemory |
關鍵字比對 | 純文字,只能追加 |
LucidMemory |
SQLite + 外部 CLI | 橋接第三方記憶工具 |
PostgresMemory |
ILIKE 模糊比對 |
雲端持久化,無 pgvector |
NoneMemory |
— | 完全 no-op,停用記憶 |
SQLite:真正的大腦
SqliteMemory 是推薦的預設後端,資料存在 workspace/memory/brain.db。它的設計最複雜,也最值得細看。
Schema
三張表,各有不同用途:
-- 主要記憶表
CREATE TABLE memories (
id TEXT PRIMARY KEY,
key TEXT UNIQUE NOT NULL, -- upsert 的 identity key
content TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'core',
embedding BLOB, -- f32 向量,little-endian bytes
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
session_id TEXT
);
-- FTS5 全文搜尋虛擬表
CREATE VIRTUAL TABLE memories_fts USING fts5(
key, content,
content=memories, content_rowid=rowid
);
-- Embedding LRU 快取
CREATE TABLE embedding_cache (
content_hash TEXT PRIMARY KEY, -- SHA-256 前 16 hex chars
embedding BLOB NOT NULL,
created_at TEXT NOT NULL,
accessed_at TEXT NOT NULL -- LRU 用
);FTS5 透過三個 trigger(INSERT、DELETE、UPDATE)和主表保持同步。store() 用的是 INSERT ... ON CONFLICT(key) DO UPDATE——upsert 語意,key 是唯一識別碼,id 是 UUID(每次 upsert 都會更新)。
SQLite 開啟時的 PRAGMA 調校:WAL 模式、8 MB mmap、2 MB page cache、MEMORY temp store。
為什麼用 spawn_blocking?
rusqlite 是 blocking API,直接在 async context 呼叫會卡住 tokio runtime。所有 SQLite 操作都包在 tokio::task::spawn_blocking 裡:
let result = tokio::task::spawn_blocking(move || {
let conn = db.lock();
conn.execute("INSERT INTO memories ...", params![...])?;
Ok(())
}).await??;連線的開啟還多了一層防護——用 worker thread + mpsc::channel 加上 recv_timeout,防止 SQLite 檔案被鎖住時永遠卡住:
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let _ = tx.send(Connection::open(&path));
});
match rx.recv_timeout(Duration::from_secs(capped)) {
Ok(Ok(conn)) => conn,
Ok(Err(e)) => return Err(e.into()),
Err(_) => return Err(anyhow!("SQLite open timed out")),
}混合語意搜尋:FTS5 + 向量
recall() 的實作是整個記憶系統最有趣的部分。它不是單一的搜尋策略,而是三層 fallback:
向量:純 Rust Cosine Similarity
向量以 f32 little-endian bytes 存在 BLOB 欄位。Cosine similarity 是純 Rust 實作,刻意用 f64 累加以減少高維向量的浮點誤差:
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
let mut dot = 0.0_f64;
let mut norm_a = 0.0_f64;
let mut norm_b = 0.0_f64;
for (x, y) in a.iter().zip(b.iter()) {
let x = f64::from(*x);
let y = f64::from(*y);
dot += x * y;
norm_a += x * x;
norm_b += y * y;
}
let denom = norm_a.sqrt() * norm_b.sqrt();
if !denom.is_finite() || denom < f64::EPSILON {
return 0.0;
}
(dot / denom).clamp(0.0, 1.0) as f32
}這是暴力掃描所有有 embedding 的 row,沒有 ANN index(如 HNSW、IVFFlat)。對一個本地 agent 的記憶量來說夠用,不需要引入 pgvector 或外部向量 DB 的依賴。
關鍵字:FTS5 BM25
查詢前先把每個 token 用雙引號包住再用 OR 連接,避免 FTS5 特殊字元造成解析錯誤:
let fts_query = query
.split_whitespace()
.map(|w| format!("\"{w}\""))
.collect::<Vec<_>>()
.join(" OR ");FTS5 的 bm25() 分數是負數(越負越相關),取負後變成越大越好,和 cosine similarity 的方向統一。
混合合併
pub fn hybrid_merge(
vector_results: &[(String, f32)], // (id, cosine)
keyword_results: &[(String, f32)], // (id, -BM25)
vector_weight: f32, // 預設 0.7
keyword_weight: f32, // 預設 0.3
limit: usize,
) -> Vec<ScoredResult> {
// BM25 分數正規化到 [0,1]
// final_score = 0.7 * cosine + 0.3 * normalized_BM25
// 去重:HashMap keyed on id
}合併後的 Top-N ID 用一次 WHERE id IN (...) 批次撈出,避免 N+1 query。
Embedding LRU 快取
每次 store() 都需要為新內容生成 embedding,但相同的文字不應該重複打 API。快取的 key 是內容的 SHA-256 前 16 hex chars——明確選擇 SHA-256 而不是 DefaultHasher,因為後者的輸出在不同 Rust 版本之間不保證穩定。
LRU 淘汰不在 Rust 記憶體裡維護資料結構,而是直接在 SQL 裡做:
-- 保留最新的 N 筆,刪除多餘的舊資料
DELETE FROM embedding_cache WHERE content_hash IN (
SELECT content_hash FROM embedding_cache
ORDER BY accessed_at ASC
LIMIT MAX(0, (SELECT COUNT(*) FROM embedding_cache) - ?1)
)這樣快取的淘汰邏輯完全不依賴記憶體狀態,重啟之後一樣有效。
Embedding Provider
Embedding 本身也是一個 trait:
#[async_trait]
pub trait EmbeddingProvider: Send + Sync {
fn name(&self) -> &str;
fn dimensions(&self) -> usize;
async fn embed(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>>;
// 唯一的預設方法:批次 embed 的單一版本
async fn embed_one(&self, text: &str) -> anyhow::Result<Vec<f32>> {
let mut results = self.embed(&[text]).await?;
results.pop().ok_or_else(|| anyhow!("Empty embedding result"))
}
}兩個具體實作:
NoopEmbedding:dimensions() = 0,回傳空向量。啟用後自動降級為純關鍵字搜尋。OpenAiEmbedding:打/v1/embeddingsendpoint。支援自訂 base URL,用custom:前綴指定:
pub fn create_embedding_provider(provider: &str, api_key: ..., model: &str, dims: usize) {
match provider {
"openai" => Box::new(OpenAiEmbedding::new("https://api.openai.com", ...)),
name if name.starts_with("custom:") => {
let base_url = name.strip_prefix("custom:").unwrap_or("");
Box::new(OpenAiEmbedding::new(base_url, ...)) // 指向 Ollama、本地推理伺服器等
}
_ => Box::new(NoopEmbedding),
}
}MarkdownMemory:只能追加的審計日誌
MarkdownMemory 的 forget() 永遠回傳 false——這個後端是只能追加的,設計上就是個審計日誌:
workspace/MEMORY.md ← Core 類別
workspace/memory/2026-02-19.md ← Daily 類別(按日期分檔)
每次 store() 就在對應的 Markdown 檔尾端追加一行 - **key**: content。recall() 則是把查詢拆成關鍵字,計算每筆記憶裡出現了幾個,算出比例當作分數。
雖然搜尋能力差,但好處是人類可讀、可以直接用 Git 追蹤,也容易備份。
LucidMemory:橋接外部 CLI
LucidMemory 是一個橋接層,SQLite 是主要儲存,外部 lucid-memory CLI 是輔助:
pub struct LucidMemory {
local: SqliteMemory,
recall_timeout: Duration, // 預設 500ms
store_timeout: Duration, // 預設 800ms
local_hit_threshold: usize, // 預設 3:本地結果夠多就不問 Lucid
failure_cooldown: Duration, // 任何錯誤後冷卻 15 秒
last_failure_at: Mutex<Option<Instant>>,
}store() 先寫 SQLite,再 fire-and-forget 同步到 Lucid。recall() 的策略:先問 SQLite,如果結果數量低於 local_hit_threshold 且 Lucid 不在冷卻期,才去呼叫 lucid context <query>,然後合併結果。
去重用的是一個 NUL 字元分隔的簽名:
let signature = format!("{}\u{0}{}", entry.key.to_lowercase(), entry.content.to_lowercase());Lucid 的輸出是自訂的 XML-like 格式:
<lucid-context>
- [decision] Use token refresh middleware
- [context] Working in src/auth.rs
</lucid-context>
方括號裡的標籤(decision、context、bug、learning)會被映射回 MemoryCategory。
Agent 的靈魂:Snapshot 與冷啟動恢復
最讓我覺得有趣的設計是「靈魂快照」機制。
在每次記憶體清理(hygiene)時,如果啟用了 snapshot_on_hygiene,所有 Core 類別的記憶都會被 dump 到工作目錄的 MEMORY_SNAPSHOT.md。這個檔案是 Git 可追蹤的純文字。
更厲害的是冷啟動恢復:如果 brain.db 不存在(例如全新部署、或資料庫損毀刪除),但 MEMORY_SNAPSHOT.md 存在,factory 會在開啟後端之前先呼叫 hydrate_from_snapshot(),把 Markdown 裡的記憶逐筆還原回 SQLite。
每次清理也會刪掉 MemoryCategory::Conversation 類別超過 conversation_retention_days 的舊紀錄——對話上下文是短暫的,不該無限累積。
在 Agent Loop 裡的角色
Memory 在每輪對話的三個時機被使用:
注意幾個細節:
- 相關度門檻:
score >= 0.4才注入,低分記憶不進 context,避免雜訊干擾 LLM。 - 自動儲存:使用者訊息存
Conversation類別(會被清理),助理回應只存前 100 字存Daily類別(會保留較久)。 - LLM 直接操控記憶:
memory_store、memory_recall、memory_forget這三個工具也被當成普通的Tool掛載到 agent 上,LLM 自己可以主動讀寫記憶。
小結
zeroclaw Memory 設計幾個值得注意的地方:
- 全部必填,沒有預設:每種後端的搜尋策略差太多,預設實作沒有意義。
- SQLite 就夠用:FTS5 + 向量暴力搜尋在本地 agent 的規模不需要 pgvector,單一
.db檔沒有外部依賴。 - 三層 fallback 搜尋:Hybrid(FTS5 + 向量)→ BM25 only →
LIKE兜底,優雅降級。 - SHA-256 快取,LRU 在 SQL 裡做:快取邏輯不依賴記憶體狀態,重啟後仍然有效。
- 靈魂快照:
MEMORY_SNAPSHOT.md是 Git 可追蹤的,讓 agent 可以從純文字重建長期記憶,冷啟動不代表失憶。 - Conversation 記憶會被清理:對話上下文是短暫的,設計上就不打算永久保留。