
CPA 中转:主会话 1h 缓存保活、子代理 5m 缓存
OpenCode v2 + 自建 Claude 中转(CPA)上,缓存 TTL 到底谁说了算:思路、验证方法、配置和踩坑,留给以后的自己查
前言
先放结论:在 OpenCode v2 + CPA 这条链上,「主会话缓存 1 小时并保活、子代理缓存 5 分钟」我已经配出来了,配置层和中转层都实测过。但还没开一个新会话完整跑一遍真实对话,保活在真实环境里也只触发过一次,所以这篇是思路和步骤的笔记,不是「已稳定跑通」的战报。没验证的我单独放在「没验证 / 没做完」那一节。
我的目的很朴素:Claude Sonnet 5.5 当主会话,想让缓存尽量多被「读」到、少被重新「写」。办法是让主会话的提示缓存(把前面发过的长前缀存起来,下次只读不重算)活够久,并且快过期前有人帮它续一下;子代理活儿短,5 分钟的缓存就够。
先把几个简称说清楚,后面不再解释:
- CPA:CLIProxyAPI,我自建的 Claude 订阅中转,开了 Claude Code CLI 指纹模式。
- bili:billion-context,OpenCode 的上下文管理插件。
- omo slim:oh-my-opencode-slim,给 orchestrator、oracle 这些角色分别指定模型的插件。
先看结论:最终配置要点
| 项目 | 主会话那一路 | 子代理那一路 |
|---|---|---|
| provider 名(本文示例) | claude_orz |
claude_orz_sub |
| 协议包 | @opencode/ai/providers/anthropic(走 /v1/messages) |
同左 |
settings.cache.ttl |
1h |
5m |
| 保活 | interval: auto,duration: 2 hours |
不配 |
| 请求头 | 不加 | 固定加 X-Claude-Code-Agent-Id |
| omo slim 里给谁用 | orchestrator | oracle、designer、fixer |
| 缓存写入倍率(相对普通输入) | 2× | 1.25× |
计费倍率按 Anthropic 官方口径(我没另外核对):5 分钟缓存写入 1.25×,1 小时缓存写入 2×,读取 0.1×(Sonnet/Opus 5.5 是 0.05×,Fable 是 0.025×)。所以 1 小时档写一次更贵,要靠后面反复「读」把它摊薄,这也是为什么它要配保活:写得贵,就得让它活久一点、读得多一点。
下面按我折腾的顺序写:先搞懂 Warming,再踩协议路径的坑,再发现 1 小时其实是中转「送」的,然后分流、改 V2 写法、验证、学会怎么停。
Warming 是什么:先读文档和源码,别靠猜
官方文档在这里:OpenCode Warming。我是对着文档和源码一起看的,下面是我整理出来的规则。
保活不会让缓存「不过期」,它是在缓存快过期之前自动再发一次请求,让计时从头开始。几条要记住的规则:
- 只作用于顶层会话,子代理会话被跳过。这是官方设计,源码里按
parentID判断。所以子代理本来就不会被保活,5 分钟就是它的自然状态,不用管。注意这个「自然状态」依赖子代理那一路固定带X-Claude-Code-Agent-Id;不带头的话,在我这台中转上会被抬成 1h(见下文「CPA 指纹」一节)。 duration是总窗口,从「最近一次真实请求发出时」开始算,保活本身不延长窗口。interval: auto取缓存 TTL 的 0.9 倍,1 小时缓存就是 54 分钟一次。如果 0.9×ttl 已经 ≥ duration,auto 会直接跳过。- 保活响应里缓存读取 < 缓存写入,就判定未命中,立刻停止后续保活。
- 配置优先级:模型的
settings.cache.warming> provider 级 > 顶层warming。 - variant 里写的 cache 设置会被丢弃,所以没法用 variant 区分 TTL,只能靠不同的 provider。
- 每次真实请求、压缩、generate 都会重置窗口;保活本身、子代理请求、Esc 不会。
- 关闭要显式写
cache.warming: false。删掉字段会回退到上层默认,"0"也不是有效的关闭写法。
我用 duration: 2 hours、interval: auto(54 分钟)算了一下:第 54、108 分钟各触发一次,第 162 分钟已经超出窗口,不触发。缓存最长撑到最后一次保活后再过 1 小时,即约 168 分钟(2 小时 48 分)。如果写成 duration: 1 hour,只会保 1 次。这是按规则推算的,不是我等满 168 分钟看到的。
协议路径:用 OpenAI 包设 TTL,等于没设
我最初的 provider 用的是 OpenAI 包(Responses 协议)。这条路径上,缓存设置只会变成请求里的 prompt_cache_options 字段,实测怎么写 TTL 都设不出 1 小时;翻日志,这一路清一色是 /v1/responses。这个字段具体在哪一层被丢弃,我没完全查清(读中转源码时看到它会剥掉该字段,但没有逐层验证)。
解决办法是换成 Anthropic 包(@opencode/ai/providers/anthropic),请求走 /v1/messages。
想控制 TTL,先确认请求实际走的是哪个协议端点,再去纠结配置写没写对。这一步我白白多绕了一圈。
CPA 指纹:1 小时是中转「送」的,5 分钟要靠请求头换
换成 Anthropic 包以后我又冒出一个疑问:设的 TTL 真的生效了吗?我干脆绕过 OpenCode,直接拿 Anthropic Messages 请求打中转,看响应 usage 里 cache_creation 的 5m/1h 拆分,一项一项试。
| 请求写法 | 响应里记成 |
|---|---|
| 显式写 ttl 1h | 1h |
| 显式写 ttl 5m | 1h |
| 不写 ttl | 1h |
请求里加 prompt_cache_options 字段 |
1h(不报错但没用,原因没查清) |
| 换 User-Agent、换 beta 头、带不带 tools | 1h |
加请求头 X-Claude-Code-Agent-Id + 显式 5m |
5m |
加请求头 X-Claude-Code-Agent-Id + 不写 ttl |
5m |
| 加请求头 + 显式 1h | 没测 |
只有加上 X-Claude-Code-Agent-Id(Claude Code 用来标记「这是子代理」的头)以后,5m 才真的记成 5m。我接着读了 CPA 的源码印证:它在 CLI 指纹模式下,对「非子代理」请求,会给没写 ttl 的缓存标记补成 1h,还会重写首条用户消息、丢掉显式写的 TTL;带了子代理头就不补。
所以结论有点反直觉:
- 主会话 1h、子代理 5m 这套行为,是 CPA 的指纹逻辑给的。
- 不是 bili 干的,也不是 OpenCode 里
ttl配置直接决定的。至少在我这台中转上,显式写 5m 也会被抬成 1h。 - 想要 5m,得靠加子代理头得到。
bili 这边我也查了配置:只开了 promptCache.routing(一个让缓存键保持稳定的开关),它不改 TTL,它自动打的缓存断点也不带 ttl。bili 自带的 cacheKeepalive 默认是关的,别和 OpenCode 的 Warming 同时开:两套机制互相不知情,不会去重。
自己复查可以照下面的思路做。这只是示意,地址和 key 换成你自己的,不是我当时的原命令:
# 示意:向中转的 /v1/messages 发一个带 cache_control 的长系统提示请求
curl -s https://<你的中转地址>/v1/messages \
-H "x-api-key: <你的中转 key>" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "claude-sonnet-5-5",
"max_tokens": 16,
"system": [{
"type": "text",
"text": "<一段足够长的系统提示>",
"cache_control": {"type": "ephemeral", "ttl": "5m"}
}],
"messages": [{"role": "user", "content": "hi"}]
}'
同样的请求连发两次,看 usage 里:
cache_creation.ephemeral_5m_input_tokens/ephemeral_1h_input_tokens:第一次写入记在了哪一档;cache_read_input_tokens:第二次有没有读到。
想验证「子代理头」的效果,就在第二组请求里多加一行 -H "X-Claude-Code-Agent-Id: opencode-subagent",对比 5m/1h 两栏。
分流方案:同一个中转,配两个 provider
既然 5m 要靠请求头,主会话不能带头,子代理必须带头,那就同一个中转地址配两个 provider:
- 主会话那一路:缓存
ttl: 1h+ 保活(interval: auto、duration: 2 hours),不加子代理头。 - 子代理那一路:缓存
ttl: 5m,不配保活,固定加请求头X-Claude-Code-Agent-Id。
两个 provider 的模型集一致:sonnet-5-5、opus-5-5、fable-5-2、haiku-5-5。另外我在 Claude 模型上补了上下文/输出上限(context 1,000,000 / output 128,000)和能力声明(文本+图片输入,推理档位 low/medium/high/xhigh/max),否则缺省会回退到 200,000/32,000。
opencode.json(V2 写法,只摘缓存和分流相关的片段,其余省略;配置带注释,所以是 JSONC):
{
"providers": {
// 主会话那一路:1h 缓存 + 保活,不带子代理头
"claude_orz": {
"package": "@opencode/ai/providers/anthropic",
"name": "Claude 主会话 (1h)",
"settings": {
"baseURL": "https://<你的中转地址>/v1",
"apiKey": "<你的中转 key>",
"cache": {
"ttl": "1h",
"warming": { "interval": "auto", "duration": "2 hours" }
}
},
"models": {
"claude-sonnet-5-5": {
"name": "Sonnet 5.5 · 主会话",
"limit": { "context": 1000000, "output": 128000 },
"capabilities": {
"tools": true,
"input": ["text", "image"],
"output": ["text"],
"reasoning": {
"supported": true,
"effort": ["low", "medium", "high", "xhigh", "max"]
}
}
}
// opus-5-5 / fable-5-2 / haiku-5-5 写法相同,省略
}
},
// 子代理那一路:5m 缓存,无保活,固定带子代理头
"claude_orz_sub": {
"package": "@opencode/ai/providers/anthropic",
"name": "Claude 子代理 (5m)",
"headers": { "X-Claude-Code-Agent-Id": "opencode-subagent" },
"settings": {
"baseURL": "https://<你的中转地址>/v1",
"apiKey": "<你的中转 key>",
"cache": { "ttl": "5m" }
},
"models": {
"claude-sonnet-5-5": {
"name": "Sonnet 5.5 · 子代理",
"limit": { "context": 1000000, "output": 128000 },
"capabilities": { /* 同上,省略 */ }
}
// opus-5-5 / fable-5-2 / haiku-5-5 写法相同,省略
}
}
// 另一个走 OpenAI 协议的 provider(explorer 等用),不参与缓存方案,省略
}
}
oh-my-opencode-slim.json(只摘角色到模型的指向,skills、mcps、prompt 等省略):
{
"preset": "claude",
"presets": {
"claude": {
"orchestrator": { "model": "claude_orz/claude-sonnet-5-5", "variant": "high" },
"oracle": { "model": "claude_orz_sub/claude-opus-5-5", "variant": "high" },
"designer": { "model": "claude_orz_sub/claude-sonnet-5-5", "variant": "high" },
"fixer": { "model": "claude_orz_sub/claude-sonnet-5-5", "variant": "high" },
// explorer 这类轻量角色用另一个走 OpenAI 协议的中转,不参与缓存方案
"explorer": { "model": "openai_orz/gpt-6.1-sol", "variant": "low" }
}
},
"agents": {
// fast-generic 同理,保持用 OpenAI 协议那一路
"fast-generic": { "model": "openai_orz/gpt-6.1-sol", "variant": "low" }
}
}
V2 原生写法:顺手把配置整体搬过来
分流的时候我顺手把整份配置改成了 OpenCode V2 原生写法。V2 会在内存里把 V1 写法规范化,不会改写你的源文件,也没有 opencode migrate 这类子命令,所以只能手改。对应关系:
| V1 | V2 |
|---|---|
provider |
providers |
npm |
package |
@ai-sdk/anthropic |
@opencode/ai/providers/anthropic |
@ai-sdk/openai |
@opencode/ai/providers/openai(默认就是 Responses,别改成 chat) |
options.baseURL / apiKey / cache |
settings.* |
options.headers |
provider 同级的 headers |
model 的 options |
model 的 settings |
variants(对象) |
variants(数组,每项 id + settings) |
plugin |
plugins(别和 V1 的 plugin 并存) |
顶层 server 块 |
V2 直接忽略,已删 |
我的流程是:改之前先备份;改完用能解析 JSONC 的解析器校验(配置里有注释,普通 JSON 解析会被注释卡住);再做两项验收:
opencode debug config:看有没有警告;opencode api model.list:看模型和它们的 settings。注意这个输出带凭据,要先用程序过滤,只留你要看的字段,别直接打在屏幕上。
验证:配置层看什么,保活怎么盯
模型层面我核对过:model.list 里主会话那一路 settings.cache 是 ttl 1h + warming auto / 2 hours,没有额外请求头;子代理那一路是 cache 5m + 头 X-Claude-Code-Agent-Id。
实时观察保活,去 OpenCode 日志目录下的 opencode.log,tail -F 并过滤关键字:
tail -F <OpenCode 日志目录>/opencode.log | grep -E "warming|failed to warm session"
关键句有四个:
scheduled session warming:给某个会话排上了保活;warming session:这一次保活发出去了;stopped warming session after a cache miss:判定未命中,停止后续保活;failed to warm session:保活本身失败。
保活请求的特征:末尾带一条临时用户文本「保活,仅回复 OK」,没有专用请求头。另外,没有「列出当前活跃保活循环 / 剩余窗口」这样的接口,想知道还剩多久,只能自己按规则算。
停止保活:退出 TUI 不管用
这一条我经常忘。退出 TUI 停不了保活,定时器在后台服务里。只有删会话才会取消该会话的保活循环;Esc、归档、切模型、移目录、fork 都不会取消。
opencode session export <id> > backup.json # 先导出备份
opencode session delete <id> # 删会话才会取消该会话的保活循环
opencode service status # 全局后台服务:status / restart / stop
opencode service restart
opencode service stop
配置里关闭则显式写 cache.warming: false。要注意,热加载后旧循环仍然带着旧参数,要等下次请求,或者 service restart 才会清掉。
如果想让某个会话按新配置重新判定,可以 opencode run --session <id> "…" 发一次请求,但必须先把 warming 关掉,否则这次请求本身会重置、延长窗口,多出一次真实请求。
踩过的小坑
旧会话报 Model unavailable
改完配置后,旧会话报 Model unavailable: <被我删掉的模型>。原因是会话里保存的模型优先于 agent 配置,而旧会话存的正好是我已经删掉的模型。处理:开新会话,或者把这个会话的模型改回现有的。
/model 选择器里分不清哪个是主会话那一路
选择器标题只显示模型的 name,provider 名只出现在分组标题或搜索态下,两个 provider 的同名模型很难区分。我的处理是:给 8 个 Claude 模型的 name 都加上「主会话 / 子代理」字样,provider 也起了带「1h」「5m」的 name。provider 没法在选择器里隐藏,把模型设成 disabled 会让 omo slim 引用不到,所以这条路走不通。
缓存匹配规则:这是理解,没有现场验证
以下是我对 Anthropic 官方规则的理解,没有现场验证,当「排查线索」用:
- 缓存按「前缀内容」匹配,不按会话。键是从头到某个断点的内容(工具定义 → 系统提示 → 历史消息);同账号体系下,前缀逐字相同,别的会话也能命中。
- 一个会话的每个断点各留一份缓存。短前缀读不到长缓存;长请求可以往回找,命中更短的已缓存前缀(往回约 20 个内容块)。
- 工具、系统提示、历史(折叠、压缩、改写)或模型变了,变化位置之后就读不到。
- 缓存按账号隔离。中转如果在多个账号间轮换,换账号就读不到。我没查我的中转有几个账号。
没验证 / 没做完
- 还没开一个新会话完整跑一遍「主会话写入 1h、子代理 5m、请求走
/v1/messages」的真实对话。目前验证到的是配置层和中转层的实测。 - 保活在真实环境只触发过一次,且那一次被判为未命中、随即停止,原因还没查清。手上只有一条未证实的线索,属于推测:保活走的可能是另一条生成路径,omo slim 的系统提示/历史转换看起来只注册在正常请求的钩子上,保活不经过它;而闲置之后第一笔请求,折叠标识和系统提示似乎会被重新构造。这些都是读源码得到的印象,没有拿两次请求逐字对比过。保活能否稳定命中,我还在观察。
- CPA 对「带子代理头 + 显式 1h」会落到哪一档,没实测(我测的是带头时显式 5m 和不写 ttl,都是 5m)。
prompt_cache_options字段在中转上无效的原因,没查清。- 中转有几个 Claude 账号、是否轮换,没查。
结语
回头看,这次实打实做成的是分流:主会话 1h 缓存加保活、子代理 5m 缓存,配置层和中转层都对上了。保活在真实对话里能不能稳定命中,我还在观察,详细没验证的清单不重复了,都在上面的「没验证 / 没做完」里。
下一步就是补第一条:开个新会话,主会话和子代理各跑几轮,对着 usage 看 5m/1h 两栏和读取量,再盯着日志看保活那一次到底会不会命中。如果你也在 CPA 上试过「带子代理头 + 显式 1h」,结果是哪一档,欢迎告诉我。
保活这东西最气人的一点,是它在后台悄悄转,而我连「现在有几个循环在转」都查不到。
参考与外链
- OpenCode Warming 官方文档:保活的窗口、间隔、未命中判定。
- billion-context(bili):上下文管理插件与它自带的缓存保活。


评论 (0)