caveman cavemem 持久化记忆系统详解:SQLite 存储、BM25 召回与 Token 预算控制
【免费下载链接】caveman🪨 why use many token when few token do trick — Claude Code skill that cuts 65% of tokens by talking like caveman项目地址: https://gitcode.com/GitHub_Trending/caveman1/caveman
caveman 项目的mem/模块(cavemem)实现了跨会话的持久化 Agent 记忆:记忆原文存入本地 SQLite 作为唯一事实来源,召回用确定性 BM25 打分并在保守阈值下过滤,命中项再经压缩引擎处理后注入——既保证tokens_added等成本数据是诚实的推断值,又让被压缩丢弃的细节可通过 CCR(内容恢复)句柄完整还原。读完本文,你可以理解 cavemem 的五个核心操作(remember/recall/supersede/history/forget)的底层实现、MCP 工具与 CLI 的完整用法、单写者 SQLite 并发纪律,以及围绕"失败即不召回、永不丢记忆、全程 inferred"设计的一系列诚实性不变量。
模块定位与核心设计
cavemem 解决的是 Agent 在多个会话之间"记不住事"的问题。它的设计可以概括为三层分工:
- 持久层:本地 SQLite 数据库保存原文记忆(raw memories),这是 source of truth。原文在任何引擎调用之前就已落盘,因此压缩失败、引擎异常都不可能导致记忆丢失。
- 召回层:
recall用确定性 BM25 对全部现行记忆打分,低于保守阈值(DefaultThreshold = 0.1,见 mem/store.go)的命中直接丢弃——离题查询召回"空",而不是猜测注入噪声。 - 压缩层:每个召回命中通过 engine 的
Compress路径压缩,命中携带recovery_handle;Recover可取回字节级一致的原文。压缩只发生在召回时刻,是瞬时的。
整个模块输出的tokens_added、匹配分数与basis字段一律标注为inferred(推断值),组件从不声称verified节省。相关背景文档可参考根目录 CLAUDE.md 与 mcp/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/forgetCLI 子命令(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,并创建三个索引。
数据目录与打开方式
Open(mem/store.go)默认在~/.caveman/mem下创建mem.db(记忆)与ccr.db(CCR 恢复库),并尊重CAVEMAN_HOME环境变量;Options.InMemory用于测试。目录权限为0o700。
单写者纪律是这个模块最重要的工程细节:
mem.db与ccr.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.5、b = 0.75(mem/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):
- 参数归一:
Limit为 0 取DefaultLimit = 5;Threshold为 0 取DefaultThreshold = 0.1;TokenBudget为 0 取DefaultTokenBudget = 2000; - 打分与过滤:对全部现行记忆做 BM25 打分,保留 ≥ 阈值者,按分数降序(同分按 ID 字典序,保证确定性平局裁决),截断到 limit;
- 逐条压缩:按排名顺序调用
engine.Compress。引擎失败时本身 fail-closed——结果保留原始字节与原 token 数,召回侧保留该记账而不是替换成 0,避免低估注入成本; - 预算打包:
- 若
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)。
针对这条"超大单命中"路径,headHit(mem/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 映射为UnlimitedTokenBudget(externalTokenBudget,mem/cmd/cavemem/main.go),外部负数一律报错。省略参数永远不解除上限。
Remember 边界:256 KiB 上限与退出码 65
Remember是 fail-closed 的:
- 空白文本直接拒绝;
- 超过
MaxMemoryBytes = 256 KiB返回ErrMemoryTooLarge,错误文案携带cave_memory_too_large惯用错误 id(mem/store.go、mem/store.go)。设计立场很明确:一条记忆是"待召回的事实",不是文件倾倒; - CLI 对该错误以退出码 65(EX_DATAERR)结束进程,日志只走 stderr,JSON 走 stdout。两个薄客户端都导出
MEMORY_TOO_LARGE_EXIT_CODE = 65常量(mem/js/index.mjs、mem/py/cavemem.py),调用方可以按退出码分支,无需解析 stderr。
另一个细节:如果某文本只以"已过期历史版本"存在(valid_until非空),Remember会拒绝并报错,而不是把它当成功返回——否则Recall仍会隐藏它,声称"记住了"却不召回就是自欺。
supersede / history / forget:版本链生命周期
- Supersede(mem/store.go):在单事务中替换一条现行记忆。旧行保留
valid_until与superseded_by供审计,正常召回排除它。fail-closed 细节包括:目标 id 必须现行(valid_until IS NULL)、替换文本不得与当前相同、替换文本的 id 不得已存在、UPDATE的RowsAffected必须恰好为 1(防止并发修改导致的竞态覆盖); - History(mem/store.go):沿
supersedes向前、superseded_by向后遍历,返回包含该 id 的完整版本链(最旧 → 最新)。断链或成环直接报错,绝不返回部分历史; - Forget(mem/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) | id、text(必填) | {id, supersedes, created_at, basis:"inferred"} |
cavemem_history(id) | id(链中任一 id) | {history: [...], basis:"inferred"} |
cavemem_forget(id) | id(必填) | {forgotten: bool} |
每个 hit 的结构(Hit,mem/store.go)为:id、text(压缩后的注入文本)、score、tokens_added(推断值)、basis、recovery_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-8:
subprocess.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.mjs 与 mem/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-closedcave_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 加"可信记忆层"的系统都有参考价值。
【免费下载链接】caveman🪨 why use many token when few token do trick — Claude Code skill that cuts 65% of tokens by talking like caveman项目地址: https://gitcode.com/GitHub_Trending/caveman1/caveman
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考