OrzLee
NEURAL VOID · 加载中0%
billion-context 插件保活只保主会话:CPA 加 sub- 前缀分流

billion-context 插件保活只保主会话:CPA 加 sub- 前缀分流

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

封面:左边是 Claude 星芒入口,经过一个道岔分成两路:主线粗而亮、带心跳脉冲(对应保活),子代理支线细而暗、带沙漏、没有脉冲

bili 保活只保主会话:CPA 加 sub- 前缀分流

把保活从 OpenCode warming 换成 bili,再用 CPA 的模型前缀让主会话和子代理在 bili 眼里「模型名不同」:思路、步骤、配置和踩坑,留给以后的自己查

前言

上一篇的结论一句话带过:CPA 对没带 X-Claude-Code-Agent-Id 头的请求补成 1h 缓存,带头的记 5m,所以我用 claude_orz(主会话 1h)和 claude_orz_sub(带头、5m)两个 provider 分流。这篇接着写保活:OpenCode 自带的 warming 在我这儿三次都没命中,我把保活换成了 bili,并用 CPA 的 sub- 前缀让 bili 只保主会话。

目前的状态是:配置已经改完,bili 里确实登记了保活,保活 ping 也能命中;但命中的是还在用旧模型 ID 的子代理会话,它们按配置本来就会被保活,不是 sub- 该有的效果。sub- 模型的子代理还没真正跑起来,所以「子代理不被保活」这一条没有运行证据;主会话的 1h 保活我没有刻意去测,也没有 54 分钟的样本。所以这篇是思路和步骤的笔记,不是战报,没验证的我单独放在「没验证 / 没做完」里。

简称沿用上一篇:CPA 是我自建的 Claude 中转,bili 是上下文管理插件 billion-context(本文用的版本是 0.1.213),omo slim 是 oh-my-opencode-slim,给 orchestrator、oracle 这些角色分别指定模型。

起因:OpenCode warming 三次保活全没命中

先说为什么要换。warming 在我这儿实测了三次,三次保活都被判为未命中,随即自动停止。每次的缓存读取只有一万多 token,写入却有六到九万多,也就是只读回了一小段,大头又重新写了一遍。

我手上有一条根因线索,只是线索,不是定论:

  • warming 的保活请求走 session.generate 重新构造请求,不是重放上一次真实请求。
  • omo slim 做系统提示转换(collapseSystemInPlace,把多个 system 块合成一块)的钩子只注册在 context 一侧,没有注册到 generate。
  • bili 日志里,每次保活都记着「system prompt changed」,系统提示大约多出 113 到 114 个字符。
  • 缓存按前缀匹配,系统提示对不上,可能导致它之后的前缀无法命中,只剩最前面的工具定义那一段(约 1 万 token)能命中。这个数字和上面「读一万多」对得上,但这是推测,没有差分验证。

公开的 issue 里有人描述了同一类问题,思路是「warm 请求不是 step 的请求」,状态是 Open:issue 61775。对应的修复 PR 改成重放真实请求,也还没合并:PR 61786。

要强调的是:我没有把保活请求体和真实请求体拿出来逐字差分,上面是读源码加看日志拼出来的印象。处置很简单:claude_orz 的 cache.warming 改成 false,ttl 保留 1h。

换思路:bili 的 cacheKeepalive 重放真实请求

bili 自带 cacheKeepalive,我读了它的规则,和 warming 的差别正好在要害上:

  • ping 的内容:重放「最近一次真实出站请求体」,只改两处:max_tokens=1、stream=false。不追加文本,不动缓存断点。前缀和真实请求一致,比走 generate 重新构造更可能命中。
  • TTL 怎么定:从请求体的 cache_control 检测,任何一处是 1h 就按 1h,否则按 5m。
  • ping 间隔:默认是 TTL×0.9,1h 就是 54 分钟,5m 就是 270 秒。
  • 窗口:从最后一次真实请求起算,ping 本身不延长;默认窗口 24 小时,可以用 maxDurationMs 改。

主会话请求体里本来就带着显式的 ttl: "1h",所以 bili 自己能检测成 1h,我没设 assumeTtl,也没设 intervalMs。

问题:bili 只能按「上游 + 模型名」开关

读到配置那一块,麻烦来了。bili 的保活开关只能按「上游 URL + 模型名」来配,没有按主/子代理、按请求头、按 TTL 过滤的选项。而我的 claude_orz 和 claude_orz_sub 是同一个上游、同样的模型名,对 bili 来说是同一类请求。

