Skip to content

detectUnannouncedHistoryRewrite 误判 1 条消息的侧边辅助请求(如 WebSearch),导致 Ref 映射表被洗空、编号雪崩耗尽(m99999)及 363 万 Token 溢出 #1075

Closed

Description

@dadimen

bug(session): detectUnannouncedHistoryRewrite 误判 1 条消息的侧边辅助请求(如 WebSearch),导致 Ref 映射表被洗空、编号雪崩耗尽(m99999)及 363 万 Token 溢出

1. 问题摘要 (Summary)

在 0.1.121 之后的版本中(引入 #1001 后),detectUnannouncedHistoryRewrite(未宣示的历史重写检测)会将仅包含 1 条消息的独立侧边请求(例如 Claude Code 的 WebSearch 联网搜索、标题生成或内部辅助 Prompt)错误判定为“客户端悄悄删改了全量历史”。

由于 Claude Code 在底层网络库中对该终端发出的所有 HTTP 请求均机械化地附加了全局的 x-claude-code-session-id,这些仅含 1 条辅助消息的请求(incomingTotal = 1, knownIncoming = 0)计算得出的旧消息比例为 $0 / 1 = 0 < 0.5$,直接命中重写判定。系统随后触发 applyCompactionArchive(),将先前累积的数千条历史 Ref 映射表(byRaw 与 byRef)全部清空。

当 Claude Code 在紧接着的下一轮对话重新发送包含全部历史的消息时(如 6,000+ 条消息),ACP 核心误以为这几千条消息全是从未见过的“全新消息”,并从最新游标开始重新分配新 Ref。对于经常调用 WebSearch 的长会话:

  1. 每一次搜索就会在下一轮烧掉整整数千个 Ref 编号。
  2. 5 位数的编号池(m00001~m99999,共 99,999 个)在短短几小时内被彻底榨干。
  3. 一旦编号突破 m99999,assignRefs() 抛出 Error: ref capacity exhausted: cannot allocate beyond m99999。
  4. prepareAnthropic() 捕获异常并触发容错降级(Fail-open),将整整 11.9 MiB、6,650 条未经压缩的原始消息直接转发给上游模型(高达 3,633,839 tokens)。
  5. 上游模型因超出 100 万 Token 上限返回 HTTP 400 (prompt is too long: 3633839 tokens > 1048576 maximum),网关将其包装为 HTTP 529 overloaded_error,导致 Claude Code 陷入无限重试死循环(attempt 4/300)。

2. 根本原因分析 (Root Cause Analysis)

A. 缺陷的数学检测逻辑

位于 src/session.ts(第 558–576 行):

export function detectUnannouncedHistoryRewrite(
    session: Session,
    knownRefsBefore: ReadonlySet<string>,
    liveRawIds: Iterable<string>,
): RewriteDetection {
    const knownBefore = knownRefsBefore.size;
    let incomingTotal = 0;
    let knownIncoming = 0;
    for (const id of liveRawIds) {
        incomingTotal++;
        if (knownRefsBefore.has(id)) knownIncoming++;
    }
    const detected =
        knownBefore >= REWRITE_MIN_KNOWN_REFS &&
        session.state.blocks.length > 0 &&
        incomingTotal > 0 &&
        knownIncoming / incomingTotal < REWRITE_MAX_KNOWN_RATIO; // 阈值为 0.5
    return { detected, knownBefore, incomingTotal, knownIncoming };
}

B. 单消息侧边请求引发的致命误判

当 Claude Code 执行诸如 WebSearch 之类的工具时:

  • Claude Code 会向大模型发送一笔独立的 HTTP 辅助请求,用于提炼或分析搜索关键词。
  • 该请求仅包含 1 条独立提示词消息(incomingTotal = 1)。
  • 由于该提示词此前从未在对话中出现过,认识的旧消息数为 0(knownIncoming = 0)。
  • 比例计算:$0 / 1 = 0 < 0.5 \implies$ 判定结果永远为 detected = true!

C. 连锁灾难:Ref 映射表被彻底洗空

一旦判定为历史重写,src/server.ts 随即调用:

markCompactionBoundary(session);
applyCompactionArchive(session, activeBefore, new Set(msgs.map((m) => m.id)), log);

而在 applyCompactionArchive(src/session.ts 第 520–530 行)中:

const { byRaw, byRef } = session.state.messageRefs;
const prunedByRaw: Record<string, string> = {};
for (const [rawId, ref] of Object.entries(byRaw)) {
    if (liveRawIds.has(rawId)) prunedByRaw[rawId] = ref;
}
session.state.messageRefs.byRaw = prunedByRaw;
session.state.messageRefs.byRef = prunedByRef;

由于本次请求中的 liveRawIds 仅仅包含那 1 条搜索辅助消息,导致先前会话累积的数千条历史消息映射全部从 byRaw 与 byRef 中被永久剔除!

D. 恶性通胀:旧历史被反复当成新消息重复发号

在紧接着的下一轮正常用户对话中:

  • Claude Code 发送包含 6,301 条消息的完整对话历史。
  • assignRefsNode 调用 assignRefs(messages, { nextIndex: highestUsedIndex(io.state.messageRefs) + 1 })。
  • 因为映射表刚刚被洗空,这 6,300 条历史消息在 byRaw 里全部丢失。
  • ACP 核心以为这 6,300 条消息全都是全新的,于是从上一次的游标继续一口气分配 6,300 个新 Ref。
  • 每调用一次 WebSearch,Ref 游标就会直接暴增整整一段历史长度 $N$!

3. 真实生产环境数据与日志铁证 (Evidence)

从实际受害会话 4eb07bcf-030b-45b0-a28b-5e373b2681b6.jsonl 与代理日志 bili.log.old 提取的数据:

1. 正常基准状态(第 59 次压缩)

  • JSONL 第 11790 行(2026-09-20T08:17:46.411Z):
    Claude 正常调用 mcp__bili__compress 压缩 m08050–m08178 区间。此时最高编号仅为 m08178,系统运转健康。

2. 30 秒内连续 3 次 WebSearch 导致的剧烈跳跃

  • JSONL 第 11875 行(2026-09-20T08:31:25.485Z):第一次 WebSearch 调用。
    • 代理日志(bili.log.old):
      2026-09-20T08:31:25.551Z [warn] [4eb07bcf...] unannounced client history rewrite detected (0/1 incoming message(s) carry pre-turn refs of 8237 known) — marking compaction boundary (#1001)
      2026-09-20T08:31:25.567Z [info] [4eb07bcf...] native compaction boundary: archived ...; pruned ref maps to undefined live raw id(s)
      2026-09-20T08:31:25.568Z [info] [4eb07bcf...] request: 1 msgs, inbound=875B, outbound=11.6KiB, local=55ms
      
    • 游标暴增 +6,301 $\rightarrow$ 跃升至 ~m14539。
  • JSONL 第 11879 行(2026-09-20T08:31:34.499Z):第二次 WebSearch 调用(仅间隔 9 秒)。
    • 代理日志:rewrite detected (0/1 incoming message(s) carry pre-turn refs of 8232 known)。
    • 游标再次暴增 +6,301 $\rightarrow$ 跃升至 ~m20841。
  • JSONL 第 11883 行(2026-09-20T08:31:54.227Z):第三次 WebSearch 调用(再间隔 20 秒)。
    • 代理日志:rewrite detected (0/1 incoming message(s) carry pre-turn refs of 8236 known)。
    • 游标第三次暴增 +6,301 $\rightarrow$ 跃升至 ~m27143。

3. 后续压缩编号直接跳跃至 m32874

  • JSONL 第 11971 行(2026-09-20T08:40:49.703Z):
    Claude 再次调用 mcp__bili__compress,目标区间赫然变成了 m32874–m32995!
    短短 23 分钟内,仅仅因为 3 次 WebSearch,编号游标直接从 m08178 狂飙 24,696 个号码跳到 m32874!

4. 最终在 m96957 彻底击穿号池爆仓

在日志中记录了共计 22 次 误判重写后,该会话编号累积到了 m96957:

2026-09-20T18:21:38.670Z [warn] [4eb07bcf...] kernel transform failed, forwarding unchanged: Error: ref capacity exhausted: cannot allocate beyond m99999
2026-09-20T18:21:38.967Z [info] [4eb07bcf...] request: 6641 msgs, inbound=11.6MiB, outbound=11.6MiB, local=432ms
2026-09-20T22:18:52.241Z [warn] [4eb07bcf...] upstream 529: {"type":"error","error":{"type":"overloaded_error","message":"Upstream provider returned error (HTTP 400): {\"error\":{\"code\":\"prompt_too_long\",\"message\":\"prompt is too long: 3633839 tokens > 1048576 maximum\"}}...

4. 多会话横向比对数据 (Cross-Session Verification)

我们横向比对了同一天运行的其他 Claude Code 会话,证实该 Bug 并非个例,所有执行 WebSearch 的会话均遭殃,唯独该会话因大体量历史而率先爆仓:

会话 ID 会话历史长度 WebSearch 次数 误判清空触发次数 最高 Ref 编号 状态
651e466d ~700 条 9 次 5 次 m08676 潜伏未爆(未达到 9.9 万上限)
2773a734 ~1,200 条 8 次 4 次 m12418 潜伏未爆(未达到 9.9 万上限)
40f6b344 ~3,000 条 2 次 1 次 m14218 潜伏未爆(未达到 9.9 万上限)
4eb07bcf 6,650 条 29 次 11+ 次 m96957 击穿号池上限,崩溃进入 529 死循环

历史短的会话之所以尚未崩溃,仅仅是因为其消耗公式为 $\text{短历史} \times \text{少量次数} &lt; 99,999$。任何持续聊下去并频繁调用工具的长会话,最终都会步入爆仓后尘。


5. 建议修复方案 (Proposed Fix)

方案 1:在 detectUnannouncedHistoryRewrite 中增加最小消息数量防护门槛(强烈推荐)

单条消息或极少量的辅助性请求,绝不可能是客户端对一个多轮长会话进行的原生压缩重写。

在 src/session.ts 中增加阈值拦截(如最少 10 条):

const REWRITE_MIN_INCOMING_TOTAL = 10; // 忽略单条或少量消息的侧边辅助请求

export function detectUnannouncedHistoryRewrite(
    session: Session,
    knownRefsBefore: ReadonlySet<string>,
    liveRawIds: Iterable<string>,
): RewriteDetection {
    const knownBefore = knownRefsBefore.size;
    let incomingTotal = 0;
    let knownIncoming = 0;
    for (const id of liveRawIds) {
        incomingTotal++;
        if (knownRefsBefore.has(id)) knownIncoming++;
    }
    const detected =
        knownBefore >= REWRITE_MIN_KNOWN_REFS &&
        session.state.blocks.length > 0 &&
        incomingTotal >= REWRITE_MIN_INCOMING_TOTAL && // <-- 新增门槛保护
        knownIncoming / incomingTotal < REWRITE_MAX_KNOWN_RATIO;
    return { detected, knownBefore, incomingTotal, knownIncoming };
}

方案 2:优化 applyCompactionArchive 的裁切策略

即使确实检测到历史收缩,也不应完全物理抹除 byRaw 中的历史映射条目,避免当客户端偶尔穿插发送单句提示词或随后重新载入主历史时,发生灾难性的游标重复激增。

Activity

  1. ranxianglei commented on Sep 21, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 收到,正在处理。已初步核对代码路径:src/session.ts:525 的 detectUnannouncedHistoryRewrite 确实没有最小入站消息数门槛(仅要求 incomingTotal > 0),src/server.ts:2161/2318 命中后即 markCompactionBoundary + applyCompactionArchive,把 byRaw/byRef 裁到本次请求的 live ids——与日志中 pruned ref maps to undefined live raw id(s)、每次 WebSearch 游标 +6,301 的现象一致。正在做重复排查 + 写回归测试复现,随后提交修复 PR。

  2. ranxianglei commented on Sep 21, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 **修复已提交 → https://github.com/ranxianglei/billion-context/pull/1076**(Fixes #1075,待人工 review/合并)

    审核结论(审核先于实现)

    1. 复现/查证:问题属实。 代码路径与你的日志铁证逐环吻合:src/session.ts:540 的判定条件只有 incomingTotal > 0,没有任何最小入站消息数门槛;单条 WebSearch 辅助请求 knownIncoming/incomingTotal = 0/1 < 0.5 必然命中 detected=true。随后 src/server.ts:2161/2318(anthropic/openai 两条 wire 各一处)执行 markCompactionBoundary + applyCompactionArchive,把 byRaw/byRef 裁到本次请求的 live ids——即日志里的 pruned ref maps to undefined live raw id(s)。内核 assignRefsNode 游标 = highestUsedIndex(map)+1,映射表被洗空后下一轮全量重放(6,301 条)全部视为新消息从游标续发 → 每次 WebSearch +N 编号 → m99999 耗尽 → prepareAnthropic fail-open 转发 11.9MiB 未压缩历史 → 上游 400 prompt_too_long → 529 重试死循环。另核对了 orphan-gc.ts:孤儿块回收需连续 3 轮完全失联,单次侧边请求不会误杀压缩块,排除该旁路。

    2. 层次判断: 你描述的"映射表洗空 → 编号雪崩 → 号池耗尽 → Token 溢出 → 529 死循环"中,根因是检测器对极小全新生成载荷的误判,其余全部是下游症状。你的分析打到了核心,不是治标。

    3. 方案评估: 采用方案 1(最小入站消息数门槛),并额外加了一道更保守的条件:

      取舍依据是风险不对称:漏判一次真实重写的代价只是陈旧映射条目驻留到会话结束(便宜、自愈);误判一次的代价是整表洗空 + N 个编号蒸发(致命,即本事故)。所以宁可保守。
      方案 2(改 applyCompactionArchive 裁切策略)未采用:误判消除后通胀路径整体消失,再改裁切语义只会动到持久化/ref-map 这一 load-bearing 区域而无剩余收益。若日后出现"真实重写零重叠"的新客户端形态,可再单独评估。

    证据

    新增 tests/fix-1075-side-request.test.ts(5 单元 + 1 e2e):

    • 单元:1 条 / 9 条 / 12 条零重叠载荷 → 不再误判;模型切换后压缩块跨模型失配,新模型收到 61.3 万 token 全量上下文 #1001 形状(8 旧+16 新)与贴地收缩(1 旧+11 新)→ 仍正常检出,功能不回退。
    • e2e(真实 proxy + preflight 建块,完整复刻事故时序):turn1 建 24 refs+活跃块 → turn2 发 1 条侧边请求 → turn3 全量重放。断言:未标记 compaction boundary、映射表存活(25 条、max=m00025)、原 24 条保留原编号、max ref = m00026(修复前此处为 m00050,即雪崩)、压缩块存活、出站载荷仍带 preflight 摘要。
    • 修复前验证:stash 掉修复后跑新测试,4 项按事故现象精确失败(含 e2e 雪崩断言);恢复后全绿。
    • typecheck ✅ · 全量测试 2160 pass / 0 fail / 2 skipped(gated e2e)✅ · build ✅

    查重: 检索了 open+closed issues/PRs(WebSearch / rewrite / m99999 / ref capacity 等关键词),无重复条目,本 issue 是该缺陷的唯一报告。

    一句话总结:修复了重写检测器把单条侧边辅助请求误判为全量历史重写的问题(补了最小入站条数 + 历史重叠两道门槛,消除洗表与编号雪崩的触发源),回归测试修复前失败/修复后通过,可以合并。

  3. ranxianglei commented on Sep 21, 2026

    @ranxianglei
    Owner

    总结下误判根本原因是啥 是否冲着根本原因修复的

  4. ranxianglei commented on Sep 21, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 误判根本原因(分层)

    • 表面症状:0/1 < 0.5 命中重写判定。这只是数学表现,不是病因。
    • 直接原因:检测条件只有 incomingTotal > 0,对退化输入(极小载荷)没有下界防护。
    • 根本原因:检测器有一个未声明的隐含假设——"凡是绑定本会话的请求,其消息集都是主对话的全量快照"(要么 append-only 增长,要么整体替换)。这个假设在 Claude Code 上不成立:它的底层网络库把全局 x-claude-code-session-id 机械地附加到该终端发出的每一个 HTTP 请求上,包括 WebSearch 查询提炼、标题生成这类独立小 prompt——它们不是快照,只是恰好骑在同一会话 id 上的旁路流量。假设一旦被违反,比例公式对这类请求的判定就必然失真(全新生成 → 比例恒为 0)。
    • 放大器:检测与后果严重不对称——一个廉价启发式布尔值直接触发破坏性状态变更(物理洗空 byRaw/byRef),中间没有任何"载荷是否真的像主对话快照"的二次确认。所以一次误判就造成不可逆的编号雪崩。

    是否冲着根本原因修的:是。

    1. 修复点在决策点本身(detectUnannouncedHistoryRewrite),即错误布尔值的产生处,而不是下游遮症状:没有动 applyCompactionArchive 的裁切行为(那才是"堵"),也没有在转发路径上加任何过滤。真实重写的处理链路原样保留,模型切换后压缩块跨模型失配,新模型收到 61.3 万 token 全量上下文 #1001 的全部测试仍通过。
    2. 两道门槛本质上是把"真正的主对话快照必须具备的结构特征"编码进判定:足够大(≥10 条,成熟会话的重写绝不以近空载荷出现)+ 与既有历史有重叠(≥1 条已知 id,客户端压缩后保留尾部;旁路流量零重叠)。这是在代理侧能拿到的信息内,对失效假设做的最小充分修正。
    3. 用仓库的试金石检验:"同一根因明天换个形态出现,修复还成立吗?"——12 条以上、零重叠的长辅助 prompt:仍被 knownIncoming > 0 挡住 ✅;唯一残余是"≥10 条且恰好含 1 条逐字重复旧消息且已知占比 <50%"的辅助请求——这种形态在 wire 上与真实重写信息论上不可区分(客户端不提供任何区分信号,session id 是机械附加的),任何纯内容启发式都分不开它。此时宁可保守判定为重写也不引入第三种猜测,因为漏判代价(陈旧条目驻留到会话结束,自愈)远小于误判代价(整表洗空)。
    4. 更彻底的修法需要客户端配合(例如辅助请求带一个"非快照"标记头),这超出 bili 单侧能力,属于边界外——已在 PR 描述中说明,不在此 PR 里用下游补偿去掩盖。

    一句话:根因是"同会话 id ≠ 主对话快照"假设失效 + 检测无下界防护,修复在决策点补上了快照的结构特征校验,是疏不是堵;wire 上不可区分的残余形态已按风险不对称做了保守取舍。

  5. added a commit that references this issue on Sep 21, 2026
  6. added 2 commits that reference this issue on Oct 11, 2026
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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions