Caveman CCR 上下文恢复机制全解:从 ccr_ 句柄到字节级恢复的完整链路
Caveman 的上下文恢复(Context Recovery,简称 CCR)解决一个根本问题:当压缩器为了省 token 删掉了低价值上下文时,如何保证被删内容对用户和 Agent 永远可用。本文围绕 docs/technical/context-recovery.md 的原始设计展开,并结合 engine/ccr/、engine/engine.go、mcp/engine_tools.go 等源码,讲清 CCR 句柄的生成规则、存储记录结构、类型化对象与 ccr:// 指针、各运行时后端的差异、512 MiB 容量规则、安全边界与运维排查清单,读完你可以完整掌握从"压缩产出句柄"到"CLI/MCP 一条命令取回原始字节"的全链路实现。
CCR 的定位:让有损压缩可逆
Caveman Engine 的压缩流水线是"检测内容类型 → 路由到压缩器 → 计数 token → 记录原始字节"。其中一部分压缩器属于有损类别(源码中称 S4),它们会真实丢弃内容——例如把一份 340 行的库存清单缩成"保留 20 行 + 不变量摘要"。CCR 的作用就是:把每一次有损压缩的精确原始字节存放在一个短引用背后,使得压缩可以移除低价值上下文,却不使源头对用户或 Agent 不可用。
这一点在引擎入口是硬性约束:Engine.Compress 只有在 store.Put 成功写入恢复记录后,才会发布变换后的输出;恢复写入失败时结果整体回退为透传(pass-through),调用方永远不会拿到"没有句柄的变换字节"。参见 engine/engine.go 中的 Compress 实现:
if info.RequiresCCR && !opts.ExternalRecovery {
handle, err := e.store.Put(ccr.Recovery{
ContentType: ct,
Compressor: comp.ContentType(),
TokensBefore: before,
TokensAfter: after,
Original: append([]byte(nil), input...),
Metadata: append([]byte(nil), meta.RecoveryMetadata...),
})
if err != nil {
// 调用方绝不能收到没有持久句柄的变换字节
return res, err
}
res.RecoveryHandle = handle
}
注意 Engine.New 的构造注释也点明了这一契约:没有 store 的引擎仍然可以检测和无损压缩,但永远不会运行有损压缩器,因为"没有恢复路径的有损结果违反可逆性契约"(见 engine/engine.go)。
句柄:内容寻址的 ccr_ 引用
字节负载使用以 ccr_ 开头的内容寻址句柄。句柄主体来自原始字节 SHA-256 摘要的前 16 字节,编码为十六进制:
ccr_0123456789abcdef0123456789abcdef
源码中这一规则只有一个地方实现,位于 engine/ccr/store.go 的 Handle 函数:
// Handle 返回负载的内容寻址句柄,不做存储。
func Handle(original []byte) string {
sum := sha256.Sum256(original)
return "ccr_" + hex.EncodeToString(sum[:16])
}
由这一实现可以直接推出三条性质:
- 幂等:相同的字节产生相同的句柄。重复压缩同一负载只会命中同一行存储,不会膨胀;
- 可校验但不加密:句柄是标识符,不是加密机制,也不是授权令牌。它不提供机密性,仅保证"这份字节曾经被原样存过";
- 失败显式:未知句柄返回明确的
ErrNotFound(ccr: recovery handle not found),存储"从不猜测恢复"(见 engine/ccr/store.go 中的错误定义)。
取回源内容的 CLI 命令:
caveman tools retrieve ccr_0123456789abcdef0123456789abcdef
MCP 客户端则可以改调恢复工具(caveman_retrieve),效果等同。
存储记录:一条恢复行里有什么
一条恢复记录包含文档列出的四类信息,对应源码中的 Recovery 结构体(engine/ccr/store.go):
// Recovery 是一条已存储的原始负载,加上统计所需的记账字段。
type Recovery struct {
ContentType string
Compressor string
TokensBefore int
TokensAfter int
Original []byte
Metadata []byte
}
映射关系如下:
| 文档描述 | 存储字段 | 说明 |
|---|---|---|
| 精确原始字节 | Original(BLOB) |
恢复时按字节返回,逐字节相等 |
| 内容类型与压缩器元数据 | ContentType / Compressor / Metadata |
Metadata 为压缩器附带的可选恢复元数据,RetrieveMetadata 单独读取,不混入原始字节 |
| 原始/压缩后大小信息 | TokensBefore / TokensAfter |
本地 token 计数基准下的前后 token 数,Stats 用它计算压缩比 |
| 本地时间戳与记账字段 | created_at |
RFC3339Nano 时间戳 |
在 SQLite 后端中这些字段落成 recoveries 表(见 engine/ccr/store_sqlite.go 的 schema),handle 为主键,metadata 列是后期迁移加上的(ensureMetadataColumn 用 ALTER TABLE ... ADD COLUMN metadata BLOB 兼容旧库)。
压缩输出中会嵌入该句柄以及足够多的说明文字,使一个"认识工具"的 Agent 能在需要时请求源内容。MCP 侧对句柄的形态做了防御性归一化——因为 Agent 会照抄它看到的一切形式,任何无法解析的形态都会把一次恢复变成"恢复风暴"。mcp/engine_tools.go 的 normalizeRecoveryHandle 接受四种形态:
ccr_…——Compress直接返回的裸句柄;<<ccr:ccr_…>>—— 嵌入在压缩内容中的标记;ccr:ccr_…—— 被剥掉一半的同类标记;ccr://…—— 原生运行时工具输出掩码使用的指针(其 id 是类型化对象 id,而非 blob 句柄)。
注释里还记录了一个真实事故背景:缺少 ccr:// 形态的处理曾导致某个库存核对场景无法回答,因为 Agent 看到的引用被恢复工具拒绝。
类型化对象:ccr_obj_ 与 ccr:// 指针
结构化工具可以以 ccr_obj_ 开头的标识符存储类型化对象,引用使用 ccr:// 指针,客户端可以选择某个字段或子树,而不是拉取整段无关负载。
类型是封闭枚举,未知类型失败关闭(fail closed)——适配器可以把未知原生负载留在 CCR 之外,但不允许为它发明恢复语义。engine/ccr/store.go 中的 ObjectType 常量 定义了 13 种工作内存类型:
ObjectFileObservation // 文件观察
ObjectSearchResult // 搜索结果
ObjectCommandResult // 命令结果
ObjectTestResult // 测试结果
ObjectBuildResult // 构建结果
ObjectDiffSnapshot // diff 快照
ObjectTaskContract // 任务契约
ObjectTaskDecision // 任务决策
ObjectExecutionState // 执行状态
ObjectDocumentationExcerpt // 文档摘录
ObjectBrowserSnapshot // 浏览器快照
ObjectRepositoryMap // 仓库地图
ObjectEvidenceBundle // 证据包
每个对象还带有 currentness(current/stale/archived)与 lifecycle(hot/warm/cold/archived)两个生命周期维度,只能通过显式的 SetObjectCurrentness / SetObjectLifecycle 方法变更,重复 Put 不会比较它们。
ccr_obj_ id 的生成规则在 prepareObject 中:取 type|session_id|source|repository_state|content_hash 拼接后 SHA-256 的前 16 字节十六进制:
obj.ID = "ccr_obj_" + hex.EncodeToString(sum[:16])
同时该函数校验 content_hash 与 sha256:data 必须一致、session_id 必填、字节长度不得为负——类型化检索"验证指针形态与对象类型,非法选择器失败而不是返回猜测的对象"正是由这一层保证的。
存储侧有两个 id 空间(blob 的 ccr_… 与对象的 ccr_obj_…),而 Agent 无法预期自己拿到的引用出自哪一个。因此 Engine.Retrieve 先查 blob 表,未命中再落到对象表,两张表都未命中才返回 ErrNotFound(见 engine/engine.go 的 Retrieve)。注释解释了为什么必须双查:无法解析的句柄会诱发反复的取回尝试,尤其当失败的取回结果本身又符合掩码条件时——"恢复不能指向另一个死指针"。
取回路径:CLI、引擎二进制与 MCP
文档给出的用户级入口是 caveman tools retrieve <handle>(完整命令参数见 docs/technical/cli-reference.md)。其底层是独立的引擎二进制 caveman-engine,engine/cmd/caveman-engine/main.go 的 retrieve 子命令支持可选的第二参数 query:
caveman-engine retrieve <handle> [query]
无 query:打印 CCR 句柄对应的原始字节(字节精确)
带 query:只打印与该查询最相关的章节(BM25 排序)
runRetrieve 调用的是 Engine.RetrieveQuery。这条"按查询收窄"的路径值得展开,它实现了一个关键安全性质:宁可多返回,绝不返回碎片。engine/retrieve_query.go 的注释写得很直白:
按行排名的 JSON 视图可能把一条记录里的字段摆到另一条记录的标识符旁边。没有显式间隙标记时,Agent 可能把字段归因到错误的记录上,报告出源中根本不存在的结果。
具体规则:
- 存储内容被分解为"整体单元"(JSON 对象记录、CSV 行、按空行切分的段落);JSON 只取"只含对象的数组"作为记录组,不提取名为
content/text的裸字段字符串,避免与记录 id 脱钩; - 用 BM25 对单元打分,保留得分最高的前 20 个(
maxRetrieveSections = 20),再按原始顺序输出; - 视图永远不会输出单元的碎片,两个单元除非在原文中相邻,否则中间必然插入标记
… [caveman: non-adjacent] …,包括视图起点和终点未覆盖到原文首尾时; - 内容无法分解出单元、或没有任何单元命中查询时,直接返回完整原始——"过度返回是安全的,返回碎片不是"。
MCP 工具 caveman_retrieve 走同一条 RetrieveQuery 路径(见 mcp/engine_tools.go),并且工具描述本身就在约束 Agent 行为:这是"最后手段"而不是分页 API;省略标记已经陈述了从被替换单元计算出的不变量(计数、总和、字段范围),能从可见视图加不变量回答的问题根本不需要取回;确实需要时,每个句柄只做一次调用、用一个覆盖全部需求的宽查询——一连串窄取回每次都要重读整个会话前缀,代价远高于压缩省下的 token。
存储后端:SQLite、内存与调用方自选
文档给出的后端对照表:
| 运行时 | 默认恢复存储 |
|---|---|
| Native Engine 与 proxy | SQLite 本地存储 |
| WebAssembly | 内存存储 |
| 测试与内嵌调用方 | 调用方自选存储 |
两种后端的代码分叉非常清晰:
SQLite 后端(engine/ccr/store_sqlite.go,构建标签 !js)——本地平台使用。原生恢复数据默认位于 ~/.caveman/ccr.db,这一点在引擎二进制的路径拼装中可以确认(engine/cmd/caveman-engine/main.go 返回 filepath.Join(caveHome(), "ccr.db"))。该后端有几个从源码看值得注意的细节:
- 并发 pragma 是承重墙而非调优。DSN 固定带
_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL),因为caveman-mcp、cavemem、caveman-engine、caveman-browse等多个进程共享同一个~/.caveman/*.db:没有 WAL,写进行时读会被锁在外面;没有 busy_timeout,竞争方直接拿到SQLITE_BUSY——"一次丢失的 Put 意味着一个省略了却没有原始可恢复的负载,一次丢失的 Get 会被读作未知句柄"(见 SQLiteDSN 的注释)。此外连接池被钳制为单连接(SetMaxOpenConns(1)),多语句迁移的锁竞争由带 5 秒预算的RetryOnBusy重试循环兜底(store_sqlite.go),并有专门的 retry_busy_test.go 验证。 - 文件权限与符号链接防御。恢复数据库可能包含提示词、凭据和工具结果,因此
PrepareSQLitePathCanonical会解析父目录符号链接、拒绝非普通文件、以0600创建/收紧数据库及其-wal/-shm边车文件,并在打开时做同文件校验防止替换攻击(store_sqlite.go)。engine/ccr/store_permissions_test.go 覆盖了权限、符号链接拒绝等路径。 - Proxy 测量数据另存。proxy 使用独立的
~/.caveman/caveman.db,与ccr.db分离。
内存后端(engine/ccr/store_wasm.go,构建标签 js && wasm)——浏览器没有文件系统,modernc.org/sqlite 也无法为 js/wasm 构建,所以恢复记录以带互斥锁的 map 形式存活于进程内存,会话作用域:页面卸载即丢失,除非宿主自行持久化记录。它暴露与 SQLite 后端完全相同的类型与方法,引擎对差异无感知。
测试与内嵌——Open(":memory:") 提供一个与文件库同 schema 的临时存储(OpenMemory),调用方通过 Open / OpenWithBudget 自选。SQLite 后端的预算还受环境变量 CAVEMAN_CCR_MAX_BYTES 覆盖,要求为正整数,否则 Open 直接报错(store_sqlite.go)。
容量行为:512 MiB 硬预算与"拒绝而不是驱逐"
恢复存储是有界的,默认负载容量 512 MiB:
const DefaultMaxStorageBytes int64 = 512 << 20 // 512 MiB 保留负载
新记录超出可用容量时被拒绝,而不是驱逐现有句柄,此时 Engine 保持原始输入透传。拒绝的错误是 ErrBudgetExceeded(ccr: storage budget exceeded)。这条规则的目的写在注释里:已发出的变换请求可能仍在引用任意句柄,驱逐会让旧的压缩上下文变成悬空引用(dangling reference)。
实现上有两道关卡:
- 写入前逻辑检查——
checkRecoveryBudget统计现有保留字节(recoveries的original+metadata与typed_objects的data+dependencies_json之和),若已用 - 同句柄旧占用 + 新字节 > 预算,返回ErrBudgetExceeded(store_sqlite.go); - 写入时 SQLite 物理检查——
configureStorageBudget把预算换算成PRAGMA max_page_count(按page_size),并联动wal_autocheckpoint与journal_size_limit,库真的写满时以SQLITE_FULL报错,再被映射回ErrBudgetExceeded。
在 CLI 边界,这个失败被显式编入 stderr 报告而不是污染 stdout:压缩失败时若结果是安全的透传,stdout 仍输出原始字节,stderr 报告带上错误码 cave_ccr_unavailable 或更精确的 cave_ccr_budget_exceeded(见 engine/cmd/caveman-engine/main.go 的 emitCompressResult)。Stats 接口同样暴露存储水位:StorageBytes(精确保留的负载+元数据字节,非 SQLite 文件开销)、MaxStorageBytes 与 StorageFull。
安全属性:CCR 提供什么、不提供什么
CCR 提供的是精确源头的可用性。它本身不提供:
- 静态加密;
- 远端身份或访问控制;
- 密钥脱敏;
- 永久性归档存储;
- "调用方有权查看某个猜测句柄"的证明。
本地 proxy 绑定 loopback,并假设只有一个受信任的操作员账户。因此实践要求是:像对待 Agent 转录一样保护本地数据库,且不要在授权层缺位的情况下把句柄跨信任边界共享。
源码侧的佐证是前述的防御性设计:0600 权限收紧、符号链接拒绝、单写连接串行化,都是针对"本地单操作员"假设的加固;而"不提供访问控制"意味着句柄一旦被另一个信任域拿到,任何人都能取回——所以文档明确警告不要裸共享句柄。类型化对象上的脱敏(masking)位于存储层之上,且必须保留 ObjectID 作为其恢复路径(见 engine/ccr/store.go 的 Object 注释)。
恢复与证据的边界
恢复证明的是"源头仍然可用"。它不证明压缩后的上下文具有等同的模型质量,也不把推断的 token 缩减变成已验证的货币化节省——后者需要独立的评估与证据。这一点与代码中的措辞纪律一致:统计输出的 Basis 字段固定为 "inferred",MCP 的 stats 工具注释明确写着字符串 "verified" 永远不应出现(mcp/engine_tools.go)。压缩比数字始终标注为本地 token 计数的推断基准,而非账单级事实。
运维排查清单
当恢复失败时,按以下顺序排查(继承自文档的 Operational checks):
- 确认请求使用的是创建该句柄的同一个本地运行时与同一个存储——不同进程/运行时打开不同的库,句柄自然查不到;
- 确认数据库文件仍然存在且可读(默认
~/.caveman/ccr.db;WAL 模式下注意-wal/-shm边车); - 确认句柄被完整复制——
ccr_后应为 32 个十六进制字符; - 检查压缩时的存储容量错误——若当时收到
cave_ccr_budget_exceeded,说明该负载从未被写入,句柄本就不存在; - 记住内存型 WebAssembly 句柄不随运行时存活——运行时被丢弃后句柄即失效,除非宿主另行持久化。
最后一条硬约束:绝不要在活动 Agent 会话期间删除或替换恢复数据库,除非可以接受现有句柄全部失效。已发出的压缩请求里散布着指向这些句柄的引用,数据库一旦被替换,所有旧引用同时变成死指针,而失败的取回结果本身可能又被压缩掩码成新的死指针——这正是 Retrieve 双表兜底与句柄归一化想要切断的连锁反应。
延伸阅读
| 主题 | 路径 |
|---|---|
| CCR 设计文档(本文主体) | docs/technical/context-recovery.md |
| CCR 存储包入口与句柄生成 | engine/ccr/store.go |
| SQLite 后端:schema、WAL、预算、权限 | engine/ccr/store_sqlite.go |
| WASM 内存后端 | engine/ccr/store_wasm.go |
| 权限/符号链接防御测试 | engine/ccr/store_permissions_test.go |
| 预算满与完整存储行为测试 | engine/ccr/store_full_test.go |
| Compress/Simulate/Retrieve 引擎契约 | engine/engine.go |
| 按查询收窄的取回实现 | engine/retrieve_query.go |
| 引擎二进制(retrieve 子命令) | engine/cmd/caveman-engine/main.go |
| MCP 恢复工具与句柄归一化 | mcp/engine_tools.go |
| CLI 命令参考 | docs/technical/cli-reference.md |
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 StartedRust0624
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