后果:子代理那一路(5m)也会每 270 秒被 ping 一次,一直到窗口结束,按 2 小时窗口算大约 26 次。子代理的活短,这些 ping 基本是白读。bili 其实能识别 agent-id 头,但这个头只用来识别,不参与保活过滤。

方案:让 bili 看见两个不同的模型名

既然 bili 只认模型名,那就让主、子两路的模型名不一样。

我先想到的是 CPA 的模型别名,但它不合适:API key 渠道不支持 fork 别名(fork: true 只有 OAuth 渠道的 model-alias 才支持),而且别名会替换默认模型目录,不是「多加一份」。

最后我在 CPA 里把同一个供应商复制了一份,给它加了前缀 sub-。前缀只存在于对外的模型名上,请求进来时 CPA 会还原成上游原模型名,所以不会新建一个缓存隔离域(同 key、同工作区)。主会话继续用原名,子代理改用 sub-claude-*,bili 就能拿模型名去区分了。

最小推理验证用了一个最小请求(max_tokens=1)。注意消息请求会真的执行一次推理、产生少量用量;只有 /v1/models 查询才是只读的:

  • /v1/models 里 claude-* 原名 8 个、sub-claude-* 8 个,共 16 个;
  • 原名和 sub- 各 4 个目标模型(sonnet-5-5、opus-5-5、fable-5-2、haiku-5-5)都返回 HTTP 200;
  • sub- 请求的响应里,model 字段返回的是原名。

自己复查的话照下面的示意来,地址和 key 换成你自己的,不是我当时的原命令:

# 列模型(只读):看原名和 sub- 前缀的各有几个
curl -s "https://<CPA 源站>/v1/models" \
  -H "x-api-key: <key>"

# 最小推理请求(会执行一次推理、产生少量用量):sub- 模型应返回 200,响应里的 model 是原名
curl -s "https://<CPA 源站>/v1/messages" \
  -H "x-api-key: <key>" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{
    "model": "sub-claude-sonnet-5-5",
    "max_tokens": 1,
    "messages": [{"role": "user", "content": "hi"}]
  }'

OpenCode 配置:新增 sub- 模型,旧 ID 先留着

opencode.json 里(配置带注释,所以是 JSONC),先看主会话那一路,保活关掉,TTL 留着:

{
  "providers": {
    "claude_orz": {
      "package": "@opencode/ai/providers/anthropic",
      "name": "Claude 主会话 (1h)",
      "settings": {
        "baseURL": "https://<CPA 源站>/v1",
        "apiKey": "<key>",
        "cache": {
          "ttl": "1h",
          // 已关闭:保活交给 bili
          "warming": false
        }
      }
      // models 不变,省略
    }
  }
}

claude_orz_sub 下新增 4 个 sub-claude-*,limit 和 capabilities 与旧模型一致;旧的 4 个原名模型不删,只在 name 末尾加「· 旧ID(兼容)」:

{
  "providers": {
    "claude_orz_sub": {
      "package": "@opencode/ai/providers/anthropic",
      "name": "Claude 子代理 (5m)",
      "headers": { "X-Claude-Code-Agent-Id": "opencode-subagent" },
      "settings": {
        "baseURL": "https://<CPA 源站>/v1",
        "apiKey": "<key>",
        "cache": { "ttl": "5m" }
      },
      "models": {
        // 新 ID:bili 里 enabled:false,所以子代理不被保活
        "sub-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"]
            }
          }
        },
        // sub-claude-opus-5-5 / sub-claude-fable-5-2 / sub-claude-haiku-5-5 写法相同,省略

        // 旧 ID:先保留,等旧子会话结束再删
        "claude-sonnet-5-5": {
          "name": "Sonnet 5.5 · 子代理 · 旧ID(兼容)",
          "limit": { "context": 1000000, "output": 128000 },
          "capabilities": { /* 同上,省略 */ }
        }
        // claude-opus-5-5 / claude-fable-5-2 / claude-haiku-5-5 同理,省略
      }
    }
  }
}

旧 ID 为什么不直接删:正在跑的子代理会话里保存的是旧的模型 ID,会话里保存的模型优先于 agent 配置,删了就会报 Model unavailable。这个坑上一篇也踩过一次,这回提前绕开。

oh-my-opencode-slim.json 的 claude 预设里,oracle、designer、fixer 指向 sub- 模型,orchestrator 仍然是主会话那一路:

