Skip to content

[Bug] 换账号后长会话反复写入大额缓存:rewrite 丢弃稳定断点(v1.3.188 离线复现) #524

Open

Description

@qazwsx-coder-01

提交前自查

  • 已升级到最新 Release,问题仍存在(或无法升级,已在下方说明原因)
  • 已搜索现有 Issues,没有重复
  • 已阅读 ISSUE_GUIDE.md,并对贴出的所有内容做了脱敏

问题类型

其他

1. 使用的版本

生产样本:基于 v1.3.178 的定制版;原版离线复现:v1.3.188,0629424e63cdcf26301b155f609defc7ba63d878。新版真实上游缓存复验尚未执行。

部署方式

手动 Docker Compose

问题描述

同一入站 Session 标识的长上下文请求切到另一账号/槽位后,首条冷写入可以理解,但后续同账号串行成功请求仍反复写入约 82–84 万 token,只读取 14,621 token(约 1.73%–1.74%)。相邻账号有的很快恢复约 83 万读取,有的四条观察内未恢复。

实际是“反复大量写入、读取率低”,并非 cache_creation_input_tokens=0。我们不要求跨账号直接共享上游缓存;希望新账号写入后,后续轮次能复用未变化的长前缀。

核对原版 v1.3.188:只有识别为官方流量且 official_cc_passthrough=true 时才保留调用方断点;默认关闭时仍清除原断点,固定标记消息尾部与倒数第二条 user。这个开关还同时控制是否保留客户端 system,需要平台 persona 时,无法用它单独选择保留客户端缓存断点。想请作者确认是否为预期及推荐处理方式。

**证据边界:**生产用量来自 v1.3.178 定制版,不声称已在原版 v1.3.188 生产复现;下方在固定新版原版源码上的离线脚本证明 Node 断点改写,缓存查找部分仅是机制模拟。历史请求无 debug body,不能据此认定本次事故唯一根因或归责用户。

2. 可复现路线

在原版 v1.3.188 源码目录,按 package-lock.json 安装依赖。将脚本保存为任意目录的 repro.mjs,执行 node /path/to/repro.mjs "$PWD"。不启动服务、不联网、不使用账号、不读写生产数据库。

合成 915 条单文本 block 消息,在稳定位置 910 放调用方断点;下一轮只改变历史位置 912。消息数保持相同是为隔离断点行为的最小反例,不是实际客户端完整对话重放。

import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
const root = path.resolve(process.argv[2] || '.')
const load = (file) => import(pathToFileURL(path.join(root, 'src/lib', file)).href)
const { prepareCliHopBody } = await load('protocol/outbound-attempt.mjs')
const { officialCcPassthroughFromRouting } = await load('identity/crs-persona.mjs')
assert.equal(officialCcPassthroughFromRouting({}), false)
const first = {
  model: 'claude-opus-5-5', max_tokens: 32,
  messages: Array.from({ length: 915 }, (_, i) => ({
    role: i % 2 ? 'assistant' : 'user',
    content: [{ type: 'text', text: `stable-${i}` }],
  })),
}
first.messages[910].content[0].cache_control = { type: 'ephemeral', ttl: '1h' }
const second = structuredClone(first)
second.messages[912].content[0].text = 'changed-historical-block'
const markers = (body) => body.messages.flatMap((m, i) =>
  m.content.some((b) => b.cache_control) ? [i] : [])
const prefix = (body, i) => createHash('sha256').update(JSON.stringify(
  body.messages.slice(0, i + 1), (key, value) => key === 'cache_control' ? undefined : value,
)).digest('hex')
for (const preserveSystem of [false, true]) {
  const options = { preserveSystem, preserveThinkingBlocks: true }
  const a = prepareCliHopBody(structuredClone(first), options)
  const b = prepareCliHopBody(structuredClone(second), options)
  const written = new Set(markers(a).map((i) => prefix(a, i)))
  // 示意模型:仅缓存实际写入断点,最多向前检查 20 个单文本 block。
  // 不是上游服务实现,不是实际缓存命中率测试。
  let match = null
  for (const end of markers(b)) {
    for (let i = end; i >= Math.max(0, end - 19); i--) {
      if (written.has(prefix(b, i))) match = Math.max(match ?? -1, i)
    }
  }
  assert.deepEqual(markers(a), preserveSystem ? [910] : [912, 914])
  assert.equal(match, preserveSystem ? 910 : null)
  console.log(JSON.stringify({ fixture_only: true, upstream_calls: 0,
    mode: preserveSystem ? 'official_passthrough' : 'rewrite',
    message_markers: markers(a), new_account_next_turn_matching_message: match }))
}

