首页
/ caveman cavemem 持久化记忆系统详解:SQLite 存储、BM25 召回与 Token 预算控制

caveman cavemem 持久化记忆系统详解:SQLite 存储、BM25 召回与 Token 预算控制

2026-09-04 20:01:43作者:卓艾滢Kingsley

caveman 项目的 mem/ 模块(cavemem)实现了跨会话的持久化 Agent 记忆:记忆原文存入本地 SQLite 作为唯一事实来源,召回用确定性 BM25 打分并在保守阈值下过滤,命中项再经压缩引擎处理后注入——既保证 tokens_added 等成本数据是诚实的推断值,又让被压缩丢弃的细节可通过 CCR(内容恢复)句柄完整还原。读完本文,你可以理解 cavemem 的五个核心操作(remember / recall / supersede / history / forget)的底层实现、MCP 工具与 CLI 的完整用法、单写者 SQLite 并发纪律,以及围绕"失败即不召回、永不丢记忆、全程 inferred"设计的一系列诚实性不变量。

模块定位与核心设计

cavemem 解决的是 Agent 在多个会话之间"记不住事"的问题。它的设计可以概括为三层分工:

  1. 持久层:本地 SQLite 数据库保存原文记忆(raw memories),这是 source of truth。原文在任何引擎调用之前就已落盘,因此压缩失败、引擎异常都不可能导致记忆丢失。
  2. 召回层recall 用确定性 BM25 对全部现行记忆打分,低于保守阈值(DefaultThreshold = 0.1,见 mem/store.go)的命中直接丢弃——离题查询召回"空",而不是猜测注入噪声。
  3. 压缩层:每个召回命中通过 engineCompress 路径压缩,命中携带 recovery_handleRecover 可取回字节级一致的原文。压缩只发生在召回时刻,是瞬时的。

整个模块输出的 tokens_added、匹配分数与 basis 字段一律标注为 inferred(推断值),组件从不声称 verified 节省。相关背景文档可参考根目录 CLAUDE.mdmcp/CLAUDE.md

目录布局

模块布局与实现职责(源自 mem/CLAUDE.md):

路径 职责
mem/store.go Remember / Recall / Supersede / History / Forget / Recover,基于 SQLite + engine;旧 schema 就地迁移
mem/bm25.go 确定性分词器 + BM25 打分器(分数非负,使阈值有意义)
mem/cmd/cavemem/ MCP 服务器 + remember/recall/supersede/history/forget CLI 子命令(JSON 输出)
mem/js/mem/py/ 薄的 TS 与 Python 客户端,shell 调用二进制(镜像库,不重新实现任何逻辑)

存储层:memories 表、内容寻址 ID 与单写者纪律

表结构与版本链字段

核心 schema 定义在 mem/store.go

CREATE TABLE IF NOT EXISTS memories (
  id TEXT PRIMARY KEY,
  text TEXT NOT NULL,
  created_at TEXT NOT NULL,
  valid_from TEXT NOT NULL,
  valid_until TEXT,
  supersedes TEXT,
  superseded_by TEXT
);
  • id 是内容寻址的:memID(text) 取文本 SHA-256 的前 8 字节十六进制,前缀 mem_(见 mem/store.go)。因此记住相同文本两次是幂等的——INSERT OR IGNORE 保留首次 created_at,只存一行。
  • valid_until IS NULL 表示"现行版本"。all() 查询与 Count() 都只取现行记忆,这实现了"current-only recall"不变量:被取代的版本永远不进入正常召回。
  • supersedes / superseded_by 构成版本链,供 History 审计。

旧版库通过 migrateMemorySchema 就地升级(mem/store.go):PRAGMA table_info 检测缺失列,逐个 ALTER TABLE ADD COLUMN(SQLite 单语句无法加多列),把遗留 created_at 回填为 valid_from,并创建三个索引。

数据目录与打开方式

Openmem/store.go)默认在 ~/.caveman/mem 下创建 mem.db(记忆)与 ccr.db(CCR 恢复库),并尊重 CAVEMAN_HOME 环境变量;Options.InMemory 用于测试。目录权限为 0o700

