IronClaw 学习笔记 03:持久化与记忆

Workspace 虚拟文件系统、FTS + Vector 混合搜索、RRF 融合算法、记忆效果评测

March 10, 2026·20 min read·Yimin
#Rust#AI#Agent#IronClaw#学习笔记#Memory#Vector Search

上一篇我们的 Agent 能思考、能行动了,但它有一个致命缺陷——金鱼记忆。每次重启,一切归零。

🎯 这一篇要解决什么问题?

Phase 02 结束时,tiny-claw 已经是一个完整的 ReAct Agent——能理解用户意图、调用工具、多轮推理。但所有对话都存在内存里。Ctrl+C 之后,Agent 忘记了一切。

这不只是"不方便"的问题。看一个场景:

┌─────────────────────────────────────────────────────────────┐
│  周一 10:00                                                  │
│  ┌─────────────────────────────────────────────────────┐    │
│  │ 你: 我在做一个 Rust 项目,数据库用的 PostgreSQL        │    │
│  │ Agent: 好的,PostgreSQL 是一个很好的选择...           │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  周三 14:00(新会话)                                        │
│  ┌─────────────────────────────────────────────────────┐    │
│  │ 你: 帮我写个查询语句                                  │    │
│  │ Agent: 好的!请问你用的是什么数据库?                  │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  😅 它完全忘了你用的是 PostgreSQL                            │
└─────────────────────────────────────────────────────────────┘

一个个人 AI 助手如果记不住你说过什么、做过什么、喜欢什么,那它永远只是一个陌生人。记忆是个人助手和通用 chatbot 的分水岭

IronClaw 用一句话概括了它的记忆哲学:

"Memory is database, not RAM." —— 如果你想记住什么,就写下来。

这一篇我们来理解 IronClaw 的持久化架构,然后在 tiny-claw 中完整实现它:

  1. Workspace —— 用数据库模拟文件系统,让 Agent 有一个"虚拟硬盘"
  2. Embedding —— 把文本转成向量,让计算机"理解"语义
  3. FTS + Vector 混合搜索 —— 关键词匹配和语义搜索双引擎
  4. RRF 融合算法 —— 把两种搜索结果合成一个最终排序
  5. Identity Files —— 用 Markdown 文件定义 Agent 的人格
  6. Memory Tools —— 让 Agent 主动读写自身记忆
  7. 记忆效果评测 —— 怎么衡量记忆系统好不好

🧠 从 OpenCode 到 IronClaw:记忆系统的概念迁移

| OpenCode | IronClaw | 核心差异 | |------|---------|----------|---------| | 会话存储 | SQLite + Session | libSQL/PostgreSQL + Conversation | IronClaw 支持双数据库后端 | | 记忆模型 | Session Rules | Workspace 虚拟文件系统 | IronClaw 有结构化的文件层级 | | 搜索 | 无 | FTS + Vector + RRF 融合 | IronClaw 有完整的混合搜索 | | 向量化 | 无 | Embedding Provider 抽象 | 支持 OpenAI / Ollama / NearAI | | 上下文构建 | 硬编码 system prompt | Identity Files 动态组装 | IronClaw 的 system prompt 从文件中读取 | | 跨会话 | Rules 文件 | MEMORY.md + Daily Logs | IronClaw 区分长期记忆和每日记录 |


🏛️ Workspace:数据库里的虚拟文件系统

IronClaw 最巧妙的设计之一是 Workspace —— 它用数据库模拟了一个文件系统。Agent 可以像操作文件一样读写记忆,但数据实际存在数据库表里。

为什么不用真正的文件系统?

三个原因:

  1. 搜索 —— 文件系统没有全文搜索和向量搜索的能力,而数据库天然支持
  2. 事务 —— 数据库的 ACID 保证避免了并发读写导致的数据损坏
  3. 部署灵活性 —— 数据库可以是本地 SQLite(嵌入式)、也可以是远程 PostgreSQL(服务器),甚至可以是 Turso(边缘计算)

虚拟文件结构

Workspace 的目录层级不是"真实的"——它是通过文档路径(path 字段)模拟的:

