首页
/ caveman-shrink:在工具目录吃掉上下文之前压缩它 —— MCP/OpenAI Tool Catalog 压缩器的两条正确性边界与 CCR 恢复

caveman-shrink:在工具目录吃掉上下文之前压缩它 —— MCP/OpenAI Tool Catalog 压缩器的两条正确性边界与 CCR 恢复

2026-09-06 12:08:55作者:温艾琴Wonderful

本文基于 caveman 仓库中 shrink/AGENTS.md 展开,讲解 caveman-shrink 这个"工具目录压缩器"的完整设计:它如何在不丢失任何选择信号(工具名、参数名、类型、enum、required)的前提下,把 MCP/OpenAI 工具定义目录的 token 成本压下来;它用哪两条正确性边界(结构选择面不变式 + 参数构造保全)来定义"压得安全"的边界;以及它如何依托持久化 CCR 恢复存储做到"真的可逆"。读完本文,你将掌握该工具的库 API 与 CLI 用法、底层压缩算法(元数据剔除、约束句保留、句界切分)的源码细节,以及 fail-open、inferred-only 这两个诚实性约定的工程含义。

一、caveman-shrink 是什么:引擎 toolschema 压缩器的专用产品面

caveman-shrink 是 caveman 项目中专用于压缩 MCP/OpenAI 工具定义目录(tool catalog)的产品。它是一个薄 Go 封装(thin wrapper),底层完全复用引擎中的 toolschema 压缩器,核心行为可以概括为三句话:

  • 剔除注释性元数据examplestitle$comment$schema 这类不影响模型选工具却最占字节的键被直接丢弃;
  • 缩减长描述:把冗长的自由文本描述压缩为"引导句 + 所有承载约束的句子",短句则原样保留;
  • 逐字节保真关键面:所有选择 token(工具名/参数名、类型、enum、required)和参数构造值defaultconst$ref 目标)逐字节保留。

shrink/AGENTS.md 的原话,这个产品输出的所有数字都是 inferred(推断值),绝不标记为 verified。它被定位为 toolschema 压缩器的专用产品面——引擎的 API/CLI 调用方也可以本地强制使用同一压缩器,但 shrink 复用它而绝不 fork 它。

二、代码布局:三个层次各管一事

仓库中 shrink 产品由三个层次组成(见 shrink/AGENTS.md 的 Layout 一节与 shrink/ 目录):

层次 位置 职责
Go 库 shrink.go Shrink(目录 → 压缩,fail-open 的 S4 变换,持久 CCR 支撑)、Recover(handle → 原始字节)、Lint(逐工具推断 token 缩减报告)、SelectionProfile(结构选择面提取)
CLI cmd/caveman-shrink/ caveman-shrink / shrink(stdin→stdout)、lint <file>recover <handle>
npm 启动器 bin/caveman-shrink.mjs + package.json npx caveman-shrink 的 MIT 许可启动器,首次运行下载预构建二进制

库的存储配置通过 functional options 完成:WithStore 传入调用方自持的 *ccr.Store(生命周期归调用方),WithStorePath 打开指定路径的存储(支持 ":memory:" 临时库)。两者都不给时,默认落到 DURABLE 共享存储——环境变量 CAVEMAN_CCR_DB,否则 ~/.caveman/ccr.db,与引擎 CLI、MCP server 和 gateway 使用同一个库。defaultCCRPath() 会先 MkdirAll 建好父目录,保证一台新机器上的第一次 shrink 就能成功。

这个默认值不是随手选的:shrink.go 的注释明确指出,旧行为使用"返回即关闭的内存存储",结果签发出来的 handle 从来都解析不了——"可逆"的保证在每次调用上都是假的。换成持久共享存储后,Shrink 签发的 handle 可以在另一个进程里通过 Recover 解析,"持久化才是全部意义所在"。

三、CLI 实战:三条命令 + npx 免安装入口

shrink/README.md 给出的标准用法(CLI 入口):

# 1. 压缩目录(stdin → stdout);推断的比率报告打到 stderr
cat tools.json | caveman-shrink > tools.min.json

# 2. 从 stderr 报告里打印的 handle 恢复原始字节
caveman-shrink recover ccr_... > tools.original.json

# 3. 只测量、不提交的逐工具缩减报告
caveman-shrink lint tools.json