在独立导出的原版 v1.3.188 上实际执行,Node v22.22.2 / macOS arm64;复用本机按锁文件已安装的依赖。退出码 0,输出:

{"fixture_only":true,"upstream_calls":0,"mode":"rewrite","message_markers":[912,914],"new_account_next_turn_matching_message":null}
{"fixture_only":true,"upstream_calls":0,"mode":"official_passthrough","message_markers":[910],"new_account_next_turn_matching_message":910}

期望结果 / 实际结果

期望:在四个总断点限制内,对合法调用方断点有明确、可配置的保留策略;需要平台 persona 时,也能保护历史稳定长前缀锚点。换账号可以先冷写入,后续不应仅因稳定锚点被网关删掉而反复重写长历史。

实际:默认 rewrite 删除 910,首轮仅在 912/914 写入;下一轮 912 改变后,两条历史条目均不匹配。向前查找未写过的位置不能凭空生成稳定前缀条目。保留 910 时,同一模拟可匹配到 910。

这证明一种能够产生该形态的机制,尚未证明是生产低命中的唯一原因。线上四条均 HTTP 200、单次 attempt,无执行重叠,详见请求日志。

官方 Prompt caching / automatic prefix checking 说明条目在断点创建,向前检查匹配已创建条目,且每断点有 block 查找窗口。延长 TTL 或保留相同 Session UUID 本身不能修复前缀失配。

复现频率

必现

3. 运行环境(VPS / 宿主机)

  • 生产:Linux x86_64,Docker Compose 控制面 + Docker 槽位,源码构建定制版。
  • inference.dataplane=wrap / CLI-hop,多账号池,本文对照四个账号/槽位。
  • 宿主机具体内核、Docker 版本、云地区、资源规格及是否多 VPS 本次未收集,不据此推断根因。
  • 四个槽核验时 kernel 与 cli-node 的 SHA-256 分别相同;persona 均为 official,默认 TTL 为 1h,不能用槽间二进制版本不同解释这个对照。
  • 离线验证环境:macOS arm64,Node v22.22.2;固定 v1.3.188 tag 的独立原版源码。

代理 / 出口

出口是否共享、代理类别/地区、DoH 和透明代理本次未核实;不披露代理账号、地址或公网 IP。各槽有固定时区差异,但固定环境差异只能说明换槽首次可能冷启动,不能单独解释随后同槽反复低命中。

账号与客户端

  • 多账号池,订阅档位未核实,不披露真实账号/凭据。
  • 经 new-api 中转到 /v1/messages。UA 自报 claude-cli/2.1.301 (external, cli),不能仅凭 UA 验证真实客户端。
  • 主模型 claude-opus-5-5,effort high,包含工具调用。夹带其他模型的辅助请求未混入下面 Opus 用量表。
  • 排查时配置:official_cc_passthrough 未设置(有效 false);detect_proxied_official_cc=true;cache_ttl=1h;cache_breakpoints={enabled:true,preserve_client:true,system_tail:false,tools_tail:false,messages:"cli-hop"}。这不是逐请求历史配置快照。

4.1 VM 创建 / 槽位日志

4.2 请求日志

来源:只读核对生产 SQLite usage_logs、request_attempts.usage_json、控制面 Docker 日志。A/B/C/D 是匿名账号/槽位;C1-C4 是请求代号,不是真实 ID。

同一新账号 C 四条 Opus 请求,按实际执行顺序:
请求 | input | cache creation | cache read | output | read/总输入
C1   | 2     | 838500         | 0          | 265    | 0%
C2   | 2     | 823969         | 14621      | 18     | 1.7435%
C3   | 2     | 831254         | 14621      | 710    | 1.7285%
C4   | 2     | 825726         | 14621      | 867    | 1.7399%