workspace/
├── README.md              ← 根目录说明
├── MEMORY.md              ← 长期记忆(核心)
├── HEARTBEAT.md           ← 定时任务清单
├── IDENTITY.md            ← Agent 名字、风格
├── SOUL.md                ← 核心价值观
├── AGENTS.md              ← 行为指令
├── USER.md                ← 用户信息
├── daily/                 ← 每日日志
│   ├── 2026-03-09.md
│   └── 2026-03-10.md
└── projects/              ← 任意自定义结构
    └── alpha/
        └── notes.md

在数据库里,这只是 memory_documents 表的不同行——path 字段是 "MEMORY.md""daily/2026-03-09.md" 等字符串。"目录"是虚拟的,通过路径前缀匹配来列出。

两张核心表

职责关键字段
memory_documents存储文档("文件")id, path, content, metadata
memory_chunks存储文档的分块(用于搜索)id, document_id, chunk_index, content, embedding

为什么需要 Chunks?因为一个文档可能很长(比如 MEMORY.md 积累了几个月的记忆),但搜索需要精确定位到相关段落。所以 IronClaw 把每个文档切成 800 字左右的块,每个块独立索引。

分块策略

┌─────────────────────────────────────────────────────────────┐
│                    文档分块示意                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  原始文档(2000 词)                                         │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 段落 1... 段落 2... 段落 3... 段落 4... 段落 5...    │  │
│  └──────────────────────────────────────────────────────┘  │
│                          │                                  │
│                          ▼                                  │
│  分块结果(800 词/块,15% 重叠)                            │
│                                                             │
│  Chunk 0:  ████████████████████                             │
│                          |||  ← 15% 重叠                    │
│  Chunk 1:             ███████████████████                   │
│                                      |||  ← 15% 重叠       │
│  Chunk 2:                         ██████████████            │
│                                                             │
│  重叠保证上下文不断裂:                                      │
│  如果一个句子跨越分块边界,重叠区域能完整保留它              │
│                                                             │
└─────────────────────────────────────────────────────────────┘

配置参数:

  • 每块约 800 词(≈800 token)
  • 相邻块有 15% 重叠,保证上下文不断裂
  • 太短的尾部块(<50 词)合并到前一块

💎 Embedding:把文字变成"含义"

搜索的核心挑战是:怎么找到"含义相近"但"用词不同"的内容?

传统的关键词搜索只能匹配字面文本。搜"开灯"找不到"打开照明设备",因为没有相同的词。但人类一眼就知道它们是同一个意思。

Embedding(向量嵌入)解决了这个问题——它把文本转换成一组浮点数(向量),语义相近的文本在向量空间中距离更近。

Embedding 是怎么工作的?

┌─────────────────────────────────────────────────────────────┐
│                    Embedding 过程                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  输入文本                                                    │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ "用户喜欢深色模式,不喜欢亮色主题"                    │   │
│  └─────────────────────────────────────────────────────┘   │
│                          │                                  │
│                          ▼                                  │
│                   Embedding 模型                            │
│           (如 text-embedding-3-small)                       │
│                          │                                  │
│                          ▼                                  │
│  输出向量 (1536 维)                                         │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ [0.0231, -0.0892, 0.1547, ..., -0.0034]             │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  这 1536 个数字"编码"了原文的语义                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

关键直觉:向量之间的距离 = 语义之间的距离。

┌─────────────────────────────────────────────────────────────┐
│                    语义空间示意                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│     "深色模式"                "暗黑主题"                     │
│        ●──────────────────────●  距离很近(语义相同)        │
│                                                             │
│     "深色模式"                "数据库配置"                   │
│        ●                          ●  距离很远(语义无关)    │
│                                                             │
│     "SSH 密钥"                "服务器登录凭据"               │
│        ●──────────────────────●  距离较近(语义相关)        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

IronClaw 的 Embedding 提供者

IronClaw 设计了一个 EmbeddingProvider trait,抽象了不同的 Embedding API:

提供者模型向量维度适用场景
OpenAItext-embedding-3-small1536云端,精度高
OpenAItext-embedding-3-large3072云端,精度更高
Ollamanomic-embed-text768本地部署,零成本
Ollamaall-minilm384本地部署,轻量

这个抽象让记忆系统不绑定任何特定的 API——想用 OpenAI 就用 OpenAI,想省钱用本地 Ollama 也行。

两个时机生成 Embedding

  1. 写入时:每次 write()append() 一个文档,Workspace 会重新分块,对每个 chunk 调用 provider.embed(content) 生成向量,存入 memory_chunks.embedding
  2. 搜索时:用户查询也会被转成向量,用于与存储的 chunk 向量做相似度比较