单写者纪律是这个模块最重要的工程细节:

  • mem.dbccr.db 均以 SetMaxOpenConns(1) + SetMaxIdleConns(1) 打开(mem/store.go);
  • DSN 由 ccr.SQLiteDSN 提供,携带 busy_timeout(5000)journal_mode(WAL)。代理侧的 spend store 也用同一个 DSN,但强制单连接池——mem/CCR 的纪律更严格;
  • 冷启动迁移是多语句 DDL,在多个全新 cavemem 进程同时启动时(例如 JS 客户端的 Promise.all(facts.map(remember)) 扇出)可能超过单次 busy_timeout,因此迁移步骤包在 ccr.RetryOnBusy 中,其内部有 5 秒墙钟预算。而运行期单语句写只依赖 busy_timeout

为什么这么严格?据 mem/CLAUDE.md 的 Gotchas 记录:没有这套纪律时,32 个并发写只有 1 个落库,其余 31 个以 SQLITE_BUSY 静默丢失。并发行为由 mem/store_concurrency_test.go 验证。

召回算法:确定性 BM25 + 保守阈值

BM25 打分器在 mem/bm25.go,要点:

  • 参数:经典 Robertson/Spärck Jones 默认值 k1 = 1.5b = 0.75mem/bm25.go);
  • 分词:小写化后按任意非字母数字字符切分,完全确定性——同一文本永远得到同一 token 序列,保证召回可复现;
  • 停用词:过滤 26 个常见功能词(the/is/where/what 等)。这使召回保守:查询 "where is the deploy key" 只靠 deploy/key 命中,而不是与无关笔记偶然重叠 is/the
  • IDF 形式:采用 +1 平滑形式 log(1 + (n - df + 0.5)/(df + 0.5)),保证分数非负,因此固定阈值 0.1 是有意义的过滤线(mem/bm25.go)。

与查询词零重叠的记忆得分为 0,天然低于阈值——这就是"fail toward nothing":不召回比乱猜更安全。

Recall 全流程:压缩、贪心打包与 Token 预算

Recall(query, RecallOptions) 的完整流程(mem/store.go):

  1. 参数归一Limit 为 0 取 DefaultLimit = 5Threshold 为 0 取 DefaultThreshold = 0.1TokenBudget 为 0 取 DefaultTokenBudget = 2000
  2. 打分与过滤:对全部现行记忆做 BM25 打分,保留 ≥ 阈值者,按分数降序(同分按 ID 字典序,保证确定性平局裁决),截断到 limit;
  3. 逐条压缩:按排名顺序调用 engine.Compress。引擎失败时本身 fail-closed——结果保留原始字节与原 token 数,召回侧保留该记账而不是替换成 0,避免低估注入成本;
  4. 预算打包
    • TokenBudget = UnlimitedTokenBudget(内部值 -1),返回全部压缩命中;
    • 排名第一的命中压缩后仍超过整个预算,只返回其"压缩头 + CCR recovery_handle",绝不返回整段正文;
    • 否则构造 contextwindow.Item(Priority 编码 BM25 排名),委托 engine/contextwindow 的确定性打包器 Pack 在预算内贪心装填——cavemem 与代理网关共享同一份预算实现。

默认 2000 token 预算与 44 万 token 事故

DefaultTokenBudget = 2000 的注释直接记录了事故背景:没有预算前,recall 会整体加载并返回每条匹配记忆——一次单条 2.5 MB 记忆的召回产生了 tokens_added = 440,000 的单项结果(mem/store.go)。

针对这条"超大单命中"路径,headHitmem/store.go)把压缩文本截断到预算内(truncateToTokens 用二分找最长的、token 数不超预算的字节前缀,并回退到 UTF-8 rune 边界),同时保证 CCR 句柄存在:如果引擎直通压缩未留句柄,就把原文存入 CCR(Compressor: "cavemem-head")——因为头部截断丢掉了尾部,丢失细节必须可恢复。回归测试 mem/recall_budget_test.go 中的 TestRecallOversizedSingleHitReturnsHeadAndHandle 精确复现了这一场景并断言:只返回一个 head、tokens_added 不超预算、句柄非空且 Recover 可取回字节级一致原文;TestRecallRespectsTokenBudget 则验证多命中贪心打包不越预算;TestRecallRejectsUnknownNegativeTokenBudget 验证未知负数预算 fail-closed。

外部 token_budget=0 哨兵语义

