tinyhumansai/openhuman · Rust + Tauri · 28,588 ★ · 源码级深拆

一棵会自己
长大的私人 AI 记忆

大多数"AI 记忆"产品本质上是 套了壳的向量库。OpenHuman 走的是另一条路——把你每天的聊天、邮件、文档喂进一条确定性桶封流水线,长出三棵互补的"摘要树",全部活在你自己的硬盘里。

Memory Tree · 一份 SQLite + 一个 Markdown vault · 三棵树 · 六种作业 · 零云依赖

3
记忆树(源 / 主题 / 全局)
50k
L0 封口 token 阈值
3k
单 chunk 最大 token
100%
本地,无外发
问题

"记忆" 这件事,被向量库做坏了

大多数 AI 记忆系统的结构是这样的:把每条消息切碎 → 跑 embedding → 塞进 pgvector / chroma / pinecone → 查询时 top-K 相似。这能回答"和我现在说的话最像的旧记忆是什么",但回答不了真正的记忆问题。

OpenHuman 的 gitbook 直白地写:

"The Memory Tree is OpenHuman's knowledge base. It is not a vector database with a thin 'memory' wrapper. It is a deterministic, bucket-sealed pipeline that turns the messy stream of your day into structured, queryable, summary-backed Markdown that lives on your machine."

向量库失败在三件事上:

  • "今天发生了什么?" —— 这是一个全局摘要问题,没有 query 向量可以和它对齐。
  • "Alice 最近怎么样?" —— 这是一个实体路由 + 时间衰减问题,需要专门为某个实体维护视图。
  • "上周二下午 3 点那个 Stripe webhook 说了啥?" —— 这是一个来源 + 时间精确定位问题,需要溯源能力。

Memory Tree 给出的答案是:把"压缩"和"导航"同时做掉。embedding 仍然存在用于语义搜索,但结构层是真正让记忆"像大脑而不是碎片袋"的东西。下面我们逐层拆开这条流水线。

第一层 · 热路径

从一条 Slack 消息到一行 SQLite,三毫秒内完成

"热路径"承诺三件事:确定性、快、有界写。所有 LLM 调用、embedding、摘要生成统统不在这条路径上——它们被推进一个持久化的作业队列,由后台 worker 处理。UI 永远不阻塞。

01
canonicalizechat / email / document → 统一的 Markdown + provenance metadata
↓
02
chunker按源类型切分(chat 按 ## 、email 按 ---\nFrom:),单 chunk ≤ 3000 token
↓
03
content_store原子写入 wiki/raw/*.md(temp → fsync → rename),YAML frontmatter + 正文
↓
04
fast-score纯启发式:token 数、词汇多样性、来源权重——不调 LLM
↓
05
SQLite 事务chunk + score + jobs 全部在一个 transaction 里入库,要么全成要么全无
↓
06
wake workers同步路径返回;后台 worker 被 Notify condvar 唤醒,开始干重活

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,从源头杜绝重复。

src/openhuman/memory/tree/types.rs — 确定性 chunk_id
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 被外部工具篡改。

wiki/raw/chat/slack-eng/0003-abc.md — 一个真实的 chunk 文件
---
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。两个阈值把分数切成三段:

src/openhuman/memory/tree/score/mod.rs — 准入决策
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 流,但封口规则、标签策略、时间窗各不相同。

SOURCE
源树 · per-source 滚动缓冲

每个 Slack 频道 / Gmail label / 文档一棵。新 leaf 进 L0 buffer,攒够就封成 L1 摘要,继续往上级联。

  • L0 封口:50k token 或 10 个 sibling
  • L1+ 封口:10 个 sibling(按数不按 token)
  • 过期强封:7 天没动也封
TOPIC
主题树 · 按"热度"懒物化

每个高热度实体(人、项目、ticker、repo)一棵。冷实体连树都不建;热度越高,刷新越积极。

  • 热度 = ln(30d 提及数) + 跨源数 + 近期衰减 + 中心度 + 用户查询权重
  • 由 topic_route 作业按门槛触发建树
  • 结构和源树相同,schema 共用
GLOBAL
全局树 · 每日一节点

单例。每天 UTC 零点后调度器跑一次,把昨天跨所有来源的最佳 L1+ 摘要合成成一节日报。

  • L0 = 日报;攒够 10 个升级 L1(周)
  • L1 升 L2(月)、L3(年)逐级展开
  • 跑出来的标签是 UnionFromChildren,免再调一次 LLM

桶封 (bucket-sealing) 的关键常量

tree_source/types.rs 直接把这套规则写成常量:

src/openhuman/memory/tree/tree_source/types.rs:188
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 封口能保证树形结构稳定地"宽展开"——一棵看起来像树的树,而不是退化的链表。

级联封口(cascade seal):当 L0 封成 L1,会立刻检查 L1 buffer 是否也到达门槛;到了就再封一层。一次 50k token 的注入可能一路级联到 L3。这套递归在 bucket_seal.rs::cascade_seals() 里完成,全程在一个 SQLite 事务内。

主题树的热度公式:用对数压住头部、奖励跨源

这是 tree_topic/hotness.rs:32 里非常值得品的设计——它在五个因子上做加权和,每个都有明确的物理意义:

src/openhuman/memory/tree/tree_topic/hotness.rs
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 恢复都从作业队列统一走。这是个非常老练的"职责分离"设计。

Dedupe key 防重发: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、AWS AKIA…、GitHub gh*_、Anthropic sk-ant-…、Google AIza…、OpenAI / Stripe sk|rk_live|test_…、Slack xox[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
核心立场:向量库回答 "和这句话最像的旧记忆是什么";Memory Tree 回答 "今天/这个人/这个源最重要的事情是什么,并能把它一路展开成原文"。前者是相似性检索,后者是结构化记忆。两者的输入相似,但输出完全不在同一类问题里。

可选后端:兼容 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 化、可以备份、可以审计、可以删。本地优先不是宣传话术,而是体现在每一个文件路径里。