diff --git "a/.agents/notes/implemented/bug-fix/2026-10-24-prompt-cache\345\206\267\347\203\255\350\257\257\345\210\244\344\270\216\346\226\255\347\202\271\350\220\275\347\202\271\344\277\256\345\244\215.md" "b/.agents/notes/implemented/bug-fix/2026-10-24-prompt-cache\345\206\267\347\203\255\350\257\257\345\210\244\344\270\216\346\226\255\347\202\271\350\220\275\347\202\271\344\277\256\345\244\215.md" new file mode 100644 index 000000000..6b99b5362 --- /dev/null +++ "b/.agents/notes/implemented/bug-fix/2026-10-24-prompt-cache\345\206\267\347\203\255\350\257\257\345\210\244\344\270\216\346\226\255\347\202\271\350\220\275\347\202\271\344\277\256\345\244\215.md" @@ -0,0 +1,30 @@ +--- +Status: implemented +Date: 2026-10-24 +--- +# Prompt cache §6 五个缺陷一次收口:冷热判定进程级、清理落成会话状态、断点避开 reminder、删 cache_edits 与 global scope + +## 决定了什么 + +- **冷热判定(问题 1/2)**:新增 `api/cache-warmth.ts`,在 Anthropic provider 出口(`anthropic.ts` `recordPromptCacheLayout`)记录每次请求的**发出**时刻,进程级保存。只有距上次触碰 ≥ 60 分钟(最长可能 TTL)才算「能证明冷」;进程内无记录 = 不知道 = 按热。旧实现用 `LoopState.lastResponseAt`,每条用户消息新建 state → 首轮必判冷。 +- **清理决策冻结**:冷清理改为 `ContextManager.applyColdCacheClear()`,决策写进会话状态,`getCleanedMessages()` 每轮逐字节重放;`setMessages`/压缩时清空。放在 `getCleanedMessages` 之前,所以 BeforeModel、raw.jsonl、prefix_break 看到的就是实际发出的前缀。判据复用 `microcompact.ts` 拆出的 `planMicrocompact`。 +- **断点避开 reminder(问题 3)**:`reminder-inject.ts` 注入的块带 `transient: true`;`markLastUserMessageCacheBreakpoint` 把断点打在**第一个** transient 块之前,消息开头就是 transient 时退到上一条消息。`transient` 在 provider 序列化时剥掉,不上线。 +- **删除 cache_edits(问题 4)**:整个 `cached-microcompact.ts` 状态机及 app/compact/auto-compact/post-compact 的接线、`notifyCacheDeletion` 一并删除。`SendParams.cacheEdits` 只保留类型并标 deprecated。 +- **删除 global scope(问题 5)**:`CacheControl` 去掉 `scope`,`buildSystemBlocks` / `markLastToolCacheBreakpoint` 去掉 global 选项。 +- **可观测性**:新增 `PromptCacheLayout`(每次 Anthropic 请求:transient 块数、断点是否落在 transient 上)和 `ColdCacheClear`(gap_ms、cleared、saved_chars、threshold_ms)两个事件,`trace/digest.ts` 的 `aggregatePromptCacheStats` 聚合,其中「gap 低于阈值的冷清理次数」就是回归计数。 + +方向:更省(不再在热缓存上制造 miss),代价是更少清理本地 token(见下)。 + +## 放弃了什么(以及为什么不选) + +- **阈值保持 5 分钟**:本机流量基本走第三方网关,网关设的 TTL 不可知。5 分钟只是猜测,猜错就多付一整段前缀写入;少清一次只是少省一点本地 token。代价不对称,取 1h,与 Claude Code `timeBasedMCConfig` 同理。刻意不做成可配置项:调小就等于允许在热缓存上改写历史。 +- **保留 cache_edits,默认关**:字段不在公开 Messages API(SDK 无此字段),ppchat 静默接受,删没删无法验证;保留一个从不生效、也测不到效果的路径就是死代码。Anthropic 公开的 context editing(`clear_tool_uses`)是另一套 beta 语义,需要的话另开方案。 +- **global scope 补上 beta 头**:公开 API 的 `cache_control` 只有 `type`/`ttl`,缓存按 workspace 隔离;而且本仓静态区含记忆索引等用户特定内容,就算字段合法也不会跨用户命中。 +- **断点只跳过末尾的 transient 块**:critical reminder 插在用户指令之前,只跳末尾的话断点仍然包含下一轮不存在的字节。 +- **把 BeforeModel 探针挪到改写之后**:清理已经前移到 `getCleanedMessages` 之前,探针天然就在改写之后,不需要再挪。 + +## 拿什么证明它生效了 + +- `bun test` 跑新增/改动的 13 个文件:233 pass / 0 fail。`cold-cache-clear.test.ts` 覆盖:从未发请求 → 热;隔 2 分钟、10 分钟 → 热;隔满 1h → 冷;清理后下一轮发送副本仍是同一份占位、原始历史不变、幂等、setMessages 后清空。conformance 测试断言请求体不含 `cache_edits`/`scope`/`transient`,且断点不落在 reminder 块上。 +- `bun run affected-tests:run`:581 文件 7566 pass / 0 fail;`make build` 自检通过,无 `will always be undefined`;`typecheck:gate` 66/66 无新增;lint、format:check、lint:boundary、lint:command-system 全绿。 +- **上线后的端到端验收(未做,需要真实流量)**:① `ColdCacheClear` 事件里 gap_ms < 3600000 的计数必须为 0(修前回放 60 次 / 41 个会话);② `PromptCacheLayout.message_breakpoint_on_transient=true` 占比从回放的 13% 降到 0。不能拿 prefix_break 次数当问题 3 的指标:reminder 字节照样会变,断裂次数不会降。问题 3 的成本收益只能看可信渠道(uniapi)的 cache_creation,ppchat 的 usage 不可信。 diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index 669727ecd..7bcf72023 100644 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -680,10 +680,6 @@ export class App { private skillManager?: import("@sid-code/core/skill/manager.ts").SkillManager; /** P2-3:Skill 文件热重载监听器(退出时 stop())。 */ private skillChangeDetector?: import("@sid-code/core/skill/change-detector.ts").SkillChangeDetector; - /** §5:共享 cached microcompact 状态机,压缩后重置。延迟创建。 */ - private cachedMicrocompactState: - | import("@sid-code/core/query/compact/cached-microcompact.ts").CachedMicrocompactState - | null = null; /** 已播报过 instructions 的 MCP server 名(去重集,避免每轮重复注入同一 server 说明)。 */ private announcedMcpServers = new Set(); /** @@ -1521,7 +1517,7 @@ export class App { autoCompact: () => this.autoCompact(), contextCollapse: (ratio) => this.contextCollapse(ratio), // P1-13/14:reactive(prompt-too-long 恢复)与 collapse(分段摘要)也走压缩后收尾。 - // 收尾要的三个会话级实例(fileReadTracker / cachedMicrocompactState / sessionDir) + // 收尾要的会话级实例(fileReadTracker / sessionDir) // 只有 App 持有,所以由这里注入,loop 侧只负责在「确认压动了」之后调一次。 postCompactTail: (info) => this.runNonSummaryPostCompact(info), emergencyFileReattach: (info) => this.runEmergencyFileReattach(info), @@ -1577,16 +1573,6 @@ export class App { | undefined; return regTool?.getLedger() ?? null; }, - // G2:暴露 cachedMicrocompact 状态机给 queryLoop,让主循环每轮可产出 cache_edits。 - getCachedMicrocompactState: () => { - if (!this.cachedMicrocompactState) { - const { - createCachedMicrocompactState, - } = require("@sid-code/core/query/compact/cached-microcompact.ts"); - this.cachedMicrocompactState = createCachedMicrocompactState(); - } - return this.cachedMicrocompactState ?? undefined; - }, getProviderName: () => this.provider.name(), // MCP server instructions 增量注入(对标 CC mcp_instructions_delta): // 每轮返回"新连接且尚未播报过"的 server 使用说明块,由 loop 经 reminderParts 注入 user 消息 @@ -3752,9 +3738,6 @@ export class App { ctxMgr: this.ctxMgr, hookSystem: this.hookSystem, fileReadTracker: this.fileReadTracker ?? undefined, - // 与 /compact 同理:为 null 说明本会话没跑过 microcompact,两个 Map/Set 本就是空的, - // 为了重置它而新建一个对象是纯噪音。 - cachedMicrocompactState: this.cachedMicrocompactState ?? undefined, sessionDir: this.resolveSessionDir(), messagesBefore: info.messagesBefore, // 缺 tokensBefore 时退回「压缩后的当前估算」→ savedRatio 算成 0, @@ -3782,12 +3765,6 @@ export class App { // §12.3:摘要走低成本模型(subAgentModels.summarize),未配则跟主模型。 const compactModel = this.config.subAgentModels?.summarize || this.config.subAgentModels?.default; - // §5:延迟创建共享 cached microcompact 状态机 - if (!this.cachedMicrocompactState) { - const { createCachedMicrocompactState } = - await import("@sid-code/core/query/compact/cached-microcompact.ts"); - this.cachedMicrocompactState = createCachedMicrocompactState(); - } // §4.1/§4.3:会话级落盘目录 let sessionDir: string | undefined; try { @@ -3807,7 +3784,6 @@ export class App { compactModel: compactModel || undefined, fileReadTracker: this.fileReadTracker ?? undefined, isMainAgent: true, - cachedMicrocompactState: this.cachedMicrocompactState ?? undefined, sessionDir, // P2-21:转交给 runPostCompact,落 PostCompactReattach 事件。 traceAppendEvent: this.postCompactTraceSink(), @@ -10996,10 +10972,6 @@ export class App { // 与自动压缩共用 query/compact/post-compact.ts。漏传会让手动压缩后模型"忘掉"刚读的文件。 fileReadTracker: this.fileReadTracker ?? undefined, sessionDir: this.resolveSessionDir(), - // P1-11:手动 /compact 的收尾也要重置 microcompact 状态机。这里刻意**不**延迟创建 - // (不写 `?? createCachedMicrocompactState()`):为 null 说明本会话一次 microcompact - // 都没跑过,两个 Map/Set 本就是空的,造一个新对象只为把它重置一遍是纯噪音。 - cachedMicrocompactState: this.cachedMicrocompactState ?? undefined, // G25:权限检查器实例注入命令上下文(/allow /deny /add-dir /permissions 使用)。 // P0-3 追加用途:fork skill 的子代理内工具权限沿用主会话 checker(经 toCommandContext 桥接)。 permissionChecker: this.permissionChecker, diff --git a/packages/cli/src/command/adapter.ts b/packages/cli/src/command/adapter.ts index 190a4de99..a7956a776 100644 --- a/packages/cli/src/command/adapter.ts +++ b/packages/cli/src/command/adapter.ts @@ -144,8 +144,6 @@ export function toCommandContext(appCtx: AppContext): CommandContext { // §12 P2-4 复审:手动 /compact 的压缩后收尾依赖这两项(文件重注入 / 质量报告落盘) fileReadTracker: appCtx.fileReadTracker, sessionDir: appCtx.sessionDir, - // P1-11:手动压缩也要重置 microcompact 状态机(漏传则 runPostCompact 第 2 步静默跳过) - cachedMicrocompactState: appCtx.cachedMicrocompactState, cwd: process.cwd(), unifiedRegistry: appCtx.unifiedRegistry, // /goal:目标驱动持续执行——桥接到新体系 CommandContext diff --git a/packages/cli/src/command/commands/compact/compact.ts b/packages/cli/src/command/commands/compact/compact.ts index 99797c224..ef67c34b9 100644 --- a/packages/cli/src/command/commands/compact/compact.ts +++ b/packages/cli/src/command/commands/compact/compact.ts @@ -249,10 +249,6 @@ async function runManualPostCompact( hookSystem: ctx.hookSystem, fileReadTracker: ctx.fileReadTracker, sessionDir: ctx.sessionDir, - // P1-11:此前漏传,runPostCompact 第 2 步(重置 microcompact 状态机)在手动路径上 - // 永远走不到——CommandContext 类型里甚至没有这个字段。代价不是数据错乱,是 - // state.tools / state.deleted 跨多次手动压缩无界增长(长会话真实内存泄漏)。 - cachedMicrocompactState: ctx.cachedMicrocompactState, // P2-21:与 auto 路径同源的重注入压力埋点。手动压缩同样走文件重注入, // 「压完立刻又要压」在这条路径上一样会发生,分母里不能少了它。 // collector 为空(trace.enabled=false)→ 不传,收尾侧只写日志。 diff --git a/packages/cli/src/command/types.ts b/packages/cli/src/command/types.ts index 5dc9cdddb..7b6a7b39f 100644 --- a/packages/cli/src/command/types.ts +++ b/packages/cli/src/command/types.ts @@ -142,11 +142,6 @@ export interface AppContext { fileReadTracker?: import("@sid-code/core/tool/file-read-tracker.ts").FileReadTracker; /** §12 P2-4 复审:会话级临时目录(手动压缩质量报告落盘)。经 toCommandContext 桥接。 */ sessionDir?: string; - /** - * P1-11:cached microcompact 状态机(手动 /compact 收尾要重置它,否则两个 Map/Set - * 跨多次手动压缩无界增长)。经 toCommandContext 桥接到 CommandContext。 - */ - cachedMicrocompactState?: import("@sid-code/core/query/compact/cached-microcompact.ts").CachedMicrocompactState; /** 命令注册表引用(/reload-plugins 重新合并插件命令用) */ commandRegistry?: import("./registry.ts").Registry; /** diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 7f018b63f..450284f85 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -304,8 +304,6 @@ Worktree 隔离: SID_DISABLE_STRICT_TOOLS 设为 1 禁用 strict 工具模式 SID_DISABLE_FGTS 设为 1 禁用 First-Guess Tool Streaming SID_ENABLE_TOKEN_EFFICIENT_TOOLS 设为 1 启用 token 高效工具 - SID_DISABLE_GLOBAL_CACHE 设为 1 禁用全局缓存 - SID_DISABLE_CACHE_EDITS 设为 1 禁用缓存编辑 SID_LOOP_EXHAUSTED_ACTION 循环耗尽动作(terminate 时回退旧行为) SID_DISABLE_PROMISE_NUDGE 设为 1 关闭「短文本承诺未兑现」续跑(提示照常) SID_STRUCTURED_OUTPUT_MAX_RETRIES 结构化输出最大重试次数(缺省 5) diff --git a/packages/cli/tests/command/compact-post-compact-wiring.test.ts b/packages/cli/tests/command/compact-post-compact-wiring.test.ts index 2d70406d8..d05c9e0e5 100644 --- a/packages/cli/tests/command/compact-post-compact-wiring.test.ts +++ b/packages/cli/tests/command/compact-post-compact-wiring.test.ts @@ -1,22 +1,18 @@ /** - * P1-11 / P1-12:手动 /compact 的压缩后收尾接线回归 + * P1-12:手动 /compact 的压缩后收尾接线回归 * * 来源:`docs-research/sid-code/bugfixes/todo/20260920-上下文工程-顺着sc-02-context核出的缺陷.md` * - * P1-11 手动路径漏传 `cachedMicrocompactState` → runPostCompact 第 2 步 - * (重置 microcompact 状态机)永远走不到,两个 Map/Set 跨多次手动压缩无界增长。 * P1-12 质量校验把「完整保留、从未进摘要」的尾部原文算进锚点分母 → 覆盖率系统性偏低, * 本该合格的部分压缩被报成「摘要质量塌陷」。 * - * 断言对着「缺陷的可观察后果」写:state 是否真被清空、覆盖率分母是否只含被压段。 + * (P1-11「手动路径漏传 cachedMicrocompactState」的用例已随该模块删除:冷清理决策 + * 现在是 ContextManager 的会话状态,压缩时由 resetLocalCompactState 统一清空, + * 见 packages/core/tests/context/cold-cache-clear.test.ts。) */ import { describe, test, expect } from "bun:test"; import compactMod from "@sid-code/cli/command/commands/compact/compact.ts"; -import { - createCachedMicrocompactState, - type CachedMicrocompactState, -} from "@sid-code/core/query/compact/cached-microcompact.ts"; import { checkCompactQuality } from "@sid-code/core/query/compact/quality-check.ts"; import { resolvePartialSplitIndex } from "@sid-code/core/query/compact/partial-compact.ts"; import type { Message } from "@sid-code/core/llm/types.ts"; @@ -46,7 +42,7 @@ function makeMockProvider() { }; } -function makeCtx(messages: Message[], mcState?: CachedMicrocompactState) { +function makeCtx(messages: Message[]) { let msgs = messages; const ctxMgr = { messageCount: () => msgs.length, @@ -82,26 +78,13 @@ function makeCtx(messages: Message[], mcState?: CachedMicrocompactState) { config: { model: "test-model" }, providerRegistry: undefined, hookSystem: undefined, - cachedMicrocompactState: mcState, getCurrentMessages: () => msgs, } as any; } -describe("P1-11 — 手动 /compact 重置 microcompact 状态机", () => { - test("传入 state → 手动压缩后被清空(此前静默跳过,长会话里是真实内存泄漏)", async () => { - const state = createCachedMicrocompactState(); - state.tools.set("toolu_old_1", { name: "read_file", tokens: 100 } as any); - state.deleted.add("toolu_old_2"); - - const result = await compactMod.call("0.5", makeCtx(buildMessages(8), state)); - - expect(result.type).toBe("text"); - expect(state.tools.size).toBe(0); - expect(state.deleted.size).toBe(0); - }); - - test("未传 state(本会话没跑过 microcompact)→ 收尾照常完成,不抛错", async () => { - const result = await compactMod.call("0.5", makeCtx(buildMessages(8), undefined)); +describe("手动 /compact 收尾", () => { + test("收尾照常完成,不抛错", async () => { + const result = await compactMod.call("0.5", makeCtx(buildMessages(8))); expect(result.type).toBe("text"); expect((result as any).value).not.toContain("压缩未执行"); }); diff --git a/packages/core/src/api/cache-detection.ts b/packages/core/src/api/cache-detection.ts index 34b53ac4e..ca3d1de56 100644 --- a/packages/core/src/api/cache-detection.ts +++ b/packages/core/src/api/cache-detection.ts @@ -6,8 +6,8 @@ * - 当缓存命中骤降时,归因到具体原因:模型变化 / system prompt 变化 / * 工具增删改 / 工具顺序变化 / 缓存策略变化 / Beta headers 变化 / * 消息数量骤减(compact) / TTL 过期 - * - 假阳性抑制(G1):compact / cache_edits 删除后下一次 cache_read 下降是预期的, - * 通过 notifyCompaction / notifyCacheDeletion 通知检测器跳过紧接的一次检测。 + * - 假阳性抑制(G1):compact / 冷缓存清理后下一次 cache_read 下降是预期的, + * 通过 notifyCompaction 通知检测器跳过紧接的一次检测。 * - 子代理隔离(G10):每个 agentId 维护独立基线,子代理的 break 不污染主循环。 * * 实现为可实例化的 CacheBreakDetector(便于单测隔离), @@ -179,14 +179,6 @@ export class CacheBreakDetector { this.suppressNext = { active: true, reason: "compaction" }; } - /** - * G1:通知检测器已通过 cache_edits 删除了缓存内容。 - * cache_read 可能下降(服务端删除了部分 KV),不应报警。 - */ - notifyCacheDeletion(deletedCount: number): void { - this.suppressNext = { active: true, reason: `cache_edits deleted ${deletedCount} items` }; - } - private snapshot(params: CacheCheckParams): PromptState { const toolOrder = params.toolSchemas.map((t) => t.name).join(","); return { @@ -218,7 +210,7 @@ export class CacheBreakDetector { * 返回 null 表示无显著失效(首次请求 / 命中正常 / 下降未达阈值 / 被抑制)。 */ checkResponse(params: CacheCheckParams): CacheBreakReport | null { - // G1:抑制本次检测(compact / cache_edits 删除后的预期下降),重置基线而非告警 + // G1:抑制本次检测(compact / 冷缓存清理后的预期下降),重置基线而非告警 if (this.suppressNext.active) { getLogger().debug("CACHE_DETECT", `抑制本次检测: ${this.suppressNext.reason}`); this.suppressNext = { active: false, reason: "" }; @@ -410,10 +402,6 @@ export class MultiSourceCacheDetector { this.get(agentId).notifyCompaction(); } - notifyCacheDeletion(deletedCount: number, agentId = "main"): void { - this.get(agentId).notifyCacheDeletion(deletedCount); - } - reset(): void { this.detectors.clear(); } @@ -441,11 +429,6 @@ export function notifyCompaction(agentId = "main"): void { defaultDetector.notifyCompaction(agentId); } -/** G1:通知检测器 cache_edits 已删除内容,抑制紧接的一次检测 */ -export function notifyCacheDeletion(deletedCount: number, agentId = "main"): void { - defaultDetector.notifyCacheDeletion(deletedCount, agentId); -} - export function resetCacheDetection(): void { defaultDetector.reset(); } diff --git a/packages/core/src/api/cache-strategy.ts b/packages/core/src/api/cache-strategy.ts index 0f78b1cc5..3b37e4220 100644 --- a/packages/core/src/api/cache-strategy.ts +++ b/packages/core/src/api/cache-strategy.ts @@ -3,8 +3,16 @@ * * 职责(对标 Claude Code 的 addCacheBreakpoints): * - 在 system / tools / 消息序列上精确放置 cache_control 标记 - * - G4 Global Scope:静态区可标记 scope=global,让所有用户共享同一份 KV Cache(SaaS 规模命中率远超 org 级) - * - G12 四块精细分区:attribution(不缓存)/ corePrefix(global)/ staticExtensions(org)/ dynamic(会话内) + * - G12 分区:system 按 DYNAMIC_BOUNDARY 拆静态区(跨会话)/ 动态区(会话内)两个断点 + * + * ## 不发 `scope` 字段(2026-10-24 移除 G4 global scope) + * + * 公开 Messages API 的 `cache_control` 只有 `type` 与 `ttl`(SDK 0.120 `CacheControlEphemeral`), + * 缓存按 workspace 隔离、从不跨组织共享。`scope:"global"` 是 Claude Code 第一方专用形态, + * 要配 `prompt-caching-scope-*` beta 头,本仓从未发过这个头;CC 自己在头缺失时就吃过 + * `cache_control.ephemeral.scope: Extra inputs are not permitted` 的 400(anthropics/claude-code#41966)。 + * 而且本仓静态区含记忆索引等用户特定内容,即使字段合法也不会跨用户命中。所以这里不是 + * 「暂时关掉」,是这个字段对本仓没有成立的前提。 * * 设计为纯函数,操作"已转换为 API 格式的载荷"(带 content 数组的对象), * 不耦合 SDK 类型 —— Provider 在转换完消息后调用本模块统一打标。 @@ -44,13 +52,9 @@ * 不必要,是因为每轮数组是新建的;哪天改成复用同一份 messages,就必须重新引入清场。** */ -/** 缓存作用域(G4)。global 让所有用户共享 KV Cache,org 为组织级(默认)。 */ -export type CacheScope = "global" | "org"; - -/** cache_control 标记(G4:可选 scope) */ +/** cache_control 标记(形状与公开 API 一致,不带 scope,理由见文件头) */ export interface CacheControl { type: "ephemeral"; - scope?: CacheScope; } /** 可被打标的 content block(鸭子类型) */ @@ -88,18 +92,7 @@ export interface CacheStrategyOptions { skipCacheWrite?: boolean; } -/** buildSystemBlocks 选项(G4) */ -export interface BuildSystemBlocksOptions { - /** - * 是否对静态区启用 global scope(G4)。 - * 仅当静态区内容确实跨用户一致(无组织/用户特定内容注入)时才应开启。 - * 动态区始终 org 级(含用户特定内容)。 - */ - globalScopeEnabled?: boolean; -} - const EPHEMERAL: CacheControl = { type: "ephemeral" }; -const EPHEMERAL_GLOBAL: CacheControl = { type: "ephemeral", scope: "global" }; /** system prompt 静态/动态分区边界(与 anthropic.ts 现状一致) */ export const DYNAMIC_BOUNDARY = "\n\n\n\n"; @@ -126,26 +119,18 @@ export function splitSystemByDynamicBoundary(system: string): { /** * 将 system prompt 字符串拆分为带 cache_control 的 SystemBlock 数组。 * 含 DYNAMIC_BOUNDARY 时拆静态/动态两区,分别打标(跨会话缓存 + 会话内缓存)。 - * - * G4:globalScopeEnabled 时,静态区标记 scope=global(跨用户共享 KV Cache); - * 动态区始终 org 级(不带 scope)。 */ -export function buildSystemBlocks( - system: string | undefined, - options?: BuildSystemBlocksOptions, -): SystemBlock[] | undefined { +export function buildSystemBlocks(system: string | undefined): SystemBlock[] | undefined { if (!system) return undefined; - const staticControl = options?.globalScopeEnabled ? EPHEMERAL_GLOBAL : EPHEMERAL; const { staticContent, dynamicContent } = splitSystemByDynamicBoundary(system); if (dynamicContent !== undefined) { return [ - { type: "text", text: staticContent, cache_control: staticControl }, - // 动态区始终 org 级(含用户特定内容),不用 global scope + { type: "text", text: staticContent, cache_control: EPHEMERAL }, { type: "text", text: dynamicContent, cache_control: EPHEMERAL }, ]; } // 无边界:整段视为静态区 - return [{ type: "text", text: staticContent, cache_control: staticControl }]; + return [{ type: "text", text: staticContent, cache_control: EPHEMERAL }]; } /** 可被打标的工具定义(鸭子类型;只关心 cache_control 字段是否可挂) */ @@ -161,19 +146,14 @@ export interface CacheableTool { * 为什么放最后一个:tools 数组整体在 system 之前、messages 之后被服务端处理, * 在末尾工具打一个断点 = 把"整个工具区"纳入前缀缓存的一个独立分层, * 工具定义每请求稳定 → 高命中。放最后一个而非每个,保证只 +1 个断点(守住 ≤4 上限)。 - * - * scope:仅直连 Anthropic 且工具区确实跨用户一致时才用 global(与 system 静态区同策略); - * 走网关时降级为普通 ephemeral(org 级)或由调用方直接不打(见 anthropic.ts 门控)。 + * 走网关时由调用方直接不打(见 anthropic.ts 门控)。 * * @returns 是否成功打标(空数组返回 false) */ -export function markLastToolCacheBreakpoint( - tools: CacheableTool[] | undefined, - options?: { globalScope?: boolean }, -): boolean { +export function markLastToolCacheBreakpoint(tools: CacheableTool[] | undefined): boolean { if (!tools || tools.length === 0) return false; const last = tools[tools.length - 1]; - last.cache_control = options?.globalScope ? EPHEMERAL_GLOBAL : EPHEMERAL; + last.cache_control = EPHEMERAL; return true; } @@ -207,6 +187,11 @@ export function clearCacheBreakpoints(messages: CacheableMessage[]): void { } } +/** 服务端不允许挂 cache_control 的块(thinking 类只能随消息隐式缓存) */ +function canCarryCacheControl(block: CacheableBlock): boolean { + return block.type !== "thinking" && block.type !== "redacted_thinking"; +} + /** * 在**最后一条 `role === "user"` 的消息**末块放 cache breakpoint(P1-4 收口)。 * @@ -214,19 +199,54 @@ export function clearCacheBreakpoints(messages: CacheableMessage[]): void { * 那个函数打的是最后一条消息(不论 role),本函数倒序找最后一条 user 消息。 * **assistant / `role: "tool"` 结尾时两者落点不同。** * - * 这是 anthropic 生产路径(流式 + 非流式)此前各自手写倒序循环的行为, - * 收口时刻意**保留**它而不是换成 `addMessageCacheBreakpoint` —— 后者是行为变更。 - * 上一版博客把这处重复描述为"功能上没错、只是三种写法",实测是语义分叉。 + * ## isTransient:断点不能落在「下一轮不会再发」的字节上 + * + * system-reminder 只进发送副本、不写回历史(reminder-inject.ts 不变量 3)。服务端只在 + * 断点处写缓存,断点若打在 reminder 块上,写进去的是一段下一轮请求里根本不存在的前缀; + * 下一轮只能回落到上上轮的断点,中间一整轮的增量按全价重写。轨迹实测 13% 的请求断点 + * 落在 reminder 上(2026-10-24,5182 次请求里 691 次)。 * + * critical reminder 插在用户指令**之前**,所以不能只「跳过末尾的 transient 块」: + * 断点必须落在**第一个** transient 块之前,否则同一条消息里它前面那段也含下一轮没有的字节。 + * 第一个 transient 就在消息开头时,退到上一条消息(不论 role)的最后一个可打标块。 + * + * @param isTransient 判定某个已转换的块是否只存在于本轮发送副本 * @returns 被打标的消息索引(-1 表示未打标) */ -export function markLastUserMessageCacheBreakpoint(messages: CacheableMessage[]): number { +export function markLastUserMessageCacheBreakpoint( + messages: CacheableMessage[], + isTransient?: (block: CacheableBlock) => boolean, +): number { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg?.role !== "user") continue; // 空 content 的 user 消息跳过继续往前找 —— 与手写循环一致 // (它要求 content.length > 0 才打标并 break) - if (markLastContentBlock(msg)) return i; + if (!Array.isArray(msg.content) || msg.content.length === 0) continue; + const firstTransient = isTransient ? msg.content.findIndex(isTransient) : -1; + if (firstTransient === -1) return markLastContentBlock(msg) ? i : -1; + // 只往前找:transient 之前的块与更早的消息都是下一轮仍会原样发送的前缀 + return markLastEligibleBlockBefore(messages, i, firstTransient); + } + return -1; +} + +/** 从 (msgIdx, blockIdx) 之前(不含)倒序找第一个可挂 cache_control 的块并打标 */ +function markLastEligibleBlockBefore( + messages: CacheableMessage[], + msgIdx: number, + blockIdx: number, +): number { + for (let i = msgIdx; i >= 0; i--) { + const content = messages[i]?.content; + if (!Array.isArray(content)) continue; + const start = i === msgIdx ? blockIdx - 1 : content.length - 1; + for (let j = start; j >= 0; j--) { + if (canCarryCacheControl(content[j])) { + content[j].cache_control = EPHEMERAL; + return i; + } + } } return -1; } @@ -291,11 +311,9 @@ export function addCacheBreakpoints(params: { messages: CacheableMessage[]; system?: string; options?: CacheStrategyOptions; - /** G4:静态区是否用 global scope */ - buildOptions?: BuildSystemBlocksOptions; }): { system?: SystemBlock[]; markedMessageIndex: number } { clearCacheBreakpoints(params.messages); - const system = buildSystemBlocks(params.system, params.buildOptions); + const system = buildSystemBlocks(params.system); const markedMessageIndex = addMessageCacheBreakpoint(params.messages, params.options); return { system, markedMessageIndex }; } diff --git a/packages/core/src/api/cache-warmth.ts b/packages/core/src/api/cache-warmth.ts new file mode 100644 index 000000000..331a02d96 --- /dev/null +++ b/packages/core/src/api/cache-warmth.ts @@ -0,0 +1,70 @@ +/** + * Prompt cache 冷热判定(§6.7「把破坏性操作对齐到代价已经产生的时刻」) + * + * 只回答一个问题:**现在改写已发送的历史,能不能证明不会制造一次本来不会发生的 miss?** + * 能证明 → 冷,可以清;证明不了 → 一律按热处理,不清。 + * + * ## 为什么是进程级、而不是 queryLoop 的 state + * + * 旧实现把「上次响应时刻」放在 `LoopState.lastResponseAt`。`queryLoop` 每次 + * `submitMessage` 都重建 state,所以**每条用户消息的第一轮都读到 undefined → 判冷**, + * 用户隔 10 秒回一句也照样改写历史。轨迹回放(2026-10-24):60 次、41/132 个 + * Anthropic 会话,间隔 p50 114s —— 全是热缓存被当冷。冷热是服务端缓存的属性, + * 生命周期跟进程(准确说跟服务端 KV)走,不跟一条用户消息走。 + * + * ## 为什么记「请求发出」而不是「响应返回」 + * + * 官方文档:TTL 从**读或写该缓存的那次请求开始时**算起,每次命中刷新。生成耗时计入 TTL。 + * 用响应时刻做起点会把一次长生成的时长白白算成「还热」的余量 —— 方向是高估冷,正好是 + * 会制造 miss 的那一边。 + * + * ## 为什么阈值是 60 分钟而不是 5 分钟 + * + * 默认 TTL 是 5 分钟,但 1 小时 TTL 也存在,而本仓 Claude 流量基本走第三方网关, + * 网关替我们设了什么 TTL 本地无从得知。只有取**最长可能的 TTL**,「已过期」才是一个 + * 能证明的事实;取 5 分钟只是一个猜测,猜错的代价是每次多付一整段前缀的写入价。 + * 少清几次只是少省一点本地 token,多清一次是真金白银 —— 两边代价不对称,取保守值。 + * 这条和 Claude Code `timeBasedMCConfig.ts` 选 60 的论证相同: + * "we never force a miss that wouldn't have happened"。 + * + * ⚠️ 这是**正确性不变量**,不是调优参数(§6.10):刻意不读环境变量 / 远程配置。 + * 改小它就是允许系统在缓存还活着的时候改写历史。 + * + * ## 为什么所有 Anthropic 请求都算一次「触碰」 + * + * 主循环、fork 子代理、压缩摘要都会复用主对话前缀并刷新它的 TTL。按调用方逐一接线 + * 一定会漏(漏一条就是一次误判冷)。多记一次触碰的后果只是「更保守、少清一次」, + * 永远不会制造 miss;所以在 provider 出口统一记,宁多勿漏。 + */ + +/** 服务端可能采用的最长 prompt cache TTL(1h)。超过它,任何前缀都必然已过期。 */ +export const MAX_PROMPT_CACHE_TTL_MS = 60 * 60 * 1000; + +/** 最近一次可能读写 prompt cache 的请求的**发出**时刻(epoch ms);进程内尚无请求时为 undefined。 */ +let lastPrefixTouchAt: number | undefined; + +/** provider 发出请求时调用(含重试:重试同样会刷新 TTL)。 */ +export function recordPrefixTouch(at: number = Date.now()): void { + if (lastPrefixTouchAt === undefined || at > lastPrefixTouchAt) lastPrefixTouchAt = at; +} + +/** 距上次触碰的毫秒数;进程内从未发过请求时返回 undefined(= 不知道,调用方必须按热处理)。 */ +export function msSincePrefixTouch(now: number = Date.now()): number | undefined { + return lastPrefixTouchAt === undefined ? undefined : now - lastPrefixTouchAt; +} + +/** + * 能否证明缓存已冷。 + * + * 「不知道」(进程内首个请求之前,例如刚 resume)一律返回 false:resume 前一个进程 + * 可能几秒前刚写过这段前缀,进程重启不会清服务端缓存。 + */ +export function isPrefixCacheProvablyCold(now: number = Date.now()): boolean { + const gap = msSincePrefixTouch(now); + return gap !== undefined && gap >= MAX_PROMPT_CACHE_TTL_MS; +} + +/** 仅测试用:模块级状态在同进程多个用例之间会串。 */ +export function resetPrefixTouchForTests(): void { + lastPrefixTouchAt = undefined; +} diff --git a/packages/core/src/command-contract/types.ts b/packages/core/src/command-contract/types.ts index e3e9d3bfc..39a2b5fbb 100644 --- a/packages/core/src/command-contract/types.ts +++ b/packages/core/src/command-contract/types.ts @@ -141,16 +141,6 @@ export interface CommandContext { * §12 P2-4 复审:会话级临时目录。手动压缩的摘要质量报告落盘到此;未注入则只算不落盘。 */ sessionDir?: string; - /** - * P1-11:cached microcompact 状态机。手动 /compact 也要重置它——压缩把消息历史整段换成 - * 摘要后,旧的 tool_use_id → toolName 映射全部失效。 - * - * 不重置不会造成数据错乱(createCacheEditsBlock 只遍历当前 messages 里实际存在的 id), - * 真实代价是 state.tools / state.deleted 两个 Map/Set 跨多次手动压缩无界增长—— - * 长会话里是真实内存泄漏。此前类型里根本没有这个字段,所以 runPostCompact 第 2 步 - * 在手动路径上永远走不到。 - */ - cachedMicrocompactState?: import("../query/compact/cached-microcompact.ts").CachedMicrocompactState; /** * 切换主模型回调。persist=true 时同时写 settings.json 顶层 model(跨会话生效)。 * 对齐 /effort 的 -p 语义:默认仅当会话生效,-p 才落盘。 diff --git a/packages/core/src/context/manager.ts b/packages/core/src/context/manager.ts index c4f0376f4..8afd4f889 100644 --- a/packages/core/src/context/manager.ts +++ b/packages/core/src/context/manager.ts @@ -16,6 +16,8 @@ import { ContentReplacementState, } from "./tool-result-storage.ts"; import { applyToolResultBudgetToContent } from "../query/compact/tool-result-budget.ts"; +import { planMicrocompact } from "../query/compact/microcompact.ts"; +import type { SendMutationCause } from "../hook/types.ts"; import { getLogger, getSessionMetrics } from "../debug/index.ts"; import { checkMessageHistoryIntegrity, @@ -447,7 +449,15 @@ export class Manager { * 只记三种 ContextManager 自己能确证的:compact(历史被整体替换)/ masking(本轮新遮罩)/ * cleaned(本轮新清理)。由主循环在 BeforeModel 前取走,随 prefix_break 事件落盘。 */ - private pendingSendCauses = new Set<"compact" | "masking" | "cleaned">(); + private pendingSendCauses = new Set(); + /** + * 冷缓存清理的决策冻结(tool_use_id → 清理后的完整内容)。 + * + * 旧实现把冷清理只做在**本轮发送副本**上:清完不回写,下一轮又发原文 —— 前缀一来一回 + * 改写两次,等于每次误判多付两次全量写入。现在决策落成会话状态:一旦清了,之后每轮都 + * 逐字节贴同一份;与 masking / replacementState 同生命周期,压缩/截断/setMessages/clear 统一清空。 + */ + private coldClearState = new Map(); /** * 可选 Plan 提供方(§3.3 压缩后 Plan 重注入)。 @@ -726,6 +736,7 @@ export class Manager { this.sentIntactToolResultIds.clear(); this.tokenFreedTracker.reset(); this.maskedState.clear(); + this.coldClearState.clear(); this.lastSendSnapshot = null; this.pendingSendCauses.add("compact"); this.validatedPrefix = []; @@ -736,12 +747,63 @@ export class Manager { * 发现 14:取走并清空「自上次取走以来改写过发送前缀的本地来源」。 * 空数组 = ContextManager 没有改写前缀(断裂另有原因:reminder / system_dynamic / 服务端)。 */ - consumeSendMutationCauses(): Array<"compact" | "masking" | "cleaned"> { + consumeSendMutationCauses(): SendMutationCause[] { const out = [...this.pendingSendCauses].sort(); this.pendingSendCauses.clear(); return out; } + /** + * 缓存已冷时清理旧工具结果(§6.7:破坏性操作只在代价已经产生时做)。 + * + * **调用方负责证明缓存已冷**(见 `api/cache-warmth.ts`);本方法只管「清什么、清了之后 + * 怎么保持字节稳定」。判据与 microcompact 管线同一份(`planMicrocompact`),额外豁免 + * 已有稳定占位的块(masking / 大输出清理 / 上一次冷清理)—— 再改一次就是第二次改写前缀。 + * + * 作用在**全量历史的投影**上而不是某一轮的发送副本:决策写进 coldClearState, + * 之后每次 getCleanedMessages 都重放,所以清理在本轮和以后各轮都成立。 + */ + applyColdCacheClear(): { cleared: number; savedChars: number } { + const plan = planMicrocompact(this.messages, { + mode: "time", + skipToolUse: (id) => + this.coldClearState.has(id) || this.maskedState.has(id) || this.replacementState.has(id), + }); + let savedChars = 0; + for (const p of plan) { + this.coldClearState.set(p.toolUseId, p.replacement); + savedChars += Math.max(0, p.originalLength - p.replacement.length); + } + if (plan.length > 0) { + if (savedChars > 0) { + this.tokenFreedTracker.recordCompact(Math.ceil(savedChars / 4), "coldCacheClear"); + } + this.pendingSendCauses.add("cold_clear"); + } + return { cleared: plan.length, savedChars }; + } + + /** 把冷清理决策贴到发送副本上(只替换内容,其余字段透传) */ + private applyColdClearState(messages: Message[]): Message[] { + if (this.coldClearState.size === 0) return messages; + let changed = false; + const out = messages.map((msg) => { + if (msg.role !== "user") return msg; + let touched = false; + const content = msg.content.map((block) => { + if (block.type !== "tool_result") return block; + const frozen = this.coldClearState.get(block.tool_use_id); + if (frozen === undefined || block.content === frozen) return block; + touched = true; + return { ...block, content: frozen }; + }); + if (!touched) return msg; + changed = true; + return { ...msg, content }; + }); + return changed ? out : messages; + } + /** * P1-6:JIT 上下文块的提供者(由 App 注入)。 * @@ -1068,6 +1130,9 @@ export class Manager { : full; cleaned = this.applyStickyMasking(full, fresh); } + // 冷清理决策在 masking 之后、大输出清理之前贴:被冷清理的块已是短占位, + // 不会再进下面的大输出候选,两套占位不会叠在同一个块上。 + cleaned = this.applyColdClearState(cleaned); // P1-3:构建 tool_use 索引 + 活跃文件集合(正在编辑的文件 read 输出豁免清理) const toolIndex = this.buildToolUseIndex(cleaned); diff --git a/packages/core/src/hook/types.ts b/packages/core/src/hook/types.ts index 6e9031bb8..190d7f4e2 100644 --- a/packages/core/src/hook/types.ts +++ b/packages/core/src/hook/types.ts @@ -173,6 +173,9 @@ export enum HookType { * 顶层决策类型(老式 decision 字段) * CC utils/hooks.ts:525-543:`approve` 等价 allow(放行),`block`/`deny` 阻塞。 */ +/** ContextManager 自己改写发送前缀的来源(落 prefix_break.cause,见 BeforeModel 的 send_mutation_causes) */ +export type SendMutationCause = "compact" | "masking" | "cleaned" | "cold_clear"; + export type HookDecision = "allow" | "approve" | "deny" | "block" | undefined; /** @@ -444,11 +447,12 @@ export interface BeforeModelInput extends HookInput { tools?: unknown[]; /** * 发现 14:上一次请求以来,ContextManager **自己**改写过发送前缀的来源 - * (compact = 历史被整体替换 / masking = 新遮罩 / cleaned = 新清理)。 + * (compact = 历史被整体替换 / masking = 新遮罩 / cleaned = 新清理 / + * cold_clear = 缓存已冷时的旧工具结果清理)。 * 采集器把它挂到 prefix_break 事件的 `cause` 上。缺省或空数组 = 本地未改写, * 断裂另有来源(reminder / system_dynamic / 服务端)。只在主循环采集。 */ - send_mutation_causes?: Array<"compact" | "masking" | "cleaned">; + send_mutation_causes?: SendMutationCause[]; /** * 本次请求解析后的**思考开关**(内部表示,非 wire body)。 * diff --git a/packages/core/src/llm/anthropic.ts b/packages/core/src/llm/anthropic.ts index fb0e89ab8..10dc10b34 100644 --- a/packages/core/src/llm/anthropic.ts +++ b/packages/core/src/llm/anthropic.ts @@ -31,9 +31,11 @@ import { emitStreamPhase, emitHttpConnected, emitStrictToolDegraded, + emitPromptCacheLayout, streamAttributionOf, cacheDimsFor, } from "../trace/stream-observer.ts"; +import { recordPrefixTouch } from "../api/cache-warmth.ts"; import { currentSseDumpContext } from "./sse-chunk-dumper.ts"; import { recordBilledRequest, @@ -58,6 +60,8 @@ import { assertCacheBreakpointBudget, markLastToolCacheBreakpoint, markLastUserMessageCacheBreakpoint, + countCacheBreakpoints, + type SystemBlock, } from "../api/cache-strategy.ts"; import { getEffectiveBetaHeaders } from "../api/beta-header-latch.ts"; import { RequestAbortedError } from "./errors.ts"; @@ -188,11 +192,14 @@ export class AnthropicProvider implements Provider { // 转换消息格式 // 发现 1:provider_reasoning 是 OpenAI Responses 的密文推理项,Anthropic 不认识 // (下方 map 对未知块 throw);「gpt → claude」中途切模型时在这里丢掉。 + const transientBlocks = new WeakSet(); const messages = params.messages.map((msg) => ({ role: msg.role, content: withoutProviderReasoning(msg.content).map((block) => { if (block.type === "text") { - return { type: "text" as const, text: block.text }; + const wire = { type: "text" as const, text: block.text }; + if (block.transient) transientBlocks.add(wire); + return wire; } else if (block.type === "tool_use") { return { type: "tool_use" as const, @@ -222,10 +229,12 @@ export class AnthropicProvider implements Provider { }), })); - // Prompt Caching:在最后一条用户消息的最后一个 content block 上标记 cache_control - // 这样 system prompt + 工具定义 + 历史消息都能被缓存,只有新增部分需要计算 + // Prompt Caching:messages 区唯一断点打在「下一轮仍会原样发送」的最后一个块上。 + // transient(system-reminder)块不落断点,理由见 markLastUserMessageCacheBreakpoint。 // P1-4:与非流式路径共用同一函数(此前两处各手写一遍倒序循环) - markLastUserMessageCacheBreakpoint(messages); + const msgBreakpoint = markLastUserMessageCacheBreakpoint(messages, (b) => + transientBlocks.has(b), + ); // 转换工具定义 // P1-3: strict 模式(Constrained Decoding)— 仅 Claude 4.x 模型支持 @@ -257,26 +266,30 @@ export class AnthropicProvider implements Provider { ).tools; // system prompt 分区缓存:按 DYNAMIC_BOUNDARY 拆分为静态区和动态区 - // 静态区跨会话可缓存,动态区会话内缓存,分别标记 cache_control - // G4:Anthropic 直连且未禁用时,静态区用 global scope(跨用户共享 KV Cache)。 - const useGlobalScope = - isDirectAnthropicEndpoint(this.client.baseURL) && !process.env.SID_DISABLE_GLOBAL_CACHE; - const system = buildSystemBlocks(params.system, { globalScopeEnabled: useGlobalScope }); + // 静态区跨会话可缓存,动态区会话内缓存,分别标记 cache_control(不带 scope,见 cache-strategy.ts 头) + const system = buildSystemBlocks(params.system); // 增强 5.1:工具区缓存断点(对齐 CC toolToAPISchema cacheControl)。 // 仅直连 Anthropic 且未禁用时,在最后一个工具上打一个断点,把整个工具区纳入前缀缓存分层。 - // 走网关时降级不打(网关对 scope/beta 字段兼容性差,且收益仅直连链路)。 - // SID_DISABLE_TOOL_CACHE=1 可一键关闭降级。 + // 走网关时降级不打(收益仅直连链路)。SID_DISABLE_TOOL_CACHE=1 可一键关闭降级。 const enableToolCache = isDirectAnthropicEndpoint(this.client.baseURL) && !process.env.SID_DISABLE_TOOL_CACHE; if (enableToolCache) { - markLastToolCacheBreakpoint(tools, { globalScope: useGlobalScope }); + markLastToolCacheBreakpoint(tools); } // 比 CC 更进一步:发请求前断言 cache_control 断点数未超 Anthropic 上限(4), // 把"comment-only 不变量"升级为运行时护栏(dev 抛错暴露 / prod 打日志容错)。 // tools 一并计入(增强 5.1 新增工具区断点后总数不得超 4)。 assertCacheBreakpointBudget(system, messages as any, getLogger(), tools as any); + recordPromptCacheLayout( + messages, + system, + tools, + msgBreakpoint, + { model, baseURL: this.client.baseURL, stream: true }, + (b) => transientBlocks.has(b), + ); try { const requestStartTime = Date.now(); @@ -335,13 +348,10 @@ export class AnthropicProvider implements Provider { format: params.outputFormat, }, }), - // G2: cache_edits — 服务器侧删除旧工具结果(Anthropic 私有字段,缓存友好压缩产出)。 - // SID_DISABLE_CACHE_EDITS=1 时一键关闭降级(见方案 §12 风险表)。 - ...(params.cacheEdits && - params.cacheEdits.length > 0 && - !process.env.SID_DISABLE_CACHE_EDITS && { - cache_edits: params.cacheEdits, - }), + // 不发 `cache_edits`(2026-10-24 移除):它不在公开 Messages API 里(SDK 无此字段), + // 网关静默接受不等于服务端执行了删除。公开的等价能力是 context_management + + // beta 头,而官方文档写明 clear_tool_uses 会使缓存前缀失效 —— 与「热时删且保缓存」 + // 的前提相反。见 .agents/notes 里本次的决策记录。 }; // § 关键改动:create({stream:true}) 替代 messages.stream() @@ -967,11 +977,14 @@ export class AnthropicProvider implements Provider { // 发现 1:provider_reasoning 是 OpenAI Responses 的密文推理项,Anthropic 不认识 // (下方 map 对未知块 throw);「gpt → claude」中途切模型时在这里丢掉。 + const transientBlocks = new WeakSet(); const messages = params.messages.map((msg) => ({ role: msg.role, content: withoutProviderReasoning(msg.content).map((block) => { if (block.type === "text") { - return { type: "text" as const, text: block.text }; + const wire = { type: "text" as const, text: block.text }; + if (block.transient) transientBlocks.add(wire); + return wire; } else if (block.type === "tool_use") { return { type: "tool_use" as const, @@ -1018,26 +1031,33 @@ export class AnthropicProvider implements Provider { ).tools; // G8: 非流式路径缓存标记对齐流式路径—— - // ① system 按 DYNAMIC_BOUNDARY 分区打 cache_control(含 G4 global scope); - // ② 在最后一条 user 消息末块打 cache_control(与流式路径同策略); - // ③ 携带 cache_edits(G2)。否则非流式降级路径完全不命中缓存,每次全价重算前缀。 - const useGlobalScope = - isDirectAnthropicEndpoint(this.client.baseURL) && !process.env.SID_DISABLE_GLOBAL_CACHE; - const system = buildSystemBlocks(params.system, { globalScopeEnabled: useGlobalScope }); - - // 在最后一条 user 消息的最后一个 content block 上标记 cache_control(与流式路径一致) + // ① system 按 DYNAMIC_BOUNDARY 分区打 cache_control; + // ② messages 区断点避开 transient 块(与流式路径同策略)。 + // 否则非流式降级路径完全不命中缓存,每次全价重算前缀。 + const system = buildSystemBlocks(params.system); + // P1-4:与流式路径共用同一函数,消除"同一件事两处手写"的漂移面 - markLastUserMessageCacheBreakpoint(messages); + const msgBreakpoint = markLastUserMessageCacheBreakpoint(messages, (b) => + transientBlocks.has(b), + ); // 增强 5.1:工具区缓存断点(与流式路径同策略,仅直连 Anthropic 且未禁用时打)。 const enableToolCache = isDirectAnthropicEndpoint(this.client.baseURL) && !process.env.SID_DISABLE_TOOL_CACHE; if (enableToolCache) { - markLastToolCacheBreakpoint(tools, { globalScope: useGlobalScope }); + markLastToolCacheBreakpoint(tools); } // 与流式路径一致:发请求前断言 cache_control 断点数未超上限(tools 一并计入)。 assertCacheBreakpointBudget(system, messages as any, getLogger(), tools as any); + recordPromptCacheLayout( + messages, + system, + tools, + msgBreakpoint, + { model: pickWireModel(params, this._model), baseURL: this.client.baseURL, stream: false }, + (b) => transientBlocks.has(b), + ); const log = getLogger(); // 与流式路径同源:发线上的是真名,不是本地别名(见 wire-model.ts) @@ -1075,12 +1095,6 @@ export class AnthropicProvider implements Provider { ...(nonStreamWireEffort && { output_config: { effort: nonStreamWireEffort }, }), - // G2: cache_edits(同流式路径门控) - ...(params.cacheEdits && - params.cacheEdits.length > 0 && - !process.env.SID_DISABLE_CACHE_EDITS && { - cache_edits: params.cacheEdits, - }), }, signal ? { signal } : undefined, ); @@ -1467,6 +1481,54 @@ function isDirectAnthropicEndpoint(baseUrl?: string): boolean { } } +/** + * 发请求前的缓存侧收口:①记一次前缀触碰(冷热判定的时间源,见 cache-warmth.ts) + * ②落 PromptCacheLayout 事件(断点到底落在哪,此前轨迹里看不到)。 + * + * 两件事放一处是因为它们的调用时机是同一个:断点已经打完、请求即将发出。 + * 流式与非流式两条路径各调一次,避免「一处接了一处漏」的老问题。 + */ +function recordPromptCacheLayout( + messages: Array<{ role: string; content: any[] }>, + system: SystemBlock[] | undefined, + tools: unknown[] | undefined, + messageBreakpointIndex: number, + meta: { model: string; baseURL?: string; stream: boolean }, + isTransient: (block: object) => boolean, +): void { + recordPrefixTouch(); + try { + let transient = 0; + let onTransient = false; + for (const m of messages) { + for (const b of m.content) { + if (!isTransient(b)) continue; + transient++; + if (b.cache_control) onTransient = true; + } + } + let host: string | undefined; + try { + host = meta.baseURL ? new URL(meta.baseURL).hostname : "api.anthropic.com"; + } catch { + host = undefined; + } + emitPromptCacheLayout({ + index: currentSseDumpContext().turnIndex, + model: meta.model, + ...(host && { endpoint_host: host }), + stream: meta.stream, + breakpoints: countCacheBreakpoints(system, messages as any, tools as any), + message_breakpoint_index: messageBreakpointIndex, + message_breakpoint_on_transient: onTransient, + transient_blocks: transient, + message_count: messages.length, + }); + } catch { + /* 可观测性不影响请求 */ + } +} + /** 去掉 provider_reasoning 块(Anthropic 协议无对应字段;跨族切模型时出现) */ function withoutProviderReasoning(content: ContentBlock[]): ContentBlock[] { return content.some((b) => b.type === "provider_reasoning") diff --git a/packages/core/src/llm/types.ts b/packages/core/src/llm/types.ts index 9f8fe8b95..341783671 100644 --- a/packages/core/src/llm/types.ts +++ b/packages/core/src/llm/types.ts @@ -12,6 +12,14 @@ export type Role = "user" | "assistant"; export interface TextBlock { type: "text"; text: string; + /** + * 只存在于本轮发送副本、下一轮不会原样再发的块(system-reminder 注入产物)。 + * + * 本地元数据,**不上线**:各 provider 序列化文本块时只取 `text`。Anthropic 据它把 + * messages 区的缓存断点挪到这类块之前(见 `markLastUserMessageCacheBreakpoint`)—— + * 断点写在下一轮不存在的字节上,等于这一轮的缓存写入白付。 + */ + transient?: true; } /** 工具调用块 */ @@ -553,10 +561,8 @@ export interface SendParams { }; }; /** - * G2:cache_edits 删除指令(Anthropic 私有字段)。 - * cachedMicrocompact 走缓存友好路径时产出的"服务器侧删除旧工具结果"指令, - * 由 anthropic.ts 在 provider.name()==="anthropic" 时携带到请求体顶层 `cache_edits`。 - * 其它 provider 完全忽略(不下发)。不传则不下发。 + * @deprecated 2026-10-24 起不再发送(字段不在公开 API)。保留类型仅为兼容外部插件构造的 SendParams。 + * 原为 cache_edits 删除指令(非公开字段)。任何 provider 都不再读取它。 */ cacheEdits?: { type: "delete"; tool_use_id: string }[]; /** diff --git a/packages/core/src/query/auto-compact.ts b/packages/core/src/query/auto-compact.ts index 572abad0e..d8e172a73 100644 --- a/packages/core/src/query/auto-compact.ts +++ b/packages/core/src/query/auto-compact.ts @@ -88,11 +88,6 @@ export interface AutoCompactDeps { * 仅主代理的压缩失败计入全局熔断器;子代理压缩失败不污染主代理的熔断状态。 */ isMainAgent?: boolean; - /** - * §5 压缩通知重置 MC state:缓存 microcompact 状态机。 - * 压缩成功后重置——压缩重组了消息历史,旧的"已删除 tool_use_id"映射全部失效。 - */ - cachedMicrocompactState?: import("./compact/cached-microcompact.ts").CachedMicrocompactState; /** * 会话级临时目录(§4.1 质量报告 / §4.3 决策外化落盘)。不传则跳过落盘。 */ @@ -715,7 +710,6 @@ async function postCompactReattachAndNotify( ctxMgr: deps.ctxMgr, hookSystem: deps.hookSystem, fileReadTracker: deps.fileReadTracker, - cachedMicrocompactState: deps.cachedMicrocompactState, sessionDir: deps.sessionDir, originalMessages, summary, diff --git a/packages/core/src/query/compact/cached-microcompact.ts b/packages/core/src/query/compact/cached-microcompact.ts deleted file mode 100644 index a275db7bb..000000000 --- a/packages/core/src/query/compact/cached-microcompact.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Cached Microcompact(Layer 3,对标 claude-code services/microcompact/cachedMicrocompact.ts) - * - * 背景:sid 现有 microcompactMessages 在"缓存模式"下仍会改写消息内容(截断 + 占位符), - * 这会改变 prompt 前缀字节 → Anthropic prompt cache 失效(cache miss),下次请求需重算整段前缀, - * 反而浪费 token。本模块提供"缓存友好"的替代路径: - * - * - **Anthropic + 缓存温热**:不改本地消息内容(前缀保持字节一致 → cache hit), - * 只记录"需要服务器侧删除内容"的 tool_use_id,生成 cache_edits 块交给 API 层携带。 - * - **非 Anthropic / 缓存已冷**:回退到现有 microcompactMessages 直接清内容(反正缓存没了, - * 不如趁机真正释放本地 token)。 - * - * ⚠️ 多供应商务实约束(见分析文档 §5.5): - * `cache_edits` 是 Anthropic 私有的、未公开进 Messages API 文档的字段。无脑把它注入每次请求体 - * 会导致非 Anthropic 供应商 400、甚至 Anthropic 自身在该字段语义变化时报错。因此本模块: - * 1. 默认 **不** 真正发射 cache_edits(emitCacheEdits=false)——只做"供应商感知的模式选择" + - * "tool_use_id 状态追踪",已能消除"缓存模式仍改前缀"这一确定性的 cache 破坏。 - * 2. 仅当调用方显式 opt-in(emitCacheEdits=true,且供应商为 anthropic)才产出 cache_edits 块。 - * 这样既补齐了能力骨架,又不会在生产里破坏现有多供应商请求。 - */ - -import type { Message } from "../../llm/types.ts"; -import { getLogger } from "../../debug/index.ts"; -import { - microcompactMessages, - isDiscardableTool, - type MicrocompactOptions, -} from "./microcompact.ts"; - -/** - * 可压缩工具判定(对标 claude-code COMPACTABLE_TOOLS)。 - * - * ⚠️ 历史上这里维护过一套独立的裸字符串白名单 `COMPACTABLE_TOOLS`,与 microcompact.ts 的 - * `DISCARDABLE_TOOLS` 不一致:① 漏收 `web_search`/`web_fetch`/`tool_search`,② 混入了不存在的 - * `list`,③ 不做 normalizeToolName 归一化(裸 `.has()` 无法命中 `read_many` 等带下划线的真实工具名)。 - * 一旦 cache_edits 路径接入并开启 emitCacheEdits,这三个工具的结果将永远不被删除 → 埋雷。 - * - * 现统一复用 microcompact.ts 的 `isDiscardableTool`(带归一化、与真实注册名一致), - * 单一事实源,消除两套白名单漂移的风险。 - */ -export const isCompactableTool = isDiscardableTool; - -/** 单个 tool_use 的缓存编辑追踪状态 */ -export interface CachedToolState { - /** tool_use_id */ - toolUseId: string; - /** 产出该结果的工具名(用于白名单判断) */ - toolName: string; - /** 对应 tool_use 的 input(bash 按命令判定只读,见 microcompact.ts 的 isDiscardableTool) */ - input?: unknown; - /** 是否已标记为"待服务器侧删除" */ - markedForDeletion: boolean; -} - -/** cache_edits 状态机(跨多次 microcompact 复用,记录哪些 tool_use 已删) */ -export interface CachedMicrocompactState { - /** tool_use_id → 状态 */ - tools: Map; - /** 已发射删除指令的 tool_use_id(防止重复删除) */ - deleted: Set; -} - -/** 新建空状态 */ -export function createCachedMicrocompactState(): CachedMicrocompactState { - return { tools: new Map(), deleted: new Set() }; -} - -/** 复位状态(对标 claude-code resetMicrocompactState,cache 失效后调用) */ -export function resetCachedMicrocompactState(state: CachedMicrocompactState): void { - state.tools.clear(); - state.deleted.clear(); -} - -/** - * 从消息历史登记所有 tool_use(建立 tool_use_id → toolName 映射)。 - * assistant 的 tool_use 块带 name,user 的 tool_result 块只带 tool_use_id, - * 因此先扫一遍 assistant 块建立映射。 - */ -export function registerToolUses(state: CachedMicrocompactState, messages: Message[]): void { - for (const msg of messages) { - if (msg.role !== "assistant") continue; - for (const block of msg.content) { - if (block.type === "tool_use" && !state.tools.has(block.id)) { - state.tools.set(block.id, { - toolUseId: block.id, - toolName: block.name, - input: block.input, - markedForDeletion: false, - }); - } - } - } -} - -/** cache_edits 单条删除指令(对标 Anthropic 私有字段形状) */ -export interface CacheEditDelete { - type: "delete"; - tool_use_id: string; -} - -/** cache_edits 块(交给 API 层在下一次请求携带) */ -export interface CacheEditsBlock { - edits: CacheEditDelete[]; -} - -/** - * 为"应被压缩"的旧工具结果生成 cache_edits 删除指令。 - * - * 只处理:① 在白名单内 ② 内容超过 minContentLength ③ 位于 preserveRecentCount 之前 - * ④ 尚未删除过 的 tool_result。生成后把对应 tool_use_id 记入 state.deleted,避免重复。 - * - * @returns 删除指令数组(可能为空);本函数 **不** 修改 messages。 - */ -export function createCacheEditsBlock( - state: CachedMicrocompactState, - messages: Message[], - options?: MicrocompactOptions, -): CacheEditsBlock | null { - const preserveRecentCount = options?.preserveRecentCount ?? 6; - const minContentLength = options?.minContentLength ?? 500; - - if (messages.length <= preserveRecentCount) return null; - - registerToolUses(state, messages); - - const cutoff = messages.length - preserveRecentCount; - const edits: CacheEditDelete[] = []; - - for (let idx = 0; idx < cutoff; idx++) { - const msg = messages[idx]; - if (msg.role !== "user") continue; - - for (const block of msg.content) { - if (block.type !== "tool_result") continue; - if (typeof block.content !== "string" || block.content.length <= minContentLength) continue; - - const toolState = state.tools.get(block.tool_use_id); - // 白名单约束:未登记(拿不到工具名)保守跳过;不可丢弃工具跳过 - // bash 必须带 input:只读命令才删,写命令(git commit / rm 等)的输出保留在前缀里 - if (!toolState || !isCompactableTool(toolState.toolName, toolState.input)) continue; - if (state.deleted.has(block.tool_use_id)) continue; - - edits.push({ type: "delete", tool_use_id: block.tool_use_id }); - toolState.markedForDeletion = true; - state.deleted.add(block.tool_use_id); - } - } - - return edits.length > 0 ? { edits } : null; -} - -/** Cached microcompact 选项 */ -export interface CachedMicrocompactOptions extends MicrocompactOptions { - /** 供应商名(来自 provider.name())。"anthropic" 时才考虑缓存友好路径。 */ - providerName: string; - /** - * 缓存是否温热(距上次 assistant 消息未超时)。 - * - true(默认):缓存温热 → Anthropic 走 cache 友好路径(不改前缀) - * - false:缓存已冷 → 直接清内容(趁机释放本地 token) - */ - cacheWarm?: boolean; - /** - * 是否真正发射 cache_edits 原始块(默认 false)。 - * cache_edits 是 Anthropic 私有字段,开启前需确认 API 层已正确携带且供应商支持。 - * 关闭时仍做"供应商感知模式选择 + 状态追踪",只是不产出原始 cache_edits 块。 - */ - emitCacheEdits?: boolean; - /** 跨调用复用的状态机(不传则内部新建,单次有效) */ - state?: CachedMicrocompactState; -} - -/** Cached microcompact 结果 */ -export interface CachedMicrocompactResult { - /** 压缩后的消息(缓存友好路径下与输入完全一致,引用不变) */ - messages: Message[]; - /** 走了哪条路径 */ - path: "cache-preserving" | "direct-clear"; - /** 直接清内容路径下:压缩的工具结果数 */ - compactedCount: number; - /** 直接清内容路径下:节省的字符数 */ - savedChars: number; - /** - * 缓存友好路径下:待 API 层携带的 cache_edits 块(emitCacheEdits=true 时才非空)。 - * 消息本身不变 → 下次请求前缀字节一致 → cache hit。 - */ - pendingCacheEdits: CacheEditsBlock | null; -} - -/** - * 供应商感知的 microcompact。 - * - * - Anthropic + 缓存温热 → cache-preserving:不改消息内容,只(可选)产出 cache_edits。 - * - 其余情况 → direct-clear:委托现有 microcompactMessages 直接清内容。 - */ -export function cachedMicrocompact( - messages: Message[], - options: CachedMicrocompactOptions, -): CachedMicrocompactResult { - const log = getLogger(); - const isAnthropic = options.providerName === "anthropic"; - const cacheWarm = options.cacheWarm ?? true; - - // 缓存友好路径:仅 Anthropic + 缓存温热 - if (isAnthropic && cacheWarm) { - const state = options.state ?? createCachedMicrocompactState(); - const pendingCacheEdits = options.emitCacheEdits - ? createCacheEditsBlock(state, messages, options) - : (registerToolUses(state, messages), null); - - log.info( - "CACHED_MICROCOMPACT", - `缓存友好路径(不改前缀),emitCacheEdits=${!!options.emitCacheEdits},` + - `待删除 ${pendingCacheEdits?.edits.length ?? 0} 项`, - ); - - return { - messages, // 引用不变 → 前缀字节一致 → cache hit - path: "cache-preserving", - compactedCount: 0, - savedChars: 0, - pendingCacheEdits, - }; - } - - // 直接清内容路径:非 Anthropic 或缓存已冷 - const result = microcompactMessages(messages, { ...options, mode: "time" }); - log.info( - "CACHED_MICROCOMPACT", - `直接清内容路径(provider=${options.providerName}, cacheWarm=${cacheWarm}),` + - `压缩 ${result.compactedCount} 项,节省 ${result.savedChars} 字符`, - ); - return { - messages: result.messages, - path: "direct-clear", - compactedCount: result.compactedCount, - savedChars: result.savedChars, - pendingCacheEdits: null, - }; -} diff --git a/packages/core/src/query/compact/microcompact.ts b/packages/core/src/query/compact/microcompact.ts index e1e629960..21373d1bd 100644 --- a/packages/core/src/query/compact/microcompact.ts +++ b/packages/core/src/query/compact/microcompact.ts @@ -124,18 +124,6 @@ const DEFAULT_OPTIONS: Required = { /** 可丢弃工具占位符 */ const DISCARDABLE_PLACEHOLDER = "[可丢弃工具输出已清空,如需查看请重新执行工具]"; -/** 从消息历史中查找 tool_use_id 对应的工具名与 input(bash 分类要看 input.command) */ -function findToolUse(messages: Message[], toolUseId: string): { name: string; input: unknown } { - for (const msg of messages) { - for (const block of msg.content) { - if (block.type === "tool_use" && block.id === toolUseId) { - return { name: block.name, input: block.input }; - } - } - } - return { name: "unknown", input: undefined }; -} - /** * 对单个 tool_result 块按工具类型生成压缩后的内容。 * @@ -174,6 +162,74 @@ function compactToolResultContent( return null; } +/** 一条 microcompact 改写决策:哪个块、改成什么。 */ +export interface MicrocompactPlanEntry { + msgIdx: number; + blockIdx: number; + toolUseId: string; + /** 改写后的完整内容(占位符或摘要) */ + replacement: string; + /** 原始内容长度(字符),用于记账 */ + originalLength: number; +} + +/** + * 只算「该改哪些块、改成什么」,不动消息。 + * + * 判据与 {@link microcompactMessages} 完全一致(后者就是在它之上做应用)。拆出来是为了 + * 让 ContextManager 能把冷缓存清理**落成会话状态**而不是只改一份发送副本: + * 决策一旦做出,之后每轮都要逐字节一致地重放,见 `ContextManager.applyColdCacheClear`。 + * + * @param skipToolUse 调用方额外豁免的 tool_use_id(已有稳定占位的块:再改一次就是第二次改写前缀) + */ +export function planMicrocompact( + messages: Message[], + options?: MicrocompactOptions & { skipToolUse?: (toolUseId: string) => boolean }, +): MicrocompactPlanEntry[] { + const opts = { ...DEFAULT_OPTIONS, ...options }; + if (messages.length <= opts.preserveRecentCount) return []; + + // 一次建好 tool_use 索引:原先每个块都 findToolUse 全量扫一遍,O(n²) + const toolUses = new Map(); + for (const msg of messages) { + for (const block of msg.content) { + if (block.type === "tool_use") + toolUses.set(block.id, { name: block.name, input: block.input }); + } + } + + const plan: MicrocompactPlanEntry[] = []; + // 只压缩 preserveRecentCount 之前的消息 + const cutoff = messages.length - opts.preserveRecentCount; + for (let msgIdx = 0; msgIdx < cutoff; msgIdx++) { + const msg = messages[msgIdx]; + // 只压缩 user 消息中的 tool_result 块 + if (msg.role !== "user") continue; + msg.content.forEach((b, blockIdx) => { + if (b.type !== "tool_result" || typeof b.content !== "string") return; + if (b.content.length <= opts.minContentLength) return; + if (options?.skipToolUse?.(b.tool_use_id)) return; + const toolUse = toolUses.get(b.tool_use_id) ?? { name: "unknown", input: undefined }; + const replacement = compactToolResultContent( + b.content, + toolUse.name, + toolUse.input, + opts.mode, + ); + // null 表示工具不在白名单内(未知 / 自定义 / MCP)→ 跳过,原样保留 + if (replacement === null) return; + plan.push({ + msgIdx, + blockIdx, + toolUseId: b.tool_use_id, + replacement, + originalLength: b.content.length, + }); + }); + } + return plan; +} + /** * 对消息列表执行 microcompact(工具类型感知) * @@ -189,64 +245,23 @@ export function microcompactMessages( options?: MicrocompactOptions, ): { messages: Message[]; compactedCount: number; savedChars: number } { const log = getLogger(); - const opts = { ...DEFAULT_OPTIONS, ...options }; + const plan = planMicrocompact(messages, options); + if (plan.length === 0) return { messages, compactedCount: 0, savedChars: 0 }; - if (messages.length <= opts.preserveRecentCount) { - return { messages, compactedCount: 0, savedChars: 0 }; - } - - let compactedCount = 0; + const byPos = new Map(plan.map((p) => [`${p.msgIdx}:${p.blockIdx}`, p])); + const touched = new Set(plan.map((p) => p.msgIdx)); let savedChars = 0; - - // 只压缩 preserveRecentCount 之前的消息 - const cutoff = messages.length - opts.preserveRecentCount; - const result = messages.map((msg, idx) => { - if (idx >= cutoff) return msg; // 保留最近的消息 - - // 只压缩 user 消息中的 tool_result 块 - if (msg.role !== "user") return msg; - - const hasLargeToolResult = msg.content.some( - (b) => - b.type === "tool_result" && - typeof b.content === "string" && - b.content.length > opts.minContentLength, - ); - - if (!hasLargeToolResult) return msg; - - const newContent: ContentBlock[] = msg.content.map((b) => { - if ( - b.type === "tool_result" && - typeof b.content === "string" && - b.content.length > opts.minContentLength - ) { - const originalLen = b.content.length; - const toolUse = findToolUse(messages, b.tool_use_id); - const compacted = compactToolResultContent( - b.content, - toolUse.name, - toolUse.input, - opts.mode, - ); - - // null 表示工具不在白名单内(未知 / 自定义 / MCP)→ 跳过,原样保留 - if (compacted === null) return b; - - savedChars += originalLen - compacted.length; - compactedCount++; - - return { ...b, content: compacted }; - } - return b; + const result = messages.map((msg, msgIdx) => { + if (!touched.has(msgIdx)) return msg; + const newContent: ContentBlock[] = msg.content.map((b, blockIdx) => { + const p = byPos.get(`${msgIdx}:${blockIdx}`); + if (!p || b.type !== "tool_result") return b; + savedChars += p.originalLength - p.replacement.length; + return { ...b, content: p.replacement }; }); - return { ...msg, content: newContent }; }); - if (compactedCount > 0) { - log.info("MICROCOMPACT", `压缩了 ${compactedCount} 个工具结果,节省 ${savedChars} 字符`); - } - - return { messages: result, compactedCount, savedChars }; + log.info("MICROCOMPACT", `压缩了 ${plan.length} 个工具结果,节省 ${savedChars} 字符`); + return { messages: result, compactedCount: plan.length, savedChars }; } diff --git a/packages/core/src/query/compact/post-compact.ts b/packages/core/src/query/compact/post-compact.ts index 1bdd681b0..dc5410389 100644 --- a/packages/core/src/query/compact/post-compact.ts +++ b/packages/core/src/query/compact/post-compact.ts @@ -35,8 +35,6 @@ export interface PostCompactOptions { hookSystem?: HookSystem; /** §2.1 文件重注入依赖;未提供则跳过 */ fileReadTracker?: import("../../tool/file-read-tracker.ts").FileReadTracker; - /** §5 microcompact 状态机;未提供则跳过重置 */ - cachedMicrocompactState?: import("./cached-microcompact.ts").CachedMicrocompactState; /** §4.1 质量报告落盘目录;未提供则只算覆盖率不落盘 */ sessionDir?: string; /** @@ -119,16 +117,8 @@ export async function runPostCompact(opts: PostCompactOptions): Promise { } } - // 2. §5:压缩重组了消息历史,microcompact 的"已删除 tool_use_id"映射全部失效 - if (opts.cachedMicrocompactState) { - try { - const { resetCachedMicrocompactState } = await import("./cached-microcompact.ts"); - resetCachedMicrocompactState(opts.cachedMicrocompactState); - log.debug("COMPACT", "已重置 cached microcompact 状态机"); - } catch { - /* 忽略 */ - } - } + // 2.(原「重置 cached microcompact 状态机」已随该模块删除:冷清理决策现在是 + // ContextManager 的会话状态,压缩走 resetLocalCompactState 一并清空。) // 3. G1:抑制紧接的一次 cache break 检测,避免误报淹没真实告警 try { diff --git a/packages/core/src/query/engine.ts b/packages/core/src/query/engine.ts index 340895bf0..c1c476167 100644 --- a/packages/core/src/query/engine.ts +++ b/packages/core/src/query/engine.ts @@ -113,11 +113,7 @@ export interface QueryEngineDeps { getHypothesisLedger?: () => import("./hypothesis-ledger.ts").HypothesisLedger | null; /** §3.1/§3.3:轨迹采集器(用于异常路径持久化 errors.jsonl + TurnError 事件) */ traceCollector?: import("../trace/collector.ts").TraceCollector; - /** G2:获取 cachedMicrocompact 状态机(缓存友好压缩产出 cache_edits)。可选 */ - getCachedMicrocompactState?: () => - | import("./compact/cached-microcompact.ts").CachedMicrocompactState - | undefined; - /** G2:当前 provider 名称(用于 cachedMicrocompact 路径判断)。可选 */ + /** 当前 provider 名称(冷缓存清理只对 Anthropic 显式缓存做)。可选 */ getProviderName?: () => string; /** MCP server instructions 增量拉取(新连接 server 的使用说明,经 reminderParts 注入)。可选 */ getMcpInstructionsDelta?: () => string[] | null; @@ -449,8 +445,6 @@ export class QueryEngine { getTodoTerminalState: this.deps.getTodoTerminalState, getHypothesisLedger: this.deps.getHypothesisLedger, sessionStore: this.deps.sessionStore, - // G2:cachedMicrocompact 状态机 + provider 名称(缓存友好压缩产出 cache_edits) - getCachedMicrocompactState: this.deps.getCachedMicrocompactState, getProviderName: this.deps.getProviderName, getMcpInstructionsDelta: this.deps.getMcpInstructionsDelta, // 审计第 22 条:IDE 选区/@提及 增量注入(与上面 MCP instructions 同一模式) diff --git a/packages/core/src/query/loop.ts b/packages/core/src/query/loop.ts index cc5890f39..a1dd7de3f 100644 --- a/packages/core/src/query/loop.ts +++ b/packages/core/src/query/loop.ts @@ -290,6 +290,11 @@ import { formatCacheBreakReport, notifyCompaction, } from "../api/cache-detection.ts"; +import { + MAX_PROMPT_CACHE_TTL_MS, + isPrefixCacheProvablyCold, + msSincePrefixTouch, +} from "../api/cache-warmth.ts"; import { getEffectiveBetaHeaders } from "../api/beta-header-latch.ts"; import type { QueryLoopYield, QueryDeps, LoopState } from "./types.ts"; import { createInitialLoopState } from "./types.ts"; @@ -685,6 +690,32 @@ function emitNagInjectedEvent( } } +/** + * 冷缓存清理落成结构化事件(`ColdCacheClear`)。 + * + * 旧实现只打 log.debug,轨迹里完全看不到「清了没、清了多少、当时隔了多久」—— + * 「热当冷」这个缺陷只能靠回放 raw.jsonl 反推。有了它,`gap_ms` 的分布直接证明 + * 修复是否生效:修复后所有事件的 gap_ms 都 ≥ MAX_PROMPT_CACHE_TTL_MS(1h)。 + * cleared=0 也落:说明判冷了但没东西可清,分母要包含它。 + */ +function emitColdCacheClearEvent( + deps: QueryDeps, + sessionId: string, + data: { gap_ms: number; cleared: number; saved_chars: number }, +): void { + if (!deps.traceAppendEvent) return; + try { + deps.traceAppendEvent({ + event: "ColdCacheClear", + session_id: sessionId, + timestamp: new Date().toISOString(), + data: { ...data, threshold_ms: MAX_PROMPT_CACHE_TTL_MS }, + }); + } catch { + /* trace 写入失败不阻断主循环 */ + } +} + /** * 把一次 todo 清单推进(`writeVersion` 增长)落成结构化 trace 事件。 * @@ -1402,6 +1433,33 @@ export async function* queryLoop(loopConfig: QueryLoopConfig): AsyncGenerator 0) { + // 本轮 cache_read 下降是预期的(缓存本来就已过期),抑制一次误报 + notifyCompaction("main"); + log.info( + "QUERY_LOOP", + `缓存已冷(距上次请求 ${Math.round((gapMs ?? 0) / 60000)} 分钟):清理 ${r.cleared} 项旧工具结果,释放 ${r.savedChars} 字符`, + ); + } + } + } + const cleanedMessages = ctxMgr.getCleanedMessages(); // 工具延迟加载开启时,首轮只发非延迟工具(activeDefinitions),延迟工具由模型经 // tool_search 按需激活后才进上下文;关闭时发全量(definitions),行为与历史一致。 @@ -2478,56 +2536,6 @@ export async function* queryLoop(loopConfig: QueryLoopConfig): AsyncGenerator 0) { - sendParams.cacheEdits = result.pendingCacheEdits.edits; - log.debug("CACHE_EDITS", `注入 ${sendParams.cacheEdits.length} 条 cache_edits`); - // G1:cache_edits 删除后下次 cache_read 可能下降——通知检测器抑制 - const { notifyCacheDeletion } = await import("../api/cache-detection.ts"); - notifyCacheDeletion(sendParams.cacheEdits.length, "main"); - } - } else if (result.compactedCount > 0) { - // direct-clear(缓存已冷):改写后的消息必须回灌,否则清理无效(本地 token 不释放)。 - // 前缀已因缓存过期失效,改写不额外损失命中;notifyCompaction 抑制这轮预期的 cache_read 下降。 - sendParams.messages = result.messages; - finalMessages = result.messages; - notifyCompaction("main"); - log.debug( - "CACHE_EDITS", - `缓存已冷 direct-clear:清理 ${result.compactedCount} 项工具结果,释放 ${result.savedChars} 字符`, - ); - } - } - } catch (err: any) { - log.debug( - "CACHE_EDITS", - `cachedMicrocompact 失败(非阻断): ${err?.message?.slice(0, 100)}`, - ); - } - // ─── F5:本地速率限制(RPM/TPM)主动节流 ─── // // 缺陷形态(2026-09-03 排查):`QuotaManager.checkRateLimit()` 的**生产调用点 = 0**。 @@ -3297,8 +3305,6 @@ export async function* queryLoop(loopConfig: QueryLoopConfig): AsyncGenerator import("../llm/provider.ts").Provider | undefined; /** - * G2:获取 cachedMicrocompact 状态机(provider 感知的缓存友好压缩)。 - * queryLoop 每轮发送前调用 cachedMicrocompact(messages, {state, ...}), - * 将产出的 pendingCacheEdits 注入 sendParams.cacheEdits。可选——未注入则跳过。 - */ - getCachedMicrocompactState?: () => - | import("./compact/cached-microcompact.ts").CachedMicrocompactState - | undefined; - /** - * G2:当前 provider 名称(用于 cachedMicrocompact 路径判断)。可选。 + * 当前 provider 名称(冷缓存清理只对 anthropic 生效,见 loop.ts)。可选。 */ getProviderName?: () => string; /** diff --git a/packages/core/src/trace/digest.ts b/packages/core/src/trace/digest.ts index 121faa87c..bb0a663cc 100644 --- a/packages/core/src/trace/digest.ts +++ b/packages/core/src/trace/digest.ts @@ -1076,6 +1076,8 @@ export interface Digest { jit?: JitDigestStats; /** P1-2:前缀断裂定位分布。无 `prefix_break` 事件时 undefined(老会话,埋点上线前)。 */ prefixBreaks?: PrefixBreakDigestStats; + /** §6:缓存断点布局 + 冷清理。两类事件全无时 undefined(老会话 / 非 Anthropic)。 */ + promptCache?: PromptCacheDigestStats; /** * todo 清单实时性度量(2026-08-02)。三类 todo 事件全无时 undefined * (老会话 / 整场没建过清单)。 @@ -2808,6 +2810,7 @@ export function buildDigest(ref: SessionRef, full: boolean, paths: DigestPaths): // ── P1-2:前缀断裂定位分布(断在哪类段 / 浪费多少)── const prefixBreaks = aggregatePrefixBreakStats(events); + const promptCache = aggregatePromptCacheStats(events); // ── 2026-08-02:todo 实时性度量(推进次数 / 回注次数 / 收尾兜底次数)── // 补的是"采了不看"这个缺口:三个事件此前只写不读,缺陷定性只能靠间接证据。 @@ -2875,6 +2878,7 @@ export function buildDigest(ref: SessionRef, full: boolean, paths: DigestPaths): subAgents: subAgents ?? undefined, jit: jit ?? undefined, prefixBreaks: prefixBreaks ?? undefined, + promptCache: promptCache ?? undefined, todo: todo ?? undefined, recovery: recovery ?? undefined, continuePaths: continuePaths ?? undefined, @@ -3523,6 +3527,30 @@ export function renderHuman(d: Digest, opts: RenderOptions = {}): string { } } + // ── §6 缓存布局 / 冷清理(2026-10-24):两个缺陷修复的直接验收口径 ── + if (d.promptCache) { + const pc = d.promptCache; + L.push(""); + const bad = pc.breakpointOnTransient > 0; + L.push( + c("bold", "缓存断点:") + + " " + + c( + bad ? "red" : "green", + `${pc.breakpointOnTransient}/${pc.layoutRequests} 次落在 reminder 上`, + ) + + c("gray", ` (应恒为 0;含 reminder 的请求 ${pc.requestsWithTransient} 次)`), + ); + if (pc.coldClears > 0 || pc.coldClearsUnderThreshold > 0) { + L.push( + ` 冷清理 ${pc.coldClears} 次,清 ${pc.coldClearedItems} 项 / ${pc.coldClearedChars} 字符` + + (pc.coldClearsUnderThreshold > 0 + ? c("red", ` ⚠ ${pc.coldClearsUnderThreshold} 次间隔低于阈值(热当冷回归)`) + : c("gray", ` 最短间隔 ${Math.round((pc.minColdGapMs ?? 0) / 60000)} 分钟`)), + ); + } + } + // §9.6:子代理执行 section(几成几败 + 串行/并行 + 每个 span 明细) if (d.subAgents && d.subAgents.total > 0) { const sa = d.subAgents; @@ -4068,6 +4096,65 @@ export interface PrefixBreakDigestStats { shrunkTurns: number; } +/** + * §6 缓存缺陷的验收口径(2026-10-24)。 + * + * 两个数都是「修复后应恒为 0」的不变量计数,不是比率: + * - breakpointOnTransient:messages 区断点落在 reminder 上的次数(PromptCacheLayout) + * - coldClearsUnderThreshold:冷清理发生时距上次请求 < 阈值的次数(ColdCacheClear) + * 分母一并给出(layoutRequests / coldClears),否则「0」分不清是修好了还是没发生。 + */ +export interface PromptCacheDigestStats { + layoutRequests: number; + requestsWithTransient: number; + breakpointOnTransient: number; + coldClears: number; + coldClearedItems: number; + coldClearedChars: number; + coldClearsUnderThreshold: number; + minColdGapMs?: number; +} + +export function aggregatePromptCacheStats( + events: Array<{ event?: string; data?: Record }>, +): PromptCacheDigestStats | null { + const s: PromptCacheDigestStats = { + layoutRequests: 0, + requestsWithTransient: 0, + breakpointOnTransient: 0, + coldClears: 0, + coldClearedItems: 0, + coldClearedChars: 0, + coldClearsUnderThreshold: 0, + }; + let seen = false; + for (const e of events) { + const d = e.data; + if (!d) continue; + if (e.event === "PromptCacheLayout") { + seen = true; + s.layoutRequests++; + if (Number(d.transient_blocks) > 0) s.requestsWithTransient++; + if (d.message_breakpoint_on_transient === true) s.breakpointOnTransient++; + } else if (e.event === "ColdCacheClear") { + seen = true; + s.coldClears++; + s.coldClearedItems += Number(d.cleared) || 0; + s.coldClearedChars += Number(d.saved_chars) || 0; + const gap = Number(d.gap_ms); + const threshold = Number(d.threshold_ms); + if (Number.isFinite(gap) && gap >= 0) { + s.minColdGapMs = s.minColdGapMs === undefined ? gap : Math.min(s.minColdGapMs, gap); + if (Number.isFinite(threshold) && gap < threshold) s.coldClearsUnderThreshold++; + } else { + // gap 未知却清了 = 违反「不知道即热」,同样计为回归 + s.coldClearsUnderThreshold++; + } + } + } + return seen ? s : null; +} + /** 从 events.jsonl 聚合前缀断裂分布(P1-2) */ export function aggregatePrefixBreakStats( events: Array<{ event?: string; data?: Record }>, diff --git a/packages/core/src/trace/stream-observer.ts b/packages/core/src/trace/stream-observer.ts index 4ad9c7379..b3a6dc34d 100644 --- a/packages/core/src/trace/stream-observer.ts +++ b/packages/core/src/trace/stream-observer.ts @@ -833,6 +833,47 @@ export function emitBilledRequest(data: { } } +// ─── PromptCacheLayout 事件(§6 缓存缺陷修复,2026-10-24) ─── + +/** + * Anthropic 请求在**线上实发形态**里的缓存断点布局。调用点:`anthropic.ts` 打完断点、发请求前。 + * + * 为什么必须在 provider 侧落:`raw.jsonl` / `prefix_break` 都在 BeforeModel hook 取样, + * 那时 reminder 刚注入、断点还没打 —— 断点落在哪一块,此前**只能靠回放推断**。 + * 「断点打在 transient 块上」这个缺陷的修复效果,只有这个字段能直接证明(应恒为 false)。 + * + * 只落位置与计数,不落任何内容。 + */ +export function emitPromptCacheLayout(data: { + index: number; + model: string; + /** 端点 host(判定 usage 是否可信要按 host 查 channel-trust) */ + endpoint_host?: string; + stream: boolean; + /** 断点总数(system + tools + messages),≤ 4 */ + breakpoints: number; + /** messages 区断点所在消息下标;-1 = 没打 */ + message_breakpoint_index: number; + /** messages 区断点是否落在 transient(system-reminder)块上 —— 修复后应恒为 false */ + message_breakpoint_on_transient: boolean; + /** 本次请求含多少个 transient 块 */ + transient_blocks: number; + message_count: number; +}): void { + try { + if (_eventWriter && _sessionId) { + _eventWriter({ + event: "PromptCacheLayout", + session_id: _sessionId, + timestamp: new Date().toISOString(), + data: data as unknown as Record, + }); + } + } catch { + /* 可观测性不影响正常流程 */ + } +} + // ─── StrictToolDegraded 事件(遗留清单 R11) ─── /** diff --git a/packages/core/tests/api/cache-detection.test.ts b/packages/core/tests/api/cache-detection.test.ts index 446ddc067..614bb4d21 100644 --- a/packages/core/tests/api/cache-detection.test.ts +++ b/packages/core/tests/api/cache-detection.test.ts @@ -300,14 +300,6 @@ describe("G1 suppressNext 抑制机制", () => { expect(report).toBeNull(); }); - test("notifyCacheDeletion 抑制下一次检测", () => { - const d = new CacheBreakDetector(); - d.checkResponse(params({ cacheReadTokens: 50000 })); - d.notifyCacheDeletion(3); - const report = d.checkResponse(params({ cacheReadTokens: 5000 })); - expect(report).toBeNull(); - }); - test("抑制仅作用一次,下次恢复正常检测", () => { const d = new CacheBreakDetector(); d.checkResponse(params({ cacheReadTokens: 50000 })); diff --git a/packages/core/tests/api/cache-strategy.test.ts b/packages/core/tests/api/cache-strategy.test.ts index 9b6e26d0d..fc91b5056 100644 --- a/packages/core/tests/api/cache-strategy.test.ts +++ b/packages/core/tests/api/cache-strategy.test.ts @@ -149,6 +149,66 @@ describe("markLastUserMessageCacheBreakpoint(P1-4 收口)", () => { }); }); +describe("markLastUserMessageCacheBreakpoint × transient(断点不落在下一轮不存在的字节上)", () => { + const T = new WeakSet(); + const t = (text: string) => { + const b = { type: "text", text }; + T.add(b); + return b; + }; + const isT = (b: object) => T.has(b); + + test("ambient 在末尾 → 断点退到用户指令块", () => { + const user = { type: "text", text: "修一下登录超时" }; + const amb = t("todo"); + const messages: CacheableMessage[] = [{ role: "user", content: [user, amb] }]; + expect(markLastUserMessageCacheBreakpoint(messages, isT)).toBe(0); + expect((user as any).cache_control).toEqual({ type: "ephemeral" }); + expect((amb as any).cache_control).toBeUndefined(); + }); + + test("critical 在开头 → 断点必须在它之前,退到上一条消息", () => { + const prev = { type: "text", text: "上一轮回答" }; + const crit = t("止损"); + const tr = { type: "tool_result", tool_use_id: "x", content: "ok" }; + const messages: CacheableMessage[] = [ + { role: "assistant", content: [prev] }, + { role: "user", content: [crit, tr] }, + ]; + // tool_result 在 critical 之后:它前面那段含下一轮没有的字节,不能当断点 + expect(markLastUserMessageCacheBreakpoint(messages, isT)).toBe(0); + expect((prev as any).cache_control).toEqual({ type: "ephemeral" }); + expect((tr as any).cache_control).toBeUndefined(); + }); + + test("退回时跳过 thinking 块(不可挂 cache_control)", () => { + const text = { type: "text", text: "答" }; + const thinking = { type: "thinking", thinking: "…", signature: "s" }; + const messages: CacheableMessage[] = [ + { role: "assistant", content: [text, thinking] }, + { role: "user", content: [t("x")] }, + ]; + expect(markLastUserMessageCacheBreakpoint(messages, isT)).toBe(0); + expect((text as any).cache_control).toEqual({ type: "ephemeral" }); + expect((thinking as any).cache_control).toBeUndefined(); + }); + + test("没有 transient 块时与旧行为完全一致(末块)", () => { + const a = [msg("assistant", "1"), msg("user", "2", "3")]; + expect(markLastUserMessageCacheBreakpoint(a, isT)).toBe(1); + expect((a[1].content as any[])[1].cache_control).toEqual({ type: "ephemeral" }); + }); + + test("仍只打一个断点", () => { + const messages: CacheableMessage[] = [ + msg("user", "a"), + { role: "user", content: [{ type: "text", text: "b" }, t("r")] }, + ]; + markLastUserMessageCacheBreakpoint(messages, isT); + expect(countCacheBreakpoints(undefined, messages)).toBe(1); + }); +}); + describe("addMessageCacheBreakpoint", () => { test("正常模式标记最后一条", () => { const messages = [msg("user", "1"), msg("assistant", "2"), msg("user", "3")]; @@ -299,10 +359,14 @@ describe("markLastToolCacheBreakpoint(增强 5.1 工具区缓存断点)", () expect(ts[2].cache_control).toEqual({ type: "ephemeral" }); }); - test("globalScope=true → 最后工具带 scope=global", () => { + test("cache_control 永不带 scope 字段(公开 API 只有 type/ttl,带了会 400)", () => { const ts = tools("read", "bash"); - markLastToolCacheBreakpoint(ts, { globalScope: true }); - expect(ts[1].cache_control).toEqual({ type: "ephemeral", scope: "global" }); + markLastToolCacheBreakpoint(ts); + const system = buildSystemBlocks(`STATIC${DYNAMIC_BOUNDARY}DYNAMIC`)!; + for (const cc of [ts[1].cache_control, ...system.map((b) => b.cache_control)]) { + expect(cc).toEqual({ type: "ephemeral" }); + expect(cc).not.toHaveProperty("scope"); + } }); test("工具区断点计入 countCacheBreakpoints 总数", () => { diff --git a/packages/core/tests/context/cold-cache-clear.test.ts b/packages/core/tests/context/cold-cache-clear.test.ts new file mode 100644 index 000000000..d2124f872 --- /dev/null +++ b/packages/core/tests/context/cold-cache-clear.test.ts @@ -0,0 +1,152 @@ +/** + * 冷缓存清理:判定 + 决策冻结(§6.7 / §6.9) + * + * 断言对着修复前的两个可观察后果写: + * 1. 热缓存被判冷 —— 冷热判定必须跨 queryLoop 存活,且「不知道」= 热; + * 2. 清理只落在一轮发送副本上 —— 清完下一轮又发原文,前缀改写两次。 + */ + +import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Manager } from "@sid-code/core/context/manager.ts"; +import type { Message } from "@sid-code/core/llm/types.ts"; +import { + MAX_PROMPT_CACHE_TTL_MS, + isPrefixCacheProvablyCold, + msSincePrefixTouch, + recordPrefixTouch, + resetPrefixTouchForTests, +} from "@sid-code/core/api/cache-warmth.ts"; + +let tmpRoot = ""; +const savedTmp = process.env.SID_CODE_TMPDIR; +beforeAll(() => { + tmpRoot = mkdtempSync(join(tmpdir(), "sid-cold-clear-")); + process.env.SID_CODE_TMPDIR = tmpRoot; +}); +afterAll(() => { + if (savedTmp === undefined) delete process.env.SID_CODE_TMPDIR; + else process.env.SID_CODE_TMPDIR = savedTmp; + rmSync(tmpRoot, { recursive: true, force: true }); +}); + +describe("cache-warmth:能证明冷才算冷", () => { + beforeEach(() => resetPrefixTouchForTests()); + + test("进程内从未发过请求 → 不知道 → 按热处理(resume 后首轮不清)", () => { + expect(msSincePrefixTouch()).toBeUndefined(); + expect(isPrefixCacheProvablyCold()).toBe(false); + }); + + test("隔 2 分钟(旧实现的典型误判场景)→ 热", () => { + recordPrefixTouch(1_000_000); + expect(isPrefixCacheProvablyCold(1_000_000 + 2 * 60_000)).toBe(false); + }); + + test("隔 10 分钟仍按热:网关可能用 1h TTL,5 分钟不足以证明已过期", () => { + recordPrefixTouch(1_000_000); + expect(isPrefixCacheProvablyCold(1_000_000 + 10 * 60_000)).toBe(false); + }); + + test("隔满最长 TTL → 冷", () => { + recordPrefixTouch(1_000_000); + expect(isPrefixCacheProvablyCold(1_000_000 + MAX_PROMPT_CACHE_TTL_MS)).toBe(true); + }); + + test("时间倒退的触碰不会把时间点往回拨(并发请求乱序落点)", () => { + recordPrefixTouch(2_000_000); + recordPrefixTouch(1_000_000); + expect(msSincePrefixTouch(2_000_000)).toBe(0); + }); +}); + +/** n 轮 read 调用,每条结果 chars 字符(> microcompact 500 字符阈值,< 大输出 30K 阈值) */ +function history(rounds: number, chars = 2_000): Message[] { + const msgs: Message[] = [{ role: "user", content: [{ type: "text", text: "开始" }] }]; + for (let i = 0; i < rounds; i++) { + msgs.push({ + role: "assistant", + content: [{ type: "tool_use", id: `t${i}`, name: "read", input: { file_path: `f${i}.ts` } }], + }); + msgs.push({ + role: "user", + content: [{ type: "tool_result", tool_use_id: `t${i}`, content: `${i}`.repeat(chars) }], + }); + } + return msgs; +} + +function toolResult(sent: Message[], id: string): string | undefined { + for (const m of sent) { + for (const b of m.content) { + if (b.type === "tool_result" && b.tool_use_id === id) return b.content; + } + } + return undefined; +} + +describe("ContextManager.applyColdCacheClear:决策冻结", () => { + test("清理落在会话状态:之后每轮发送都是同一份占位(前缀只改写一次)", () => { + const mgr = new Manager({ maxTokens: 1_000_000 }); + mgr.setMessages(history(10)); + mgr.consumeSendMutationCauses(); + + const r = mgr.applyColdCacheClear(); + expect(r.cleared).toBeGreaterThan(0); + const first = mgr.getCleanedMessages(); + const placeholder = toolResult(first, "t0"); + expect(placeholder).not.toBe("0".repeat(2_000)); + + // 旧缺陷:下一轮又发原文。现在:后续轮次不再调 applyColdCacheClear,也必须贴同一份 + mgr.addMessage({ role: "assistant", content: [{ type: "text", text: "继续" }] }); + mgr.addMessage({ role: "user", content: [{ type: "text", text: "下一步" }] }); + const second = mgr.getCleanedMessages(); + expect(toolResult(second, "t0")).toBe(placeholder); + // 原始历史不被改写(清理只作用于发送投影) + expect(toolResult(mgr.getMessages(), "t0")).toBe("0".repeat(2_000)); + }); + + test("保护窗口内(最近 6 条)不清", () => { + const mgr = new Manager({ maxTokens: 1_000_000 }); + mgr.setMessages(history(10)); + mgr.applyColdCacheClear(); + const sent = mgr.getCleanedMessages(); + expect(toolResult(sent, "t9")).toBe("9".repeat(2_000)); + }); + + test("重复调用幂等:已清的块不再被改第二次", () => { + const mgr = new Manager({ maxTokens: 1_000_000 }); + mgr.setMessages(history(10)); + const a = mgr.applyColdCacheClear(); + const b = mgr.applyColdCacheClear(); + expect(a.cleared).toBeGreaterThan(0); + expect(b.cleared).toBe(0); + }); + + test("清理会被记成 cold_clear,供 prefix_break.cause 归因", () => { + const mgr = new Manager({ maxTokens: 1_000_000 }); + mgr.setMessages(history(10)); + mgr.consumeSendMutationCauses(); + mgr.applyColdCacheClear(); + expect(mgr.consumeSendMutationCauses()).toContain("cold_clear"); + }); + + test("没东西可清时不记 cause(避免把无改写的轮次误归因)", () => { + const mgr = new Manager({ maxTokens: 1_000_000 }); + mgr.setMessages(history(2)); + mgr.consumeSendMutationCauses(); + expect(mgr.applyColdCacheClear().cleared).toBe(0); + expect(mgr.consumeSendMutationCauses()).toEqual([]); + }); + + test("压缩 / setMessages 后决策清空(与 masking / replacementState 同生命周期)", () => { + const mgr = new Manager({ maxTokens: 1_000_000 }); + mgr.setMessages(history(10)); + mgr.applyColdCacheClear(); + mgr.setMessages(history(10)); + const sent = mgr.getCleanedMessages(); + expect(toolResult(sent, "t0")).toBe("0".repeat(2_000)); + }); +}); diff --git a/packages/core/tests/llm/provider-anthropic-conformance.test.ts b/packages/core/tests/llm/provider-anthropic-conformance.test.ts index e2db0f27a..a199aedb0 100644 --- a/packages/core/tests/llm/provider-anthropic-conformance.test.ts +++ b/packages/core/tests/llm/provider-anthropic-conformance.test.ts @@ -19,6 +19,7 @@ import { describe, test, expect } from "bun:test"; import { AnthropicProvider } from "@sid-code/core/llm/anthropic.ts"; import type { SendParams, StreamEvent } from "@sid-code/core/llm/types.ts"; import { initStreamObserver, resetStreamObserver } from "@sid-code/core/trace/stream-observer.ts"; +import { msSincePrefixTouch, resetPrefixTouchForTests } from "@sid-code/core/api/cache-warmth.ts"; // ─── 测试脚手架 ────────────────────────────────────────────────────────── @@ -582,3 +583,68 @@ describe("Anthropic conformance — tool_choice 透传(OPT-2)", () => { expect(getBody().tool_choice).toEqual({ type: "auto" }); }); }); + +// ─── §6 Prompt Cache:线上实发形态(2026-10-24)──────────────────────────── + +describe("Anthropic conformance — 缓存断点的线上形态", () => { + const REMINDER_PARAMS: SendParams = { + ...BASE_PARAMS, + messages: [ + { role: "user", content: [{ type: "text", text: "修一下登录超时" }] }, + { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "read", input: {} }] }, + { + role: "user", + content: [ + { type: "text", text: "止损", transient: true }, + { type: "tool_result", tool_use_id: "t1", content: "ok" }, + { type: "text", text: "todo", transient: true }, + ], + }, + ], + }; + + test("transient 是本地元数据:不上线", async () => { + const { provider, getBody } = makeCapturingProvider(); + await drain(provider.sendMessageStream(REMINDER_PARAMS)); + expect(JSON.stringify(getBody().messages)).not.toContain("transient"); + }); + + test("断点不落在 transient 块及其之后(critical 在前 → 退到上一条消息)", async () => { + const { provider, getBody } = makeCapturingProvider(); + await drain(provider.sendMessageStream(REMINDER_PARAMS)); + const msgs = getBody().messages; + expect(msgs[2].content.every((b: any) => !b.cache_control)).toBe(true); + expect(msgs[1].content[0].cache_control).toEqual({ type: "ephemeral" }); + }); + + test("请求体里没有 cache_edits,也没有 scope", async () => { + const { provider, getBody } = makeCapturingProvider(); + await drain(provider.sendMessageStream({ ...REMINDER_PARAMS, system: "S" })); + const body = JSON.stringify(getBody()); + expect(body).not.toContain("cache_edits"); + expect(body).not.toContain('"scope"'); + }); + + test("落 PromptCacheLayout 事件:断点不在 transient 上", async () => { + const captured: any[] = []; + initStreamObserver("sess-layout", "/tmp", (ev) => captured.push(ev)); + try { + const { provider } = makeCapturingProvider(); + await drain(provider.sendMessageStream(REMINDER_PARAMS)); + const ev = captured.find((e) => e.event === "PromptCacheLayout"); + expect(ev).toBeDefined(); + expect(ev.data.message_breakpoint_on_transient).toBe(false); + expect(ev.data.transient_blocks).toBe(2); + expect(ev.data.message_breakpoint_index).toBe(1); + } finally { + resetStreamObserver(); + } + }); + + test("每次请求都刷新前缀触碰时间(冷热判定的时间源)", async () => { + resetPrefixTouchForTests(); + const { provider } = makeCapturingProvider(); + await drain(provider.sendMessageStream(BASE_PARAMS)); + expect(msSincePrefixTouch()).toBeLessThan(5_000); + }); +}); diff --git a/packages/core/tests/query/compact/cached-microcompact.test.ts b/packages/core/tests/query/compact/cached-microcompact.test.ts deleted file mode 100644 index 15d13cb15..000000000 --- a/packages/core/tests/query/compact/cached-microcompact.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Layer 3:Cached Microcompact — 单元测试 - * - * 覆盖:供应商感知路径选择、tool_use_id 状态追踪、白名单约束、cache_edits 生成、 - * 缓存友好路径下消息引用不变(前缀字节一致 → cache hit)。 - */ - -import { describe, it, expect } from "bun:test"; -import type { Message } from "@sid-code/core/llm/types.ts"; -import { - isCompactableTool, - createCachedMicrocompactState, - resetCachedMicrocompactState, - registerToolUses, - createCacheEditsBlock, - cachedMicrocompact, -} from "@sid-code/core/query/compact/cached-microcompact.ts"; - -/** 构造 N 轮(assistant tool_use + user tool_result)消息,工具结果内容很长 */ -function buildToolMessages(count: number, toolName = "read", contentLen = 2000): Message[] { - const messages: Message[] = []; - for (let i = 0; i < count; i++) { - messages.push({ - role: "assistant", - content: [{ type: "tool_use", id: `t${i}`, name: toolName, input: { file: `f${i}.ts` } }], - }); - messages.push({ - role: "user", - content: [{ type: "tool_result", tool_use_id: `t${i}`, content: "x".repeat(contentLen) }], - }); - } - return messages; -} - -describe("isCompactableTool 工具判定(与 microcompact 单一事实源)", () => { - it("包含可重新获取的只读类工具", () => { - expect(isCompactableTool("read")).toBe(true); - expect(isCompactableTool("bash", { command: "ls -la" })).toBe(true); - // D3:写命令 bash 不进 cache_edits 删除名单 - expect(isCompactableTool("bash", { command: "git commit -m x" })).toBe(false); - expect(isCompactableTool("bash")).toBe(false); - expect(isCompactableTool("grep")).toBe(true); - }); - - it("回归:曾被旧白名单漏掉的 web/搜索类工具现在能命中", () => { - // 旧 COMPACTABLE_TOOLS 裸字符串集合漏收这三个 → cache_edits 永远不删它们的结果 - expect(isCompactableTool("web_search")).toBe(true); - expect(isCompactableTool("web_fetch")).toBe(true); - expect(isCompactableTool("tool_search")).toBe(true); - }); - - it("回归:带下划线的真实注册名经归一化后命中", () => { - // 旧白名单用裸 .has() 无归一化,read_many 等无法稳定命中 - expect(isCompactableTool("read_many")).toBe(true); - }); - - it("回归:不存在的 list / 不可丢弃工具不命中", () => { - expect(isCompactableTool("list")).toBe(false); // sid 实际注册名是 ls,无 list - expect(isCompactableTool("edit")).toBe(false); - expect(isCompactableTool("write")).toBe(false); - }); -}); - -describe("状态机", () => { - it("registerToolUses 建立 tool_use_id → toolName 映射", () => { - const state = createCachedMicrocompactState(); - registerToolUses(state, buildToolMessages(3)); - expect(state.tools.size).toBe(3); - expect(state.tools.get("t0")?.toolName).toBe("read"); - }); - - it("reset 清空 tools 和 deleted", () => { - const state = createCachedMicrocompactState(); - registerToolUses(state, buildToolMessages(2)); - state.deleted.add("t0"); - resetCachedMicrocompactState(state); - expect(state.tools.size).toBe(0); - expect(state.deleted.size).toBe(0); - }); -}); - -describe("createCacheEditsBlock", () => { - it("为白名单内的旧大工具结果生成删除指令", () => { - const state = createCachedMicrocompactState(); - const messages = buildToolMessages(10); // 20 条消息,cutoff = 20 - 6 = 14 - const block = createCacheEditsBlock(state, messages, { - preserveRecentCount: 6, - minContentLength: 500, - }); - expect(block).not.toBeNull(); - expect(block!.edits.length).toBeGreaterThan(0); - expect(block!.edits[0].type).toBe("delete"); - }); - - it("保留最近 N 条不删除", () => { - const state = createCachedMicrocompactState(); - const messages = buildToolMessages(10); - const block = createCacheEditsBlock(state, messages, { - preserveRecentCount: 6, - minContentLength: 500, - }); - // 最近 6 条消息(3 轮)的 tool_use_id 不应出现在删除列表 - const deletedIds = new Set(block!.edits.map((e) => e.tool_use_id)); - expect(deletedIds.has("t9")).toBe(false); - expect(deletedIds.has("t8")).toBe(false); - expect(deletedIds.has("t7")).toBe(false); - }); - - it("不在白名单的工具结果不删除", () => { - const state = createCachedMicrocompactState(); - const messages = buildToolMessages(10, "edit"); // edit 不在白名单 - const block = createCacheEditsBlock(state, messages, { - preserveRecentCount: 6, - minContentLength: 500, - }); - expect(block).toBeNull(); - }); - - it("短内容工具结果不删除", () => { - const state = createCachedMicrocompactState(); - const messages = buildToolMessages(10, "read", 100); // 内容 < 500 - const block = createCacheEditsBlock(state, messages, { - preserveRecentCount: 6, - minContentLength: 500, - }); - expect(block).toBeNull(); - }); - - it("同一 tool_use_id 不重复删除(跨调用)", () => { - const state = createCachedMicrocompactState(); - const messages = buildToolMessages(10); - const first = createCacheEditsBlock(state, messages, { preserveRecentCount: 6 }); - const firstCount = first!.edits.length; - // 再次调用同一状态:已删除的不再出现 - const second = createCacheEditsBlock(state, messages, { preserveRecentCount: 6 }); - expect(second).toBeNull(); // 全部已删除过 - expect(state.deleted.size).toBe(firstCount); - }); - - it("消息过少时返回 null", () => { - const state = createCachedMicrocompactState(); - const block = createCacheEditsBlock(state, buildToolMessages(2), { preserveRecentCount: 6 }); - expect(block).toBeNull(); - }); -}); - -describe("cachedMicrocompact 路径选择", () => { - it("Anthropic + 缓存温热 → 缓存友好路径,消息引用不变", () => { - const messages = buildToolMessages(10); - const result = cachedMicrocompact(messages, { providerName: "anthropic", cacheWarm: true }); - expect(result.path).toBe("cache-preserving"); - expect(result.messages).toBe(messages); // 引用相同 → 前缀字节一致 - expect(result.compactedCount).toBe(0); - }); - - it("Anthropic + emitCacheEdits=true → 产出 cache_edits 块", () => { - const messages = buildToolMessages(10); - const result = cachedMicrocompact(messages, { - providerName: "anthropic", - cacheWarm: true, - emitCacheEdits: true, - }); - expect(result.path).toBe("cache-preserving"); - expect(result.pendingCacheEdits).not.toBeNull(); - expect(result.pendingCacheEdits!.edits.length).toBeGreaterThan(0); - }); - - it("Anthropic + emitCacheEdits=false(默认) → 不产出原始块但仍走缓存友好路径", () => { - const messages = buildToolMessages(10); - const result = cachedMicrocompact(messages, { providerName: "anthropic", cacheWarm: true }); - expect(result.path).toBe("cache-preserving"); - expect(result.pendingCacheEdits).toBeNull(); - }); - - it("非 Anthropic → 直接清内容路径", () => { - const messages = buildToolMessages(10); - const result = cachedMicrocompact(messages, { providerName: "openai", cacheWarm: true }); - expect(result.path).toBe("direct-clear"); - expect(result.compactedCount).toBeGreaterThan(0); - expect(result.pendingCacheEdits).toBeNull(); - }); - - it("Anthropic + 缓存已冷 → 直接清内容路径", () => { - const messages = buildToolMessages(10); - const result = cachedMicrocompact(messages, { providerName: "anthropic", cacheWarm: false }); - expect(result.path).toBe("direct-clear"); - expect(result.compactedCount).toBeGreaterThan(0); - }); -}); diff --git a/packages/core/tests/trace/prompt-cache-digest.test.ts b/packages/core/tests/trace/prompt-cache-digest.test.ts new file mode 100644 index 000000000..fb24377c9 --- /dev/null +++ b/packages/core/tests/trace/prompt-cache-digest.test.ts @@ -0,0 +1,56 @@ +/** + * §6 缓存缺陷的 digest 验收口径(PromptCacheLayout / ColdCacheClear 聚合) + */ + +import { describe, test, expect } from "bun:test"; +import { aggregatePromptCacheStats } from "@sid-code/core/trace/digest.ts"; + +const HOUR = 60 * 60 * 1000; + +describe("aggregatePromptCacheStats", () => { + test("两类事件都没有 → null(老会话 / 非 Anthropic,不画这一段)", () => { + expect(aggregatePromptCacheStats([{ event: "BeforeModel", data: {} }])).toBeNull(); + }); + + test("断点落在 reminder 上的次数单独计数,分母一并给出", () => { + const s = aggregatePromptCacheStats([ + { + event: "PromptCacheLayout", + data: { transient_blocks: 2, message_breakpoint_on_transient: false }, + }, + { + event: "PromptCacheLayout", + data: { transient_blocks: 0, message_breakpoint_on_transient: false }, + }, + { + event: "PromptCacheLayout", + data: { transient_blocks: 1, message_breakpoint_on_transient: true }, + }, + ])!; + expect(s.layoutRequests).toBe(3); + expect(s.requestsWithTransient).toBe(2); + expect(s.breakpointOnTransient).toBe(1); + }); + + test("冷清理间隔低于阈值计为回归;未知间隔也计为回归(违反「不知道即热」)", () => { + const s = aggregatePromptCacheStats([ + { + event: "ColdCacheClear", + data: { gap_ms: 2 * HOUR, threshold_ms: HOUR, cleared: 3, saved_chars: 900 }, + }, + { + event: "ColdCacheClear", + data: { gap_ms: 114_000, threshold_ms: HOUR, cleared: 1, saved_chars: 100 }, + }, + { + event: "ColdCacheClear", + data: { gap_ms: -1, threshold_ms: HOUR, cleared: 1, saved_chars: 50 }, + }, + ])!; + expect(s.coldClears).toBe(3); + expect(s.coldClearedItems).toBe(5); + expect(s.coldClearedChars).toBe(1050); + expect(s.coldClearsUnderThreshold).toBe(2); + expect(s.minColdGapMs).toBe(114_000); + }); +}); diff --git a/website/ref/env.md b/website/ref/env.md index c84d14fe1..d4af11531 100644 --- a/website/ref/env.md +++ b/website/ref/env.md @@ -18,9 +18,9 @@ description: 全部可用环境变量及其作用。 -> 共 **126** 个环境变量,取自 `sid-code --help` 的环境变量段, -> 并与源码里实际的 `process.env` 读取点(扫到 185 个)交叉核对。 -> 扫到的 185 个里 122 个在上表、63 个列在页尾;表里另有 4 个是扫描认不出的读法(如拼接出的变量名),仍以 help 为准。 +> 共 **124** 个环境变量,取自 `sid-code --help` 的环境变量段, +> 并与源码里实际的 `process.env` 读取点(扫到 183 个)交叉核对。 +> 扫到的 183 个里 120 个在上表、63 个列在页尾;表里另有 4 个是扫描认不出的读法(如拼接出的变量名),仍以 help 为准。 > 优先级:环境变量 > `settings.json`。`SID_*` 前缀的变量只对 sid-code 生效, > 不与同机的其他工具共享。 @@ -189,8 +189,6 @@ description: 全部可用环境变量及其作用。 | `SID_DISABLE_STRICT_TOOLS` | 设为 1 禁用 strict 工具模式 | | `SID_DISABLE_FGTS` | 设为 1 禁用 First-Guess Tool Streaming | | `SID_ENABLE_TOKEN_EFFICIENT_TOOLS` | 设为 1 启用 token 高效工具 | -| `SID_DISABLE_GLOBAL_CACHE` | 设为 1 禁用全局缓存 | -| `SID_DISABLE_CACHE_EDITS` | 设为 1 禁用缓存编辑 | | `SID_LOOP_EXHAUSTED_ACTION` | 循环耗尽动作(terminate 时回退旧行为) | | `SID_DISABLE_PROMISE_NUDGE` | 设为 1 关闭「短文本承诺未兑现」续跑(提示照常) | | `SID_STRUCTURED_OUTPUT_MAX_RETRIES` | 结构化输出最大重试次数(缺省 5) | diff --git a/website/use/cost.md b/website/use/cost.md index 80d850ac6..e54f85e30 100644 --- a/website/use/cost.md +++ b/website/use/cost.md @@ -183,8 +183,8 @@ sid-code 会替你判断并在 `/cost` 里标注,就是那行括号里的提 所以这里该做的是监测而不是排查**。 ::: tip compact 会触发一次中断,但会被抑制 -`/compact` 会让消息数量骤减,正常应该报一次中断。但 `notifyCompaction()` / -`notifyCacheDeletion()` 会跳过紧接的那次检测(假阳性抑制),所以 compact 后看到命中率 +`/compact` 会让消息数量骤减,正常应该报一次中断。但 `notifyCompaction()` +会跳过紧接的那次检测(假阳性抑制),所以 compact 后看到命中率 下降是正常的,不会在 `--breaks` 里报成异常。 :::