{
  "preset": "claude",
  "presets": {
    "claude": {
      "orchestrator": { "model": "claude_orz/claude-sonnet-5-5",             "variant": "high" },
      "oracle":       { "model": "claude_orz_sub/sub-claude-opus-5-5",       "variant": "high" },
      "designer":     { "model": "claude_orz_sub/sub-claude-sonnet-5-5",     "variant": "high" },
      "fixer":        { "model": "claude_orz_sub/sub-claude-sonnet-5-5",     "variant": "high" },
      // explorer 走另一个 OpenAI 协议的中转,不参与缓存方案
      "explorer":     { "model": "openai_orz/gpt-6.1-sol",                   "variant": "low" }
    }
  }
}

这份预设只摘了角色到模型的指向;fast-generic 保持原配置,没有列出,也没改动。

bili 配置:原名开保活,sub- 关保活

billion-context.json 是纯 JSON,不能带注释,所以下面的说明放在代码块外面。providers 以上游源站(不含 /v1)为键,models 下原名 4 个开保活,sub- 4 个关保活:

{
  "providers": {
    "https://<CPA 源站>": {
      "models": {
        "claude-sonnet-5-5": {
          "cacheKeepalive": { "enabled": true, "maxDurationMs": 7200000 }
        },
        "claude-opus-5-5": {
          "cacheKeepalive": { "enabled": true, "maxDurationMs": 7200000 }
        },
        "claude-fable-5-2": {
          "cacheKeepalive": { "enabled": true, "maxDurationMs": 7200000 }
        },
        "claude-haiku-5-5": {
          "cacheKeepalive": { "enabled": true, "maxDurationMs": 7200000 }
        },
        "sub-claude-sonnet-5-5": { "cacheKeepalive": { "enabled": false } },
        "sub-claude-opus-5-5":   { "cacheKeepalive": { "enabled": false } },
        "sub-claude-fable-5-2":  { "cacheKeepalive": { "enabled": false } },
        "sub-claude-haiku-5-5":  { "cacheKeepalive": { "enabled": false } }
      }
    }
  },
  "promptCache": { "routing": "enabled" }
}

几处取舍:

  • maxDurationMs: 7200000 就是 2 小时,沿用上一篇 warming 的窗口。
  • 没设全局的 cacheKeepalive,也没设 assumeTtl / intervalMs,原因见上一节:主会话请求体里有显式 ttl: "1h",bili 能自己检测。
  • promptCache.routing 是上一篇就开着的,这里只是原样保留;文件里其余的(自动重启、rollout 之类)与本文无关,省略。

我读源码确认了三条匹配规则,决定了这份配置为什么写得通:

  • 路由按上游 URL 做最长前缀匹配,去掉尾部斜杠,所以只写源站(origin)也能匹配 /v1/messages;
  • 模型名先精确匹配,再只做小写和 .、_ 的归一化,不会去掉 sub-,因此 claude-sonnet-5-5 与 sub-claude-sonnet-5-5 不会撞车;
  • 配置优先级是:环境变量 > 模型 > 路由 > 全局 > 默认。

验证:真跑过什么

只写真看到的:

  • 配置改完后不久,bili 日志里出现了 [keepalive] armed 的登记行。
  • 一个仍用旧 ID 的 fixer 子会话,被按 5m、270 秒登记。第一次 ping 命中约 12.9 万 token,第二次间隔约 272 秒又命中。说明「重放真实请求」这条保活链路在我这儿是通的,命中量也不再是 warming 那一万多。

配置是怎么被 bili 读到的,我单独放在下一节讲。

配置热加载:bili 怎么读到新配置

日志里没有 reload 记录,我最初没找到 bili 读到新配置的原因。但按我的印象,bili、OpenCode、omo slim 本来都有自动重载配置的能力,这个印象和我读到的源码不完全一致,所以我把「源码里看到的」和「日志里看到的」分开记。

先说源码。下面是我对 bili 0.1.213 打包源码(dist)的只读阅读,没有实测:

  • bili 的「全局配置块」是读取时自动刷新的:每次用到时读一遍配置文件全文,内容和缓存不同就重新解析并替换。不是按修改时间判断,也没有固定的检查间隔。这种刷新成功时不打日志,只有解析失败才有错误日志。
  • 但 providers 路由,也就是本文给 sub- 禁用、给原名开启的那一块(按上游 URL + 模型名匹配),我只在源码里看到三条路径:启动时加载;网页端保存配置时原地更新;显式 POST /__bili/config/reload 时原地更新,后者才会打一条 info 日志「routes hot-reloaded」。
  • 我没找到文件监听(watch、watchFile、chokidar 这类),也没找到「每个请求按修改时间重建路由」的路径。