几个 CLI 细节值得注意:

  • 无参数即 shrinkmain() 中,不带子命令的调用整体按 stdin→stdout 的 shrink 处理,caveman-shrink shrink 与之等价。
  • stderr 报告是 JSONrunShrinkResulttokens_beforetokens_afterratiobasiscontent_typerecovery_handle)序列化后写到 stderr,stdout 只留压缩后的目录本体——管道里可以放心重定向。
  • lint 输出对齐表格TOOL / BEFORE / AFTER / RATIO 四列逐工具列出,末行是 TOTAL 并标注 basis: inferredrunLint)。
  • 32 MiB 输入上限:stdin 通过 io.LimitReader(r, maxBytes+1) 读取,超限直接以 cave_input_too_large 报错,而不是无界缓冲(readBoundedInput)。

不装 Go 工具链、不装全局 Caveman 也能用:MIT 许可的 npm 启动器首次运行下载对应平台的 BSL-1.1 二进制,校验密钥签名的 checksum 清单与产物 SHA-256,缓存在 ~/.caveman/bin

npx -y caveman-shrink lint tools.json

许可模型见 BINARY_LICENSE.md:MIT 只覆盖 npm 启动器文件;caveman-shrink 的 Go 源码与官方二进制受 BSL 1.1 约束(对应仓库根的 LICENSE.BSLLICENSING.md)。

仓库自带一份可直接当输入示例的目录:shrink/testdata/catalog.json,包含 search_files(带 enum/default/title/$schema/examples 等各种可压项)与 run_command 两个工具,caveman-shrink lint shrink/testdata/catalog.json 即可复现逐工具报告。

四、压缩到底改变了什么:toolschema 压缩器源码解析

shrink 自己不做任何压缩逻辑,一切发生在引擎的 toolschema 压缩器。它的 ContentType"toolschema"SafetyClassS4engine/safety/safety.go 中登记为 ByteSafe: false, RequiresCCR: true——即"改变模型可见字节、必须写 CCR")。shrink 库通过把 engine.Options.Type 固定为 "toolschema" 来强制路由(schemaType 常量);引擎的 Detect 不会自动路由到它,因此代理的通用 JSON 路径不受影响。

4.1 元数据剔除与键级保留

toolschema.go 用三张键表精确区分三类 schema 键:

  • schemaMetaDrop(直接丢弃)examplesexample$commenttitle$schema——注释性元数据,不影响工具选择,字节最多;
  • keepSchemaKeys(逐字保留、不再递归)enumrequired(选择面)与 defaultconst(参数构造意义——agent 省略参数时就靠 default 继承,丢了它等于静默改变调用;const 钉死唯一合法值,同样危险);
  • userDefinedKeys(键名是用户数据)properties$defsdefinitionspatternPropertiesdependentSchemasdependentRequireddependencies——这些键下的子键是用户自定义的名字(属性名、定义名、正则、依赖属性名),必须全部原样存活;一个叫 title 的属性是用户命名,不是元数据,不会被误删。只有这些键下的 schema 值继续压缩。

这个"用户键 vs schema 词汇"的区分是 compressSchema 的核心:遍历时用 inUserKeys 标志切换处理策略。

4.2 描述压缩:短句不动,长句留"引导句 + 约束句"

compressDescription 的规则:

  1. 短句整体保留:描述长度不超过 smallDescBudget(= maxDescLen * 2 = 160 字节)时一字不动。注释写明理由:省下那点字节不值得冒"标记词集没认出的约束句被误删"的风险——短描述是常见情况,应该直接通过;
  2. 长描述切句重组:保留第一句非约束句作为"选择引导"(引导句按 rune 边界截断到 maxDescLen = 80 字节,见 capRunes,绝不把 UTF-8 字符切碎成替换符,且尽量回退到词边界,避免发出半截单词),再按原顺序追加每一句承载约束的句子(约束句不受 80 字节上限约束——丢掉它会直接产出非法工具调用);
  3. 约束识别constraintRe 正则:覆盖义务词(must/cannot/shall/require*/reject*)、禁止词(forbidden/invalid/not allowed)、数量约束(exactly one/one of/at least/at most/mutually exclusive)、界限词(max/min/range/between/over/above/exceed*)、格式锚点(format*/iso*/rfc*(允许尾随数字,RFC3339ISO8601 能匹配)/absolute)。匹配刻意偏向多留:多留一句只损失一点比率,丢一句约束会换来一次非法调用加重试循环,代价远高于省下的 token;
  4. 句界切分很保守:splitSentences 只在"句号 + 空白 + 大写字母"处断句,并有缩写守卫(e.g.i.e.etc.Node.js 以及 v1.23.14 这类尾点都不会被误判为句界),保证 e.g. /srv/x 这种片段完整存活。

