首页
/ caveman cavemem 实战详解:基于 SQLite + BM25 + 压缩引擎的跨会话持久化 Agent 记忆

caveman cavemem 实战详解:基于 SQLite + BM25 + 压缩引擎的跨会话持久化 Agent 记忆

2026-09-04 15:58:31作者:胡易黎Nicole

cavemem 是 caveman 项目(mem/README.md)中专为 AI Agent 设计的持久化记忆组件:记忆以原始文本落入本地 SQLite,召回时用确定性 BM25 排序并设置保守阈值,每一条命中再经 Caveman 压缩引擎处理后注入上下文,丢弃的细节可通过 CCR recovery handle 逐字节恢复。读完本文,你将掌握 cavemem 的五个核心操作(remember / recall / supersede / history / forget)的完整 CLI 与 MCP 用法、token 预算机制的语义细节,以及字节安全写入、有界召回等保证背后的源码级实现。

一、定位与设计原则

cavemem 解决的是 Agent 跨会话记忆问题:把一条事实或笔记持久化下来,后续会话能按语义查询召回,同时注入上下文的 token 成本是诚实且可恢复的。其官方定位(mem/README.md)是:

Durable, compression-native agent memory: remember, recall, supersede, history, forget.

三个设计支柱直接体现在包注释中(mem/store.go#L1-L11):

  1. SQLite 存储原始记忆,raw text 是持久化真相源(source of truth);
  2. BM25 召回 + 保守阈值——宁可什么都不召回,也不注入噪声;
  3. 召回时才压缩——压缩是瞬态行为(transient),只为让注入成本真实、丢弃细节可恢复。

另一条贯穿全部接口的基础约定:所有上报的数字(tokens_added、score、basis)都是 inferred(推断值),组件从不声称 verified 节省。

二、五个核心操作:完整 CLI 工作流

构建与基本用法(继承自 mem/README.md,并补充了源码中实际存在的 recover 子命令):

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   # explicit 0 token budget = unlimited
./cavemem supersede mem_xxxxxxxx "deploy key moved to vault ops/deploy-v2"
./cavemem history   mem_yyyyyyyy                  # oldest → current
./cavemem forget   mem_xxxxxxxx
./cavemem          # 无参数(或显式 mcp)运行 stdio MCP 服务器
./cavemem recover <recovery_handle>               # 将召回命中的原始字节写回 stdout

子命令的分发逻辑在 mem/cmd/cavemem/main.go#L54-L87rememberrecallsupersedehistoryforgetrecover 属于 store-backed 动词,只有这些动词才会打开数据目录;help 与未知子命令不会创建数据目录(未知命令退出码 2)。各命令的输出契约:

命令 参数 stdout 输出 失败退出码
remember <text>--stdin {id, created_at, basis} 超长时 65cave_memory_too_large
recall <query> [limit] [token_budget] {hits: [...], basis: "inferred"} 1(参数必须是非负整数)
supersede <id> <text> {id, supersedes, created_at, basis} 1
history <id> {history: [...], basis: "inferred"}(旧→新) 1
forget <id> {forgotten: bool} 1
recover <handle> 原始文本的原始字节 1
(无参数)/mcp stdio 上的 MCP 帧 1

几个值得注意的行为细节:

  • remember 支持 --stdincavemem remember --stdin 从标准输入读取,且读取上限为 MaxMemoryBytes+1 字节(mem/cmd/cavemem/main.go#L239-L250),超过 256 KiB 的输入直接被拒;
  • 内容寻址 ID:记忆 ID 是正文 SHA-256 的前 8 字节十六进制,形如 mem_xxxxxxxxxxxxmem/store.go#L709-L712),因此重复 remember 相同文本是幂等的INSERT OR IGNORE 保留原始 created_at);
  • 退出码 65(POSIX EX_DATAERR)是超长拒绝的契约,两个薄客户端都导出 MEMORY_TOO_LARGE_EXIT_CODE 常量,调用方无需解析 stderr 即可分支处理(mem/cmd/cavemem/main.go#L32-L33)。

三、SQLite 存储层:schema 与单写者并发纪律

3.1 表结构

存储 schema 定义在 mem/store.go#L34-L43

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
);

valid_until / supersedes / superseded_by 三列共同实现版本链(supersession chain):supersede 时旧行不删除,而是被打上 valid_until 并指向新版,旧行永久保留用于审计。旧库会通过 migrateMemorySchema 就地迁移(逐列 ALTER TABLE + 建索引,mem/store.go#L657-L707)。

3.2 数据位置与并发

  • 数据目录默认 ~/.caveman/mem,含 mem.db(记忆)与 ccr.db(压缩恢复缓存),可被环境变量 CAVEMAN_HOME 覆盖(mem/store.go#L714-L724)。
  • 单写者连接 + WALOpen 强制 SetMaxOpenConns(1),DSN 携带 busy_timeout(5000)journal_mode(WAL)mem/store.go#L111-L121)。注释解释了动机:多个 cavemem 进程(含 MCP 服务器)共享同一个 mem.db,JS 客户端还会 Promise.all(facts.map(remember)) 并发写入;没有这套纪律,32 个并发写只会落地 1 个、静默丢弃 31 个(SQLITE_BUSY)。冷启动的多语句 DDL 迁移额外包在五秒预算内的 ccr.RetryOnBusy 重试里。

3.3 supersede 与 forget 的事务语义

  • Supersedemem/store.go#L201-L266)是原子事务:校验旧记忆仍是 current、替换文本必须不同、新 ID 不得已存在;随后在同一事务里插入新行并给旧行打过期标记,若 RowsAffected != 1 则判定"supersede 期间记忆被并发改动"并回滚。已过期或未知的 ID 一律 fail closed。
  • Forgetmem/store.go#L324-L363)删除目标行后,会原子修复相邻血缘指针:删掉 current 版本意味着忘掉该事实,绝不会静默复活更旧的过期版本。
  • History 沿 supersedes 向上、沿 superseded_by 向下展开整条链(旧→新),遇到断链或环直接报错而不是返回部分历史(mem/store.go#L277-L319)。

四、BM25 召回:分词、停用词与保守阈值

打分实现全部在 mem/bm25.go,完全确定性,保证召回可复现:

  1. 分词mem/bm25.go#L18-L22):小写化后按任何非字母数字字符切分。
  2. 停用词mem/bm25.go#L27-L33):theiswhere 等 30 个无选择信号的虚词被同时从查询和文档中剔除。官方注释给了个例子:查询 "where is the deploy key" 是靠 deploy/key 命中的,而不是靠与无关笔记里偶然出现的 "is"/"the" 重叠。
  3. BM25 参数mem/bm25.go#L9-L13):Robertson/Spärck Jones 默认值 k1 = 1.5b = 0.75
  4. 非负分数:IDF 使用 +1 平滑形式 log(1 + (n-df+0.5)/(df+0.5)),得分恒非负,因此固定阈值才有意义(mem/bm25.go#L48-L109)。

召回端的关键常量(mem/store.go#L45-L56):

常量 语义
DefaultThreshold 0.1 低于该分的命中直接丢弃;无关查询召回空集而非猜测
DefaultLimit 5 默认返回的命中数上限
DefaultTokenBudget 2000 单次 Recall 所有命中的推断 token 总量上限
UnlimitedTokenBudget -1(内部哨兵) 关闭打包上限,但保留 Limit

Recall 的排序还带确定性平局处理:分数相同时按记忆 ID 字典序(mem/store.go#L444-L449),保证同一数据集两次召回顺序一致。

五、压缩原生召回:token 预算、贪心打包与恢复句柄

这是 cavemem 与"存了就算"型记忆方案的本质区别。Recall 的完整流水线(mem/store.go#L411-L535):

  1. 先加载、先打分:拉取所有 current 记忆(valid_until IS NULL),BM25 过滤阈值、按分排序、截取 limit 条;
  2. 逐条压缩:每条候选按排名顺序经 engine.Compress 压缩,被注入的是压缩形态,预算核算的也是压缩后的 token 数。引擎失败时保持 fail-closed 的原始字节与原始计数,而不是记 0(那会低估注入开销);
  3. 贪心打包:预算内按 BM25 排名贪心装填,实际委托给引擎的确定性打包器 contextwindow.Pack,与网关共享同一套预算实现;
  4. 单条超预算的特例:若排名第一的记忆压缩后仍单独超过整个预算,只返回一个预算尺寸的 head(头部截断)+ CCR recovery handle,绝不注入整段正文(mem/store.go#L494-L503)。截断用二分查找保证落在 UTF-8 rune 边界(mem/store.go#L568-L591)。如果引擎原样透传没有留下 handle,headHit 会在此刻把原文存入 CCR,确保"丢掉的尾部必须可恢复"这一不变量。

token_budget 的三态语义是 README 里"explicit 0 = unlimited"注释背后的关键设计(mem/cmd/cavemem/main.go#L299-L313externalTokenBudget):

  • 省略(或 Go 侧零值)→ 安全默认 2000;
  • 显式 0(CLI/MCP/JS/Python 公共接口)→ 映射到内部 UnlimitedTokenBudget,召回全部排名命中;
  • 负值 → 公共接口一律报错,内部哨兵 -1 不对外暴露。

每条召回命中都是一个 Hitmem/store.go#L385-L392):

{
  "id": "mem_…", "text": "压缩后的注入文本",
  "score": 1.23, "tokens_added": 187,
  "basis": "inferred", "recovery_handle": "…"
}

配套的回归测试锁死了这套契约(mem/recall_budget_test.go):

  • TestRecallRespectsTokenBudget:12 条相似记忆 + 60 token 小预算,断言总注入不超预算且必然丢弃部分命中;
  • TestRecallUnlimitedSentinelReturnsEveryRankedHit:显式 unlimited 时 12 条全返回;
  • TestRecallRejectsUnknownNegativeTokenBudget:负预算 fail closed;
  • TestRecallOversizedSingleHitReturnsHeadAndHandle:复现"单条 440,000 token 一次性召回"的真实事故——超尺寸单条必须返回 head + 有效 handle,且 Recover 还原结果与原文逐字节一致
  • TestRememberRejectsOversized:超过 256 KiB 上限(MaxMemoryBytesmem/store.go#L63-L71)的 remember 必须携带 cave_memory_too_large 拒绝且不落库

六、MCP 服务器:把记忆接进 Agent

cavemem 不带参数(或显式 mcp)即在 stdio 上运行 MCP 服务器(mem/cmd/cavemem/main.go#L98-L107),通过项目共用的 mcp.NewServer 框架提供与 caveman-mcp 完全一致的帧格式。五个工具(mem/cmd/cavemem/main.go#L111-L231):

MCP 工具 参数 说明
cavemem_remember text(必填) 存储记忆;相同文本幂等
cavemem_recall query(必填)、limit?token_budget? token_budget 默认 2000,显式 0 解除上限
cavemem_supersede idtext 替换 current 记忆,历史保留
cavemem_history id 返回该链路的旧→新版本
cavemem_forget id 删除记忆

所有错误都带 cave_* 惯用错误 ID(cave_invalid_argumentscave_memory_too_large 等),所有成功结果都标 basis: "inferred"。客户端配置(继承自 mem/README.md):

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

七、薄 JS / Python 客户端:为什么把正文走 stdin

mem/js/index.mjsmem/py/cavemem.py 是镜像的薄客户端:它们把文本 shell 到 Go 二进制,不重新实现任何存储、BM25 或压缩逻辑。README 给出的用法:

import { remember, recall, supersede, history, forget } from "cavemem"; // js/index.mjs
await remember("…"); await recall("…", 5, 2000); await recall("…", 5, 0); // unlimited
import cavemem                                            # py/cavemem.py
cavemem.remember("…"); cavemem.recall("…", limit=5, token_budget=2000)

实现上有三个针对真实环境的取舍:

  1. 正文走 remember --stdin 而非 argv,避开操作系统单参数大小限制;二进制通过 CAVEMEM_BIN 环境变量解析,否则走 PATH(mem/js/index.mjs#L9-L11)。JS 侧 maxBuffer 设为 32 MiB,且刻意把写入超长输入时收尾的 EPIPE 当作第二次失败——退出码 65 才是契约(mem/js/index.mjs#L18-L25);
  2. Python 双向固定 UTF-8text=True 单独使用会在 Windows 上落到 ANSI 代码页,remember("café") 可能在子进程见到之前就 UnicodeEncodeError,非 ASCII 召回则会乱码——因此显式 encoding="utf-8"mem/py/cavemem.py#L29-L39);
  3. 契约常量:两个客户端都导出 MEMORY_TOO_LARGE_EXIT_CODE = 65,与 CLI 的 EX_DATAERR 退出码对齐(mem/js/index.mjs#L6-L7mem/py/cavemem.py#L16)。

八、保证清单(Guarantees)与许可证

README 的五条保证(mem/README.md)逐条都有源码支撑:

  1. 字节安全写入——raw text 在任何压缩发生前就落盘 SQLite,记忆永不丢失;压缩只发生在召回时且是瞬态的(mem/store.go#L7-L10);
  2. 无关查询召回空集而非猜测——BM25 平滑 IDF + DefaultThreshold = 0.1 双重保守(mem/bm25.go#L48-L50);
  3. 默认 2000 推断 token 上限——CLI/MCP/JS/Python 均可显式 token_budget: 0 请求无限制;省略该参数绝不会解除上限mem/store.go#L52-L56);
  4. 召回默认排除被取代的事实——历史仅本地保留、可审计(all() 只查 valid_until IS NULL 的行,mem/store.go#L599-L601);
  5. 每条压缩命中都携带 recovery_handle——cavemem recover <handle>(对应 Go 的 Recovermem/store.go#L593-L596)返回逐字节原文。注意它读的是 cavemem 自己的 CCR 库(~/.caveman/mem/ccr.db),不要与 caveman 网关的 caveman retrieve 混用(mem/cmd/cavemem/main.go#L362-L366)。

许可证边界:Go 核心与二进制遵循 BSL 1.1(source-available,Change Date 前不属于 OSI 开源),薄 JS/Python 客户端保持 MIT(mem/LICENSEmem/js/LICENSE);更完整的说明见 LICENSING.md。构建与测试约定为 make product-build PRODUCT=mem / make product-test PRODUCT=memmem/CLAUDE.md)。

九、小结:适合什么场景

cavemem 的设计取向非常一致:失败时宁可什么都没有(fail toward nothing)、成本永远诚实(inferred-only)、丢掉的细节永远可找回(reversible)。它适合需要跨会话记住运维事实、配置位置、迁移上下文的本地 Agent 工作流,通过 CLI、stdio MCP 或 JS/Python 薄客户端接入。局限同样明确:召回是基于本地词法重叠的 BM25 而非语义向量检索(同义不同词、非英语词表下召回能力受限);记忆单条上限 256 KiB,定位是"事实与笔记",不是文件转储。若你要在 Agent 系统中引入持久化记忆,cavemem 提供的"SQLite 真相源 + 确定性召回 + 压缩注入 + 恢复句柄"四件套是一个可以直接对照实现的工程范本。

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

项目优选

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