Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 不可信。
30 changes: 1 addition & 29 deletions packages/cli/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
/**
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 消息
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 0 additions & 2 deletions packages/cli/src/command/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 0 additions & 4 deletions packages/cli/src/command/commands/compact/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)→ 不传,收尾侧只写日志。
Expand Down
5 changes: 0 additions & 5 deletions packages/cli/src/command/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
2 changes: 0 additions & 2 deletions packages/cli/src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 8 additions & 25 deletions packages/cli/tests/command/compact-post-compact-wiring.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -46,7 +42,7 @@ function makeMockProvider() {
};
}

function makeCtx(messages: Message[], mcState?: CachedMicrocompactState) {
function makeCtx(messages: Message[]) {
let msgs = messages;
const ctxMgr = {
messageCount: () => msgs.length,
Expand Down Expand Up @@ -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("压缩未执行");
});
Expand Down
23 changes: 3 additions & 20 deletions packages/core/src/api/cache-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(便于单测隔离),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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: "" };
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
}
Expand Down
Loading
Loading