再说日志里看到的:配置文件改完后约一分半钟,日志里出现了对旧模型的保活登记(armed);这段时间没有 reload 日志,只有改完后约 20 秒的一条「没有匹配的 provider 路由」警告。所以路由很可能已经刷新了,但触发机制我没能在源码里定位:打包后是单行的压缩文件,我可能漏读了;也可能被我没操作过的网页保存或别的途径触发了,这些都是推测,没法确认。机制写成「待查」。

目前能写的结论是:直接改文件之后,保活确实在约一分半钟内按新配置生效了,有日志证据;稳妥起见,改完去后台看一眼有没有登记,必要时用网页保存或 reload 补一下。

另外一条仍然成立(源码层):已经 armed 的定时器不会因为改配置被清掉。触发保活时不会重新读取 enabled,reload 也不清注册表,要等该会话下一次合格的真实请求才会重新判定。

踩坑

旧 ID 的子代理被保活,已经 6 次

现象:同一个主会话派发的 designer 子会话,在 bili 后台一直被保活。先后登记(armed)多次,已经有 6 次保活 ping,每次间隔约 272 秒(270 秒档),每次命中约 8 万 token,tier 是 5m。

我的排查是两头对:

  • 用只读 sqlite 查 session_v2:这个子会话保存的模型是 claude_orz_sub/claude-sonnet-5-5,是旧 ID,不是 sub- 前缀。
  • bili 日志里该会话请求的模型名是 claude-sonnet-5-5,所以 enabled: true 的原名配置对它生效。

所以被保活是符合配置的,不是 sub- 的 enabled: false 失效。「sub- 前缀不被保活」这条仍然没有运行证据,因为还没有一个子会话真的用上 sub- 模型。

旧 ID 的子代理与主模型同名、同上游,bili 分不出来,把它当主会话保活,窗口是 2 小时,按 270 秒一次大约 26 次 ping;而且只要这个会话还有真实请求,窗口就会被刷新。这是旧模型保留的过渡期内已知的副作用;等旧子代理会话全部结束,只清理 claude_orz_sub 下旧的 4 个原名模型,bili 里原名四个模型的保活配置要继续保留,主会话在用。

预设快照:重载后子代理仍用旧 ID

配置改了、重载了,新派的子代理保存下来的模型却还是旧 ID。排查办法是只读查数据库,不动它:

  • 用 sqlite3 -readonly 读 OpenCode 数据库里的 session_v2,看子会话保存的 model、创建时间、与父会话的关系;
  • 再拿 bili 日志里 armed / ping 的时间和转发行的模型名去对。

示意如下,库路径和 ID 换成你自己的。示例假定表里有 id 这样的列,表名和列名都以 .schema 的实际输出为准,对不上就照输出改:

# 只读打开,不会改库
sqlite3 -readonly "<OpenCode 数据库文件>" ".schema session_v2"
sqlite3 -readonly "<OpenCode 数据库文件>" \
  "SELECT * FROM session_v2 WHERE id = '<会话 ID>';"

父会话(主会话)在 kv 里存着 omo slim 的「团队预设快照」:presetName 虽然是 claude,里面 designer 的模型却仍是旧 ID,快照最后更新时间停在新前缀配置出现之前(当天中午)。另一个父会话(别的项目的主会话)的快照,则在重载之后(晚上)更新成了新的 sub- 模型。所以不同父会话的快照新旧不一,要逐个确认。至于那个快照为什么会更新,我没查,推测是做过某种「应用到该会话」的操作或有自动刷新,没证实。

原因归纳:配置文件改了、重载了,但 omo slim 的预设快照存在每个父会话里,已有快照的父会话派发子代理时直接读快照;快照没换,子代理就继续用旧 ID。

处置,我只描述,由我自己去执行:

  • 不要为此重启服务,会中断正在运行的会话;
  • 在该父会话里用 omo slim 的 /preset claude,选「应用到该会话」,只影响这个会话之后新派发的子代理;
  • 它不会改已存在子会话保存的模型,也不会清除已 armed 的定时器,所以那个旧 ID 子会话会继续被保活到窗口结束;
  • 之后观察新派发的子代理保存的模型是不是变成了 sub-。这一步我还没看到结果。

