caveman cavemem 实战详解:基于 SQLite + BM25 + 压缩引擎的跨会话持久化 Agent 记忆
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):
- SQLite 存储原始记忆,raw text 是持久化真相源(source of truth);
- BM25 召回 + 保守阈值——宁可什么都不召回,也不注入噪声;
- 召回时才压缩——压缩是瞬态行为(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-L87:remember、recall、supersede、history、forget、recover 属于 store-backed 动词,只有这些动词才会打开数据目录;help 与未知子命令不会创建数据目录(未知命令退出码 2)。各命令的输出契约:
| 命令 | 参数 | stdout 输出 | 失败退出码 |
|---|---|---|---|
remember |
<text> 或 --stdin |
{id, created_at, basis} |
超长时 65(cave_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 支持
--stdin:cavemem remember --stdin从标准输入读取,且读取上限为MaxMemoryBytes+1字节(mem/cmd/cavemem/main.go#L239-L250),超过 256 KiB 的输入直接被拒; - 内容寻址 ID:记忆 ID 是正文 SHA-256 的前 8 字节十六进制,形如
mem_xxxxxxxxxxxx(mem/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)。 - 单写者连接 + WAL:
Open强制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 的事务语义
- Supersede(mem/store.go#L201-L266)是原子事务:校验旧记忆仍是 current、替换文本必须不同、新 ID 不得已存在;随后在同一事务里插入新行并给旧行打过期标记,若
RowsAffected != 1则判定"supersede 期间记忆被并发改动"并回滚。已过期或未知的 ID 一律 fail closed。 - Forget(mem/store.go#L324-L363)删除目标行后,会原子修复相邻血缘指针:删掉 current 版本意味着忘掉该事实,绝不会静默复活更旧的过期版本。
- History 沿
supersedes向上、沿superseded_by向下展开整条链(旧→新),遇到断链或环直接报错而不是返回部分历史(mem/store.go#L277-L319)。
四、BM25 召回:分词、停用词与保守阈值
打分实现全部在 mem/bm25.go,完全确定性,保证召回可复现:
- 分词(mem/bm25.go#L18-L22):小写化后按任何非字母数字字符切分。
- 停用词(mem/bm25.go#L27-L33):
the、is、where等 30 个无选择信号的虚词被同时从查询和文档中剔除。官方注释给了个例子:查询 "where is the deploy key" 是靠deploy/key命中的,而不是靠与无关笔记里偶然出现的 "is"/"the" 重叠。 - BM25 参数(mem/bm25.go#L9-L13):Robertson/Spärck Jones 默认值
k1 = 1.5、b = 0.75。 - 非负分数: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):
- 先加载、先打分:拉取所有 current 记忆(
valid_until IS NULL),BM25 过滤阈值、按分排序、截取limit条; - 逐条压缩:每条候选按排名顺序经
engine.Compress压缩,被注入的是压缩形态,预算核算的也是压缩后的 token 数。引擎失败时保持 fail-closed 的原始字节与原始计数,而不是记 0(那会低估注入开销); - 贪心打包:预算内按 BM25 排名贪心装填,实际委托给引擎的确定性打包器
contextwindow.Pack,与网关共享同一套预算实现; - 单条超预算的特例:若排名第一的记忆压缩后仍单独超过整个预算,只返回一个预算尺寸的 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-L313 的 externalTokenBudget):
- 省略(或 Go 侧零值)→ 安全默认 2000;
- 显式 0(CLI/MCP/JS/Python 公共接口)→ 映射到内部
UnlimitedTokenBudget,召回全部排名命中; - 负值 → 公共接口一律报错,内部哨兵
-1不对外暴露。
每条召回命中都是一个 Hit(mem/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 上限(MaxMemoryBytes,mem/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 |
id、text |
替换 current 记忆,历史保留 |
cavemem_history |
id |
返回该链路的旧→新版本 |
cavemem_forget |
id |
删除记忆 |
所有错误都带 cave_* 惯用错误 ID(cave_invalid_arguments、cave_memory_too_large 等),所有成功结果都标 basis: "inferred"。客户端配置(继承自 mem/README.md):
{ "mcpServers": { "cavemem": { "command": "cavemem" } } }
七、薄 JS / Python 客户端:为什么把正文走 stdin
mem/js/index.mjs 与 mem/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)
实现上有三个针对真实环境的取舍:
- 正文走
remember --stdin而非 argv,避开操作系统单参数大小限制;二进制通过CAVEMEM_BIN环境变量解析,否则走 PATH(mem/js/index.mjs#L9-L11)。JS 侧maxBuffer设为 32 MiB,且刻意不把写入超长输入时收尾的EPIPE当作第二次失败——退出码 65 才是契约(mem/js/index.mjs#L18-L25); - Python 双向固定 UTF-8:
text=True单独使用会在 Windows 上落到 ANSI 代码页,remember("café")可能在子进程见到之前就UnicodeEncodeError,非 ASCII 召回则会乱码——因此显式encoding="utf-8"(mem/py/cavemem.py#L29-L39); - 契约常量:两个客户端都导出
MEMORY_TOO_LARGE_EXIT_CODE = 65,与 CLI 的EX_DATAERR退出码对齐(mem/js/index.mjs#L6-L7、mem/py/cavemem.py#L16)。
八、保证清单(Guarantees)与许可证
README 的五条保证(mem/README.md)逐条都有源码支撑:
- 字节安全写入——raw text 在任何压缩发生前就落盘 SQLite,记忆永不丢失;压缩只发生在召回时且是瞬态的(mem/store.go#L7-L10);
- 无关查询召回空集而非猜测——BM25 平滑 IDF +
DefaultThreshold = 0.1双重保守(mem/bm25.go#L48-L50); - 默认 2000 推断 token 上限——CLI/MCP/JS/Python 均可显式
token_budget: 0请求无限制;省略该参数绝不会解除上限(mem/store.go#L52-L56); - 召回默认排除被取代的事实——历史仅本地保留、可审计(
all()只查valid_until IS NULL的行,mem/store.go#L599-L601); - 每条压缩命中都携带
recovery_handle——cavemem recover <handle>(对应 Go 的Recover,mem/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/LICENSE、mem/js/LICENSE);更完整的说明见 LICENSING.md。构建与测试约定为 make product-build PRODUCT=mem / make product-test PRODUCT=mem(mem/CLAUDE.md)。
九、小结:适合什么场景
cavemem 的设计取向非常一致:失败时宁可什么都没有(fail toward nothing)、成本永远诚实(inferred-only)、丢掉的细节永远可找回(reversible)。它适合需要跨会话记住运维事实、配置位置、迁移上下文的本地 Agent 工作流,通过 CLI、stdio MCP 或 JS/Python 薄客户端接入。局限同样明确:召回是基于本地词法重叠的 BM25 而非语义向量检索(同义不同词、非英语词表下召回能力受限);记忆单条上限 256 KiB,定位是"事实与笔记",不是文件转储。若你要在 Agent 系统中引入持久化记忆,cavemem 提供的"SQLite 真相源 + 确定性召回 + 压缩注入 + 恢复句柄"四件套是一个可以直接对照实现的工程范本。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00