Embedding 的成本

Embedding 调用比 LLM 聊天便宜得多:

模型价格100 万条记忆的成本
text-embedding-3-small$0.02 / 1M tokens≈ $2
text-embedding-3-large$0.13 / 1M tokens≈ $13
本地 Ollama$0(电费)$0

每条记忆约 100 tokens,所以 Embedding 的成本几乎可以忽略。真正贵的是 LLM 聊天,不是 Embedding。


🔍 混合搜索:FTS + Vector

记忆写下来了,怎么找回来?IronClaw 用两种完全不同的搜索方式互补。

Full-Text Search(FTS)——关键词匹配

FTS 就是传统的全文搜索,通过关键词匹配来查找文档。

当你搜 "dark mode preference" 时,FTS 会找到包含这些词(或词干:dark, mode, prefer)的所有块,按相关性排序。

数据库FTS 引擎排序函数
PostgreSQLtsvector + GIN 索引ts_rank_cd
SQLite/libSQLFTS5 虚拟表 + 同步触发器FTS5 内置 rank

优势:精确匹配——如果你记得用过某个特定的词,FTS 一定能找到。

劣势:不理解语义——搜"开灯"找不到"打开照明设备",因为词不一样。

Vector Search——语义匹配

Vector Search 把搜索查询也转成向量,然后在数据库中找距离最近的向量(余弦相似度)。

┌─────────────────────────────────────────────────────────────┐
│                    Vector Search 流程                       │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  搜索: "怎么连接服务器"                                      │
│                    │                                        │
│                    ▼                                        │
│            Embedding 模型                                   │
│                    │                                        │
│                    ▼                                        │
│         query_vec = [0.12, -0.45, ...]                      │
│                    │                                        │
│                    ▼                                        │
│    ┌─────────────────────────────────────────────────────┐ │
│    │ 数据库中的所有 chunk 向量                            │ │
│    │                                                     │ │
│    │  chunk_1: "通过 SSH 登录"       → 余弦距离 0.12 ✅  │ │
│    │  chunk_2: "用户喜欢深色模式"     → 余弦距离 0.89 ❌  │ │
│    │  chunk_3: "服务器配置文档"       → 余弦距离 0.25 ✅  │ │
│    │  chunk_4: "每天早上喝咖啡"       → 余弦距离 0.95 ❌  │ │
│    └─────────────────────────────────────────────────────┘ │
│                    │                                        │
│                    ▼                                        │
│    结果: chunk_1, chunk_3(语义最相近的)                    │
│                                                             │
└─────────────────────────────────────────────────────────────┘
数据库向量存储距离函数
PostgreSQLpgvector VECTOR余弦距离 <=>
SQLite/libSQLBLOB 列(序列化 f32)应用层余弦计算

优势:理解语义——搜"连接服务器"能找到"SSH 登录",因为向量距离很近。

劣势:可能丢失精确匹配——搜一个专有名词时,语义搜索可能返回"差不多但不完全对"的结果。

为什么需要两者?

这是关键问题。单独用任何一种都有盲区:

┌─────────────────────────────────────────────────────────────┐
│                FTS vs Vector:各有盲区                       │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  查询: "Alice 的 SSH 密钥"                                  │
│  ┌─────────────────┬───────────────────────────────────┐   │
│  │ FTS ✅ 精确匹配  │ Vector ❌ 可能返回 Bob 的密钥配置 │   │
│  │ "Alice" + "SSH"  │ 语义相近但人名错误                │   │
│  └─────────────────┴───────────────────────────────────┘   │
│                                                             │
│  查询: "怎么连接服务器"                                      │
│  ┌─────────────────┬───────────────────────────────────┐   │
│  │ FTS ❌ 没有匹配  │ Vector ✅ 找到"通过 SSH 登录"     │   │
│  │ 记忆中没有"连接" │ 理解"连接"≈"登录"                 │   │
│  └─────────────────┴───────────────────────────────────┘   │
│                                                             │
│  查询: "上周讨论的那个 API 问题"                             │
│  ┌─────────────────┬───────────────────────────────────┐   │
│  │ FTS ❌ 太模糊    │ Vector ✅ 语义搜索定位相关讨论     │   │
│  │ 不知道哪个 API   │ 向量空间中找到最近邻               │   │
│  └─────────────────┴───────────────────────────────────┘   │
│                                                             │
│  查询: "dark mode config"                                   │
│  ┌─────────────────┬───────────────────────────────────┐   │
│  │ FTS ✅ 精确匹配  │ Vector ✅ 语义也匹配              │   │
│  │ 关键词命中       │ 向量距离也近                       │   │
│  └─────────────────┴───────────────────────────────────┘   │
│                                                             │
│  最后一种情况最有价值:两种方法都认可的结果,               │
│  大概率是最相关的。这就是 hybrid search 的核心直觉。         │
└─────────────────────────────────────────────────────────────┘

