工作原理
签名算法、为何匹配必须精确,以及账本实际记录的内容。
签名
每个决策在用作缓存键之前都会被规范化为稳定的签名:
export function signature(decision: Decision): string {
const normalized = {
intent: decision.intent,
params: sortKeysDeep(decision.params),
};
return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
}
规范化刻意保持浅层——对象键会被递归排序,因此 { a: 1, b: 2 } 和 { b: 2, a: 1 } 会生成相同的哈希值,但不会触及 intent 或参数值的任何内容。不会进行词干提取、大小写折叠或语义规范化。
{ intent: "summarize_pr", params: { repo: "acme/widgets", pr: 482 } }
{ intent: "summarize_pr", params: { pr: 482, repo: "acme/widgets" } }
│ │
└──────────────── 相同签名 ─────────────┘
{ intent: "summarize_pr", params: { repo: "acme/widgets", pr: 483 } }
│
└── 不同签名(不同的 pr)────────────────┘
为什么使用精确匹配,而非语义匹配
语义/模糊缓存会捕获更多重复请求——“总结这个 PR”和“给我这份拉取请求的摘要”都会命中。但这也意味着 Runic 必须判断两种不同措辞的请求是否足够接近、可以共享缓存答案;而这正是 Runic 被设计为避免做出的那类非确定性判断。
精确匹配意味着缓存命中是可证明为同一个决策,而不只是可能是同一个决策。代价是调用方必须谨慎决定 params 中包含什么——参见快速入门。
账本记录的内容
账本是只追加的日志,而非可能产生偏差的累计总额:
interface LedgerEvent {
signature: string;
kind: "hit" | "miss";
tokensSpent: number;
timestamp: number;
}
- 未命中时,Runic 会记录你的代码为生成该产物所报告的
tokensSpent。 - 命中时,Runic 会查找该签名最初的未命中记录,并记录与当时保存的相同
tokensSpent值——绝非估算,也绝非猜测。如果某个正在命中的签名不知为何不存在先前的未命中记录,则会记录0,而不会虚构一个数字。
summary.totalSpent = sum of all miss events
summary.totalSaved = sum of all hit events
tokensWithoutRunic = totalSpent + totalSaved (what it would've cost with no cache at all)
savingsPercent = totalSaved / tokensWithoutRunic
Runic 从不自行衡量 token 成本。它只会复述调用代码通过 storeResult(decision, artifact, tokensSpent) 告知它的值——参见基准测试,了解 openrouter-savings 如何从真实 API 响应而非估算中获取该数值。
存储后端
@runic-labs/cache 和 @runic-labs/ledger 都基于一个小型存储接口构建,v1 附带两种实现:
| 后端 | 生命周期 | 使用场景 |
|---|---|---|
MemoryCacheStore / MemoryLedgerStore |
仅限进程生命周期 | 测试、临时脚本、基准测试 |
FileCacheStore / FileLedgerStore |
持久化到磁盘(默认 .runic/) |
CLI 进程与代理进程在没有运行中服务器的情况下读取同一状态 |
两者在 v1 中均为每会话作用域——参见作用域,了解为什么跨会话共享被刻意安排为后续阶段,而不是现在仓促添加。
过期状态仅作建议
缓存条目带有 stale 标志,该标志根据可配置的时间窗口计算(默认 24 小时)。Runic 永远不会自动驱逐或拒绝过期条目——它只是供调用方在需要时采取行动的信息,而非强制机制。如果你的使用场景需要严格过期,请自行检查 entry.stale 并决定如何处理。