一棵会自己
长大的私人 AI 记忆
大多数"AI 记忆"产品本质上是 套了壳的向量库。OpenHuman 走的是另一条路——把你每天的聊天、邮件、文档喂进一条确定性桶封流水线,长出三棵互补的"摘要树",全部活在你自己的硬盘里。
Memory Tree · 一份 SQLite + 一个 Markdown vault · 三棵树 · 六种作业 · 零云依赖
"记忆" 这件事,被向量库做坏了
大多数 AI 记忆系统的结构是这样的:把每条消息切碎 → 跑 embedding → 塞进 pgvector / chroma / pinecone → 查询时 top-K 相似。这能回答"和我现在说的话最像的旧记忆是什么",但回答不了真正的记忆问题。
OpenHuman 的 gitbook 直白地写:
向量库失败在三件事上:
- "今天发生了什么?" —— 这是一个全局摘要问题,没有 query 向量可以和它对齐。
- "Alice 最近怎么样?" —— 这是一个实体路由 + 时间衰减问题,需要专门为某个实体维护视图。
- "上周二下午 3 点那个 Stripe webhook 说了啥?" —— 这是一个来源 + 时间精确定位问题,需要溯源能力。
Memory Tree 给出的答案是:把"压缩"和"导航"同时做掉。embedding 仍然存在用于语义搜索,但结构层是真正让记忆"像大脑而不是碎片袋"的东西。下面我们逐层拆开这条流水线。
从一条 Slack 消息到一行 SQLite,三毫秒内完成
"热路径"承诺三件事:确定性、快、有界写。所有 LLM 调用、embedding、摘要生成统统不在这条路径上——它们被推进一个持久化的作业队列,由后台 worker 处理。UI 永远不阻塞。
## 、email 按 ---\nFrom:),单 chunk ≤ 3000 tokenwiki/raw/*.md(temp → fsync → rename),YAML frontmatter + 正文Canonicalize:把异构源压成同一种 Markdown
src/openhuman/memory/tree/canonicalize/ 下每种源一个适配器,全部产出同一个 CanonicalisedSource { markdown, metadata }。各适配器的处理细节很克制——拒绝在这一层做"理解":
- chat.rs:消息按时间戳防御性排序,每条以
## YYYY-MM-DDTHH:MM:SSZ — User起头;空 batch 直接返回Ok(None)让上层跳过。 - email.rs:每封邮件之间用
---\nFrom:分隔;正文先过email_clean::clean_body()把引用历史、退订链接、营销页脚剥掉——给后面的摘要器一个信号干净的输入。 - document.rs:本身已经是 Markdown 或纯文本,加一点 front-matter 就送下游。
Chunker:内容寻址的确定性 ID
这是整个系统能"幂等"的根因。chunk 的 ID 不是自增的,而是对内容求 SHA-256 取前 32 位十六进制。同一段输入永远算出同一个 ID,重复 ingest 直接 upsert,从源头杜绝重复。
pub fn chunk_id(
source_kind: SourceKind,
source_id: &str,
seq_in_source: u32,
content: &str,
) -> String {
let mut hasher = Sha256::new();
hasher.update(source_kind.as_str().as_bytes());
hasher.update([0u8]);
hasher.update(source_id.as_bytes());
hasher.update([0u8]);
hasher.update(seq_in_source.to_be_bytes());
hasher.update([0u8]);
hasher.update(content.as_bytes());
// → SHA256 取前 32 位 hex(128-bit,单用户 workspace 抗碰撞绰绰有余)
}
注意 content 也参与了哈希——这就允许同一来源在不同时间窗内有相同 seq_in_source 而仍然产生唯一 ID(比如 Slack 每 6 小时一桶,每桶 seq=0 起,但内容不同所以 ID 不同)。这是个不张扬但关键的设计取舍。
Content Store:vault 里的每个 .md 文件都是一笔"凭证"
chunk 写盘有两层冗余:SQLite 存索引和截断后的 content(≤2000 字符),原文以 .md 形式存到 wiki/raw/,YAML frontmatter + 正文。写入用 atomic::write_if_new()——先写临时文件、fsync、再 rename——保证就算进程崩在中间也不会留下半截文件。读回时还要核对 SHA256 防止 vault 被外部工具篡改。
---
chunk_id: abc1234567890123
source_kind: chat
source_id: slack:#eng
owner: alice@example.com
timestamp_ms: 1700000000000
seq_in_source: 3
token_count: 847
tags:
- eng
- on-call
---
## 2026-04-21T10:12:00Z — Alice
Storage scaled to 3× this morning, CDN warm-up looks healthy.
## 2026-04-21T10:12:40Z — Bob
Worried about the API rate limit during the burst window...
SQLite 持久化:八张表撑起整棵树
所有结构化状态压在一个 SQLite 文件里:<workspace>/memory_tree/chunks.db,用 rusqlite,WAL + busy_timeout = 15s 让 ingest / worker / scheduler / scheduler 并发写不打架。核心表:
mem_tree_chunks—— 主表,128-bit ID + 来源/时间/owner 索引。mem_tree_chunk_embeddings—— 按model_signature存向量 BLOB,支持多模型并存方便迁移。mem_tree_score—— 每条 chunk 的全部信号分项(token / unique words / metadata / source / interaction / entity_density),可审计。mem_tree_entity_index—— 实体倒排表,(entity_id, node_id)主键,节点跨 chunk 与 summary。mem_tree_trees/mem_tree_summaries/mem_tree_buffers—— 三棵树的状态机。mem_tree_hotness—— 实体热度分(驱动 topic tree 物化)。mem_tree_jobs—— 持久化作业队列(下一节展开)。
Scoring:两段式打分 + 准入门
分数控制"这条 chunk 配不配进树"。设计上分两段:快速路径在 ingest 同步阶段就跑,只用 token 数、词汇多样性这种纯启发式;慢速路径在 worker 上跑,做 LLM 实体抽取与 embedding。两个阈值把分数切成三段:
pub struct ScoringConfig {
// ...
pub drop_threshold: f32, // 默认 0.30
pub definite_keep_threshold: f32, // 默认 0.85 — 这分以上免 LLM 直接收
pub definite_drop_threshold: f32, // 默认 0.15 — 这分以下免 LLM 直接丢
}
// 决策伪码:
// fast >= 0.85 → admit (no LLM)
// fast <= 0.15 → drop (no LLM)
// 0.15 .. 0.85 → 调 LLM 实体抽取做二次判定 (borderline 才烧 token)
这个设计意图明确:用启发式吃掉两端的"显然要 / 显然不要",把宝贵的 LLM 调用集中在中间灰区。准入失败的 chunk 不会被删——mem_tree_score.dropped = 1 记着,原文还在 vault 里,只是不会再被树或检索引用。这保留了完整溯源能力。
三棵树,三种问题→ 深度页
Memory Tree 的招牌不是"一棵更复杂的树",而是三棵独立、互不干扰的树同时生长,每棵回答一类不同的检索需求。它们都吃同一个 leaf 流,但封口规则、标签策略、时间窗各不相同。
源树 · per-source 滚动缓冲
每个 Slack 频道 / Gmail label / 文档一棵。新 leaf 进 L0 buffer,攒够就封成 L1 摘要,继续往上级联。
- L0 封口:50k token 或 10 个 sibling
- L1+ 封口:10 个 sibling(按数不按 token)
- 过期强封:7 天没动也封
主题树 · 按"热度"懒物化
每个高热度实体(人、项目、ticker、repo)一棵。冷实体连树都不建;热度越高,刷新越积极。
- 热度 = ln(30d 提及数) + 跨源数 + 近期衰减 + 中心度 + 用户查询权重
- 由
topic_route作业按门槛触发建树 - 结构和源树相同,schema 共用
全局树 · 每日一节点
单例。每天 UTC 零点后调度器跑一次,把昨天跨所有来源的最佳 L1+ 摘要合成成一节日报。
- L0 = 日报;攒够 10 个升级 L1(周)
- L1 升 L2(月)、L3(年)逐级展开
- 跑出来的标签是 UnionFromChildren,免再调一次 LLM
桶封 (bucket-sealing) 的关键常量
tree_source/types.rs 直接把这套规则写成常量:
pub const INPUT_TOKEN_BUDGET: u32 = 50_000;
pub const OUTPUT_TOKEN_BUDGET: u32 = 5_000;
pub const SUMMARY_FANOUT: u32 = 10;
pub const DEFAULT_FLUSH_AGE_SECS: i64 = 7 * 24 * 60 * 60;
// should_seal: L0 看 token 预算,L1+ 看兄弟数
pub fn should_seal(buf: &Buffer) -> bool {
if buf.level == 0 {
buf.token_sum >= INPUT_TOKEN_BUDGET as i64
|| buf.item_ids.len() as u32 >= SUMMARY_FANOUT
} else {
buf.item_ids.len() as u32 >= SUMMARY_FANOUT
}
}
为什么 L0 按 token、L1+ 按数?一个非常工程师的考虑:summarizer 的输出长度有时长有时短。如果 L1 也按 token 封,万一摘要器吐得很短,L1 buffer 就要凑很多条才达到 50k token,造成"长链条窄树"。改成按 10 个 sibling 封口能保证树形结构稳定地"宽展开"——一棵看起来像树的树,而不是退化的链表。
bucket_seal.rs::cascade_seals() 里完成,全程在一个 SQLite 事务内。主题树的热度公式:用对数压住头部、奖励跨源
这是 tree_topic/hotness.rs:32 里非常值得品的设计——它在五个因子上做加权和,每个都有明确的物理意义:
pub fn hotness(entity_id: &str, idx: &EntityIndexStats) -> f32 {
let mention = ((idx.mention_count_30d as f32) + 1.0).ln(); // log 压头部
let source = (idx.distinct_sources as f32) * 0.5; // 跨源加分
let recency = recency_decay(idx.last_seen_ms, now_ms); // 分段线性衰减
let central = idx.graph_centrality.unwrap_or(0.0); // 图中心度 (Phase 4+)
let query = (idx.query_hits_30d as f32) * 2.0; // 用户问了几次
mention + source + recency + central + query
}
recency_decay 是分段线性的:≤1 天 = 1.0,1–7 天线性降到 0.5,7–30 天降到 0,再老不计入。这避免了指数衰减把"两个月前但很重要"的实体直接清零,又让 30 天的天花板硬性截断。query 权重 ×2 表达了一个朴素但正确的偏好:你查过的,比别人提过的更值得维护一棵树。
标签策略:三种打标方式
摘要节点封口时还要决定"这一节摘要的标签从哪来"。LabelStrategy 给了三个选项:
- ExtractFromContent:对刚生成的摘要文本再跑一次实体抽取——能捕捉到"几条 leaf 各说一点、合起来才浮现的主题"。源树、主题树用这个。
- UnionFromChildren:把子节点已有的实体/topic 去重并取并集——免一次 LLM 调用。全局树用这个,因为它的子节点本身就是已经被 L1 抽取过的源摘要。
- Empty:留空,留给后续阶段填,防止跨树污染。
慢活全部丢给队列,UI 永不阻塞→ 深度页
ingest 在热路径上只做"切碎、快速打分、入库、入队"。所有真正烧资源的事情——embedding、实体抽取、摘要生成、级联封口、日报合成——都被推进一个持久化的 SQLite 作业队列,由后台 worker 池跑。
六种作业类型
| Kind | 做什么 | 同步 / 异步 | 调 LLM |
|---|---|---|---|
extract_chunk | 深度打分 + 实体抽取;决定 admitted / dropped | 异步 | 是 |
append_buffer | 把 admitted leaf 推进树的 L0 buffer;可能触发 seal | 异步 | 否 |
seal | 把 buffer 压成一节摘要;视情况向上级联 | 异步 | 是(summarizer) |
topic_route | 把 leaf 按实体路由进主题树(热度门控) | 异步 | 是 |
digest_daily | 合成全局日报;满 10 天后级联到周/月节点 | 异步 | 是(summarizer) |
flush_stale | 巡视 L0 buffer,强制封口超过 7 天没动的 | 异步 | 否 |
Workers:三个协程 + 一个 LLM 信号量
默认 3 个 worker 协程,循环从 mem_tree_jobs 表里抢 status='ready' 且 available_at_ms <= now 的作业。一个细节非常用心:所有标记 is_llm_bound()=true 的作业,在执行前必须从一个全局信号量获取许可——这把 LLM 并发调用上限锁住,防止"一次性接入 10 个 Slack 频道"把 embedding API 打到 rate-limited。
领到作业后,worker 立刻把行标记成 running,写入自己的 worker_id 与 lease_until_ms。如果它崩在中间,下一轮启动时 recover_stale_locks() 会扫到 lease 已过期的行,把它们重置回 ready 让别的 worker 重做。
失败处理:指数退避 + 显式 Defer
作业返回三种结果:Done 完成;Defer { until_ms, reason } 不消耗重试预算地延后(用于 LLM 限流这种瞬时不可用);Err 真失败——按 2^attempts 秒指数退避丢回队列;超过 max_attempts(默认 5)就标 failed 进死信。
Scheduler:唯一的"墙钟"心跳
日历驱动的工作(日报、周强封)由一个独立的 scheduler 协程发起。它每天 UTC 大约 00:05 醒一次,向队列推两件事:
DigestDaily(yesterday)—— 合成昨天的全局日报FlushStale(today)—— 检查所有 L0 buffer 中年龄 ≥7 天的,强制封口
scheduler 自己绝不执行 summarizer——它只发作业,让重试、去重、stale-lock 恢复都从作业队列统一走。这是个非常老练的"职责分离"设计。
mem_tree_jobs.dedupe_key 是 UNIQUE 列,"digest_daily:2026-04-21" 这种键的第二次插入会直接被 SQLite 约束挡掉。即使 scheduler 重启两次,也只会有一份日报作业排队。六个检索原语,覆盖所有问法
树建得再好,对 agent 没用就是白搭。OpenHuman 把检索拆成六个明确的入口,每个回答一类不同的问题,统一返回 RetrievalHit——它把 leaf 和 summary 包装成同一种壳,agent 不用关心命中的是原始 chunk 还是某层摘要。
query_source
在某个源树里搜(或不指定源、跨所有源)。支持时间窗 + 自然语言 query;无 query 时按时间倒序。
query_topic
给一个实体 ID(人/项目/repo),把它的主题树过一遍。这是"Alice 最近在干嘛"的标准入口。
query_global
查全局树,配合日期参数取"昨天" / "上周" / "本月" 的整体摘要。
drill_down
给一个 summary 节点 ID,把它的 child_ids 全部展开——一路下钻到原始 chunk + provenance。
fetch
按 ID 精确取节点(leaf 或 summary)。给 UI 跳转 / IDE 链接用。
sync_status_list
不是检索而是监控:每个 provider 已同步 / 待处理 chunk 数 + "freshness"(active/recent/idle)。
混合排序:结构信号 + 语义相似 + 时间衰减 + 实体匹配
有 query 时走 hybrid:embedding 算余弦相似 → 与 BM25-ish 结构分加权 → 加入 recency → 如果 query 里包含已知实体名,命中实体的节点再加权。无 query 时则纯按时间和结构分排序,这让"列出最近的"和"语义搜索"是同一个入口的两种用法。
溯源:每个 hit 都带 provenance
命中里有 source_ref(永久指向 Slack 消息 / Gmail 线程 / 文档段落的不透明指针),UI 上点 hit 就跳回 Obsidian vault 的原文件。这一点彻底解决了"AI 模糊地引用一句话但说不出哪来"的问题——所有摘要都能被打开还原成它的全部子节点和原始 chunk。
Agent 怎么用:DefaultMemoryLoader 注入
agent 端的 memory_loader.rs 在每次用户发消息时调 memory.recall(user_msg) 取相关条目,然后把它们组织成四个段落注入 system prompt:
- [Memory context] —— 语义召回的相关记忆
- [User working memory] —— ≤3 条用户画像(时区、偏好)
- [Cross-chat context] —— 其他对话线程里的近期命中
- [Prior conversations] —— 之前会话里的高优先 episodic 条目
SessionMemoryState:用三个增量决定何时抽取
context/session_memory.rs 跟踪当前会话的累计 token、tool 调用数、turn 数。只有这三个增量同时跨过门槛(token +4000、tool +8、turn +4)才会触发后台的"档案管理员"子代理把会话压成结构化记忆条目——避免每隔几句话就启一次抽取作业。
本地存储,但不等于裸奔
所有数据都不出机器,但"不出机器"不代表"可以随便存"。memory/safety/mod.rs 在写入与索引时就做了四类拦截,故意倾向于宁可误删也别留下密钥的"保守派"策略。
- Block 模式(直接删):PEM 私钥(RSA / EC / OpenSSH / PGP 各变体),高熵 base64 段。
- Redact 模式(替换为
[REDACTED]):Bearer token、AWSAKIA…、GitHubgh*_、Anthropicsk-ant-…、GoogleAIza…、OpenAI / Stripesk|rk_live|test_…、Slackxox[abprs]-…、JWT、OAuth。 - Key Redaction:JSON 里
"api_key" / "token" / "password" / "secret"这种键的值,直接擦掉——不管值长啥样。 - Depth Limit:深嵌套 JSON 做截断防 DoS。
这套清洗发生在三个时机:写入 chunk 前、抽取实体前、把记忆切片写进日志前。"宁可误擦"的取向意味着偶尔会把无害字符串当成 token 扔掉,但绝不会让一个真 token 进入摘要再被某天的检索意外暴露给 LLM。
为什么不是又一个向量库
把所有细节合起来看,Memory Tree 与"套壳向量库"差的不是某一个特性,而是把记忆当作什么的根本分歧。
| 问题 | 向量库 | Memory Tree |
|---|---|---|
| 今天发生了什么? | 无解(没有 query 向量) | 全局树昨日 L0 节点 |
| Alice 最近怎么样? | top-K 相似,可能漏掉关键 | 她的 topic tree,热度自动维护 |
| 上周二 3 点 Stripe 说了什么? | 需要全文 + 时间过滤补打 | 源树 + time range 直接命中 |
| 这条摘要从哪来? | top-K 命中没有溯源链 | summary.child_ids → 原 chunk → vault .md |
| 重新 ingest 相同内容? | 会产生重复向量 | SHA-256 chunk ID 直接 upsert |
| 能 git 化、和团队共享? | 难(向量是 blob) | vault 就是 Markdown,可 commit |
| 断电恢复? | 看实现 | 持久化 job 队列 + lease 过期回收 |
| LLM 用量? | 每次查询都嵌入 | 双阈值切灰区,只在 0.15–0.85 调 LLM |
可选后端:兼容 agentmemory
值得一提的是 OpenHuman 没有把自己锁死。MemoryConfig.backend = "agentmemory" 可以让它把存储层换成另一个独立项目 agentmemory 的 REST 服务——这样多个 coding agent(Claude Code、Cursor、Codex、OpenCode、OpenHuman)可以共享同一个持久化记忆。Memory Tree 流水线本身仍在跑,但底层 Memory trait 的存储被换掉。这种"流水线 + 后端"解耦的态度,比大多数声称要做生态的项目都更脚踏实地。
对个人 AI 的意义
Memory Tree 的真正价值在两件事上。第一,它示范了"确定性流水线 + LLM 重活异步化"这种组合拳——把可以确定的部分(切分、哈希、入库、调度)做得像数据库一样稳,把 LLM 调用约束在借灰区判断、摘要生成这种能容忍重试和失败的位置。第二,它把"个人 AI 的记忆"从云服务的私有 schema 抢回了用户的硬盘——你可以打开 ~/.openhuman/wiki/ 直接用 Obsidian 读,可以 git 化、可以备份、可以审计、可以删。本地优先不是宣传话术,而是体现在每一个文件路径里。