// tool_schema_hash.go - 工具 Schema 变化追踪与缓存稳定性分类(模块 22.2). // // 解决的问题: // // Anthropic Prompt Cache 会缓存工具列表(tools 数组). // 只要工具的描述/Schema 发生任何变化,整个 tools 缓存前缀失效, // 触发 cache write,该轮所有工具 token 按全价计费. // // 假设系统有 50 个工具,系统提示词 + 工具列表共 50k tokens: // 命中缓存:50k × $3/M × 10% = $0.015/轮 // 未命中: 50k × $3/M = $0.15/轮(贵 10 倍) // // 如果一个工具描述含有动态内容(session ID,时间戳,每轮变化的列表), // 每轮都会破坏所有工具的缓存. // // 本模块的职责: // 1. 精确识别哪个工具的 Schema 变了(不是"工具集变了"这种粗粒度) // 2. 统计每个工具的稳定性(过去 N 轮是否有变化) // 3. 提供 StableFirst 排序:稳定工具放前面,不稳定工具放后面 // 调用方据此把 cache_control 标记放在最后一个稳定工具后, // 保护前缀缓存不被不稳定工具破坏 // // 升华改进(ELEVATED): 早期实现用 djb2 32-bit 哈希, // 碰撞率在 50 个工具场景下不可忽视(用于诊断日志,偶尔误判可接受). // 我们改用 SHA-256:[32]byte 直接 == 比较,零分配,碰撞概率工程上可忽略. // 早期方案仅做诊断日志;我们额外实现稳定性窗口 + StableFirst 排序,真正影响 API 费用. // 替代方案:<沿用 djb2,只做诊断> - 否决:工具描述稳定性直接影响 Prompt Cache 命中率, // 值得更可靠的哈希 + 可操作的排序输出. package cache import ( "crypto/sha256" "sort" "sync" ) // ───────────────────────────────────────────────────────────────────────────── // 公开类型 // ───────────────────────────────────────────────────────────────────────────── // ToolEntry 是提交给 ToolSchemaTracker 的最小单元. // // Content 是工具的规范化字节表示--调用方负责生成,通常是 // json.Marshal 后的 {name, description, input_schema} 结构体. // cache_control 字段不应包含在 Content 中(它是元数据,不是工具"内容"). type ToolEntry struct { Name string // 工具唯一标识符 Content []byte // 规范化内容字节(用于 SHA-256 哈希) } // ToolSchemaChanges 描述两次 Track 调用之间工具集的变化. // // Stable = true 当且仅当 Added/Removed/Changed 均为空, // 即本轮工具集与上一轮完全相同(字节级等价). type ToolSchemaChanges struct { Added []string // 新增工具名(上轮没有,本轮出现) Removed []string // 删除工具名(上轮有,本轮消失) Changed []string // 变化工具名(同名但 Content 哈希不同) Stable bool // true 当 Added=Removed=Changed=[] } // ToolStabilityReport 是工具稳定性快照. // 用于调试和外部决策(如决定 cache_control 边界放哪里). type ToolStabilityReport struct { // StableTools 在过去 Window 轮内未变化. // 把这些工具排在工具列表前面 + 在最后一个稳定工具处加 cache_control, // 可以最大化 Anthropic Prompt Cache 命中率. StableTools []string // UnstableTools 在过去 Window 轮内至少变化一次. // 排在工具列表后面,不影响前缀缓存. UnstableTools []string // TurnCount 当前累计轮次数(调试用). TurnCount int // Window 稳定性判定窗口(轮次). Window int } // ───────────────────────────────────────────────────────────────────────────── // ToolSchemaTracker // ───────────────────────────────────────────────────────────────────────────── // defaultStabilityWindow 是默认的稳定性判定窗口(轮次). // // 精妙之处(CLEVER): 5 轮是经验值-- // 足够过滤"工具描述偶尔带了一次动态内容"的误报(一次性波动不被标为不稳定). // 又不至于太保守(10 轮会让频繁变化的工具长期占用稳定席位). // 替代方案:1 轮(任何一次变化就不稳定,过于敏感;丢失"一次性波动"过滤效果). const defaultStabilityWindow = 5 // ToolSchemaTracker 逐轮追踪工具 Schema 哈希,统计稳定性, // 支持按稳定性排序以优化 Anthropic Prompt Cache 命中率. // // 线程安全:所有方法均持 mu 锁,可从任意 goroutine 调用. // 生命周期:与 Engine 实例绑定(Engine 级单例),Session 不拥有 Tracker. // // 关键不变量: // - hashes[name] 始终是最近一次 Track 时该工具的 SHA-256 // - lastChangeTurn[name] 是该工具最近一次内容变化的轮次号 // - firstSeenTurn[name] 是该工具首次出现的轮次号 // - turnCount 单调递增,每次 Track 调用 +1 type ToolSchemaTracker struct { mu sync.Mutex hashes map[string][32]byte // toolName → 当前 SHA-256 哈希 lastChangeTurn map[string]int // toolName → 最近变化的轮次(含初次出现) firstSeenTurn map[string]int // toolName → 首次出现的轮次 turnCount int // 已 Track 的轮次总数 window int // 稳定性窗口(轮次) } // NewToolSchemaTracker 创建 ToolSchemaTracker,使用默认稳定性窗口(5 轮). func NewToolSchemaTracker() *ToolSchemaTracker { return newTrackerWithWindow(defaultStabilityWindow) } // NewToolSchemaTrackerWithWindow 创建指定稳定性窗口的 ToolSchemaTracker. // window < 1 时自动修正为 1. func NewToolSchemaTrackerWithWindow(window int) *ToolSchemaTracker { if window < 1 { window = 1 } return newTrackerWithWindow(window) } func newTrackerWithWindow(window int) *ToolSchemaTracker { return &ToolSchemaTracker{ hashes: make(map[string][32]byte), lastChangeTurn: make(map[string]int), firstSeenTurn: make(map[string]int), window: window, } } // Track 记录本轮工具集,返回与上一轮相比的变化情况. // 每次 API 请求前调用一次,入参是当轮将要发送的完整工具列表. // // 算法: // 1. turnCount++ // 2. 逐工具计算 SHA-256(Content),与缓存哈希比较 // 3. 新增 = 上轮没有,本轮有;删除 = 上轮有,本轮没有;变化 = 同名但哈希不同 // 4. 更新内部状态(哈希,lastChangeTurn,firstSeenTurn) // 5. 返回 ToolSchemaChanges(切片已排序,便于确定性比较) // // 精妙之处(CLEVER): Added/Removed/Changed 切片按名称排序-- // 相同变化集合的两次调用返回完全相同的切片,方便测试断言和日志去重. // 替代方案:按遍历顺序(非确定性,依赖 map 迭代顺序,测试会 flaky). func (t *ToolSchemaTracker) Track(tools []ToolEntry) ToolSchemaChanges { t.mu.Lock() defer t.mu.Unlock() t.turnCount++ // 构建本轮名称集合,O(n) 查找 newNames := make(map[string]struct{}, len(tools)) for _, tool := range tools { newNames[tool.Name] = struct{}{} } // 找出被删除的工具(上轮有,本轮无) var removed []string for name := range t.hashes { if _, ok := newNames[name]; !ok { removed = append(removed, name) } } sort.Strings(removed) // 清理被删除工具的状态 for _, name := range removed { delete(t.hashes, name) delete(t.lastChangeTurn, name) delete(t.firstSeenTurn, name) } // 处理本轮工具:新增 or 内容变化 var added, changed []string for _, tool := range tools { h := sha256.Sum256(tool.Content) prevHash, exists := t.hashes[tool.Name] switch { case !exists: // 新工具 added = append(added, tool.Name) t.hashes[tool.Name] = h t.lastChangeTurn[tool.Name] = t.turnCount t.firstSeenTurn[tool.Name] = t.turnCount case prevHash != h: // 同名工具,内容变化 changed = append(changed, tool.Name) t.hashes[tool.Name] = h t.lastChangeTurn[tool.Name] = t.turnCount // firstSeenTurn 不变(工具仍然是同一个工具,只是描述改了) } // 若 exists && prevHash == h:无变化,什么都不做 } sort.Strings(added) sort.Strings(changed) stable := len(added) == 0 && len(removed) == 0 && len(changed) == 0 return ToolSchemaChanges{ Added: added, Removed: removed, Changed: changed, Stable: stable, } } // StableFirstWithBoundary 返回按稳定性排序的工具名,以及稳定工具的数量. // // 返回值: // - sorted:工具名切片,前 stableCount 个是稳定工具,后面是不稳定工具 // - stableCount:稳定工具的数量 // // 调用方可据此把 cache_control 放在 sorted[stableCount-1] 对应的工具定义上, // 保护前缀缓存不被后面的不稳定工具影响. // // 升华改进(ELEVATED): 早期实现 只计算 changedToolSchemas(诊断日志), // 不影响工具列表顺序,也不放 cache_control--cache break 只能事后归因,不能预防. // 我们的 StableFirstWithBoundary 让调用方能主动调整工具顺序 + cache_control 放置, // 从"事后诊断"变为"主动预防",对 SDK/SaaS 场景每轮节省最多 90% 工具 token 费用. // 替代方案:<仅做诊断日志,不排序> - 否决:诊断可以有,但费用优化更有价值. func (t *ToolSchemaTracker) StableFirstWithBoundary(names []string) (sorted []string, stableCount int) { t.mu.Lock() defer t.mu.Unlock() stable := make([]string, 0, len(names)) unstable := make([]string, 0, len(names)) for _, name := range names { if t.isStableLocked(name) { stable = append(stable, name) } else { unstable = append(unstable, name) } } return append(stable, unstable...), len(stable) } // StabilityReport 返回当前所有追踪工具的稳定性快照. // 主要用于调试和 observer 事件(不影响请求路径). func (t *ToolSchemaTracker) StabilityReport() ToolStabilityReport { t.mu.Lock() defer t.mu.Unlock() var stable, unstable []string for name := range t.hashes { if t.isStableLocked(name) { stable = append(stable, name) } else { unstable = append(unstable, name) } } sort.Strings(stable) sort.Strings(unstable) return ToolStabilityReport{ StableTools: stable, UnstableTools: unstable, TurnCount: t.turnCount, Window: t.window, } } // Reset 清除所有追踪状态,使下次 Track 从零开始. // // 适用场景: // - Plugin 系统重载(工具集可能大幅变化,旧稳定性数据无参考价值) // - Engine Close 后重用同一实例(理论上不会发生,但作为防御性接口提供) func (t *ToolSchemaTracker) Reset() { t.mu.Lock() defer t.mu.Unlock() t.hashes = make(map[string][32]byte) t.lastChangeTurn = make(map[string]int) t.firstSeenTurn = make(map[string]int) t.turnCount = 0 } // TurnCount 返回累计 Track 轮次(调试用). func (t *ToolSchemaTracker) TurnCount() int { t.mu.Lock() defer t.mu.Unlock() return t.turnCount } // ───────────────────────────────────────────────────────────────────────────── // 内部辅助 // ───────────────────────────────────────────────────────────────────────────── // isStableLocked 判断指定工具是否"稳定".调用前必须持有 t.mu 锁. // // 稳定的条件(三者同时满足): // 1. 已追踪轮次 >= window(系统尚无足够历史时,所有工具视为不稳定) // 2. 该工具已存在 >= window 轮(新出现的工具稳定性未知) // 3. 该工具在最近 window 轮内未发生内容变化 // // 精妙之处(CLEVER): 三重条件共同防止"假稳定"-- // // 条件 1 防止系统刚启动时就把所有工具标为稳定(历史不足). // 条件 2 防止刚注册的新工具被认为是稳定的(我们还不知道它会不会频繁变化). // 条件 3 是核心:真正的内容稳定性检查. // 替代方案:只检查条件 3(可能在系统启动的前几轮就产生误判的稳定分类). // // 精妙之处(CLEVER): 条件 2 和 3 使用 >= window-1 而非 >= window-- // // 一个工具在第 t₀ 轮首次出现,到第 t₀+window-1 轮已历经 window 次 Track. // 用 turnCount-firstSeen >= window-1 表达"已被追踪 window 次",边界精确. // 若改用 >= window(直觉上更自然),需要多一轮才能稳定,偏保守, // 在工具描述确实稳定的情况下多付一轮的全价 API 费用. func (t *ToolSchemaTracker) isStableLocked(name string) bool { // 条件 1:系统级历史轮次足够(快速路:整个 tracker 刚启动时无需逐工具检查) if t.turnCount < t.window { return false } // 条件 2:该工具已被追踪足够多轮 firstSeen, ok := t.firstSeenTurn[name] if !ok || t.turnCount-firstSeen < t.window-1 { return false } // 条件 3:最近 window 轮内未变化 changeTurn := t.lastChangeTurn[name] return t.turnCount-changeTurn >= t.window-1 }