IronClaw 的策略:两者都跑,然后融合结果


🧠 RRF:Reciprocal Rank Fusion

两种搜索各返回一个排序列表,怎么合成一个最终排序?这是信息检索中的经典问题。IronClaw 用的算法叫 Reciprocal Rank Fusion(RRF)

为什么不能直接平均分数?

FTS 和 Vector 的分数不在同一量级

搜索方法分数范围含义
FTS ts_rank_cd0.001 ~ 10+词频统计
Vector 余弦相似度0.0 ~ 1.0向量距离

一个 FTS 分数 5.0 和一个 Vector 分数 0.8,直接平均 = 2.9?毫无意义。

RRF 用了一个巧妙的方法:只用排名(rank),不用分数(score)

核心公式

score(d) = Σ 1/(k + rank(d))  对每种搜索方法中 d 出现的排名求和
  • k 是常数(默认 60),控制排名差异的敏感度
  • rank(d) 是文档在该搜索方法中的排名(1-based)

一个完整的例子

假设搜索 "dark mode",FTS 和 Vector 各返回了不同的结果:

┌─────────────────────────────────────────────────────────────┐
│                    RRF 融合过程                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  FTS 结果              Vector 结果                          │
│  ┌──────────────┐     ┌──────────────┐                     │
│  │ 1. Chunk A   │     │ 1. Chunk C   │                     │
│  │ 2. Chunk B   │     │ 2. Chunk A   │                     │
│  │ 3. Chunk D   │     │ 3. Chunk D   │                     │
│  └──────────────┘     └──────────────┘                     │
│                                                             │
│  RRF 计算(k=60):                                          │
│                                                             │
│  Chunk A: 1/(60+1) + 1/(60+2) = 0.01639 + 0.01613         │
│         = 0.03252 ← 两种方法都排名靠前!                    │
│                                                             │
│  Chunk D: 1/(60+3) + 1/(60+3) = 0.01587 + 0.01587         │
│         = 0.03175 ← 都出现了,但排名低一些                  │
│                                                             │
│  Chunk C: 1/(60+1) = 0.01639 ← 只在 Vector 中              │
│                                                             │
│  Chunk B: 1/(60+2) = 0.01613 ← 只在 FTS 中                 │
│                                                             │
│  最终排序: A(0.0325) > D(0.0317) > C(0.0164) > B(0.0161)  │
│                                                             │
│  关键洞察:Chunk A 和 D 同时出现在两种搜索中,               │
│  分数自然被提升(hybrid match)                              │
└─────────────────────────────────────────────────────────────┘

RRF 的精髓

  1. 对分数尺度不敏感——只用排名,不用原始分数。FTS 的 ts_rank_cd = 5.2 和 Vector 的 cosine_sim = 0.87 都只是"排第几"
  2. 双重验证提升权重——同时出现在 FTS 和 Vector 结果中的 chunk,RRF 分数自然更高
  3. 优雅的解耦——可以随时加入第三种搜索方法(比如时间衰减排序),RRF 公式不需要改变
  4. k 值的作用——k 越大,不同排名之间的分数差距越小(更"平滑");k 越小,排名靠前的优势越大。默认 k=60 是经验值。

IronClaw 的搜索管线

