首页
/ Caveman CCR 上下文恢复机制全解:从 ccr_ 句柄到字节级恢复的完整链路

Caveman CCR 上下文恢复机制全解:从 ccr_ 句柄到字节级恢复的完整链路

2026-09-06 19:40:59作者:盛欣凯Ernestine

Caveman 的上下文恢复(Context Recovery,简称 CCR)解决一个根本问题:当压缩器为了省 token 删掉了低价值上下文时,如何保证被删内容对用户和 Agent 永远可用。本文围绕 docs/technical/context-recovery.md 的原始设计展开,并结合 engine/ccr/engine/engine.gomcp/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])
}

由这一实现可以直接推出三条性质:

  1. 幂等:相同的字节产生相同的句柄。重复压缩同一负载只会命中同一行存储,不会膨胀;
  2. 可校验但不加密:句柄是标识符,不是加密机制,也不是授权令牌。它不提供机密性,仅保证"这份字节曾经被原样存过";
  3. 失败显式:未知句柄返回明确的 ErrNotFoundccr: 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 列是后期迁移加上的(ensureMetadataColumnALTER 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_hashsha256: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-engineengine/cmd/caveman-engine/main.goretrieve 子命令支持可选的第二参数 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"))。该后端有几个从源码看值得注意的细节:

  1. 并发 pragma 是承重墙而非调优。DSN 固定带 _pragma=busy_timeout(5000)&_pragma=journal_mode(WAL),因为 caveman-mcpcavememcaveman-enginecaveman-browse 等多个进程共享同一个 ~/.caveman/*.db:没有 WAL,写进行时读会被锁在外面;没有 busy_timeout,竞争方直接拿到 SQLITE_BUSY——"一次丢失的 Put 意味着一个省略了却没有原始可恢复的负载,一次丢失的 Get 会被读作未知句柄"(见 SQLiteDSN 的注释)。此外连接池被钳制为单连接(SetMaxOpenConns(1)),多语句迁移的锁竞争由带 5 秒预算的 RetryOnBusy 重试循环兜底(store_sqlite.go),并有专门的 retry_busy_test.go 验证。
  2. 文件权限与符号链接防御。恢复数据库可能包含提示词、凭据和工具结果,因此 PrepareSQLitePathCanonical 会解析父目录符号链接、拒绝非普通文件、以 0600 创建/收紧数据库及其 -wal/-shm 边车文件,并在打开时做同文件校验防止替换攻击(store_sqlite.go)。engine/ccr/store_permissions_test.go 覆盖了权限、符号链接拒绝等路径。
  3. 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 保持原始输入透传。拒绝的错误是 ErrBudgetExceededccr: storage budget exceeded)。这条规则的目的写在注释里:已发出的变换请求可能仍在引用任意句柄,驱逐会让旧的压缩上下文变成悬空引用(dangling reference)。

实现上有两道关卡:

  1. 写入前逻辑检查——checkRecoveryBudget 统计现有保留字节(recoveriesoriginal+metadatatyped_objectsdata+dependencies_json 之和),若 已用 - 同句柄旧占用 + 新字节 > 预算,返回 ErrBudgetExceededstore_sqlite.go);
  2. 写入时 SQLite 物理检查——configureStorageBudget 把预算换算成 PRAGMA max_page_count(按 page_size),并联动 wal_autocheckpointjournal_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 文件开销)、MaxStorageBytesStorageFull

安全属性: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):

  1. 确认请求使用的是创建该句柄的同一个本地运行时与同一个存储——不同进程/运行时打开不同的库,句柄自然查不到;
  2. 确认数据库文件仍然存在且可读(默认 ~/.caveman/ccr.db;WAL 模式下注意 -wal/-shm 边车);
  3. 确认句柄被完整复制——ccr_ 后应为 32 个十六进制字符;
  4. 检查压缩时的存储容量错误——若当时收到 cave_ccr_budget_exceeded,说明该负载从未被写入,句柄本就不存在;
  5. 记住内存型 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
登录后查看全文
热门项目推荐
相关项目推荐