zeroclaw 的 Memory 是 agent 的長期記憶——它記錄對話歷史、使用者偏好、決策紀錄,並在每次對話前把相關記憶召回注入給 LLM。這篇記錄它如何用一個 trait 統一四種後端(SQLite、Markdown、Lucid、Postgres),以及它最有趣的設計:混合式語意搜尋、Embedding LRU 快取、還有讓 agent 在冷啟動時從 Markdown 文字檔重建「靈魂」的機制。

Memory Trait:七個方法,全部必填

ChannelProvider 的設計不同——後兩者都有預設實作——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() 都是精確查找,沒有「相關度」的概念,所以這個欄位在那兩個情境下永遠是 NoneCustom(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:

graph TD A["recall(query, limit)"] --> B{有設定 Embedding provider?} B -->|是| C[生成查詢向量] C --> D[FTS5 BM25 搜尋] D --> E["hybrid_merge(向量結果, 關鍵字結果)"] E --> F[回傳 Top-N] B -->|否| G[純 BM25 搜尋] G --> F D -->|FTS5 解析錯誤| H["LIKE %keyword% 兜底"] H --> F

向量:純 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"))
    }
}

兩個具體實作:

  • NoopEmbeddingdimensions() = 0,回傳空向量。啟用後自動降級為純關鍵字搜尋。
  • OpenAiEmbedding:打 /v1/embeddings endpoint。支援自訂 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:只能追加的審計日誌

MarkdownMemoryforget() 永遠回傳 false——這個後端是只能追加的,設計上就是個審計日誌:

workspace/MEMORY.md                  ← Core 類別
workspace/memory/2026-02-19.md       ← Daily 類別(按日期分檔)

每次 store() 就在對應的 Markdown 檔尾端追加一行 - **key**: contentrecall() 則是把查詢拆成關鍵字,計算每筆記憶裡出現了幾個,算出比例當作分數。

雖然搜尋能力差,但好處是人類可讀、可以直接用 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>

方括號裡的標籤(decisioncontextbuglearning)會被映射回 MemoryCategory


Agent 的靈魂:Snapshot 與冷啟動恢復

最讓我覺得有趣的設計是「靈魂快照」機制。

在每次記憶體清理(hygiene)時,如果啟用了 snapshot_on_hygiene,所有 Core 類別的記憶都會被 dump 到工作目錄的 MEMORY_SNAPSHOT.md。這個檔案是 Git 可追蹤的純文字。

更厲害的是冷啟動恢復:如果 brain.db 不存在(例如全新部署、或資料庫損毀刪除),但 MEMORY_SNAPSHOT.md 存在,factory 會在開啟後端之前先呼叫 hydrate_from_snapshot(),把 Markdown 裡的記憶逐筆還原回 SQLite。

graph LR A["brain.db 存在?"] -->|是| B[正常開啟 SqliteMemory] A -->|否| C["MEMORY_SNAPSHOT.md 存在?"] C -->|是| D[hydrate_from_snapshot] D --> E[重新開啟 SqliteMemory] C -->|否| F[全新 SqliteMemory] B --> G[完成] E --> G F --> G

每次清理也會刪掉 MemoryCategory::Conversation 類別超過 conversation_retention_days 的舊紀錄——對話上下文是短暫的,不該無限累積。


在 Agent Loop 裡的角色

Memory 在每輪對話的三個時機被使用:

sequenceDiagram participant U as 使用者 participant AL as Agent Loop participant M as Memory participant LLM as LLM U->>AL: 送出訊息 AL->>M: recall(user_msg, 5) M-->>AL: 相關記憶(score >= 0.4 才納入) AL->>LLM: [Memory context]\n- key: content\n...\n{user_msg} LLM-->>AL: 回應 AL->>M: store("user_msg_{uuid}", msg, Conversation) AL->>M: store("assistant_resp_{uuid}", summary[:100], Daily) AL-->>U: 最終答覆

注意幾個細節:

  • 相關度門檻score >= 0.4 才注入,低分記憶不進 context,避免雜訊干擾 LLM。
  • 自動儲存:使用者訊息存 Conversation 類別(會被清理),助理回應只存前 100 字存 Daily 類別(會保留較久)。
  • LLM 直接操控記憶memory_storememory_recallmemory_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 記憶會被清理:對話上下文是短暫的,設計上就不打算永久保留。