这是一个容易踩坑的约定,值得单独强调:

  • Go 内部:TokenBudget = 0 表示"未设置",走安全的 2000 默认;UnlimitedTokenBudget = -1 才是无限;
  • 公共接口(CLI / MCP / JS / Python):调用方必须显式传 token_budget = 0 才请求无限召回。适配器把外部的 0 映射为 UnlimitedTokenBudgetexternalTokenBudgetmem/cmd/cavemem/main.go),外部负数一律报错。省略参数永远不解除上限。

Remember 边界:256 KiB 上限与退出码 65

Remember 是 fail-closed 的:

  • 空白文本直接拒绝;
  • 超过 MaxMemoryBytes = 256 KiB 返回 ErrMemoryTooLarge,错误文案携带 cave_memory_too_large 惯用错误 id(mem/store.gomem/store.go)。设计立场很明确:一条记忆是"待召回的事实",不是文件倾倒;
  • CLI 对该错误以 退出码 65(EX_DATAERR)结束进程,日志只走 stderr,JSON 走 stdout。两个薄客户端都导出 MEMORY_TOO_LARGE_EXIT_CODE = 65 常量(mem/js/index.mjsmem/py/cavemem.py),调用方可以按退出码分支,无需解析 stderr。

另一个细节:如果某文本只以"已过期历史版本"存在(valid_until 非空),Remember 会拒绝并报错,而不是把它当成功返回——否则 Recall 仍会隐藏它,声称"记住了"却不召回就是自欺。

supersede / history / forget:版本链生命周期

  • Supersedemem/store.go):在单事务中替换一条现行记忆。旧行保留 valid_untilsuperseded_by 供审计,正常召回排除它。fail-closed 细节包括:目标 id 必须现行(valid_until IS NULL)、替换文本不得与当前相同、替换文本的 id 不得已存在、UPDATERowsAffected 必须恰好为 1(防止并发修改导致的竞态覆盖);
  • Historymem/store.go):沿 supersedes 向前、superseded_by 向后遍历,返回包含该 id 的完整版本链(最旧 → 最新)。断链或成环直接报错,绝不返回部分历史;
  • Forgetmem/store.go):按 id 删除,并在同一事务中原子修复相邻血缘指针——被删节点的前驱的 superseded_by、后继的 supersedes 都改接到彼此上。已过期的前驱保持过期:删除现行版本意味着"忘记这个事实",而不是悄悄复活旧版本。对不存在的 id 返回 {forgotten: false} 而非报错。

MCP 服务器与五个 cavemem_* 工具

不带子命令(或显式 mcp)运行时,cavemem 启动 stdio MCP 服务器,经 mcp.NewServer 提供与 caveman-mcp 完全一致的帧格式。工具定义在 mem/cmd/cavemem/main.go

工具 参数 返回 / 错误 id
cavemem_remember(text) text(必填) {id, created_at, basis:"inferred"}cave_memory_too_large / cave_remember_failed
cavemem_recall(query, limit?, token_budget?) query(必填);limit 默认 5;token_budget 默认 2000,显式 0 解除上限 {hits: [...], basis:"inferred"}cave_recall_failed
cavemem_supersede(id, text) idtext(必填) {id, supersedes, created_at, basis:"inferred"}
cavemem_history(id) id(链中任一 id) {history: [...], basis:"inferred"}
cavemem_forget(id) id(必填) {forgotten: bool}

每个 hit 的结构(Hitmem/store.go)为:idtext(压缩后的注入文本)、scoretokens_added(推断值)、basisrecovery_handle(可选,指向 CCR)。

MCP 客户端注册配置(见 mem/README.md):

{ "mcpServers": { "cavemem": { "command": "cavemem" } } }

CLI 用法

构建并直接操作(命令契约见 mem/cmd/cavemem/main.go):

go build -o cavemem ./mem/cmd/cavemem
./cavemem remember "the deploy key lives in vault under ops/deploy"
./cavemem recall   "where is the deploy key"      # JSON: { hits: [...], basis: "inferred" }
./cavemem recall   "full migration context" 5 0   # 显式 0 token 预算 = 不限量
./cavemem supersede mem_xxxxxxxx "deploy key moved to vault ops/deploy-v2"
./cavemem history   mem_yyyyyyyy                  # 最旧 → 现行
./cavemem forget   mem_xxxxxxxx
./cavemem                                                 # 启动 stdio MCP 服务器