分母=input+cache_creation+cache_read,input=2 不代表只发了2 token。
均 HTTP 200,attempt_count=1,执行区间不重叠。写入全部在 1h 分项,写入总量与 TTL 分项求和一致,attempt 与汇总 usage 一致。
这是 kernel 返回/控制面记录值,不是独立抓取的 Anthropic 原始 SSE;不能完全排除更下层统计问题。

同一入站 Session 标识下相邻 Opus 的 cache read 顺序:
A:41次,0 → 822811 → 824752 → … → 837283
B: 4次,0 → 14620 → 14620 → 837873
C: 4次,0 → 14621 → 14621 → 14621
D: 4次,0 → 14620 → 14620 → 838028

A 第二条已大量读取;B/D 后来恢复,不是新账号永久不能缓存。C 只观察四条,不能推断继续使用也不会恢复。相同入站标识不足以证明每条都是严格追加的同一分支。

匿名控制面日志:
[cache-prefix] request <C2> turn 3 broke at tools
[cache-prefix] request <C3> turn 4 broke at messages[912]
[cache-prefix] request <C4> turn 5 broke at messages[912]

tools 分歧前夹带不同模型;当时 tracker 按 account/session 而非 model 隔离,不能据此认定同模型工具改变。A 的高命中轮次也出现过 messages[912] 分歧,不能只凭该警告断定缓存必然全部失效。

5. 源码定位(可选)

以下均为固定原版 v1.3.188 commit 的源码:

  1. handle-protocol.mjs:officialPassthrough / prepareCliHopBody:只有识别为 official 且开关 true,才传 preserveSystem:true;同一条件也决定跳过平台 persona 改写。
  2. crs-persona.mjs:开关默认 false:只接受 compatibility.official_cc_passthrough===true。
  3. outbound-attempt.mjs:清除后硬编码 rewrite:!preserveSystem 时 removeCacheControlFields,再 applyMessageBreakpoints(...,'rewrite');未直接使用 preserve_client / messages=cli-hop 选择保留策略。
  4. cache-ttl.mjs:倒数第二个 user 与消息尾部断点:rewrite 丢掉已有标记再选两个位置,没有专门保护历史首次变化之前的稳定锚点。
  5. cli-hop-body.test.mjs:透传混合 TTL 与断点保留 已覆盖透传;本报告关注开关关闭时另一条路径,不是认为新版完全没有缓存修复。

**已确认:**Node 默认 rewrite 会丢掉客户端稳定断点;缓存策略与 system/persona 透传绑定。

**待证解释:**旧账号可能积累变化点前的早期缓存条目,新账号首次接入完整长历史时只在变化点后的尾部写入;历史中间再变,两条新条目都无法复用。这能解释“旧号正常、新号反复写入”,但没有最终 wire,不能作为历史事故唯一归因。

建议与咨询作者

以下仅为修复讨论方向,尚未真实上游验真:

  1. 把“保留调用方缓存断点”和“采用平台 persona/system”拆开,或让 CLI-hop 明确遵循 preserve_client。继续校验四断点上限、位置和 TTL 顺序,不无条件叠加。
  2. 必须 rewrite 的长历史冷迁移,在断点预算内保留变化点前的稳定长前缀锚点,再安排必要尾部锚点。结构摘要可用于选择位置,不能当成已迁移上游缓存。
  3. 增加“新账号首轮写入 → 同账号下一轮历史局部改变”回归;比较入站、Node、最终 CLI wire 的结构哈希与断点,真实验证再对照 usage。

想请教:

  • 默认 rewrite 丢弃客户端断点是协议要求,还是可调整策略?preserve_client=true 对 CLI-hop 不生效是否应在 UI/文档明确?
  • 需要平台 persona,又希望保留客户端稳定断点,目前推荐什么配置?是否只能开 official_cc_passthrough 并接受保留客户端 system?
  • kernel/CLI 是否在 Node 后另有断点规则,会修正这个反例?推荐怎样在不暴露提示词/凭据的情况下确认最终布局?
  • 对冷账号后续反复重写,作者更推荐保留客户端断点、稳定锚点,还是已有其他修复?如需补证,请告知最小采集项。

已尝试的操作 / 补充

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions