Skip to content

Commit 27abec0

Browse files
committed
fix(cache): stable instructions head for prompt_cache_key + claude cache test method
1 parent e95bb05 commit 27abec0

3 files changed

Lines changed: 203 additions & 8 deletions

File tree

‎docs/claude-cache-test-method.md‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Claude Code 缓存测试法(网关强制指向)
2+
3+
> 任何"缓存命中率 / `prompt_cache_key` / instructions 变化"类测试,
4+
> 一律按此法执行。合成 curl 只用于冒烟,结论必须来自真机 + 网关日志。
5+
6+
## 1. 铁律(违反则结论作废)
7+
8+
1. 模型必须用网关现存模型(如 `muse-spark-1.3-contributor-free`)。
9+
网关没有的模型会触发 `[claude-code:unrecognized_model]`,流量可能旁路网关。
10+
2. 必须用 `--settings <gw-settings.json>` 强制指向网关。
11+
`~/.claude/settings.json` 自带 `ANTHROPIC_BASE_URL` 与 `ANTHROPIC_AUTH_TOKEN`,
12+
进程 env 的优先级不稳定,裸 `export` 经常被覆盖,导致流量根本没进网关
13+
(现象:回了 `TURN_ONE_OK` 但网关 `cache_debug_key_parts` 计数不涨)。
14+
不要手配 `ANTHROPIC_AUTH_TOKEN=sk-123` 类 env(`launch_env.go` 已证明它会劫持
15+
Claude Code 网络栈)。一切以 `--settings` 文件为准。
16+
3. 两轮必须同会话:首轮 `--session-id $UUID`,次轮 `--resume $UUID`。
17+
4. 网关必须带 `OPENCODE2API_CACHE_DEBUG=1` 启动,且是最新 `bin/opencode2api`
18+
(`make build` 后重启才生效)。
19+
20+
## 2. 标准步骤
21+
22+
```bash
23+
# 0. 起网关(新二进制 + debug)
24+
OPENCODE2API_CACHE_DEBUG=1 ./bin/opencode2api -port 8080 -log-stdout
25+
26+
# 1. 写强制网关的 settings(模型换成网关现存的)
27+
python3 -c "
28+
import json
29+
m='muse-spark-1.3-contributor-free'
30+
env={
31+
'ANTHROPIC_BASE_URL':'http://127.0.0.1:8080',
32+
'ANTHROPIC_AUTH_TOKEN':'sk-123',
33+
'ANTHROPIC_MODEL':m,
34+
'ANTHROPIC_DEFAULT_OPUS_MODEL':m, 'ANTHROPIC_DEFAULT_OPUS_MODEL_NAME':m,
35+
'ANTHROPIC_DEFAULT_SONNET_MODEL':m, 'ANTHROPIC_DEFAULT_SONNET_MODEL_NAME':m,
36+
'ANTHROPIC_DEFAULT_HAIKU_MODEL':m, 'ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME':m,
37+
'ANTHROPIC_DEFAULT_FABLE_MODEL':m, 'ANTHROPIC_DEFAULT_FABLE_MODEL_NAME':m,
38+
'CLAUDE_CODE_SUBAGENT_MODEL':m,
39+
}
40+
json.dump({'env':env}, open('/tmp/cc-cache-test/gw-settings.json','w'), indent=2)
41+
"
42+
43+
# 2. 第一轮(记 BASE 计数)
44+
UUID=$(uuidgen | tr 'A-Z' 'a-z'); echo "$UUID" > /tmp/cc-cache-test/session.id
45+
echo "BASE=$(grep -a -c cache_debug_key_parts opencode2api.log)"
46+
cd /tmp/cc-cache-test && timeout 280 claude -p --session-id "$UUID" \
47+
--settings /tmp/cc-cache-test/gw-settings.json \
48+
"Reply with exactly: TURN_ONE_OK and nothing else. Do not use any tools." \
49+
--max-turns 3 2>&1 | tail -4
50+
51+
# 3. 有效性检查:计数必须涨,否则流量没进网关,本轮作废
52+
grep -a -c "cache_debug_key_parts" opencode2api.log # 必须 > BASE
53+
54+
# 4. 同会话第二轮
55+
UUID=$(cat /tmp/cc-cache-test/session.id)
56+
cd /tmp/cc-cache-test && timeout 280 claude -p --resume "$UUID" \
57+
--settings /tmp/cc-cache-test/gw-settings.json \
58+
"Reply with exactly: TURN_TWO_OK and nothing else. Do not use any tools." \
59+
--max-turns 3 2>&1 | tail -4
60+
```
61+
62+
## 3. 标准读数
63+
64+
```bash
65+
# 跨轮 pck / 全长 / 入 key 长
66+
grep -a "cache_debug_key_parts" opencode2api.log | tail -n 6 | python3 -c "
67+
import sys,re
68+
for line in sys.stdin:
69+
ts=line[:30]
70+
pck=re.search(r'pck=(\S+)',line).group(1)[-12:]
71+
fl=re.search(r'instr_full_len=(\d+)',line)
72+
fs=re.search(r'instr_full_segs=(\d+)',line)
73+
pl=re.search(r'part0_len=(\d+)',line)
74+
print(ts,'pck=..'+pck,'part0_len=',pl.group(1) if pl else '?','full_len=',fl.group(1) if fl else '?','full_segs=',fs.group(1) if fs else '?')
75+
"
76+
# 命中
77+
grep -a "cache_debug_usage" opencode2api.log | tail -n 4 | grep -oE "input_tokens=[0-9]+ output_tokens=[0-9]+.*cached_tokens=[0-9]+"
78+
# 池 key 是否钉住
79+
grep -a "upstream_attempt" opencode2api.log | tail -n 4 | grep -oE "key_id=k[0-9]+"
80+
```
81+
82+
判定:
83+
84+
- `part0_len` 同 + `pck` 同 + 池 key 同 + 轮 2 `cached>0` = 修好。
85+
- `instr_full_len` 涨但 `part0_len` 不涨 = 稳定头截断生效。
86+
- 计数不涨 = 流量没进网关,重查模型名 / settings / `--resume`。
87+
88+
## 4. 背景:2026-10-08 缓存命中率修复
89+
90+
- 现象:同会话 `part0_len 31397→36694`,`first_user`/tools 不变,两轮 `cached=0`。
91+
- 定位:`instructions` = system 参数 + `role=system` 消息 join,
92+
Claude Code 每轮尾部追加约 18 段(`59→77` 段,`+6KB`),全量哈希导致每轮换 key。
93+
- 修复:`contentPromptCacheKey` 只取 `instructions` 前 16 段
94+
(`stableInstructionsHead`,见 `internal/app/chat.go`)入 key;
95+
`config.json` 本地 `key_pool.strategy round_robin→sticky`、
96+
`prompt_cache_retention off→24h`。
97+
- 验证(本测试法):尾部 `+5KB` 时 `pck` 不变、池 key 钉住、
98+
轮 2 `7025/16984≈41%` 命中。回归测试
99+
`TestContentPromptCacheKey_StableHeadIgnoresAppendedTail`。