以后判断「这个子代理用的是哪个模型」,先看 session_v2 保存的模型,再对 bili 日志里转发行的模型名,不要只看配置文件。

并发上限:同名旧 ID 子会话互相挤

bili 的并发上限 maxConcurrentPings 默认是 4。日志里出现过一次「concurrency-cap」解除登记,被挤掉的条目随后又立刻重新登记。这说明多个同名旧 ID 子会话同时在线时,保活条目会互相挤。

bili 保活的行为边界:源码层,没实测

这一节是我读源码整理的,没有逐条实测,只当排查线索。

  • 窗口:最后一次真实请求会刷新(noteSuccessfulForward 重新登记并替换定时器),ping 不刷新。
  • 自动停止的条件:超过 maxDurationMs;缓存读取为 0;收到 429;连续 3 次错误;并发上限(maxConcurrentPings 默认 4)挤掉最早的;bili 内存里的会话消失。
  • 没有按会话手动停止的接口,后台页面只显示、不能停。
  • OpenCode 删除、归档、闲置会话,都不会通知 bili。
  • reload 和改 enabled: false,都不会清已经存在的定时器(触发保活时不重新读取 enabled,reload 也不清注册表),要等该会话下一次合格的真实请求才重新判定。
  • 想立即停掉现有全部定时器:只能让 bili 进程退出;要继续代理的话,带 BILI_CACHE_KEEPALIVE=0 重启。
  • maxDurationMs 配在模型级而不是 TTL 级,所以旧 ID 的 5m 会话也套用 2 小时窗口,前面说的 26 次就是这么来的。

成本只说倍率,不算钱:保活的读取按缓存读取价,重写按缓存写入价。5m 写入 1.25×、1h 写入 2×、读取 0.1×(Sonnet、Opus 5.5 的读取是 0.05×)。倍率口径按 Anthropic 官方,我没再另外核对。所以保活划不划算,看的是「读几次能把一次 1h 写入摊薄」,而前提是 ping 要命中。

没验证 / 没做完

  • 重新绑定预设后,新派发的子代理是不是真的用 sub- 模型、并且不再被保活:待验证。
  • sub-claude-* 的 enabled: false 在运行态是不是真的不被登记:没有样本,因为还没有子会话真的用上 sub- 模型,目前只有静态配置和源码依据。
  • providers 路由的刷新由什么触发:没定位。源码里只见启动、网页保存、显式 reload 三条路径;实际观察是改文件后约一分半钟生效、没有 reload 日志。打包后的源码是单行压缩文件,可能漏读。
  • 保活跨过 TTL 之后的真实读取量对比:没做对比实验。
  • 主会话 1h 保活的实际命中:没有 54 分钟的 ping 样本。这不是失败,是我没有刻意去测;目前命中的都是旧 ID 子代理的 5m。
  • bili 与 OpenCode warming 同时开会不会重复保活:只是机制上的推断,没实测,当前 warming 已关。
  • warming 失败的根因:只有线索,没做请求体差分。
  • 旧 ID 清理(只清 claude_orz_sub 下旧的 4 个原名模型,bili 里原名模型的保活配置保留):要等旧子代理会话全部结束,还没做。

结语

这次的思路其实只有一条:先找一个能把「主、子」区分开的维度,再想办法造出来。bili 只认「上游 + 模型名」,那模型名就是我能动的维度,CPA 的 sub- 前缀正好能造出这个差别,还不会把缓存隔离开。保活换成 bili 以后,命中量从 warming 的一万多变成了十几万,至少在旧 ID 子代理那几次 ping(约 8 万到 12.9 万 token)上是这样。

教训有两条,都是改完配置之后的事:一是去查「已存的会话里到底存了什么」,别只看磁盘上的配置文件,旧 ID 的会话、omo slim 的预设快照都比配置文件「记性好」;二是去查「配置是怎么被服务读到的」,路由刷新靠什么触发,我到现在还没在源码里定位到。

下一步:等新派的 fixer 确认是 sub- 模型;等旧子代理会话全部结束,清掉 claude_orz_sub 下旧的 4 个原名模型(bili 里原名模型的保活配置继续保留,主会话用);再等一次主会话的 54 分钟 ping,看它到底命没命中。保活这东西,不盯着看,我连它在替谁续命都说不清。

参考与外链

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

评论 (0)

验证码,点击刷新
取消