EMBER GLASS · 20260%

正在点燃余烬…

CPA 中转Claude:openCode主会话 1h 缓存保活、子代理 5m 缓存

CPA 中转Claude:openCode主会话 1h 缓存保活、子代理 5m 缓存

orzlee
2026-11-13 / 0 评论 / 1 阅读 / 正在检测是否收录...

封面:深蓝色代码世界背景里,方块屏幕脑袋的 OpenCode v2 角色与浅色的 omo slim 原创小机器人伸出胶囊手臂、用圆拳头在中间牵手,上方悬着橙色的 Claude 星芒,周围有放射线和火花

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」,结果是哪一档,欢迎告诉我。

保活这东西最气人的一点,是它在后台悄悄转,而我连「现在有几个循环在转」都查不到。

参考与外链

0
取消
扫码打赏
支付金额随意哦!
赏

评论 (0)

验证码,点击刷新
取消