┌─────────────────────────────────────────────────────────────┐
│                    完整搜索管线                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  输入: query = "SSH 密钥配置"                                │
│                                                             │
│  Step 1: 并行搜索                                           │
│  ┌─────────────────────┬─────────────────────────────────┐ │
│  │ FTS 搜索             │ Vector 搜索                     │ │
│  │ query → FTS5 MATCH   │ query → embed() → 余弦距离     │ │
│  │ 返回 Top-50          │ 返回 Top-50                     │ │
│  │ (pre_fusion_limit)   │ (pre_fusion_limit)              │ │
│  └──────────┬──────────┴──────────────┬──────────────────┘ │
│             │                         │                     │
│             └───────────┬─────────────┘                     │
│                         ▼                                   │
│  Step 2: RRF 融合                                           │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ HashMap<chunk_id, ChunkInfo>                        │   │
│  │ • 遍历 FTS 结果:score += 1/(k + fts_rank)         │   │
│  │ • 遍历 Vector 结果:score += 1/(k + vec_rank)      │   │
│  │ • 去重:同一 chunk 出现在两边,分数叠加             │   │
│  └─────────────────────────────────────────────────────┘   │
│                         │                                   │
│                         ▼                                   │
│  Step 3: 后处理                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ • 归一化到 0-1(除以最高分)                         │   │
│  │ • 过滤 min_score 以下的结果                          │   │
│  │ • 按分数降序排列                                     │   │
│  │ • 截断到 limit(默认 10)                            │   │
│  └─────────────────────────────────────────────────────┘   │
│                         │                                   │
│                         ▼                                   │
│  输出: Vec<SearchResult> {                                  │
│    document_path, content, score,                           │
│    fts_rank: Option<u32>,                                   │
│    vector_rank: Option<u32>,                                │
│  }                                                          │
│                                                             │
│  is_hybrid_match = fts_rank.is_some() && vector_rank.is_some()│
│                                                             │
└─────────────────────────────────────────────────────────────┘

每个 SearchResult 都带有 fts_rankvector_rank,调用方可以看出这条结果是 FTS 命中、Vector 命中、还是 hybrid match(两者都命中)。hybrid match 是最高质量的结果。


🏛️ Identity Files:Agent 的人格系统

Workspace 不仅存储记忆,还定义了 Agent 的人格。IronClaw 通过一组 Markdown 文件来组装系统提示词:

文件作用类比
AGENTS.md行为指令——"每次会话开始做什么"工作手册
SOUL.md核心价值观——"什么该做、什么不该做"人生信条
USER.md用户信息——"你在帮谁"客户档案
IDENTITY.mdAgent 身份——"你叫什么、什么风格"名片
MEMORY.md长期记忆——"你记得什么"笔记本
daily/YYYY-MM-DD.md每日日志——"今天发生了什么"日记

系统提示词的组装顺序:

AGENTS.md(如果存在)
→ SOUL.md
→ USER.md
→ IDENTITY.md
→ MEMORY.md
→ 今天 + 昨天的 daily log

这种设计的好处是人格可编辑。用户可以直接编辑 SOUL.md 来改变 Agent 的价值观,编辑 IDENTITY.md 来给它取名字,编辑 AGENTS.md 来修改工作流程。Agent 也可以通过 memory tools 自己更新 MEMORY.md 和 daily log。

IronClaw 还有一个巧妙的安全设计:AGENTS.md、SOUL.md、USER.md、IDENTITY.md 是写保护的——Agent 的工具不能修改这些文件,只有用户能手动编辑。这防止了 prompt injection 攻击通过 memory_write 工具篡改 Agent 的行为指令。


🏛️ Database trait:统一的持久化抽象

IronClaw 的数据库层是一个大型 trait 体系——Database 由 7 个子 trait 组合而成,共约 78 个异步方法。其中与记忆系统最相关的是 WorkspaceStore

WorkspaceStore trait
├── 文档操作
│   ├── get_document_by_path()
│   ├── get_or_create_document_by_path()
│   ├── update_document()
│   ├── delete_document_by_path()
│   ├── list_directory()
│   └── list_all_paths()
├── 分块操作
│   ├── insert_chunk(content, embedding: Option<&[f32]>)
│   ├── delete_chunks()
│   ├── update_chunk_embedding()
│   └── get_chunks_without_embeddings()
└── 搜索
    └── hybrid_search(query, embedding: Option<&[f32]>, config)

注意 insert_chunk 的签名——embeddingOption。如果没有配置 Embedding Provider,chunk 照样能写入,只是没有向量,搜索退化为 FTS-only。这是一个优雅的渐进增强设计。

双数据库后端

IronClaw 支持两个数据库后端:

后端向量存储FTS 引擎适用场景
PostgreSQLpgvector VECTORtsvector + GIN生产服务器
libSQLBLOB(little-endian f32)FTS5 虚拟表嵌入式/个人使用

