Repository navigation
detectUnannouncedHistoryRewrite 误判 1 条消息的侧边辅助请求(如 WebSearch),导致 Ref 映射表被洗空、编号雪崩耗尽(m99999)及 363 万 Token 溢出 #1075
Description
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)计算得出的旧消息比例为 applyCompactionArchive(),将先前累积的数千条历史 Ref 映射表(byRaw 与 byRef)全部清空。
当 Claude Code 在紧接着的下一轮对话重新发送包含全部历史的消息时(如 6,000+ 条消息),ACP 核心误以为这几千条消息全是从未见过的“全新消息”,并从最新游标开始重新分配新 Ref。对于经常调用 WebSearch 的长会话:
- 每一次搜索就会在下一轮烧掉整整数千个 Ref 编号。
- 5 位数的编号池(
m00001~m99999,共 99,999 个)在短短几小时内被彻底榨干。 - 一旦编号突破
m99999,assignRefs()抛出Error: ref capacity exhausted: cannot allocate beyond m99999。 prepareAnthropic()捕获异常并触发容错降级(Fail-open),将整整 11.9 MiB、6,650 条未经压缩的原始消息直接转发给上游模型(高达 3,633,839 tokens)。- 上游模型因超出 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 死循环 |
历史短的会话之所以尚未崩溃,仅仅是因为其消耗公式为
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
ranxianglei commentedon Sep 21, 2026
[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。ranxianglei commentedon Sep 21, 2026
[bot] 🏷 **修复已提交 → https://github.com/ranxianglei/billion-context/pull/1076**(Fixes #1075,待人工 review/合并)
审核结论(审核先于实现)
复现/查证:问题属实。 代码路径与你的日志铁证逐环吻合:
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耗尽 →prepareAnthropicfail-open 转发 11.9MiB 未压缩历史 → 上游400 prompt_too_long→529重试死循环。另核对了orphan-gc.ts:孤儿块回收需连续 3 轮完全失联,单次侧边请求不会误杀压缩块,排除该旁路。层次判断: 你描述的"映射表洗空 → 编号雪崩 → 号池耗尽 → Token 溢出 → 529 死循环"中,根因是检测器对极小全新生成载荷的误判,其余全部是下游症状。你的分析打到了核心,不是治标。
方案评估: 采用方案 1(最小入站消息数门槛),并额外加了一道更保守的条件:
REWRITE_MIN_INCOMING_TOTAL = 10(你建议的值):成熟会话的真实重写绝不以近空载荷出现;knownIncoming > 0:真实重写必然与既有历史共享至少一条消息 id(客户端压缩后保留尾部,模型切换后压缩块跨模型失配,新模型收到 61.3 万 token 全量上下文 #1001 的 opencode 场景即 8/24 已知);侧边辅助请求与主会话零重叠。取舍依据是风险不对称:漏判一次真实重写的代价只是陈旧映射条目驻留到会话结束(便宜、自愈);误判一次的代价是整表洗空 + N 个编号蒸发(致命,即本事故)。所以宁可保守。
方案 2(改
applyCompactionArchive裁切策略)未采用:误判消除后通胀路径整体消失,再改裁切语义只会动到持久化/ref-map 这一 load-bearing 区域而无剩余收益。若日后出现"真实重写零重叠"的新客户端形态,可再单独评估。证据
新增
tests/fix-1075-side-request.test.ts(5 单元 + 1 e2e):查重: 检索了 open+closed issues/PRs(WebSearch / rewrite / m99999 / ref capacity 等关键词),无重复条目,本 issue 是该缺陷的唯一报告。
一句话总结:修复了重写检测器把单条侧边辅助请求误判为全量历史重写的问题(补了最小入站条数 + 历史重叠两道门槛,消除洗表与编号雪崩的触发源),回归测试修复前失败/修复后通过,可以合并。
ranxianglei commentedon Sep 21, 2026
总结下误判根本原因是啥 是否冲着根本原因修复的
ranxianglei commentedon Sep 21, 2026
[bot] 🏷 误判根本原因(分层)
0/1 < 0.5命中重写判定。这只是数学表现,不是病因。incomingTotal > 0,对退化输入(极小载荷)没有下界防护。x-claude-code-session-id机械地附加到该终端发出的每一个 HTTP 请求上,包括 WebSearch 查询提炼、标题生成这类独立小 prompt——它们不是快照,只是恰好骑在同一会话 id 上的旁路流量。假设一旦被违反,比例公式对这类请求的判定就必然失真(全新生成 → 比例恒为 0)。byRaw/byRef),中间没有任何"载荷是否真的像主对话快照"的二次确认。所以一次误判就造成不可逆的编号雪崩。是否冲着根本原因修的:是。
detectUnannouncedHistoryRewrite),即错误布尔值的产生处,而不是下游遮症状:没有动applyCompactionArchive的裁切行为(那才是"堵"),也没有在转发路径上加任何过滤。真实重写的处理链路原样保留,模型切换后压缩块跨模型失配,新模型收到 61.3 万 token 全量上下文 #1001 的全部测试仍通过。knownIncoming > 0挡住 ✅;唯一残余是"≥10 条且恰好含 1 条逐字重复旧消息且已知占比 <50%"的辅助请求——这种形态在 wire 上与真实重写信息论上不可区分(客户端不提供任何区分信号,session id 是机械附加的),任何纯内容启发式都分不开它。此时宁可保守判定为重写也不引入第三种猜测,因为漏判代价(陈旧条目驻留到会话结束,自愈)远小于误判代价(整表洗空)。一句话:根因是"同会话 id ≠ 主对话快照"假设失效 + 检测无下界防护,修复在决策点补上了快照的结构特征校验,是疏不是堵;wire 上不可区分的残余形态已按风险不对称做了保守取舍。
Merge pull request #1076 from ranxianglei/2026-09-21_rewrite-min-inco…
fix: require min incoming size + history overlap for unannounced-rewr…
fix: guard rememberedPluginMessages against side-shaped snapshot evic…
feat: inherit refs/blocks/lineage across resume forks (#1486)
fix(#2598): wire #1001 unannounced-history-rewrite detection into the…
fix: #2815 review follow-ups — persisted-chain tiebreaker, schema v2,…