300 轮长会话实测:哪些内容会被 fast-jev-compaction 的「二元删除决策」误伤?
【免费下载链接】fast-jev-compactionClaude Code plugin that replaces the compaction summary with Jev decisions: every tool call and result is scored in one fast request, stale ones are dropped or truncated, everything kept stays verbatim.项目地址: https://gitcode.com/gh_mirrors/fa/fast-jev-compaction
上下文压缩正在从「让 LLM 写一段摘要」切换到「让决策模型做一道道选择题」。fast-jev-compaction 是这个方向最具代表性的实现:它不生成一个字,只对每条工具调用和结果输出keep/drop概率,删除 Jev 判定不再需要的内容,保留的一切逐字原样。社区把它包装成「无损压缩」,源码里也确实写死了「用户与助手文本永远不改写」。
但「只删不写」不等于「无损失」。删除动作一旦发生,被删内容就永久消失,而做出删除判断的依据——喂给 Jev 的状态——本身是经过七级降级处理的。本文构造了一个 300 轮的长会话,复用仓库测试同款的受控概率注入方法(见 tests/fast-jev-compaction.test.ts 的fakeJev),跑通compact → fitState → decideCall → applyDecisions完整真实链路(src/compact.ts、src/state.ts),逐一盘点「看似非关键、实则关键」的内容会在哪些环节被误伤,以及钉住策略和回退机制到底能兜住多少。
一、实验设计:把 300 轮会话喂进真实的决策链路
先明确这条链路的四个确定性环节,它们是所有误伤的来源:
- 配对:
collectToolCalls按tool_use_id把每条tool_use与tool_result配对(src/state.ts)。没有结果的调用不参与决策——这本身就是一个安全边界。 - 钉住:
isPinned判定index === 0 || index >= total - preserveRecentMessages。首条消息永远保留,最新preserveRecentMessages(默认 6)条消息永不触碰。 - 提问:对每条非钉住调用,
questionsFor生成两个noul问题——「这个调用本身(连同其输入)是否还需要留在历史里」与「这个调用的完整输出是否还需要逐字保留」(src/compact.ts)。 - 执行:
decideCall按keepThreshold(默认 0.5)做三态判决:keepResult ≥ 0.5→ 调用与结果都保留;否则keepCall ≥ 0.5→ 保留调用、结果截断;否则 → 调用与结果一并删除。applyDecisions据此重建消息列表。
实验的 300 轮会话按如下规则构造:第 1 轮(钉住)给出任务约束,第 5~280 轮散布读文件、Grep、跑测试、改代码、查日志等 240 次带结果的工具调用,第 290~300 轮(钉住)做最终验证。这意味着 1~293 轮之间的所有内容全部处于候选区,任何一个决策失误都可能让一段已发生的工程过程永久消失。
需要说明实验方法的边界:本实验验证的是决策机制的确定性行为——哪些内容在何种概率组合下必然被删、被截,依据的是代码本身与边界条件推演,而非依赖 Jev 在线 API 的随机输出。这与仓库测试的方法论一致,结论可复现、可审计。
二、误伤清单:被删掉的「看似非关键」内容
1. 长输出的「头 300 字符幸存者偏差」
drop_result分支执行的是截断而非删除:truncatedResultText保留结果的前truncateHeadChars(默认 300)个字符,再追加一行说明(src/compact.ts):
[fast-jev-compaction truncated N chars of this tool result; re-run the tool if needed]300 轮会话里最典型的受害者是两类输出:测试日志尾部(失败断言、堆栈帧往往出现在日志末尾)与大文件读取(相关配置节在文件后段)。而一个容易被忽略的细节是:truncatedResultText对长度 ≤headChars + 120(即 ≤ 420 字符)的结果直接原样返回——所以「drop_result」实际只对超过约 420 字符的结果产生效果,短结果即使被判 drop_result 也会逐字幸存。误伤集中在「长而尾部关键」的输出上,而这类输出恰恰是长会话里 token 占比最高的部分。
2. 调用与结果的「连坐」:drop_call 的双杀
decideCall的最后一个分支是drop_call:调用连同其结果一起消失,不留任何占位。这在语义上假设「重跑工具等价于拥有结果」,但该假设由 src/state.ts 的STATE_CONTEXT显式写死并喂给了 Jev:
Whatever is not kept is deleted permanently, but the assistant can always re-run a tool or re-read a file.
对幂等工具(Read、Grep)成立,对非幂等工具完全不成立:WebFetch 抓取的外部页面会过期、Bash 里的写操作有副作用、Git 历史与网络状态不可重放。300 轮会话里这类调用一旦被 Jev 判为 stale(旧调用的 keepCall 概率天然偏低),删除就是不可逆的。
3. 判断依据降级:Jev 只看到「ok, 4213 chars (omitted)」
这是最结构性的盲区。喂给 Jev 的状态里,所有工具结果都被替换成一行注记(src/state.ts 的resultNote):
ok, 4213 chars (omitted) # 或 error, 812 chars (omitted)于是「这个结果是否还需要逐字保留」这道题,Jev 是在完全看不到结果内容的前提下作答的。它只能根据调用名、截断后的输入和结果长度做推断。结果越长,Jev 对其中内容的了解反而越少——一个 5000 字符的构建日志和一个 5000 字符的配置 dump,在状态里是同一句话。这解释了为什么「保留了大文件读取」往往是碰运气:判断不是基于内容,而是基于元数据。
4. 输入截断到 60 字符后的「残缺证据」
当历史放不进maxStateTokens(默认 25000),fitState按顺序降级:工具输入从 1000 → 200 → 60 字符逐级截断(INPUT_CHARS = [1000, 200, 60])。300 轮会话几乎必然触发后两级。后果是:一条 200 字符的Bash命令在 Jev 眼里只剩前 60 个字符,一条Edit的old_string/new_string可能被截到看不出改了什么。「这个调用是否还需要」的答案,是基于残缺输入做出的——判断依据本身先失真,误伤只是时间问题。
5. 报错堆栈与证据链:只有被转述过才幸存
早期失败的报错堆栈是后期修复的核心线索,但在状态里它只是error, 812 chars (omitted)。Jev 无从判断这段堆栈与后续修改的因果关系。唯一能保住堆栈的路径是:助手消息把堆栈逐字转述进了文本——因为文本消息在输出中永不删除(README.md 明示:only tool calls and results are candidates)。于是出现一个微妙的规则:信息是否幸存,取决于它在哪一层——工具结果层的信息默认被抹除,文本层的转述则永存。长会话里没被转述、只躺在工具结果里的关键证据,就是第一类被误伤对象。
6. 跨轮隐含依赖:决策时刻的「现在视角」
300 轮会话最致命的问题是跨轮依赖:第 20 轮读的一份配置文件,第 200 轮改代码时才用到。压缩发生在上下文涨到阈值的那一刻,Jev 用「此刻还需要吗」的视角去判 200 轮前的读取——结论几乎必然是 stale。goal的默认值只取最近 3 条用户提示(截断到 500 字符,见 src/state.ts 的goalFromMessages),早期约束如果没在近期提示里被重申,就不会进入 goal 强化窗口。钉住的首条消息虽然全文留在状态里,但它与第 5 轮「读了 src/generated」之间的关联,没有任何机制被强化。
7. 视野折叠与输出保留的错位
最后一种误伤形态最隐蔽:状态降级的最终阶段,旧的无调用消息直接从 Jev 视野中移除,旧文本折叠成[… N chars omitted …],旧调用压成一行(t12 Read file_path=src/a.ts → ok 480ch)。这意味着中间轮的推理过程、阶段性结论、计划切换,Jev 一概看不到——而压缩后的输出却又把助手文本逐字保留。于是出现「判断是损失化的,保留是原样的」的错位:压缩结果看着完整,但删除决策是在一个被折叠过的世界里做出的。误伤的不是幸存者,而是幸存者之间的叙事连续性——助手文本还在,但它所依赖的、被删掉的证据已不在。
三、钉住策略能救什么、救不了什么
能救的:
- 首条消息与最近 6 条消息在任何概率下都不会被触碰(
pinned决策的 reason 是pinned,直接短路三态判决)。 - 短结果(≤ 420 字符)在 drop_result 下实际原样保留——小输出天然免疫截断。
- 全程可审计:钩子会输出逐条决策日志(hooks/fast-jev.ts 的
decisionLog),格式如t3:Bash:drop_call/call=0.10/result=0.10,每次压缩的概率、动作、原因都可回放。
救不了的:
- 悬挂引用:钉住的消息可以引用已被删除的早期证据。最后 6 条里用户说「把第 20 轮那个配置的问题一起修了」,而第 20 轮的读取早已消失。
- 阈值硬切:0.49 与 0.50 之间是生与死的差别。概率本身是连续量,决策是二元的,
keepThreshold附近没有过渡带。 - 逃生舱悖论:钩子设定
minReductionRatio = 0.25(hooks/fast-jev.ts)。当 Jev 删得不够多(短会话、保守阈值)或请求失败时,钩子return next(event)退回 Claude Code 内置摘要——也就是把「无损保真」的承诺,在删不够的情况下换回一个有损摘要。钉住策略兜住了「误删」,却把「删不够」的场景直接移交给了它要替代的东西。
四、规避误伤的工程手段与回退机制
参数层对冲(全部为CompactOptions,定义于 src/types.ts):
keepThreshold调高(如 0.6~0.7)让 keepCall/keepResult 更挑剔,牺牲压缩率换取保守;truncateHeadChars调大,缓解「头 300 字符幸存者偏差」;preserveRecentMessages调大,把钉住窗口从 6 条扩展到 10~20 条;goal显式传入完整任务描述,替代「最近 3 条提示」的默认窗口,能显著缓解第 6 类跨轮依赖误伤。
回退机制:钩子对 Jev 失败、畸形响应、缺少 API Key、历史无法装进状态预算(fitState抛history too large)四种情况统一 catch 并next(event)回退内置摘要,同时 toast 展示回退原因——失败不会让会话崩溃,代价是退回有损路径。turn.complete钩子在context.percent达到compactAtPercent(默认 60%)时触发自动压缩,并有 in-flight 防重入。
替换决策源:README 明示可以自实现JevAsker(一个ask(state, questions)方法)替换远程 Jev,buildJevRequest/parseJevResponse也单独导出——对结果内容敏感的场景,完全可以换成本地规则或更强的决策模型。这是把「判断依据降级」问题从源头解决的正路。
承认的局限:README.md 的 Limitations 写得很诚实——token 是字符级估算而非 tokenizer 计数;概率不是「删除安全」的证明;每次请求都重发完整状态,接近状态上限的历史「一个请求只装得下几道题」,300 轮会话在 25k 状态预算下往往要拆成多个并发请求(batchCalls,src/compact.ts),每次都是同一份降级状态。
结语
回到标题的问题:二元删除决策误伤的,从来不是「看似非关键」的内容本身,而是决策依据中被省略掉的那部分关键性。fast-jev-compaction 的取舍很清晰——把风险从「改写失真」转移到「判断依据降级」,用确定性换掉幻觉。它守住了文本的 verbatim,却把误删的责任交给了「ok, N chars (omitted)」这行注记背后的元数据判断。
对长会话使用者,这给出了一份可操作的检查清单:长输出的尾部证据要靠truncateHeadChars保护;非幂等工具调用要慎用低keepThreshold;跨轮依赖要靠显式goal与更大的钉住窗口对冲;而一旦出现「删不够」或「删错了」,minReductionRatio与内置摘要的回退路径是最后一道不完美但必要的安全网。理解这些边界,比相信「无损」二字更重要——任何压缩,都应当先回答「谁在为删除负责」。
【免费下载链接】fast-jev-compactionClaude Code plugin that replaces the compaction summary with Jev decisions: every tool call and result is scored in one fast request, stale ones are dropped or truncated, everything kept stays verbatim.项目地址: https://gitcode.com/gh_mirrors/fa/fast-jev-compaction
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考