IronClaw 学习笔记 03:持久化与记忆
Workspace 虚拟文件系统、FTS + Vector 混合搜索、RRF 融合算法、记忆效果评测
上一篇我们的 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 中完整实现它:
- Workspace —— 用数据库模拟文件系统,让 Agent 有一个"虚拟硬盘"
- Embedding —— 把文本转成向量,让计算机"理解"语义
- FTS + Vector 混合搜索 —— 关键词匹配和语义搜索双引擎
- RRF 融合算法 —— 把两种搜索结果合成一个最终排序
- Identity Files —— 用 Markdown 文件定义 Agent 的人格
- Memory Tools —— 让 Agent 主动读写自身记忆
- 记忆效果评测 —— 怎么衡量记忆系统好不好
🧠 从 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 可以像操作文件一样读写记忆,但数据实际存在数据库表里。
为什么不用真正的文件系统?
三个原因:
- 搜索 —— 文件系统没有全文搜索和向量搜索的能力,而数据库天然支持
- 事务 —— 数据库的 ACID 保证避免了并发读写导致的数据损坏
- 部署灵活性 —— 数据库可以是本地 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:
| 提供者 | 模型 | 向量维度 | 适用场景 |
|---|---|---|---|
| OpenAI | text-embedding-3-small | 1536 | 云端,精度高 |
| OpenAI | text-embedding-3-large | 3072 | 云端,精度更高 |
| Ollama | nomic-embed-text | 768 | 本地部署,零成本 |
| Ollama | all-minilm | 384 | 本地部署,轻量 |
这个抽象让记忆系统不绑定任何特定的 API——想用 OpenAI 就用 OpenAI,想省钱用本地 Ollama 也行。
两个时机生成 Embedding
- 写入时:每次
write()或append()一个文档,Workspace 会重新分块,对每个 chunk 调用provider.embed(content)生成向量,存入memory_chunks.embedding列 - 搜索时:用户查询也会被转成向量,用于与存储的 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 引擎 | 排序函数 |
|---|---|---|
| PostgreSQL | tsvector + GIN 索引 | ts_rank_cd |
| SQLite/libSQL | FTS5 虚拟表 + 同步触发器 | 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(语义最相近的) │
│ │
└─────────────────────────────────────────────────────────────┘
| 数据库 | 向量存储 | 距离函数 |
|---|---|---|
| PostgreSQL | pgvector VECTOR 列 | 余弦距离 <=> |
| SQLite/libSQL | BLOB 列(序列化 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_cd | 0.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 的精髓
- 对分数尺度不敏感——只用排名,不用原始分数。FTS 的
ts_rank_cd = 5.2和 Vector 的cosine_sim = 0.87都只是"排第几" - 双重验证提升权重——同时出现在 FTS 和 Vector 结果中的 chunk,RRF 分数自然更高
- 优雅的解耦——可以随时加入第三种搜索方法(比如时间衰减排序),RRF 公式不需要改变
- 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_rank 和 vector_rank,调用方可以看出这条结果是 FTS 命中、Vector 命中、还是 hybrid match(两者都命中)。hybrid match 是最高质量的结果。
🏛️ Identity Files:Agent 的人格系统
Workspace 不仅存储记忆,还定义了 Agent 的人格。IronClaw 通过一组 Markdown 文件来组装系统提示词:
| 文件 | 作用 | 类比 |
|---|---|---|
AGENTS.md | 行为指令——"每次会话开始做什么" | 工作手册 |
SOUL.md | 核心价值观——"什么该做、什么不该做" | 人生信条 |
USER.md | 用户信息——"你在帮谁" | 客户档案 |
IDENTITY.md | Agent 身份——"你叫什么、什么风格" | 名片 |
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 的签名——embedding 是 Option。如果没有配置 Embedding Provider,chunk 照样能写入,只是没有向量,搜索退化为 FTS-only。这是一个优雅的渐进增强设计。
双数据库后端
IronClaw 支持两个数据库后端:
| 后端 | 向量存储 | FTS 引擎 | 适用场景 |
|---|---|---|---|
| PostgreSQL | pgvector VECTOR 列 | tsvector + GIN | 生产服务器 |
| libSQL | BLOB(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 | 相关结果数 / K | K 条结果中有多少是相关的 |
| MRR | 1 / 首个相关结果的排名 | 第一条相关结果排多高 |
┌─────────────────────────────────────────────────────────────┐
│ 评测指标示例 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 场景: 搜索 "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。
| 维度 | IronClaw | tiny-claw (Phase 03) |
|---|---|---|
| 数据库 | PostgreSQL + libSQL 双后端 | SQLite(rusqlite)✅ |
| Database trait | ~78 方法,7 子 trait | ~20 方法,单一 trait |
| FTS | FTS5 / tsvector | FTS5 ✅ |
| Vector | pgvector / libSQL BLOB | SQLite BLOB + 应用层余弦计算 ✅ |
| RRF 融合 | 完整实现 | 完整实现 ✅ |
| Embedding Provider | OpenAI / NearAI / Ollama | OpenAI + 可选本地 ✅ |
| Identity Files | 写保护 | 无写保护(安全是 Phase 04) |
| Session 模型 | Session → Thread → Turn | 简化的 Conversation 模型 |
| 记忆评测 | 无内置 | 内置基准测试 ✅ |
关键设计决策:
- SQLite 而非 libSQL——
rusqlite是 Rust 社区操作 SQLite 的事实标准(月下载量 1500 万),零部署依赖,cargo run即可使用 - Embedding 是可选的——如果不配置
EMBEDDING_PROVIDER,系统自动退化为 FTS-only,不影响基本功能 - 默认使用 OpenAI
text-embedding-3-small——精度高、成本低($0.02/1M tokens)、1536 维 - 支持 Ollama 本地部署——
nomic-embed-text完全免费,768 维,适合离线使用 - BLOB 存储向量——SQLite 使用 BLOB 列存储 little-endian f32 字节序列,向量搜索在 Rust 应用层计算余弦距离。对于个人助手的数据量(几千条 chunks),暴力搜索 <10ms,完全够用
- pre_fusion_limit = 50——FTS 和 Vector 各返回 Top-50,RRF 在 100 条中融合,最终输出 Top-10
🧪 动手试试
Phase 03 之后 tiny-claw 有了完整的记忆系统。试试这些场景:
| 场景 | 操作 | 观察点 |
|---|---|---|
| 首次启动 | cargo run | Workspace 自动 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_PROVIDER | FTS-only 搜索仍然正常工作 |
| 基准测试 | cargo test bench | 运行内置记忆评测,查看 Recall/Precision/MRR |
📝 本篇总结
| 理解项 | 描述 |
|---|---|
| Workspace | 用数据库模拟虚拟文件系统,Agent 的"硬盘" |
| Documents + Chunks | 文档按 800 词分块索引,搜索精确到段落 |
| Embedding | 把文本转成向量,让计算机"理解"语义距离 |
| FTS | 关键词匹配,精确但不理解语义 |
| Vector Search | 语义匹配,理解含义但可能丢失精确匹配 |
| RRF | 用排名(而非分数)融合两种搜索,对分数尺度不敏感 |
| Identity Files | Markdown 文件定义 Agent 人格,组装系统提示词 |
| Memory Tools | 4 个工具让 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 设计哲学、四层安全防护、密钥加密管理。