首页
/ Caveman TypeScript SDK(@caveman-ai/sdk) 深度解析:Cave 客户端、延迟工具检索、字节安全压缩与本地运行时策略

Caveman TypeScript SDK(@caveman-ai/sdk) 深度解析:Cave 客户端、延迟工具检索、字节安全压缩与本地运行时策略

2026-09-06 12:24:36作者:申梦珏Efrain

本文为 Caveman 项目 TypeScript SDK 开发指引 的完整技术展开:以单文件、零运行时依赖的 @caveman-ai/sdk 为对象,讲清 Cave 主客户端、CaveTrace 请求级追踪、BM25 服务端工具检索、字节安全的 compress() 委托压缩、可逆 checkpoint/上下文打包、依赖免费的 OTLP/JSON 导出器,以及带 Ed25519 签名验证的 RuntimePolicyClient 的完整契约与实现细节。读完本文,你可以把该 SDK 接入自己的 Agent 网关调用链,并理解它在 wire 协议、fail-closed 语义与跨语言 parity 上做出的每一个设计取舍。

一、SDK 定位:单文件、零运行时依赖、ES Module

package.json 定义了该包的发布形态与硬约束:

  • 包名 @caveman-ai/sdk,当前版本 1.0.0;CHANGELOG.md 记录了 1.0.0(2026-07-26)“记录了稳定的 TypeScript SDK API 与 /sdk/v1/* wire 基线,并与 Python SDK 锁定 coordinated-major 与 parity 规则”;
  • "type": "module",入口 dist/index.js、类型 dist/index.d.ts,"sideEffects": false,发布物只有 dist/、README 和 LICENSE;
  • engines 要求 Node.js ≥ 22.13(README.md 同样强调此要求);
  • 零运行时依赖:dependencies 为空,devDependencies 只有 typescript@5.9.3;
  • 脚本:build = tsc;test:typestsconfig.test.json 做纯类型编译;test:nodenode --test --test-force-exit tests/*.runtime.mjs;完整 test 会先 build 再依次跑类型测试与运行时测试。

整个 SDK 的实现只有一个文件 src/index.ts(约 2400 行),导出 Cave(主客户端)、CaveTrace(请求级追踪)、CaveOptionsCaveToolToolSearchResultCompressOptionsCompressResult 以及 ContextPack* 系列类型。零依赖的直接后果是:SDK 内部所有加密(WebCrypto Ed25519/SHA-256)、base64 解码、URL 解析都自己实现,不引入任何第三方包。

二、目录结构与测试分层

AGENTS.md 给出的 Layout 与仓库实际一一对应,测试按“类型断言 / 运行时行为”严格分层:

路径 职责
src/index.ts 整个 SDK 的实现与全部公开导出
tests/tool-search.test.ts 类型级断言,只被 tsc --noEmit 编译,不执行
tests/tool-search.runtime.mjs 运行时测试,node:test + 全局 fetch mock,导入 dist/ 产物
tests/runtime-policy.runtime.mjstests/runtime-policy.test.ts 驱动 packages/sdk/parity/ 下与 Python SDK 共享的 runtime-policy fixture:fetch wire、签名用例、全部 assignment_vectors(精确浮点相等)、全部 guard_cases(算子真值表放在 fixture 里而非测试文件中)、全部 decision_cases;要求“迭代数组,永远不要硬编码数量”
tests/parity.runtime.mjs 跨语言一致性套件,驱动共享 fixtures.json —— 同一组 fixture 两个语言,任一 SDK 缺字段即 CI 失败
tests/trace-continuity.runtime.mjs trace/span id 铸造规则 + 哪些请求携带 x-cave-trace-id / x-cave-parent-span-id,镜像 Python 侧 test_trace_continuity.py
tsconfig.json / tsconfig.test.json 两份独立配置,测试配置覆盖 tests/;两者都继承仓库根目录tsconfig.base.json

其余运行时测试(如 tests/exporter.runtime.mjstests/context-pack.runtime.mjstests/assembly.runtime.mjs)分别覆盖后文各 API 的行为契约。开发约定是:先构建再跑运行时测试 —— pnpm build && pnpm test:node

三、Cave 主客户端:连接参数与校验语义

new Cave(options) 是入口,apiKeybaseURLagent 三者必填,缺失即抛错(index.ts#L350-L376)。CaveOptions 的完整字段(index.ts#L4):

字段 说明
apiKey / baseURL / agent 必填。连接 Caveman 网关的项目密钥、网关地址与 agent 标识
defaultWorkflow 默认工作流标签;每个请求的 x-cave-workflow 头都不得省略
retention "metadata" | "zdr" | "configured",数据保留策略声明
verifyOnInit 构造时是否执行连接校验
controlURL 可选的 control-api(/api/v1/*)地址,与 baseURL 分离
user 不透明的终端用户标识,以 x-cave-user-hash 转发;若是 PII 需自行先哈希
timeoutMs 所有 SDK HTTP 请求的总截止期,默认 30 秒,必须是正整数
signal 调用方取消信号,作用于所有 SDK HTTP 请求

两个值得注意的校验/归一化行为:

  1. URL 严格校验:normalizedServiceURL() 要求 baseURL/controlURL 是绝对 http(s) URL、无内嵌凭据、无 query/fragment、首尾无空白,并剥掉末尾斜杠(index.ts#L335-L348)。
  2. CAVE_WORKFLOW 环境变量兜底:未显式设置 defaultWorkflow 时,构造函数会读取 CAVE_WORKFLOW 并归一化为网关标签规则(小写 [a-z0-9_-],最长 96);不合法的环境值被静默忽略而不是让每个请求 400(index.ts#L358-L375)。源码注释说明其用途:cave wrap --workflow x 这类包装器可以为整条应用链路打标签而无需改代码。

最小可用示例来自 README.md:

import { Cave } from "@caveman-ai/sdk";

const cave = new Cave({
  apiKey: process.env.CAVE_API_KEY!,
  baseURL: "http://127.0.0.1:8787",
  agent: "support-agent",
});

const result = await cave.compress("large payload");
console.log(result.output, result.basis); // basis is inferred

四、CaveTrace:trace 连续性与 ID 铸造规则

cave.trace(opts, fn) 把回调包进一个 CaveTrace(index.ts#L391-L393),它承担三件事:

  1. 铸造连续 ID:traceId 为 32 位小写十六进制、根 spanId 为 16 位小写十六进制,由 OTel 导出器同款 RNG 生成。若 opts.traceId/opts.spanId 用于续接入站 trace,则形状不符的值会被替换而不是发上 wire(防止把任意字符串放进请求头)。
  2. 给经过 trace 的每个 provider 调用加头:凡是 CaveTrace 发起的 provider 请求都携带 x-cave-trace-id + x-cave-parent-span-id;而直接走 Cave 构造的通用 /sdk/v1/* 调用与 provider 客户端两者都不带。唯一的 SDK 端点例外是 CaveTrace.tool,它的 /sdk/v1/events 调用会携带 trace id 与根 parent span id。
  3. 工具调用埋点:trace.tool(name, options, fn) 在回调前后向 /sdk/v1/events 发送 span_type: "tool.call" 事件(含 outcome: "ok"|"error"sequenceduration_mstags),且best-effort —— 遥测失败永远不覆盖工具结果或异常(index.ts#L793-L817)。

CaveTrace.exporter({serviceName?}) 返回的 OTelExporterdefaultTraceId 就是该 trace 的 id —— 这是让“SDK 自己记录的 span”与“网关落库的请求行”汇入同一条 trace 的机制(index.ts#L784-L791);同一 service name 重复调用返回同一个 buffer(含 runtime-policy 决策 span),便于调用方统一 flush。该 API 镜像 Python 的 Trace.exporter

trace 内还暴露了两类便捷面:

  • trace.model.openai.responses.create(body, {cave:{latencyClass, toolSessionId}}):传 latencyClass 会设置 x-cave-async 头(值非 "interactive" 时为 "true");传 toolSessionId 会设置 x-cave-tool-session 头(index.ts#L824-L829)。
  • trace.artifacts.page(value, options) / trace.artifacts.get(id):page 发送版本化信封(头 x-cave-artifact-envelope: value-v1)到 /sdk/v1/artifacts,gateway 只存储 JSON value,成功时返回一个含 artifact_id 的可检索占位块;strategy:"verbatim"绕过存储直接原样返回;stored !== true 或缺 artifact_id 时抛错而不是猜(index.ts#L831-L845)。

五、延迟工具检索:tools() 与 toolSearch()

工具目录句柄 cave.tools({ catalog, strategy, initialToolCount?, maxLoadedTools? }) 返回 { initial, strategy, search(query, opts?) }(index.ts#L468-L520):

  • strategy "all"(默认):initial 即整个目录;search() 依然请求服务端。
  • strategy "deferred":initial = 所有 alwaysLoad 工具 + 至多 initialToolCount(默认 8)个非 alwaysLoad 工具;maxLoadedTools 若设置,必须不小于 alwaysLoad 工具数,且 lazy 配额取 min(initialToolCount, maxLoadedTools - alwaysLoadCount)任何情况下都不返回全量目录——必须显式调用 search()

search() 的关键契约:

  1. 1.0 破坏性变更:search()async,返回 Promise<ToolSearchResult>(此前是同步返回 CaveTool[]),调用方必须 await;
  2. opts.ranker("bm25" | "embeddings")被原样透传给网关;SDK 自身从不计算相似度 —— 这是“byte-safe + 零依赖”原则的一部分;
  3. opts.toolSessionId 以请求体 session_id 下发,使 provider 回调能把“已调用的延迟工具”重新注入;响应 sessionId 对应 wire 的 session_id,provider 侧头为 x-cave-tool-session;
  4. 返回值的 token 口径:sentSchemaTokens/fullSchemaTokens估算,tokenBasis 披露所用计数器,basis 恒为 "inferred";savedTokens 是本地派生量(full - sent),reductionPct 四舍五入到一位小数(index.ts#L1614-L1615)。

实现层还有防御性校验:目录名必须非空且唯一;网关返回的每个工具名必须能映射回本地 catalog 且不得重复,否则抛错(index.ts#L1617-L1634);sentSchemaTokens <= fullSchemaTokens 不成立时两个计数按 0 处理。

cave.toolSearch(catalog, query, opts?) 是脱离 tools() 句柄的直接变体,契约完全一致(index.ts#L526-L532)。

六、compress():委托式压缩与字节安全 fail-closed

cave.compress(payload, opts?) POST /sdk/v1/compress 并映射 Engine 报告(index.ts#L1650-L1706)。核心语义是 “SDK 委托,不重实现压缩器”;任何一步出问题都走 fail-closed 直通:

  • 触发条件:非 2xx、网络异常、响应体不是带字符串 output 的报告、tokens_before/tokens_after 非严格非负整数、tokensAfter > tokensBefore、或 output === payload 但计数不一致;
  • 直通结果:output 为原始输入、ratio: 0、无 recoveryHandletokenCountBasis: "unavailable";
  • ratio 永远由客户端用校验后的计数重新推导((before - after) / before),不复用服务端字段,避免“乐观”报告;
  • basis 恒为 "inferred" —— SDK 永不输出 verified(那需要 Cloud active 路径背书)。

CompressResult 还包含 contentTypetokensBefore/AfterrecoveryHandle(恢复字节级原稿的句柄,未存储时缺省)、method(如 "toon""elision")、losslessToModel(模型可见输出是否完整保留值)。CompressOptions.contentType 可作为检测器提示,取值 "json" | "toon" | "log" | "code" | "diff" | "search-result" | "text" | "toolschema" 等(index.ts#L43)。

七、上下文打包与可逆 Checkpoint

cave.context.pack(query, items, options)仅连接态(connected-only)的有损选择器:POST /sdk/v1/context/pack,把调用方拥有的片段字节发给网关,由它决定“什么进入模型窗口”(缓存最优装配 assemble() 决定“放在哪里”,两者互补而非替代)。它从不写 CCR、不跑在本地 wrap 里,并要求调用方保留 deferredIds 点名的每一条片段以便重供(index.ts#L1708-L1796)。

输入契约(ContextPackItem / ContextPackOptions,index.ts#L78-L107):

  • item:id(稳定、调用方拥有,用于精确报告被省略项)、text、可选 tokens(缺省或 0 时由网关计数)、timestamp(RFC 3339,供 recency 信号)、priority(调用方相关性加权)、pin(必须入选;若 pin 项放不下,调用返回诚实的零);
  • options:maxTokens(必须 > 0)、reserveTokens(为下一轮响应/工具调用预留)、now(RFC 3339 确定性评分时钟)、recencyHalfLifeMsrecencyWeighterrorBoost

返回值校验极其严格,任何一条不满足都回退为“全部原始 item + 零推断节省”的直通:选中 id 必须全部存在于请求且无重复;deferredIds 必须与“请求集减选中集、按请求序”逐位相等;tokensUsed + tokensSaved === tokensBefore;deferredCount === deferredIds.length。这保证 ContextPackResult 中的数字自洽,basis 恒为 "inferred"(该选择器从不进入 verified-savings 账本)。

可逆 checkpoint 分两半:

  • CaveTrace.context.checkpoint(messages, options) → POST /sdk/v1/checkpoints,网关持久化(Valkey)并返回可逆的 source_ref;
  • CaveTrace.context.expand(sourceRef) → GET /sdk/v1/checkpoints/{ref}/expand,取回存储的 { source_ref, version, messages, checkpoint }。响应缺 source_refmessages 数组时抛错——源码注释明确:“不能展开的 checkpoint 是 bug”,可逆性是强制要求(index.ts#L847-L864)。

八、Provider 客户端与 Bedrock 描述符

cave.openai()/anthropic()/gemini()/vertex() 是薄 provider 客户端,全部经网关前缀(/openai/v1/anthropic/gemini/vertex)代理,每个都暴露 .raw fetch 逃生舱(镜像 Python Provider.raw);upstreamKey 用于转发上游凭据(如 Vertex 的 Google OAuth2 access token 会以 Authorization: Bearer … 转发)。

cave.bedrock({region, endpoint?})不发任何网络请求的第一方路由描述符(index.ts#L413-L425):

  • endpoint 缺省为 "runtime",网关前缀 /bedrock;显式 "mantle" 返回 /bedrock/anthropic;其他值直接抛错;
  • 返回 { region, endpoint, gatewayPrefix, instrumented: true, sdkOnly: false },供 AWS SDK 或 agent 包装器自行接线,本身不携带任何 AWS 密钥。

另外 cave.prompts.internalBrevity({style, preserveErrorsVerbatim?, preserveCodeVerbatim?}) 生成输出风格片段(style:"none" 返回空串),镜像 Python cave.prompts.internal_brevity(index.ts#L597-L600)。

九、零依赖 OTel 导出器

cave.exporter({serviceName?}) 返回 OTelExporter(index.ts#L443-L456):

  • recordSpan(...) 把当前 GenAI 字段映射到 gen_ai.* 属性;SpanOptions 涵盖 inputTokens/outputTokens/cachedTokens(cached 是 input 的子集,绝不叠加)、costUsdprovider/model/operation/toolNamestatus("unset"|"ok"|"error")、纳秒级起止时间与自由 attributes;
  • export()OTLP/JSON POST 到标准 /v1/traces,携带 Caveman 标准头(x-cave-api-key / x-cave-agent / x-cave-workflow,由 otlpHeaders() 组装);legacy 路径 /otlp/v1/traces 仅保留为服务端兼容;
  • serviceName 缺省为 Cave 的 agent slug(网关用它兜底 agent 标签);span 落库到 caveman.spans,全程无需外部 OpenTelemetry 接线。

十、RuntimePolicyClient:本地决策 + Ed25519 签名策略

cave.runtimePolicy({publicKey?, autoRefreshSeconds?, killEnv?}) 返回 RuntimePolicyClient(index.ts#L1068-L1388),是路由面而非节省面——“只做路由,不含任何 savings 词汇”。

refresh():唯一的网络调用(GET /sdk/v1/runtime-policy,标准头去掉 content-type):

  1. 限流读:响应上限 1 MiB,超限(content-length 预判或流式累计)抛 oversized_response 并取消读取(index.ts#L873-L912);
  2. 30 秒超时:与 Python timeout=30 镜像,挂死的端点不会挂住调用方;
  3. 先验签、后解析:对 bundle 字符串的精确 UTF-8 字节做 Ed25519 验证(WebCrypto SPKI 导入,index.ts#L1534-L1547);
  4. TOFU 钉死时机:publicKey签名验证成功的瞬间即被钉死——早于 schema/sequence 检查。这样“一个签过名但本客户端因形状拒绝的 bundle”不会打开降级窗口:随后到达的未签名 bundle 会被拒;
  5. 拒绝回退 sequence、拒绝未知 schema_version(当前接受 caveman.runtime-policy.v1)、拒绝回退版本;
  6. 任何失败都保留 last-known-good:refresh() 永不抛错,只返回 {ok, signed, error?};
  7. autoRefreshSeconds 的 tick 若落在 refresh 飞行期间则跳过而不是堆叠(refreshing 标志,index.ts#L1085-L1095)。

decide(taskFamily, {unitKey, context, trace}):同步、纯本地、永不抛错。求值链(index.ts#L1268-L1329):本地 kill(kill()killEnv 环境变量,缺省 CAVEMAN_POLICY_KILL,每次 decide 重读,0/false/no/off/空 视为未置位)→ 无 bundle(policy_unavailable)→ bundle kill 旗标 → 无匹配 task_family(no_policy)→ 无效策略(invalid_policy)→ 全 disabled(disabled)→ guard AND 求值(eq/ne/gt/gte/lt/lte/in,类型不符判 false 而非强转,未知算子 fail-closed)→ 匹配多于 1 条即 ambiguous_policy(与服务器“胜层 >1 ⇒ 全弃”语义一致,绝不猜)→ 实验分臂:holdout 切片先切并强制压制到 fallback 路径(“holdout 是抑制,不是更危险的变体”);缺 unitKey 或实验配置非法一律 fallback,不猜臂。

分臂的确定性分数由 policyUnitFraction(...keys) 计算:index.ts#L1509-L1524 逐字节复刻 Go 侧 shared/platform/sampling.Fraction——每个 key 前缀 8 字节大端长度后整体 SHA-256,取前 8 摘要字节为大端 uint64,右移 11 位除以 2^53 得到 [0,1) 分数;该函数被导出以便调用方复现分臂、让跨语言 fixture 逐位钉死这一移植。

decide() 可选地把决策写成一个 span(caveman.policy.decision,属性含 cave.policy.decision/reason/signed/id/version 与实验 id/臂/propensity),sink 是鸭子类型(接受任何带 recordSpan 的对象或 CaveTrace),且所有与 sink 的交互都在 try 内——“遥测永不破坏路由”。state() 返回快照(hasBundlesigned、bundle kill 旗标、killedLocallypolicyVersion/sequence),close() 释放后台定时器(镜像 Python close())。

十一、RetryLoopBreaker 与预留的 JobsClient

cave.retryLoopBreaker(threshold=3) 返回 RetryLoopBreaker(index.ts#L664-L709):record(name, args) 以“工具名 + 键排序 JSON 序列化参数”作为签名,同一签名连续重复超过阈值即在第 threshold+1 次抛出 RetryLoopError(携带签名、重复次数与阈值);任何不同的调用重置连击;guard(name, args, fn) 先记录(可能抛错)再执行 fn;reset() 在新任务开始时清零。

cave.jobs 是预留的异步作业面:submit/status/cancel/wait/submitAndWait 全部在任何网络 I/O 之前本地失败,抛 AsyncJobsUnavailableError(code cave_async_jobs_unavailable)。源码注释解释了原因:在持久加密请求存储、凭据托管与排空 worker 就位之前,该表面拒绝制造“假 queued/completed”的假象。

十二、Wire 约定、Gotchas 与验证路径

AGENTS.md 的 Conventions 与 Gotchas 是该 SDK 的行为红线,逐条对应源码事实:

  1. byte-safe:SDK 把请求体原样发往网关,绝不重写;compress() 是唯一产出更小字节的路径,且它委托 Engine,任何异常直通原始输入(index.ts#L1651-L1660);
  2. 命名映射:到网关的请求体键是 snake_case(如 input_schemaalways_loadsession_idmax_tools),响应在 ToolSearchResult 等类型上映射为 camelCase;
  3. x-cave-workflow 永不省略:默认值链为 defaultWorkflow ?? "unlabeled-workflow"(见 index.ts#L392index.ts#L1599);
  4. 延迟工具会话交接三要素:请求 session_id、结果 sessionId、provider 头 x-cave-tool-session;改动需同步 sdk-python 与 parity fixture;
  5. context packing 仅连接态且刻意有损:它选择“什么进窗口”,缓存最优装配决定“放哪里”;
  6. mirror sdk-python:每个字段/方法在两个 SDK 中都存在,由共享 parity 套件强制——分歧即 CI 失败;“改一个 SDK 就要改两个,还要改 fixture”;
  7. 发布名 @caveman-ai/sdk,workspace 名在 npm 重定向计划落地前保持不变;
  8. reductionPct 保留一位小数;savedTokens 为派生值而非网关字段。

验证路径固定为三步:

# 1. 构建(dist/ 是运行时测试的导入源)
pnpm build
# 2. 类型级断言(只编译,不执行)
pnpm test:types
# 3. 运行时测试(against dist)
pnpm test:node

十三、小结

Caveman 的 TypeScript SDK 是一个“把网关能力翻译成类型安全本地 API”的薄层:所有重活(压缩、排序、策略下发、checkpoint 存储)都在网关/Engine 侧,SDK 只负责三件事——忠实透传字节、fail-closed 地降级、以及让本地决策(策略、断点装配、重试打断)永不阻塞、永不猜测。跨语言 parity fixture 与“basis 恒为 inferred”的诚实性纪律,使它成为在多 SDK 表面共享同一套 wire 契约时,一个可直接对照 packages/sdk/ 下 Python 实现逐条核对的工程样本。

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