上下文压缩:压缩有没有真的发生,靠什么验证
TUI 弹出一条"对话已压缩",底部占用率却一直显示 17%。第一反应大概是—— 是不是压缩策略选得不好、阈值设得不对?
不是。压缩根本没有发生过。
结论先放这里
- 2026-07-29 假压缩事故:横幅弹出、模型被灌了"系统已为你精简上下文"的假话、 连续 30 条自我否定——真因是压缩函数静默 no-op,却被硬编码返回
success: true。 已用"实测差值定义成功"修复:安全分割点 0→53,会话 156→104 条消息, 28 条防回归测试今天重跑仍 28 pass。 - 压缩机制里还有一处"成功"从未被验证:负责让压缩"越用越省"的自适应调参子系统 (
adaptive-strategy.ts),从接线那天起读到的信号里没有一条来自真实压缩—— 脚本实测:单独跑一个测试文件就写入 8 条假数据,全量bun test后它的 50 条环形缓冲区 全是测试固件;显式设置项目统一的隔离变量对它完全无效,因为源码绕开了那层契约。 - 污染为什么不会自愈:环形缓冲区要挤进 39 条真实的失败样本,均值才会跌破告警阈值—— 而 67 个真实会话(2026-08-09 实测的滚动窗口)里,峰值上下文占用率从未超过 9.9%, 压缩三档阈值是 78%/82%/90%,阈值压缩路径至今一次真实触发都没有。
一、现象:横幅弹出来了,占用率却没动
2026-07-29 的原始报告是这样的:用户看到 TUI 弹出"对话已压缩",随后模型开始反复说 "上下文被压缩了,效率有点打折扣",越说越绕。底部状态栏的占用率显示 17%, 从会话开始到结束,缓慢从 14% 涨到 18%,从没接近任何"即将压缩"的阈值。
排查轨迹的五个独立信号互相印证同一件事:session.traj 记录的峰值占用率是 17.6%;warn.log 里代表阈值压缩的关键字命中数是 0;代表压缩函数执行的日志 命中数也是 0;压缩后历史里必然出现的摘要标记(_meta.origin === "compact-summary")出现次数是 0。而压缩横幅弹出的那一秒,消息数没有减少, 反而从 61 涨到了 63。
二、三段代码,一个被无条件采信的"成功"
导火索是模型吐了一个非法 JSON(hypothesis_challenge 工具的某个字段值忘了加引号), 触发了空参数重试路径。这类协议违规在任何 agent 里都该被容错,只应导致"重试这次调用"。 但当时的代码在重试前会无条件调用一次响应式压缩:
// 修复前:src/query/loop.ts(空参数重试路径)
const compactResult = reactiveCompact(ctxMgr);
if (compactResult.success) { /* 画横幅、注入"已精简上下文"提示 */ }reactiveCompact 只检查消息数是否 > 4,压缩逻辑执行完就硬编码返回 success: true,不管底下那次调用到底删没删消息:
// 修复前:src/query/reactive-compact.ts
if (snipCount > 0) {
ctxMgr.compactWithSummary(summary); // 可能静默 no-op
return { success: true, ... }; // 硬编码 true,不看结果
}而 compactWithSummary 找不到安全分割点时,唯一的动作就是直接返回, 连日志都不落一条:
// 修复前:src/context/manager.ts
compactWithSummary(summary) {
const splitPoint = this.findCompressSplitPoint();
if (splitPoint <= 0) return; // 什么都没做,调用方也无从察觉
...
}三段串起来:压缩什么都没做 → 上层认定成功 → 画出"对话已压缩"横幅 → 给模型注入"系统已为你精简对话上下文以释放空间"。这句话进了模型上下文后, 接下来 30 条 assistant 回复、100 次提及压缩,模型在忠实地相信一句我们自己编的假话—— 这不是模型退化,是我们喂了假信息。
三、为什么会一直是 no-op:安全分割点的定义太窄
findCompressSplitPoint 只把"角色是 user 且不含 tool_result"的消息当安全分割点。 事故会话的 156 条历史里,78 条 user 消息中符合这个条件的只有 2 条—— 下标 0 和下标 62,而下标 0 因为"必须 > 0"这条约束天生不可用,下标 62 在压缩发生的那一刻还不存在。
根因不难理解:agent 工作流里绝大多数 user 消息都是工具结果回传, 真正"不含 tool_result 的纯人类发言"极少。这不是这一次会话的特例, 而是这类判据在 agent 场景下的结构性缺陷——只是它被"谎报成功"完美地掩盖了: 策略一谎报成功后直接返回,策略二(emergencyTruncate)永远走不到, 它那行"无条件打印"的日志也就从未出现过,warn.log 干干净净, 排查时完全看不出压缩能力其实早就是零。
对照 Claude Code 的做法能看出差在哪:它的压缩函数返回的是一个必须被填满的结构体 (preCompactTokenCount / postCompactTokenCount 等字段必填),压不动就直接 throw。"压缩没压动"这个状态在它的类型系统里根本无法被表示成成功; 在我们这里,那却是默认表示。
四、修法:把"成功"的定义从代码路径宣告改成实测差值
核心改动只有一条原则:success 不再由某段代码"宣告",而由 messageCountAfter < messageCountBefore 这个可验证的差值决定。策略一 no-op 时 必须降级到策略二,而不是原地返回成功——这是当时最关键的一处不一致: 策略二本来就已经在正确地用差值判定,只有策略一是硬编码。
分割点本身也重新定义了:不再局限于"不含 tool_result 的 user 消息", 改成"按 API 轮次组边界"判定——只要切开后尾部不残留孤儿 tool_result, 这个位置就是安全的。事故会话上实测,安全分割点从 0 涨到 53, 压缩效果从静默 no-op 变成 156 → 104 条消息(178K → 67K tokens)。
配套修了 8 项,包括压缩后序列的合法性从"只 warn 不阻塞"升级为"不合法就回滚"、 补上连续失败 3 次的熔断器、把 8 处相互解耦的横幅触发点收敛成一个统一判据函数 settleCompaction(脚本数过,当前 9 处调用点全部走它)。这批改动锁了 14 条不变式, 今天重跑仍是 28 pass 0 fail:
bun test tests/context/false-compaction-report.test.ts \
tests/context/context-display-alignment.test.ts \
tests/query/false-compaction-banner.test.ts
# 28 pass / 0 fail / 198 expect() calls五、以为这就是终点
这批修复是扎实的——不是"看起来修好了",是有回归测试锁着、有事故会话原样本回放验证过、 今天重跑依然全绿。压缩这条主链路上"成功"这两个字,现在真的对应着"消息数确实少了"。
但压缩机制不止这一条链路。除了这次出问题的响应式压缩(reactiveCompact, 由协议错误触发的应急恢复路径),还有另一条完全独立的阈值压缩路径 (autoCompact,占用率触及 78%/82%/90% 三档时触发),后者旁边还挂着一个 "自适应调参"子系统——它的职责是让压缩越用越聪明。
这个子系统有没有被验证过?这次调研顺手查了一下,答案是没有,而且原因和上面这次 事故是同一个模式的另一种写法。
六、第二处"成功":从没吃过真实数据的自适应调参
src/query/compact/adaptive-strategy.ts 的设计不复杂:每次压缩后,记录 "压缩前 token / 压缩后 token / 节省比例 / 是否走了 LLM 摘要 / 摘要覆盖率"这五个特征, 写进 ~/.sid-code/compact-stats.json 的一个 50 条环形缓冲区;下次压缩前, 读这个缓冲区算出两个软参数的推荐值:
// src/query/compact/adaptive-strategy.ts
// 覆盖率长期偏低 → 少压、多留原文
if (avgCoverage < 0.5) params.preserveRecent = 8;
// 节省比例长期很低 → 更早触发
if (avgSaved < 0.15) params.targetUsageRatio = 0.6;这两个参数直接喂给生产路径:auto-compact.ts 里 PRESERVE_RECENT 就取自 recommendParams().preserveRecent。设计本身没问题——用真实压缩质量的历史反过来调整 下一次压缩的行为,这正是"让机制越用越省"的常规做法。
问题出在它读写这份统计文件的方式上——而且不是理论风险,本机这份文件此刻 就已经写脏了:~/.sid-code/compact-stats.json 的环形缓冲区当前正好 50 条(满), 去重后只有 4 种数值组合,tokensBefore 只取过 120 或 160 两个值,coverage 全部是 1.0。第四节那次事故压缩一次就是 178K → 67K token——120/160 连零头都不到,这不是"可能被污染",是现在就是测试固件的内容。
七、脚本实测:三层验证
项目对"哪些函数落盘到 ~/.sid-code/"有一条统一约定:默认必须经过 getSidHome(), 它会优先读 SID_CONFIG_DIR 环境变量,没设才落回真实家目录,测试靠这条环境变量 把落盘目标重定向到临时目录。adaptive-strategy.ts 没有走这条路:
// src/query/compact/adaptive-strategy.ts:47/64
// 直接拼 homedir(),不经过 getSidHome()
const dir = join(homedir(), ".sid-code");第一层验证:既然现状已经是脏的,先确认它还在持续被污染,而不是历史遗留。 单独跑一个真实存在的测试文件,看它是否会继续写入真实路径。
cp ~/.sid-code/compact-stats.json /tmp/backup.json
bun test tests/command/compact-focus.test.ts
# 跑之前:50 条(环形缓冲区已满)
# 跑之后:md5 变了——8 条新记录写进了 ~/.sid-code/compact-stats.json第二层验证:这不是这一个文件的孤例。从空文件起跑全量 bun test(9013 个测试、644 个文件),跑完后环形缓冲区里是 18 条,全部是同一批测试固件的 四种数值组合之一,没有一条的 tokensBefore 超过 160。真实压缩的量级是多少? 第四节那次事故压缩,一次就是 178K → 67K token——测试固件的 120/160 连零头都算不上,一眼就能分辨真假,只是从来没人去看过这个文件。
第三层验证,也是最反直觉的一条:显式设置项目统一的隔离变量,对这个模块 完全没有效果。
SID_CONFIG_DIR=/tmp/should-not-matter \
bun test tests/command/compact-focus.test.ts
ls /tmp/should-not-matter # 不存在——从未被创建
# ~/.sid-code/compact-stats.json 的条数、内容原样被覆盖因为它压根不读 SID_CONFIG_DIR。测试隔离契约的兜底防线(bunfig.toml 里的 preload,进程启动时把 SID_CONFIG_DIR 默认指向临时目录)同样救不了它—— 兜底防线的作用机制是"让默认值指向临时目录",而这个模块从不查那个环境变量, 默认值指向哪里跟它没有关系。
八、稀释数学:为什么这份污染不会被真实数据冲淡
一个自然的想法是——即使被污染过,只要后面有真实压缩发生,新数据迟早会把假数据挤出 50 条的环形缓冲区。算一下需要多少条:
| 触发条件 | 当前均值 | 阈值 | 需要多少条真实"失败"样本才能压过阈值 |
|---|---|---|---|
avgSaved < 0.15 | ≈0.65(固件) | 0.15 | 39 条 savedRatio=0 的真实压缩 |
avgCoverage < 0.5 | 1.00(固件) | 0.5 | 26 条 coverage=0 的真实压缩 |
39 条"压了等于没压"的真实压缩,均值才会跌破 0.15——不是 39 次压缩,是 39 次失败的 压缩。而第九节会给出另一个数字:67 个真实会话里,阈值压缩路径至今一次都没有真实触发过。 两个数字放在一起:假数据不会被稀释,因为触发这个反馈环的事件本身就极其稀少。
九、67 个会话,阈值压缩一次都没真的响过
这条不是推测,是对当前滚动窗口内全部可读轨迹跑了一遍脚本的结果(2026-08-09 实测, ~/.sid-code/trajectories/sessions/ 当前窗口最早到 2026-07-10):
67 个真实会话;其中 36 个带 context_usage_trend 采样点
全部采样点里的最高峰值占用率:9.9%(20260807-195304-7a410a71)
压缩三档阈值:78% / 82% / 90%
43 个有 events.jsonl 的会话中,CompactionAttempt 事件数:0
抽样到的 session.traj 里,metadata.compactions 字段:全部是空数组这句是"当前滚动窗口内没有观测到",不是"永远没发生过"——滚动窗口会清理旧会话, 这个数字本身会随时间移动。但它至少说明:autoCompact、Session Memory 优先路径、 adaptive-strategy 这一整条"阈值压缩 + 自适应调参"链路,在最近一个月的真实使用里, 没有被真实触发过一次,唯一被触发过的是完全独立的另一条路径—— 第四节那次事故里出问题的响应式压缩(由协议错误触发,不看占用率)。
十、两个"成功",同一个模式
把两次发现放在一起看:第一次是压缩函数对用户和模型撒了一次谎——显式的、 会被立刻看见后果的谎(横幅弹出、模型自我否定)。第二次是统计文件对调参逻辑 撒了一个谎——隐蔽的、退化成"照常返回默认值"因而永远不会报错的谎。
两次的根因结构其实是同一件事:某个信号被无条件采信,而这个信号是不是真的 从来没有被检验过。第一次校验的是"压缩这个动作真的发生了吗";第二次该校验、 却没人去校验的是"用来评估压缩效果的数据,真的来自压缩本身吗"。第一层的谎言 2026-07-29 就被用户发现了;第二层的谎言,因为它只是安静地把参数留在默认值上、 从不报错,直到这次带着"上一次事故到底有没有真正闭环"这个问题回头细查, 才第一次被看见。
十一、当前的能力边界
adaptive-strategy.ts的路径隔离缺陷本文只做了发现与验证(三层脚本证据见第七节), 修复方式很直接(把join(homedir(), ".sid-code")换成getSidHome()), 但截至发文尚未落地,也没有清理已经写脏的compact-stats.json。- 全仓还有 9 处同类"直接
homedir()拼接而不走getSidHome()"的写法(脚本数过), 本文只逐一验证了会真实落盘的那几处,adaptive-strategy.ts是其中污染面已确认、 且直接影响生产调参逻辑的一处;其余几处是否也有同类风险,本文没有逐一复核。 - 阈值压缩路径(
autoCompact/ Session Memory 优先 / 自适应调参)近期没有真实触发样本, 这意味着"LLM 摘要会不会断片""Session Memory 优先路径接上线之后质量如何"这两个问题, 目前只能验证代码路径存在,无法用真实长会话数据验证效果。 - 2026-06-15 那份分析文档列出的四项差距(结构化摘要 prompt、post-compact 消息重组、Session Memory 接线、cached microcompact)经现场核对均已实现—— 这也是一条边界:本文只确认了核心函数存在且被正确调用,没有逐段核对每处细节 是否与原方案完全一致。
cached-microcompact.ts的cache_edits默认关闭(emitCacheEdits=false),本文没有找到显式开启它的调用方。
十二、可迁移的教训
一个"从历史学习"的反馈环,如果它的存储媒介和测试环境共用同一份隔离契约, 那么这份契约里的任何一个漏洞,都会从"测试期间的小麻烦"塌缩成 "生产决策依据被污染"——而且这类污染大概率不会主动报错,因为大多数反馈环的 退化路径就是"样本不足则用默认值",一切看起来照常运行。
这条结论不依赖 sid-code 的任何具体实现:任何把"用真实运行数据自我调参"这件事 当作卖点的系统,都值得反过来问一句——喂给这个反馈环的数据,真的来自它自称 观测的那个对象吗,还是来自离它最近的一次单元测试。
这篇服务的是"更省"——压缩存在的意义是控制成本、避免上下文溢出拖垮响应。 张力在于:2026-07-29 那批修复用更多校验换回正确性(结构合法性双重校验、 压不动就回滚、连续失败熔断),这是为"更安全/更可信"牺牲了一点"更省"的极致—— 压不动时选择的是"如实报告失败"而不是"想办法多省一点"。而自适应调参这个 想让"更省"越用越聪明的投入,恰恰因为承载它的可观测性基础设施本身没有被验证过, 长期处于空转——多出来的"信任成本",不会自动兑现成信任,它自己也需要被验证。
相关
- 上下文与压缩 —— 压缩什么时候触发、怎么自己调触发点、
/compact的三种用法 - JIT 上下文:让规则在正确的时刻进入上下文 —— 同样在讲"进没进上下文",但那篇讲的是规则该不该被注入,这篇讲的是 历史该不该被压缩
- Prompt Cache:一个读错的字段名,把 95% 的命中率记成 2.2% —— 同一个模式的另一次实例:一个被无条件采信、从没验证过的字段,把真实命中率 记错了 93 个百分点
- 环境变量 ——
SID_CODE_AUTOCOMPACT_PCT等压缩相关变量的完整清单