From 1e0906724ebeb4bc4c1d79c930361c4f0692ce6c Mon Sep 17 00:00:00 2001 From: unclee Date: Sat, 14 Mar 2026 14:40:00 +0800 Subject: [PATCH 1/3] research: prompt caching cost analysis and optimization - Add research doc analyzing why prompt caching hit rate was only ~20% in long multi-turn sessions (root cause: CLI explicit breakpoints limited to last 2 messages + API 20-block lookback window) - Add CLAUDE_CODE_EXTRA_BODY={"cache_control":{"type":"ephemeral"}} to .env enabling automatic caching (tested: -58% cost reduction) - Add test scripts for cache optimization and resume scenarios Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/research/prompt-cache-analysis.md | 131 +++++++++++++++++++++ scripts/test-cache-optimization.mjs | 150 +++++++++++++++++++++++++ scripts/test-cache-resume.mjs | 150 +++++++++++++++++++++++++ 3 files changed, 431 insertions(+) create mode 100644 docs/research/prompt-cache-analysis.md create mode 100644 scripts/test-cache-optimization.mjs create mode 100644 scripts/test-cache-resume.mjs diff --git a/docs/research/prompt-cache-analysis.md b/docs/research/prompt-cache-analysis.md new file mode 100644 index 00000000..d37c77dd --- /dev/null +++ b/docs/research/prompt-cache-analysis.md @@ -0,0 +1,131 @@ +--- +summary: "Anthropic API prompt caching 成本分析与优化" +status: completed +owner: claude +last_updated: "2026-03-14" +read_when: + - 分析 Claude API 费用异常 + - 优化 Agent SDK 调用成本 + - 研究 prompt caching 行为 +--- + +# Prompt Caching 成本分析与优化 + +## 背景 + +一个飞书 thread 中的查询累计消耗了 **$69**,其中单条 24-turn 查询花费 **$17.53**。调查发现 prompt caching 命中率远低于预期,核心原因是 **Claude Code CLI 的缓存策略在多 turn 场景下效率低下**。 + +## 调查过程 + +### 1. 数据收集 + +通过 pm2 日志提取该 thread 所有查询的 cache 统计: + +``` +Query turns cost creation read hit% +fresh start 67 $14.11 2,053,661 1,784,855 46.5% +resume 9 10 $1.57 168,917 856,025 83.5% ← system prompt 没变 +resume 15 ★ 24 $17.53 2,716,587 643,084 19.1% ← 典型的差表现 +resume 18 2 $1.93 293,680 21,326 6.8% +``` + +### 2. 关键发现:cache_read/turn 恒定 ~27K + +跨所有查询,**每个 turn 的 cache_read 约 27K tokens**,恰好等于 system prompt 大小。对话历史几乎从未被缓存命中。 + +### 3. 根因分析 + +#### 根因 1: CLI 的 explicit breakpoint 策略(主因) + +**代码位置**: `cli.js` 中 `O6z` 函数 + +```javascript +let X = j > A.length - 3; // 只有最后 2 条消息设置 cache_control +``` + +每个 turn,cache_control breakpoint 随消息增长向后移动。结合 Anthropic API 的两个限制: + +- **最多 4 个 explicit breakpoint** — system prompt 占 1-2 个,只剩 2 个给消息 +- **20-block lookback 窗口** — 每个 breakpoint 只往前查 20 个 content block + +24 turns ≈ 72 content blocks,但 breakpoint 的 lookback 只覆盖最后 ~20 blocks,前面 52 blocks 全部变成 cache_creation。 + +#### 根因 2: injectMemories() 改变 system prompt(跨 query 问题) + +**代码位置**: `src/memory/injector.ts` → `src/claude/executor.ts:437-438` + +每次 resume 前,`injectMemories(rawPrompt, ...)` 基于当前用户消息搜索记忆。不同消息 → 不同搜索结果 → system prompt 变化 → 缓存前缀从变化点失效。 + +**证据**: +- resume 9(距上次 78s)→ 83.5% hit — system prompt 没变 +- resume 10(距上次 **7s**)→ 18.5% hit — system prompt 变了,缓存全失效 + +### 4. 解决方案验证 + +Anthropic API 支持顶层 `cache_control` 参数,启用 automatic caching: + +```json +// messages.create 请求体 +{ + "cache_control": {"type": "ephemeral"}, // 自动管理缓存前缀 + "model": "...", + "messages": [...] +} +``` + +通过 `CLAUDE_CODE_EXTRA_BODY` 环境变量注入。测试结果(Haiku): + +| 指标 | DEFAULT | AUTO-ONLY | 改善 | +|------|---------|-----------|------| +| Phase 1 cache hit | 66.2% | **89.3%** | +23% | +| Phase 1 cost | $1.12 | **$0.39** | **-65%** | +| Phase 1 cache_creation | 149,822 | **33,972** | -77% | +| Resume cache hit | 0% | 34.6% | +34.6% | +| **总成本** | **$1.42** | **$0.59** | **-58%** | + +## 已实施的优化 + +### 环境变量配置(.env) + +```bash +CLAUDE_CODE_EXTRA_BODY={"cache_control":{"type":"ephemeral"}} +``` + +效果:在 CLI 的 explicit breakpoints 基础上,追加顶层 automatic caching。API 自动管理缓存前缀,不受 4-breakpoint 和 20-block lookback 限制。 + +## 未解决的问题 + +### 1. injectMemories() 导致跨 query 缓存失效 + +每次 resume 前搜索记忆并注入 system prompt,不同的查询文本产生不同的记忆搜索结果 → system prompt 变化 → 整个缓存前缀失效。 + +**可能的修复方案**: +- 同一 session 内缓存 memoryContext,不每次重新搜索 +- 将记忆内容放到 user message 而非 system prompt +- 对记忆搜索结果做确定性排序和 hash,内容不变则复用 + +### 2. cache_reference 功能未对 SDK 启用 + +CLI 代码中有 `cache_reference` 机制(用指针替代重发 tool_result),但条件为: + +```javascript +$1 = j && C7() === "firstParty" && w.querySource === "repl_main_thread"; +// j = false (硬编码), querySource 在 SDK 中不是 "repl_main_thread" +``` + +这是内部 beta 功能,当前不可用。如果未来开放,可进一步降低成本。 + +### 3. 长对话的 auto-compact + +session 文件 2.4MB / ~614K tokens,远超 200K 上下文窗口。CLI 有 auto-compact 能力(`SDKCompactBoundaryMessage` 类型),但在这个 session 中未触发。可能需要调查 auto-compact 的触发条件。 + +## 测试脚本 + +- `scripts/test-cache-optimization.mjs` — 单 query 缓存测试(`--auto-only` 对比) +- `scripts/test-cache-resume.mjs` — resume 场景缓存测试 + +## 参考资料 + +- [Prompt caching - Claude API Docs](https://platform.claude.com/docs/en/build-with-claude/prompt-caching) +- API 限制:最多 4 个 explicit breakpoint,20-block lookback 窗口 +- `cache_control` 顶层参数:自动在最后一个 cacheable block 添加 breakpoint diff --git a/scripts/test-cache-optimization.mjs b/scripts/test-cache-optimization.mjs new file mode 100644 index 00000000..65f1a86a --- /dev/null +++ b/scripts/test-cache-optimization.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/** + * 测试 prompt caching 优化效果 + * + * 对比两种模式: + * A) 默认模式(CLI explicit breakpoints) + * B) automatic caching 模式(禁用 explicit + 顶层 cache_control) + * + * 使用方式: + * node scripts/test-cache-optimization.mjs # 默认模式 (baseline) + * node scripts/test-cache-optimization.mjs --auto # automatic caching 模式 + * + * 测试逻辑:发一个需要多 turn 的任务,对比 cache_creation vs cache_read + */ + +import { query } from '@anthropic-ai/claude-agent-sdk'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const useAutoCaching = process.argv.includes('--auto'); +const useAutoOnly = process.argv.includes('--auto-only'); // 只加顶层 cache_control,不禁用 explicit +const workDir = process.env.DEFAULT_WORK_DIR || '/root/dev/anywhere-code'; + +const mode = useAutoOnly ? 'AUTO-ONLY' : useAutoCaching ? 'AUTO+DISABLE' : 'DEFAULT'; +console.log(`\n=== Prompt Caching Test ===`); +console.log(`Mode: ${mode}`); +console.log(`Working dir: ${workDir}`); +console.log(); + +// 设置环境变量 +const env = { ...process.env }; +delete env.CLAUDECODE; + +if (useAutoCaching) { + env.DISABLE_PROMPT_CACHING = '1'; + env.CLAUDE_CODE_EXTRA_BODY = JSON.stringify({ + cache_control: { type: 'ephemeral' }, + }); + console.log('Env overrides:'); + console.log(` DISABLE_PROMPT_CACHING=1`); + console.log(` CLAUDE_CODE_EXTRA_BODY=${env.CLAUDE_CODE_EXTRA_BODY}`); + console.log(); +} else if (useAutoOnly) { + // 不禁用 explicit,只追加顶层 cache_control + const existing = env.CLAUDE_CODE_EXTRA_BODY ? JSON.parse(env.CLAUDE_CODE_EXTRA_BODY) : {}; + env.CLAUDE_CODE_EXTRA_BODY = JSON.stringify({ + ...existing, + cache_control: { type: 'ephemeral' }, + }); + console.log('Env overrides:'); + console.log(` CLAUDE_CODE_EXTRA_BODY=${env.CLAUDE_CODE_EXTRA_BODY}`); + console.log(); +} + +// 构造一个需要多 turn 的任务(读多个文件 + 分析) +const prompt = `请依次完成以下步骤(每步都要用工具): +1. 读取 src/index.ts 文件 +2. 读取 src/server.ts 文件 +3. 读取 src/config.ts 文件 +4. 读取 package.json 文件 +5. 列出 src/ 目录下所有 .ts 文件 +6. 读取 src/claude/executor.ts 的前 50 行 +7. 读取 src/session/manager.ts 的前 50 行 +8. 最后用一句话总结这个项目的主要功能 + +注意:每个步骤都必须单独执行对应的工具调用,不要跳过。`; + +console.log(`Prompt length: ${prompt.length} chars`); +console.log(`Starting query...`); +console.log(); + +const startTime = Date.now(); +let totalTokens = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 }; +let turnCount = 0; +let output = ''; + +try { + const session = query({ + prompt, + options: { + cwd: workDir, + env, + permissionMode: 'acceptEdits', + model: 'claude-opus-4-6', + maxTurns: 20, + maxBudgetUsd: 5, + thinking: { type: 'disabled' }, + canUseTool: async (_toolName, inputObj) => { + return { behavior: 'allow', updatedInput: inputObj }; + }, + }, + }); + + for await (const msg of session) { + if (msg.type === 'assistant') { + turnCount++; + const usage = msg.message?.usage; + if (usage) { + totalTokens.input += usage.input_tokens || 0; + totalTokens.output += usage.output_tokens || 0; + totalTokens.cacheRead += usage.cache_read_input_tokens || 0; + totalTokens.cacheCreation += usage.cache_creation_input_tokens || 0; + + const turnTotal = (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.input_tokens || 0); + const hitPct = turnTotal > 0 ? ((usage.cache_read_input_tokens || 0) / turnTotal * 100).toFixed(1) : '0'; + console.log(` Turn ${turnCount}: input=${usage.input_tokens || 0}, creation=${usage.cache_creation_input_tokens || 0}, read=${usage.cache_read_input_tokens || 0}, hit=${hitPct}%`); + } + } + if (msg.type === 'result') { + output = msg.subtype === 'success' ? 'success' : `error: ${msg.error}`; + // SDK cost info + if (msg.cost_usd !== undefined) { + console.log(`\n SDK reported cost: $${msg.cost_usd.toFixed(4)}`); + } + } + } +} catch (err) { + console.error(`Error: ${err.message}`); + output = `error: ${err.message}`; +} + +const durationMs = Date.now() - startTime; +const totalInput = totalTokens.cacheCreation + totalTokens.cacheRead + totalTokens.input; +const hitRate = totalInput > 0 ? (totalTokens.cacheRead / totalInput * 100).toFixed(1) : '0'; + +// Opus pricing (approximate) +const creationCost = totalTokens.cacheCreation * 6.25 / 1_000_000; +const readCost = totalTokens.cacheRead * 0.625 / 1_000_000; +const outputCost = totalTokens.output * 60 / 1_000_000; +const totalCost = creationCost + readCost + outputCost; + +console.log(`\n=== Results ===`); +console.log(`Mode: ${mode}`); +console.log(`Status: ${output}`); +console.log(`Turns: ${turnCount}`); +console.log(`Duration: ${(durationMs / 1000).toFixed(1)}s`); +console.log(`Cache creation: ${totalTokens.cacheCreation.toLocaleString()} tokens`); +console.log(`Cache read: ${totalTokens.cacheRead.toLocaleString()} tokens`); +console.log(`Regular input: ${totalTokens.input.toLocaleString()} tokens`); +console.log(`Cache hit rate: ${hitRate}%`); +console.log(`Output tokens: ${totalTokens.output.toLocaleString()}`); +const inputCost = totalTokens.input * 15 / 1_000_000; +console.log(`Est. cost: $${(totalCost + inputCost).toFixed(2)} (input=$${inputCost.toFixed(2)} + creation=$${creationCost.toFixed(2)} + read=$${readCost.toFixed(2)} + output=$${outputCost.toFixed(2)})`); +console.log(); + +if (!useAutoCaching) { + console.log(`Next: run with --auto to compare:`); + console.log(` node scripts/test-cache-optimization.mjs --auto`); +} diff --git a/scripts/test-cache-resume.mjs b/scripts/test-cache-resume.mjs new file mode 100644 index 00000000..f5958e40 --- /dev/null +++ b/scripts/test-cache-resume.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/** + * 测试 resume 场景下的 prompt caching + * + * 模拟生产环境:先跑一个多 turn query 积累上下文,然后 resume 再跑一个 query + * 对比 resume 时的 cache 命中情况 + * + * 用法: + * node scripts/test-cache-resume.mjs # 默认 + * node scripts/test-cache-resume.mjs --auto-only # 加顶层 cache_control + */ + +import { query } from '@anthropic-ai/claude-agent-sdk'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const useAutoOnly = process.argv.includes('--auto-only'); +const workDir = process.env.DEFAULT_WORK_DIR || '/root/dev/anywhere-code'; +const mode = useAutoOnly ? 'AUTO-ONLY' : 'DEFAULT'; + +console.log(`\n=== Resume Cache Test ===`); +console.log(`Mode: ${mode}`); +console.log(); + +const env = { ...process.env }; +delete env.CLAUDECODE; + +if (useAutoOnly) { + env.CLAUDE_CODE_EXTRA_BODY = JSON.stringify({ cache_control: { type: 'ephemeral' } }); + console.log(` CLAUDE_CODE_EXTRA_BODY=${env.CLAUDE_CODE_EXTRA_BODY}`); + console.log(); +} + +function trackUsage(msg, label, stats) { + if (msg.type !== 'assistant') return; + stats.turns++; + const u = msg.message?.usage; + if (!u) return; + stats.input += u.input_tokens || 0; + stats.creation += u.cache_creation_input_tokens || 0; + stats.read += u.cache_read_input_tokens || 0; + stats.output += u.output_tokens || 0; +} + +function printStats(label, stats, durationMs) { + const total = stats.creation + stats.read + stats.input; + const hitPct = total > 0 ? (stats.read / total * 100).toFixed(1) : '0'; + const creationCost = stats.creation * 6.25 / 1e6; + const readCost = stats.read * 0.625 / 1e6; + const inputCost = stats.input * 15 / 1e6; + const outputCost = stats.output * 60 / 1e6; + const totalCost = creationCost + readCost + inputCost + outputCost; + + console.log(`\n--- ${label} ---`); + console.log(` Turns: ${stats.turns}`); + console.log(` Duration: ${(durationMs / 1000).toFixed(1)}s`); + console.log(` Cache creation: ${stats.creation.toLocaleString()}`); + console.log(` Cache read: ${stats.read.toLocaleString()}`); + console.log(` Regular input: ${stats.input.toLocaleString()}`); + console.log(` Cache hit rate: ${hitPct}%`); + console.log(` Est. cost: $${totalCost.toFixed(2)}`); + return totalCost; +} + +// ===== Phase 1: 积累上下文 ===== +console.log('Phase 1: Building context (reading multiple files)...'); +const p1Stats = { turns: 0, input: 0, creation: 0, read: 0, output: 0 }; +let sessionId; + +const p1Start = Date.now(); +try { + const s1 = query({ + prompt: `请逐个读取以下文件的完整内容: +1. src/index.ts +2. src/server.ts +3. src/feishu/client.ts +4. src/claude/executor.ts (前 100 行) +5. src/session/manager.ts +6. src/session/queue.ts +每个文件都必须用 Read 工具单独读取。`, + options: { + cwd: workDir, env, + permissionMode: 'acceptEdits', + model: process.env.TEST_MODEL || 'claude-haiku-4-5-20251001', + maxTurns: 20, maxBudgetUsd: 5, + thinking: { type: 'disabled' }, + canUseTool: async (_, input) => ({ behavior: 'allow', updatedInput: input }), + }, + }); + + for await (const msg of s1) { + trackUsage(msg, 'p1', p1Stats); + if (msg.type === 'system' && msg.subtype === 'init') { + sessionId = msg.session_id; + console.log(` Session ID: ${sessionId}`); + } + } +} catch (err) { + console.error(`Phase 1 error: ${err.message}`); +} +const p1Cost = printStats('Phase 1 (build context)', p1Stats, Date.now() - p1Start); + +if (!sessionId) { + console.error('No session ID captured, cannot resume'); + process.exit(1); +} + +// ===== Phase 2: Resume 并做新任务 ===== +console.log('\n\nPhase 2: Resume session with new task...'); +const p2Stats = { turns: 0, input: 0, creation: 0, read: 0, output: 0 }; + +const p2Start = Date.now(); +try { + const s2 = query({ + prompt: `基于你之前读取的代码,回答以下问题: +1. 这个项目的入口文件做了哪些初始化工作? +2. session manager 的清理机制是什么? +3. executor.ts 中 canUseTool 的权限检查逻辑是怎样的? +每个问题请简要回答 2-3 句话。`, + options: { + cwd: workDir, env, + permissionMode: 'acceptEdits', + model: process.env.TEST_MODEL || 'claude-haiku-4-5-20251001', + maxTurns: 10, maxBudgetUsd: 5, + resume: sessionId, + thinking: { type: 'disabled' }, + canUseTool: async (_, input) => ({ behavior: 'allow', updatedInput: input }), + }, + }); + + for await (const msg of s2) { + trackUsage(msg, 'p2', p2Stats); + } +} catch (err) { + console.error(`Phase 2 error: ${err.message}`); +} +const p2Cost = printStats('Phase 2 (resume)', p2Stats, Date.now() - p2Start); + +// ===== Summary ===== +console.log(`\n=== TOTAL ===`); +console.log(` Mode: ${mode}`); +console.log(` Total cost: $${(p1Cost + p2Cost).toFixed(2)}`); +console.log(` Phase 1: $${p1Cost.toFixed(2)} (${p1Stats.turns} turns)`); +console.log(` Phase 2: $${p2Cost.toFixed(2)} (${p2Stats.turns} turns, RESUME)`); + +const p2Total = p2Stats.creation + p2Stats.read + p2Stats.input; +const p2HitPct = p2Total > 0 ? (p2Stats.read / p2Total * 100).toFixed(1) : '0'; +console.log(` Resume cache hit: ${p2HitPct}%`); +console.log(); From 0e72c24f8eab4c37f98d28cb4ad09e8ba97ff664 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Mon, 16 Mar 2026 22:02:48 +0800 Subject: [PATCH 2/3] fix: skip LFS smudge filter when cloning from bare cache Bare cache repos don't contain LFS objects, causing checkout to fail when the smudge filter tries to download large files from a local path. Co-Authored-By: Claude Opus 4.6 --- src/workspace/manager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/workspace/manager.ts b/src/workspace/manager.ts index 93ccffae..b7819af0 100644 --- a/src/workspace/manager.ts +++ b/src/workspace/manager.ts @@ -155,6 +155,7 @@ export function setupWorkspace(options: SetupWorkspaceOptions): SetupWorkspaceRe execFileSync('git', cloneArgs, { timeout: 120_000, stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, GIT_LFS_SKIP_SMUDGE: '1' }, }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); From 61b9feeb8c42e098d3fe3c42fdb4f2f537bf402d Mon Sep 17 00:00:00 2001 From: lishuceo Date: Mon, 16 Mar 2026 22:02:53 +0800 Subject: [PATCH 3/3] test: add regression test for LFS skip smudge env var Co-Authored-By: Claude Opus 4.6 --- src/workspace/__tests__/manager.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/workspace/__tests__/manager.test.ts b/src/workspace/__tests__/manager.test.ts index e2f0a518..feed408b 100644 --- a/src/workspace/__tests__/manager.test.ts +++ b/src/workspace/__tests__/manager.test.ts @@ -257,6 +257,15 @@ describe('setupWorkspace', () => { expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/workspaces', { recursive: true }); }); + it('should set GIT_LFS_SKIP_SMUDGE=1 to avoid LFS smudge failures from bare cache', () => { + setupWorkspace({ repoUrl: 'https://github.com/user/repo.git' }); + + const cloneCall = mockExecFileSync.mock.calls[0]; + const cloneOpts = cloneCall[2] as { env?: Record }; + expect(cloneOpts.env).toBeDefined(); + expect(cloneOpts.env!.GIT_LFS_SKIP_SMUDGE).toBe('1'); + }); + it('should wrap git clone errors', () => { mockExecFileSync.mockImplementationOnce(() => { throw new Error('fatal: repository not found');