整个变换是确定且幂等的:对自己输出再压一次得到同一字符串。

4.3 工具信封与 schema 的边界

compressDocument 先区分 provider 信封与裸 JSON Schema:{"tools":[…]}{"functions":[…]}、OpenAI 的 {function: …} 嵌套和裸数组都走 compressToolEnvelope,其中信封层的 description 走描述压缩路径,但信封层的 MCP annotations.title、厂商元数据等键不受 schema 元数据删除规则影响(它们是模型可见的工具元数据);只有声明为 schema 值的字段(如 inputSchema/parameters)才跨入 compressSchema

五、正确性边界一:结构选择面逐字节不变

shrink/AGENTS.md 定义的第一条正确性边界是:

SelectionProfile(input) == SelectionProfile(Shrink(input).Output) 恒成立。

SelectionProfile 提取每个工具对"宿主决定要不要调用它"有结构性影响的面:参数名列表、每参数 enum 取值、required 列表——描述被刻意排除在外(描述正是被压缩的对象)。profileOf 对参数名和 required 做排序,保证比较确定。

这条不变式证明的是什么、不证明什么,文档说得非常克制:它证明结构选择面(names/params/enums/required)逐字节存活;它证明模型会选到同一个工具——描述是模型可见的,而长描述压缩是有损的。行为级选择等价需要 model-eval 夹具来验证,结果一律保持 inferred

测试侧有双重证据:

  • TestStructuralSelectionSurfaceInvariant:对压缩前后目录各取一次 profile,reflect.DeepEqual 断言相等,并抽查 search_files.mode 的 3 个 enum 值仍在;
  • extractTools 同时支持 MCP({"tools":[…]})、OpenAI({function: …} 数组或扁平)和 {"functions":[…]} 三种目录形态,schema 分别从 inputSchema(MCP)和 parameters(OpenAI)下取(schemaOf)。

六、正确性边界二:参数构造保全(选择面之外还要保住"能构造出合法参数")

第二条边界:光保住选择面不够——agent 的参数是从描述里读出来的shrink/AGENTS.md 的表述:短描述整句保留;长描述保留引导句 + 每个被识别的约束句(must / cannot / required / 界限 / format / ISO / RFC / absolute 等);defaultconst 和内部 $ref 目标存活。golden 与对抗性一致性测试把这些"构造面"钉死,原则是多留比重试更安全(over-keep is safer than a retry)。

shrink_test.go 中的 TestShrinkPreservesArgumentConstraints 是这条边界的验收用例:构造一个 put_object 工具,描述含填充散文与真实规则,断言压缩输出中下列片段必须存活:

断言片段 对应的参数构造规则
must be an absolute path 绝对路径规则
exactly one of body 互斥约束(body / body_b64 二选一)
format must be RFC3339 时间戳格式规则
e.g. /srv/x 缩写完整保留(不会被截成 e.

同时断言 This trailing clause is filler 这类无规则填充散文应该被丢掉——即压缩真的发生了(ratio > 0),而不是空转。

七、fail-open 与"真的可逆":CCR 恢复存储

7.1 S4 有损 + fail-open 语义

shrink/AGENTS.md 的 Gotchas 第一条:这是 S4 有损、fail-open 变换。成功压缩会改变模型可见字节;任何解析问题、或结果并不更小,输入都原样通过(ratio: 0、无 handle)。Shrink 的调用链是 Shrink → engine.Compress(input, Options{Mode: ModeCompress, Type: "toolschema"}),失败时保留引擎返回的"完整记账、字节相同的透传结果"连同错误一起返回给库调用方。TestShrinkByteSafeOnMalformed 验证非法 JSON 输入字节不变、ratio == 0、无 handle;TestShrinkReturnsPassThroughResultWhenCCRIsUnavailable 验证 CCR 存储不可用时返回"记账完整的原始字节透传"(tokens_after == tokens_before)。

这里有个文档明示的库/CLI 行为差:存储打不开会让库的 Shrink 返回错误,而 CLI 捕获该错误并转发原始字节、不附 handlerunShrinkerr != nil 时直接 os.Stdout.Write(input),stderr 打印 passed through 说明)——字节安全优先于失败退出。

7.2 恢复路径:内容寻址的持久 handle