CLI 细节:

  • remember <text>|--stdin--stdin 模式用 io.LimitReader(stdin, MaxMemoryBytes+1) 读入,防止超大输入撑爆进程;超限走退出码 65;
  • recall <query> [limit] [token_budget]:两个数值参数均为非负整数,负数报参数错误;
  • recover <handle>:将某个召回命中的 recovery_handle 解析为字节级一致原文并写 stdout(针对 cavemem 自己的 CCR 库 ~/.caveman/mem/ccr.db;注意与全局 caveman retrieve 读取的是不同 CCR 库);
  • 只有 remember/recall/supersede/history/forget/recover 会打开数据目录,help 与未知子命令不会。

JS / Python 薄客户端

两个客户端是严格的"外壳":shell 调二进制,不在 TS/Python 侧重新实现任何验证、召回或打分逻辑(这正是 mem/CLAUDE.md Conventions 一节强调的约定)。

  • 二进制解析:优先环境变量 CAVEMEM_BIN,否则从 PATH 找 cavemem
  • remember 一律走 remember --stdin,把记忆文本通过 stdin 传递而不是塞进 argv——避开操作系统命令行长度限制,也不会在 argv 中暴露文本;
  • JS 客户端(mem/js/index.mjs)用 execFile 并设 maxBuffer: 32 MiB;对超大输入,二进制可能在读完 MaxMemoryBytes+1 后关闭 stdin,此时写完的 EPIPE 不是第二个错误,不能覆盖退出码 65 契约;
  • Python 客户端(mem/py/cavemem.py)仅用标准库,并双向锁定 UTF-8subprocess.run(..., encoding="utf-8")——否则 Windows 上 locale 的 ANSI 代码页会让 remember("cafe\u0301") 在到达 Go 二进制之前就抛 UnicodeEncodeError,召回非 ASCII 记忆时返回乱码;
  • API 镜像:remember(text)recall(query, limit?, token_budget?)supersede(id, text)history(id)forget(id),其中 token_budget=0 同样是不限量哨兵。

对应测试:mem/js/tests/client.test.mjsmem/py/tests/test_cavemem.py

诚实性不变量速查

mem/CLAUDE.md 的 Gotchas 一节完整对照源码后,可以汇总为七条工程不变量:

不变量 含义 源码依据
byte-safe write 原文先落 SQLite,压缩只在召回时临时发生,记忆永不丢 mem/store.go
single-writer store 单连接 + busy_timeout(5000) + WAL;迁移走 5 秒预算的 RetryOnBusy,否则 32 并发写丢 31 mem/store.go
bounded recall 默认 2000 推断 token 总预算;超大单命中返回"头部 + 恢复句柄",杜绝 440k token 单项召回 mem/store.go
bounded remember 单条 ≤ 256 KiB,超限 fail-closed cave_memory_too_large,CLI 退出码 65 mem/store.go
fail toward nothing 低于阈值或零词重叠 → 空召回,绝不猜测 mem/bm25.go
current-only recall 被取代版本仅经 History 可审计,不进正常召回 mem/store.go
inferred-only / reversible 成本与分数均为 inferred;每个压缩命中携带 CCR 句柄,Recover 返回字节级一致原文 mem/store.go

构建与测试

mem/CLAUDE.md 的 Conventions:

make product-build PRODUCT=mem   # 构建 Go 核心
make product-test PRODUCT=mem    # 运行 Go 核心测试
make test                        # 根级测试,额外运行镜像的 JS/Python 包装器测试

核心 Go 测试覆盖:召回预算与哨兵语义(mem/recall_budget_test.go)、并发写(mem/store_concurrency_test.go)、存储行为(mem/store_test.go)、BM25 打分(mem/bm25_test.go)以及 CLI 分发表(mem/cmd/cavemem/main_test.go)。

小结

cavemem 把"Agent 长期记忆"做成了一个纪律严明的本地系统:SQLite 单写者存储保证原文不丢,确定性 BM25 + 0.1 阈值保证"宁可空召回、不注入噪声",2000 token 默认预算 + 头部截断 + CCR 句柄保证注入成本诚实且可恢复,supersede/history/forget 的版本链保证事实更新可审计。所有数字标注 inferred,MCP、CLI、JS、Python 四个面共享同一份 Go 实现——这套设计对任何需要给 Agent 加"可信记忆层"的系统都有参考价值。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
504
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384