‎internal/app/chat.go‎

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1910,24 +1910,45 @@ func preferContentKey(hasToken bool) bool {
19101910
return !hasToken
19111911
}
19121912

1913+
// stableInstrHeadSegs 是 instructions 稳定前缀的段数:"\n\n" 切段后只取前 N 段
1914+
// 参与 prompt_cache_key 哈希。Claude 侧 instructions = system 参数 +
1915+
// role=system 消息顺序 join,实测头 16 段跨轮逐段同哈希、尾部每轮追加约 18 段;
1916+
// 只哈希稳定头则同会话跨轮 key 不变,上游 prefix-cache 才能命中。N=16 覆盖实测
1917+
// 稳定区并留出余量;不同会话/不同 system 头天然不同段,不会误撞。
1918+
const stableInstrHeadSegs = 16
1919+
1920+
// stableInstructionsHead 返回参与缓存 key 的 instructions 稳定前缀:按 "\n\n"
1921+
// 切段取前 stableInstrHeadSegs 段 join。段数不足时返回原文(短 instructions 不
1922+
// 截断);尾部追加段被排除在 key 之外,但仍会原样发往上游,不丢上下文。
1923+
func stableInstructionsHead(instr string) string {
1924+
segs := strings.Split(instr, "\n\n")
1925+
if len(segs) <= stableInstrHeadSegs {
1926+
return instr
1927+
}
1928+
return strings.Join(segs[:stableInstrHeadSegs], "\n\n")
1929+
}
1930+
19131931
// contentPromptCacheKey 对上游请求体(已 marshal 的 chat/responses JSON)的
1914-
// 公共前缀求 SHA-256 派生稳定 key:system 提示、工具清单、instructions 与
1915-
// 首个 user message 决定缓存前缀;assistant/tool 后续消息会随轮次变化,
1932+
// 公共前缀求 SHA-256 派生稳定 key:system 提示、工具清单、instructions 稳定
1933+
// 前缀与首个 user message 决定缓存前缀;assistant/tool 后续消息会随轮次变化,
19161934
// 不参与哈希。同一份系统提示 + 工具栈在跨 launch 之间生成同一 key,让上游
19171935
// 一致性哈希到同一 zen 缓存分片;前缀内容一旦改动则自然换 key,避免串污染。
1936+
// instructions 只取稳定头(见 stableInstructionsHead):实测 Claude Code 每轮
1937+
// 在尾部追加 role=system 消息(59→77 段,+6KB),全量哈希会导致同会话每轮换
1938+
// key、prefix-cache 归零;头 16 段跨轮逐段同哈希,是真正的稳定前缀。
19181939
func contentPromptCacheKey(m map[string]any) string {
19191940
if m == nil {
19201941
return ""
19211942
}
1922-
// 采样:system 块、tools 形状、instructions、首个 user 消息文本——这些
1923-
// 是任何 agent 会话的稳定前缀;其它动态字段忽略。
1943+
// 采样:system 块、tools 形状、instructions 稳定头、首个 user 消息文本——
1944+
// 这些是任何 agent 会话的稳定前缀;其它动态字段忽略。
19241945
parts := []string{}
19251946
if sys, ok := m["system"]; ok {
19261947
b, _ := json.Marshal(sys)
19271948
parts = append(parts, "sys:"+string(b))
19281949
}
19291950
if instr, ok := m["instructions"].(string); ok && instr != "" {
1930-
parts = append(parts, "instr:"+instr)
1951+
parts = append(parts, "instr:"+stableInstructionsHead(instr))
19311952
}
19321953
if tools, ok := m["tools"].([]any); ok {
19331954
if b, err := json.Marshal(tools); err == nil {
@@ -2002,8 +2023,10 @@ func contentPromptCacheKey(m map[string]any) string {
20022023
if len(parts) == 0 {
20032024
return ""
20042025
}
2026+
sum := sha256.Sum256([]byte(strings.Join(parts, "\n")))
2027+
pck := "oc2api:csha:" + hex.EncodeToString(sum[:16])
20052028
if cacheDebugEnabled() {
2006-
attrs := []any{"parts", len(parts)}
2029+
attrs := []any{"parts", len(parts), "pck", pck}
20072030
for i, p := range parts {
20082031
idx := strings.Index(p, ":")
20092032
name, body := p, p
@@ -2012,9 +2035,38 @@ func contentPromptCacheKey(m map[string]any) string {
20122035
}
20132036
attrs = append(attrs, fmt.Sprintf("part%d", i), name+"="+hashTextPrefix(body))
20142037
attrs = append(attrs, fmt.Sprintf("part%d_len", i), len(body))
2038+
if name == "instr" {
2039+
// part0 已是稳定头(stableInstructionsHead 截断后),full 长度
2040+
// 需从原始请求体另取:m["instructions"] 即原文。
2041+
if full, _ := m["instructions"].(string); full != "" {
2042+
attrs = append(attrs, "instr_full_len", len(full))
2043+
attrs = append(attrs, "instr_full_segs", len(strings.Split(full, "\n\n")))
2044+
}
2045+
segs := strings.Split(body, "\n\n")
2046+
attrs = append(attrs, "instr_segs", len(segs))
2047+
// 头 16 + 尾 16:Claude 侧 instructions = system 参数 +
2048+
// role=system 消息 join,膨胀段常在尾部,32 上限截断后看不见。
2049+
// 只记哈希前缀+长度,不记原文。
2050+
emit := func(si int) {
2051+
attrs = append(attrs, fmt.Sprintf("instr_seg%d", si), hashTextPrefix(segs[si]))
2052+
attrs = append(attrs, fmt.Sprintf("instr_seg%d_len", si), len(segs[si]))
2053+
}
2054+
n := len(segs)
2055+
for si := 0; si < n && si < 16; si++ {
2056+
emit(si)
2057+
}
2058+
if n > 32 {
2059+
for si := n - 16; si < n; si++ {
2060+
emit(si)
2061+
}
2062+
} else {
2063+
for si := 16; si < n && si < 32; si++ {
2064+
emit(si)
2065+
}
2066+
}
2067+
}
20152068
}
20162069
slog.Info("cache_debug_key_parts", attrs...)
20172070
}
2018-
sum := sha256.Sum256([]byte(strings.Join(parts, "\n")))
2019-
return "oc2api:csha:" + hex.EncodeToString(sum[:16])
2071+
return pck
20202072
}

‎internal/app/main_test.go‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,50 @@ func TestPromptCacheKeyRecomputedAfterFingerprint(t *testing.T) {
339339
}
340340
}
341341

342+
func TestContentPromptCacheKey_StableHeadIgnoresAppendedTail(t *testing.T) {
343+
// 实测 Claude Code 每轮在 instructions 尾部追加 role=system 消息
344+
// (59→77 段,+6KB),全量哈希会导致同会话每轮换 key、prefix-cache 归零。
345+
// key 只取前 stableInstrHeadSegs 段:尾部追加不换 key,头部改动仍换 key。
346+
mkSegs := func(n int, prefix string) string {
347+
segs := make([]string, n)
348+
for i := range segs {
349+
segs[i] = prefix + "_seg_" + string(rune('a'+i%26)) + "_body_padding_for_length"
350+
}
351+
return strings.Join(segs, "\n\n")
352+
}
353+
head := mkSegs(stableInstrHeadSegs, "stable")
354+
body := func(instr string) map[string]any {
355+
return map[string]any{
356+
"model": "muse-spark-1.3-contributor-free",
357+
"instructions": instr,
358+
"tools": []any{map[string]any{"type": "function", "name": "Bash"}},
359+
"input": []any{
360+
map[string]any{"role": "user", "content": []any{
361+
map[string]any{"type": "input_text", "text": "hi"},
362+
}},
363+
},
364+
}
365+
}
366+
base := contentPromptCacheKey(body(head))
367+
appended := contentPromptCacheKey(body(head + "\n\n" + mkSegs(18, "appended_tail")))
368+
if base == "" || appended == "" {
369+
t.Fatalf("keys must be non-empty: base=%q appended=%q", base, appended)
370+
}
371+
if base != appended {
372+
t.Fatalf("tail append changed key: base=%q appended=%q", base, appended)
373+
}
374+
// 头部改动必须换 key(避免串污染)。
375+
mutated := contentPromptCacheKey(body(mkSegs(stableInstrHeadSegs, "other") + "\n\ntail"))
376+
if mutated == base {
377+
t.Fatalf("head mutation must change key: %q", mutated)
378+
}
379+
// 短 instructions(不足 N 段)不截断:行为与原来一致。
380+
short := "only one segment"
381+
if got := stableInstructionsHead(short); got != short {
382+
t.Fatalf("short instr truncated: %q", got)
383+
}
384+
}
385+
342386
func TestCallOpenCodeAPIKeyedAuthDoesNotCrossModelFallback(t *testing.T) {
343387
tests := []struct {
344388
name string

0 commit comments

Comments
 (0)