可逆性的实现全在 engine/ccr:每次成功的有损压缩,先把原始字节精确提交进持久共享 CCR store,再发布变换后的字节并返回 handle。handle 是内容寻址的(原文字节的 sha256),因此压缩同一 payload 两次得到同一 handle、只存一份(store.go 包注释)。存储实现按平台分裂:宿主平台用本地 SQLite(store_sqlite.go),js/wasm 用纯 Go 内存 map(store_wasm.go),接口一致,引擎无感。

Recover 的行为边界同样有测试钉死:未知 handle 返回 ccr.ErrNotFound——恢复从不猜测TestRecoverUnknownHandleFails)。跨进程可解析性由 TestShrinkRoundTripsThroughAFreshStore 把关:它特意新开一个同路径的 store 实例(正是新进程里 recover 命令看到的场景),取出字节与原始目录逐字节比对——测试注释直言,旧内存存储时代这个不变式"每次调用都是假的",而一个 handle != "" 的自说自话检查把它藏了十年。

另外注意 ErrBudgetExceeded:本地存储的 payload 预算超限时,新恢复被拒绝在发布有损字节之前,既有 handle 不受影响,调用方必须透传——这正对应 7.1 的透传测试。

八、inferred-only:为什么所有 token 数都是估计值

shrink/AGENTS.md 的第二条 Gotcha 是硬性约定:token 数一律来自引擎的离线计数器tokens.Default()),因此永远是 inferred绝不 verified,也绝不对结果做二次投影(re-project)。从源码看这一点贯彻得很彻底:ResultReport 都带 Basis 字段且恒为 inferredShrinkLint);Lint 对每个工具独立跑 NewToolSchema().Compress 并只在下压后计数更小时采纳后值,逐工具汇总出总体比率;即使压缩器对某工具返回 ok 但没省字节,该工具的 after 也保持等于 before。CLI 的 lint 表尾 (basis: inferred) 标注与 stderr 报告里的 "basis":"inferred" 都是这条约定的外露接口。

九、与托管网关的关系,以及产品边界

shrink/AGENTS.md 的 Conventions 一节划清了 shrink 在整个 caveman 体系中的位置,值得逐条对照理解:

  1. 结构压缩器住在引擎里:实现唯一在 engine/compressors/toolschema.go,shrink 复用它、绝不 fork——产品层只有存储配置、目录解析、profile 提取与 CLI 包装;
  2. 托管网关的适配器不碰它:managed-gateway 适配器把工具数组留在冻结的 prompt-cache 前缀里,从不交给该压缩器;网关另有的 S2 工具搜索/延迟路径不是压缩,两者不要混谈;
  3. 本地缩减保持 inferred:本地跑 shrink 得到的数字不进任何已验证账目;
  4. 计费化的前置条件:文档明确,未来若要为这个变换开一条计费路由,需要三样东西——cache-versus-schema 的成本证明、字节级一致的稳定前缀输出、以及一道 eval 闸门。这是把"有损 S4"从本地优化升级成计费功能所需的诚实门槛。

十、构建、测试与验证入口

  • 文档约定的构建/测试命令(见 shrink/AGENTS.md):make product-build PRODUCT=shrinkmake product-test PRODUCT=shrink
  • Go 侧一致性测试集中在 shrink_test.go,覆盖:压缩确实发生(ratio > 0basis == inferred)、跨新 store 的恢复往返、结构选择面不变式、约束句保全、非法输入字节安全、CCR 不可用透传、未知 handle 必败、lint 报告完整性——测试统一用 WithStorePath(t.TempDir()…) 指向临时库,避免写脏共享的 ~/.caveman/ccr.dbtmpStore);
  • npm 启动器侧测试为 node --test --test-force-exit tests/*.test.mjspackage.json),对应 shrink/tests/ 下的 package.test.mjs

小结

caveman-shrink 的价值不在"省了多少 token"(那个数字永远是 inferred),而在它把"有损压缩"这个危险操作关进了两条可测试的边界里:结构选择面逐字节存活SelectionProfile 不变式 + 测试),参数构造规则不丢失(约束句识别 + 多留策略 + golden 测试)。再叠加 fail-open 透传、内容寻址的持久 CCR 恢复(跨进程 recover)、inferred-only 记账,它给出了一个可复制的范式:当你想对模型可见的输入做有损变换时,先写下不变式,让测试替你证明它,而不是让 ratio 报表替你承诺它。若要进一步深入,建议按 shrink/AGENTS.md 末尾的指引阅读引擎总览 engine/CLAUDE.md 与压缩器实现 engine/compressors/toolschema.go

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