两个后端实现同一个 trait,上层代码完全不感知。tiny-claw 选择 SQLite(rusqlite 作为唯一后端——成熟、零依赖、FTS5 完全相同,且是 Rust 社区操作 SQLite 的事实标准。


🏛️ Session → Thread → Turn:状态化的对话模型

Phase 02 中 tiny-claw 用 Vec<ChatMessage> 存对话,简单但有局限。IronClaw 的对话模型是三层结构:

Session(每个用户一个)
└── Thread(每段对话一个,可以有多个)
    └── Turn(每次问答一个)
        ├── user_input: String
        ├── response: Option<String>
        ├── tool_calls: Vec<TurnToolCall>
        └── state: TurnState
能力Vec<ChatMessage>Session/Thread/Turn
多线程对话✅ 切换 Thread
Undo/Redo✅ 回滚到之前的 Turn
Compaction✅ 截断旧 Turn
持久化✅ Thread 可序列化存储
工具调用追踪部分✅ Turn 记录完整调用链

🏛️ Memory Tools:Agent 的记忆接口

IronClaw 给 Agent 提供了 4 个记忆工具,让 LLM 可以主动读写 Workspace:

工具作用典型用法
memory_search混合搜索记忆"搜一下用户之前提到的 SSH 配置"
memory_write写入任意路径"把这个决定记到 MEMORY.md"
memory_read读取指定文件"看看今天的日志写了什么"
memory_tree查看目录结构"看看 workspace 里有哪些文件"

这些工具的特殊之处在于——它们改变的是 Agent 自身的状态。大多数工具(如 time、web_search)操作外部世界,而 memory tools 操作 Agent 的"大脑"。

memory_search 的返回值特别值得注意——它包含 is_hybrid_match 字段,告诉 Agent 这条结果是否同时被 FTS 和 Vector 命中。这是一个高质量信号。

IronClaw 的 AGENTS.md 里有一条重要指令:

Always search memory before answering questions about prior conversations.

这告诉 Agent:在回答关于过去的问题时,先搜记忆,再回答。否则 Agent 会倾向于用 LLM 的通用知识来"编造"一个看似合理的回答。


📊 记忆效果评测:怎么知道记忆系统好不好?

一个记忆系统的好坏不能靠直觉,需要量化评测。这部分我们建立一套完整的测试方案。

挑战:记忆检索 ≠ 普通搜索

普通搜索引擎的评测有成熟方案(NDCG、MAP 等),但 Agent 记忆检索有独特的挑战:

维度搜索引擎Agent 记忆
数据来源静态文档库动态对话 + 手动笔记
查询类型用户明确输入LLM 生成(可能模糊)
时间因素通常无关"上周说的"、"最近"
更新频率周期性实时写入
冲突处理"搬到上海了"覆盖"住在北京"

四类测试场景

我们设计 4 类测试场景,覆盖记忆系统的不同能力:

类型 1:精确召回(Exact Recall)

写入: "用户的 SSH 端口是 2222"
查询: "SSH 端口"
期望: 命中,内容包含 "2222"
评测: FTS 能否精确匹配关键词

类型 2:语义召回(Semantic Recall)

写入: "用户偏好使用暗黑主题"
查询: "dark mode preference"
期望: 命中,尽管语言不同
评测: Vector 能否跨语言/同义词匹配

类型 3:多跳推理(Multi-hop)

写入 1: "Alice 在 Team Alpha"
写入 2: "Team Alpha 负责支付系统"
查询: "Alice 负责什么项目"
期望: 至少召回其中一条相关信息
评测: 搜索能否找到间接相关的记忆

类型 4:时序理解(Temporal)

写入 1 (周一): "用户住在北京"
写入 2 (周三): "用户搬到了上海"
查询: "用户现在住在哪"
期望: 返回最新的记忆(上海)
评测: 时间因素是否影响排序

三项核心指标

指标公式说明
Recall@K相关结果数 / 总相关数K 条结果中召回了多少相关信息
Precision@K相关结果数 / KK 条结果中有多少是相关的
MRR1 / 首个相关结果的排名第一条相关结果排多高
┌─────────────────────────────────────────────────────────────┐
│                    评测指标示例                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  场景: 搜索 "SSH 配置",期望找到 chunk_A 和 chunk_C         │
│                                                             │
│  搜索结果(Top-5):                                          │
│  ┌───────────────────────────────────────────────────┐     │
│  │ 1. chunk_A  "SSH 端口 2222"           ✅ 相关     │     │
│  │ 2. chunk_X  "数据库配置"              ❌ 不相关   │     │
│  │ 3. chunk_C  "SSH 密钥路径"            ✅ 相关     │     │
│  │ 4. chunk_Y  "Docker 网络"             ❌ 不相关   │     │
│  │ 5. chunk_Z  "Nginx 配置"             ❌ 不相关   │     │
│  └───────────────────────────────────────────────────┘     │
│                                                             │
│  Recall@5  = 2/2 = 1.0    (两条相关都找到了)               │
│  Precision@5 = 2/5 = 0.4   (5条中2条相关)                  │
│  MRR = 1/1 = 1.0           (第1条就是相关的)               │
│                                                             │
└─────────────────────────────────────────────────────────────┘

FTS-only vs Hybrid 对比测试

这是最关键的对比——在相同的测试数据上,比较三种搜索策略:

搜索策略精确召回语义召回综合
FTS-only✅ 强❌ 弱中等
Vector-only❌ 弱✅ 强中等
Hybrid (FTS+Vector+RRF)✅ 强✅ 强最优

根据 Mem0 论文 在 LOCOMO 基准上的数据,混合搜索相比全上下文方案:

  • 准确率提升 26%(避免了"Lost in the Middle"问题)
  • Token 消耗仅 10%(只注入相关记忆,而非全部历史)
  • 延迟降低 91%(搜索 + 少量 token 远快于巨大上下文窗口)

具体测试用例

我们为 tiny-claw 设计以下自动化基准测试:

Memory Benchmark Suite
├── test_exact_keyword_recall
│   写入 10 条含特定关键词的记忆
│   用关键词搜索,检查 Recall@5 ≥ 0.8
│
├── test_semantic_recall
│   写入 "用户偏好暗色界面"
│   搜索 "dark theme preference"
│   检查 Recall@3 ≥ 0.5 (Vector 有效才能通过)
│
├── test_hybrid_boost
│   写入 10 条记忆,构造只有 hybrid match 才能排第一的场景
│   检查 hybrid match 的 RRF 分数 > 单引擎 match
│
├── test_fts_only_fallback
│   不配置 EmbeddingProvider
│   搜索仍然能用 FTS 返回结果
│   检查搜索不报错,Recall > 0
│
├── test_cross_document_search
│   在 MEMORY.md 和 daily/2026-03-10.md 各写入相关内容
│   搜索应该跨文档命中
│
├── test_noise_resistance
│   写入 50 条无关记忆 + 1 条相关记忆
│   搜索应该在 Top-5 中找到那 1 条
│   Precision@5 ≥ 0.2
│
├── test_update_supersedes
│   写入 "用户住北京",再写入 "用户搬到上海"
│   搜索 "用户住在哪"
│   检查最新记忆排序更高
│
└── test_empty_workspace
    空 workspace 搜索
    返回空结果,不报错

"Lost in the Middle" 问题

这是评测中一个重要的认知——即使 LLM 上下文窗口足够大,把所有历史塞进去也不是好方案:

┌─────────────────────────────────────────────────────────────┐
│                   LLM 注意力分布                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  注意力                                                      │
│    ▲                                                        │
│    │  ████                                      ████        │
│    │  ████                                      ████        │
│    │  ████   ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░    ████        │
│    │  ████   ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░    ████        │
│    │  ████   ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░    ████        │
│    └──────────────────────────────────────────────────▶     │
│       开头        中间(容易被忽略)           结尾          │
│                                                             │
│    当上下文很长时,模型对开头和结尾更敏感,                   │
│    中间的内容容易被"遗忘"。                                   │
│                                                             │
│    记忆系统的价值:只注入 Top-K 相关记忆,                    │
│    保持上下文简短且密度高。                                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

这就是记忆系统的核心价值——不是"记住所有",而是在需要的时候精准找到最相关的几条,注入到有限的上下文窗口中。


📝 tiny-claw Phase 03 的设计决策

与之前的版本不同,这次我们不做简化——完整实现 FTS + Vector + RRF

维度IronClawtiny-claw (Phase 03)
数据库PostgreSQL + libSQL 双后端SQLite(rusqlite)✅
Database trait~78 方法,7 子 trait~20 方法,单一 trait
FTSFTS5 / tsvectorFTS5 ✅
Vectorpgvector / libSQL BLOBSQLite BLOB + 应用层余弦计算 ✅
RRF 融合完整实现完整实现 ✅
Embedding ProviderOpenAI / NearAI / OllamaOpenAI + 可选本地 ✅
Identity Files写保护无写保护(安全是 Phase 04)
Session 模型Session → Thread → Turn简化的 Conversation 模型
记忆评测无内置内置基准测试 ✅

关键设计决策:

  1. SQLite 而非 libSQL——rusqlite 是 Rust 社区操作 SQLite 的事实标准(月下载量 1500 万),零部署依赖,cargo run 即可使用
  2. Embedding 是可选的——如果不配置 EMBEDDING_PROVIDER,系统自动退化为 FTS-only,不影响基本功能
  3. 默认使用 OpenAI text-embedding-3-small——精度高、成本低($0.02/1M tokens)、1536 维
  4. 支持 Ollama 本地部署——nomic-embed-text 完全免费,768 维,适合离线使用
  5. BLOB 存储向量——SQLite 使用 BLOB 列存储 little-endian f32 字节序列,向量搜索在 Rust 应用层计算余弦距离。对于个人助手的数据量(几千条 chunks),暴力搜索 <10ms,完全够用
  6. pre_fusion_limit = 50——FTS 和 Vector 各返回 Top-50,RRF 在 100 条中融合,最终输出 Top-10

🧪 动手试试

Phase 03 之后 tiny-claw 有了完整的记忆系统。试试这些场景:

场景操作观察点
首次启动cargo runWorkspace 自动 seed,日志显示创建了哪些文件
写入记忆"记住我喜欢深色模式"Agent 调用 memory_write 写入 MEMORY.md
精确搜索"我之前说过我喜欢什么?"Agent 调用 memory_search,FTS 命中"深色模式"
语义搜索"What's my UI preference?"Vector 搜索跨语言找到"深色模式"
Hybrid match搜一个既有关键词又有语义关联的内容观察 is_hybrid_match = true
跨会话退出再启动,问同样的问题记忆还在!Agent 依然记得
目录浏览"看看 workspace 有什么"Agent 调用 memory_tree,展示文件结构
每日日志"记录一下今天完成了项目 X"Agent 写入 daily/2026-03-10.md
无 Embedding不设置 EMBEDDING_PROVIDERFTS-only 搜索仍然正常工作
基准测试cargo test bench运行内置记忆评测,查看 Recall/Precision/MRR

📝 本篇总结

理解项描述
Workspace用数据库模拟虚拟文件系统,Agent 的"硬盘"
Documents + Chunks文档按 800 词分块索引,搜索精确到段落
Embedding把文本转成向量,让计算机"理解"语义距离
FTS关键词匹配,精确但不理解语义
Vector Search语义匹配,理解含义但可能丢失精确匹配
RRF用排名(而非分数)融合两种搜索,对分数尺度不敏感
Identity FilesMarkdown 文件定义 Agent 人格,组装系统提示词
Memory Tools4 个工具让 Agent 主动读写自身记忆
记忆评测Recall@K、Precision@K、MRR 量化评测效果

核心洞察

1. 记忆系统 = 存储 + 搜索 + 注入

写入: 对话/笔记 → 分块 → FTS索引 + Embedding → 数据库
搜索: 查询 → FTS + Vector → RRF 融合 → Top-K 结果
注入: Top-K 结果 → system prompt → LLM 上下文

三个环节缺一不可。存储没有搜索是堆垃圾,搜索没有注入是白搜。

2. 渐进增强优于一步到位

没有 Embedding Provider?退化为 FTS-only。没有 Workspace?使用默认 system prompt。每个能力都是可选的增强,而非必须的依赖。

3. 记忆的设计哲学是"显式优于隐式"

Agent 不会"自动记住"一切,它需要主动调用 memory_write 来存储信息。这看似多了一步,但好处是记忆是可控的、可审计的、可编辑的。用户可以随时查看、修改、删除 Agent 的记忆,而不是面对一个不透明的"模型记忆"黑箱。


下一篇IronClaw 学习笔记 04:安全 + 密钥 —— Defense in Depth 设计哲学、四层安全防护、密钥加密管理。