过去都是假的,回忆是一条没有归途的路,以往的一切春天都无法复原,唯有孤独永恒。
—— 加西亚·马尔克斯,《百年孤独》
引子
最近 Jev 很火。fast-jev-compaction 拿它做上下文压缩:不是调用LLM做summary,而是让 Jev 对每个 tool call 和它的 result 做判断,判断"该保留还是丢弃",这个项目还得了官方点赞,最近几天在github狂揽接近5k star。我觉得这个思路比较新颖,于是写了个插件 pi-jev,想在 pi 里试试这种新的压缩算法。
刚用的时候确实很爽,几乎在我敲下 /compact 的瞬间就完成了压缩,终于不用再等了。但用了一阵儿之后有点担心,这种压缩会不会丢掉什么重要的信息?我去翻了翻 fast-jev-compaction,没找到任何对压缩效果的评测,单元测试用的是假 Jev,唯一的量化输出是体积缩减率,它只关心压得够不够狠,但压得对不对,有没有把重要信息遗漏,没人关心。于是这个周末下午,我用本地近 2000 个 pi session,自己测了一把。
先说结论
- 用 Jev 做上下文压缩,约等于无脑丢弃所有 tool result,而后者还不用花钱。Jev 在默认阈值(0.5)下,9471 个tool result它一个不留;把阈值调低,它确实保留了一些,但是效果不比随机保留更好,甚至还更差;把tool result内容和最近几条user prompt作为state信息喂给它,效果也没有变化。
- Jev 没有错,其实我们没有必要保留 tool result。约 800 万 token 的工具输出里,之后真正会用到的只有大概 3.6% 到 7.5%,可以简单地全部丢弃,模型在需要的时候重新执行一次 tool call,问题也不大。
- 我们让 LLM 做 summary,一边在浪费时间,一边在浪费 token。压缩这件事不需要动用 LLM:把工具输出丢掉、调用记录留着,不花一分钱,瞬间完成。模型拿着这样压缩出来的上下文接着干活,表现和用 LLM 写的摘要几乎没差别。
一、Jev compaction 好快我好爱,但是……它把所有的 tool result 都丢弃了?!
Jev 是 TypeSafe 提供的模型 API。它不是聊天模型,是个决策接口:发过去一个 state 和一组 questions,返回一组 answers。问题有三种类型:noul 对一个陈述返回 0 到 1 的概率,choice 多选一,score 按等级打分。
压缩只用到 noul 和 score 两种,做法是对每个 tool call 各问三道题,然后按概率裁决:
const { answers } = await jev.decide({
model: "typesafe/jev-1.13",
state, // 被压缩的那段历史
questions: toolCalls.flatMap(call => [
{ type: "noul", claim: "这个调用本身值得保留" }, // → 0~1
{ type: "noul", claim: "它的完整结果值得原样保留" }, // → 0~1
{ type: "score", claim: "这个结果有多过期" }, // → 按等级打分
]),
});
// keepThreshold 默认 0.5
toolCalls.forEach((call, i) => {
const { keepCall, keepResult } = answers[i];
if (keepResult >= 0.5) keep(call, call.result); // 原样保留
else if (keepCall >= 0.5) keep(call, head(call.result, 300)); // 结果只留开头 300 字符
else drop(call); // 整个删掉
});
实际用下来,Jev 压缩是秒级的,几乎感觉不到等待;pi 自带的 LLM 摘要是分钟级的,每次都要干等一阵。从分钟级到秒级,快了不止一个量级,非一般的感觉。我好爱。
评它有个天生的麻烦:“该删哪些 tool result"没有标准答案。摘要写得好不好可以让人读,但 9471 个工具结果里哪个"该留”,谁说了算?
我的办法是让未来当裁判。pi 的 session 文件是只追加的,压缩并不会删掉之前的原始消息,所以可以在原始历史上回放:按 pi 的触发逻辑,token 一超窗口就在回合结束处切一刀,切的位置直接用 pi 自己的 prepareCompaction 算,保证交给 Jev 的那一段和生产环境一模一样。128k 窗口下切出 91 个压缩点,来自 43 个项目,共 9471 次工具调用、约 800 万 token 的结果。每个压缩点有三部分:被压缩的那段历史(Jev 看到的),pi 原样保留的最近约 2 万 token,还有压缩点之后 agent 实际做的事。
判断"某个结果有没有被用到"不用判官模型,靠比字符串:从结果里挑出够独特的词,比如多段路径、驼峰标识符、十六进制 id,再看之后 agent 有没有在见到任何新信息之前,自己先说出这些词。说出来了,就是凭记忆在用。
结果第一轮就出了意外。默认阈值 0.5 下,Jev 一个结果都不保留。9471 个结果里,“值得原样保留"的概率只有 0.3% 达到 0.3,没有一个达到 0.5。这不是评测推出来的,是 Jev 返回的原始概率,不依赖任何标签。我历史上仅有的 4 次真实 Jev 压缩也一样:其中一次 103 个调用丢了 101 个,306K 字符压到 26K,保留数是 0。
“好快我好爱"的东西,原来根本没有在挑,它等于全部丢掉、只留 300 字符开头。那全丢到底亏不亏?
算账,居然不亏。摘要大小加上之后需要重新取回的 token,Jev 是 19.1k,随机丢弃同样大小是 19.0k,全保留是 105k,而"只留真正被用到的"这个理论最优是 18.4k。全丢几乎贴着最优解。但标签说有 208 个"之后被用到"的调用,全部被丢。这算谁的锅?最顺手的说法,是怪那个 0.5 的阈值。
二、是不是 0.5 的阈值太高了?降低阈值试试
在动阈值之前,先干一件更重要的事:审计尺子。“208 个有用"是代码算出来的,规则是这次现写的,拿这套标签给 Jev 定罪之前,先看看标签本身准不准。
抽 50 个调用:25 个规则标成"有用”,25 个标成"没用”。打乱顺序、隐去标签,逐个读原始上下文,先写下判断再对答案。判断细化成四类:Y 是凭记忆用了这个结果独有的内容;D 是用了但别处还有副本;R 是之后确实重读了,但不压缩它也照样重读;N 是没用。
| 规则说 | n | Y | D | R | N |
|---|---|---|---|---|---|
| 有用,靠重跑 | 10 | 0 | 2 | 7 | 1 |
| 有用,靠复述 | 15 | 5 | 7 | 0 | 3 |
| 没用 | 25 | 0 | 6 | 0 | 19 |
这一审,两条规则都翻了车。“重跑"是个坏信号,10 个里没有一个真正需要凭记忆。原因藏在工具机制里:read 返回带哈希锚点的行,edit 必须用新鲜的锚点,agent 本来就会重读还躺在上下文里的文件。重读不代表丢失。“复述"方向对、归因太宽:15 个里 12 个确实用了,但只有 5 个用的是这个结果独有的内容,其余在别处有副本,丢掉这份副本还在。反方向倒是好消息:标"没用"的 25 个里,没有一个是真用了独有内容却被漏掉的。
规则重写,删掉重跑这条,复述的字符串还必须独有。重算之后,“有用"从 208 个掉到 61 个,占调用数的 0.6%、结果 token 的 3.6%。不同口径的全貌:
| 口径 | 调用数 | 占调用数 | 占结果 token |
|---|---|---|---|
| 严格:凭记忆说出且独有 | 61 | 0.6% | 3.6% |
| 较宽:说出但别处可能有副本 | 150 | 1.6% | 7.5% |
| 最宽:说出至少一个字符串 | 428 | 4.5% | 16.0% |
| 之后重跑了同样调用 | 68 | 0.7% | 2.0% |
| 被 span 里更新的调用取代 | 489 | 5.2% | 12.2% |
好,现在动阈值。把 keepThreshold 从 0.5 一路往下扫,统计 61 个"真正有用"的调用各保住了多少:
| 阈值(压缩程度) | Jev | 随机 |
|---|---|---|
| 0.14(减 26%) | 37/61 | 35/61 |
| 0.17(减 54%) | 10/61 | 20/61 |
| 0.19(减 62%) | 6/61 | 16/61 |
| 0.21(减 68%) | 1/61 | 8/61 |
阈值几乎不压缩时(0.14)两者持平。但那不算压缩,43% 的压缩点压缩不足 15%,会回退到 pi 默认摘要。一旦真的压起来,Jev 保住的有用内容比随机还少一半。“只有 Jev 丢了"的事件 12 个,反方向只有 2 个,符号检验 p 约 0.01。不过这些事件来自 33 个压缩点,并不独立,显著性要打折,但方向够清楚了。
所以不是阈值太高的问题:阈值降了,它保住的也不是该保的。难道它的判断本身就是随机的?先别急着定罪,还有一种辩护没试过:它压根没见过证据。
三、是不是 context 没有设置好?补充上下文,再看看 Jev 的效果
我去读了实际发给 Jev 的请求,答案有点好笑:它从头到尾没见过任何一个 tool result 的内容。
state 里有:一段"被丢的内容随时可以重读"的说明,最近 3 条用户消息,还有整段历史。历史里每个调用的入参被截到 1000/200/60 字符,结果只有一行"成功,N 字符(已省略)";pi 保留的最近 2 万 token 也不发。它要判断"这个结果之后要不要留”,依据只有"调用过什么、结果多大、对话进行到哪”。盲人摸象。
于是做 enrich 实验:给每个调用带上结果开头最多 300 字符,附上 pi 保留的最近 6 条消息,“目标"换成最新的用户提问。多出来的内容从别处省回来,基础历史压一压,请求总大小跟生产一致;结果开头的总预算约 5000 token,调用多的压缩点每段就短些。为排除"根本没发出去"这种无聊 bug,先验证了 31/31 个请求确实带上了新内容。
结果:什么都没变。同工具同大小的排序能力,AUC 从 0.507 到 0.510,配对变化 +0.003,置信区间 ±0.07。保留概率的分布几乎原样,仍然没有一个结果过 0.5;同等摘要大小下,它保住的有用内容还是不比随机多。看来信息不足不是主因。准确说,大于 0.07 AUC 的改善被排除了,更小的改善靠这 29 个压缩点测不出来。没试过的只剩问法:提示里那句"丢掉的内容随时可以重读”,可能在问题层面就把它推向了全丢。
信息喂了,没用。剩下的问题就有点难堪了。
四、Jev 和 random 差不多?那我的钱不是白花了?
把证据摆到一起:排序能力,基础版 AUC 0.507,喂饱版 0.510,随机 0.501,而 0.5 就是抛硬币;保住的有用内容,真压缩时比随机少一半。每一次压缩多花的几个请求、那两秒钟、那几分钱,换来的是掷硬币。是的,钱基本白花了。
但钱没有全白花,实验顺手带回一个更值钱的结论:丢了之后,天没塌。
L2 行为回放:找到 agent 第一次凭记忆使用某个被丢内容的那个时刻,把当时的上下文换成压缩后的版本,让另一个模型从那里走一步,对比完整历史、Jev 压缩、随机保留三种条件。模型用 deepseek-v4.1-flash。这里有个小插曲:第一版选的是 v3.2,我随口问了句为什么不用 flash,一查 flash 更便宜,上下文还更长。
61 个事件跑完,Jev 条件下的输出我逐个人工读过。41% 换个方式把东西取了回来,重读文件、重新搜索、重新执行;51% 这一步根本不需要那个内容,做了合理的别的事;7% 卡住没输出,只集中在 2 个上下文;还有 2% 是乌龙,结果只有 83 token,压根没触发截断。至于凭印象瞎编着往下走,一次都没有。
“凭空出现的名字"这种最硬的瞎编证据,平均每个回放里也只有 0.03 个。典型行为是:原 agent 当年说"这函数我刚写的,不用工具直接答”,压缩后的模型不信邪,先去把代码读了再答。代价是多几次工具调用,不是错误。对照组拿着完整历史,也有 25% 在重新取。agent 就是爱重读,第二章已经见过了。
Jev 和随机保留 51/61 个事件行为完全一致;低阈值下"只有 Jev 丢了"的 12 个事件,补跑"内容还在时模型怎么做"的对照组,逐个读过,有内容和没内容,下一步几乎一样。
所以这一章算下来,Jev 的钱确实白花了,但换来一个好消息:挑选是一个不存在的问题。东西丢了,模型自己会去取。那 Jev 还需要吗?不需要的话,直接丢和 LLM summary 比呢?
五、直接丢弃跟 summary 比呢?好吧,summary 是在浪费时间和 token
既然挑选不需要智能,就写一条零成本规则,叫 mask:保留所有用户消息、助手文字、调用的名字和入参;结果只留成功还是报错、大小和开头 300 字符;很短的结果和不可重取的工具(子代理、网页抓取、ask_user)例外整段保留。没有模型调用,没有延迟。
这不是新想法。查了一下,Anthropic 的 context editing 里 clear_tool_uses 做的就是清旧结果、留调用记录;JetBrains 有论文专门对比过,简单的 observation masking 在 SWE-bench 上成本减半、解决率和 LLM 摘要相当。我的数据只是又给这边添了一票。
但一直留了个空白:从头到尾都没跟 pi 自带的 LLM 摘要比过。在 32 个"确实有凭记忆使用"的压缩点上,四个方案同台,指标是那 60 个"之后真的被用到"的调用各保住了多少:
| 摘要 token | 压缩率 | 保住的有用调用 | 调用记录召回 | |
|---|---|---|---|---|
| [email protected] | 9.4k | 88% | 4/60 | 28% |
| mask | 26.5k | 69% | 16/60 | 68% |
| pi 摘要 | 3.2k | 96% | 4/60 | 42% |
| 随机 | 8.8k | 89% | 4/60 | 27% |
mask 比其余三个多保住 12 个,配对区间 +17.4 个百分点、下限还有 8.1。pi 的 LLM 摘要压得最狠,3.2k token,但保住的具体内容不比随机丢弃多。要替它说句公道话:字符串口径天然低估散文摘要,它可以用自己的话保住意思。但行为不撒谎:同样的 61 个事件,三种压缩各自回放一步,重取 15% 到 18%,做别的工具调用 75% 到 78%,凭空出现的名字都接近零,几乎不可区分。唯一的纯文字回答出现在 LLM 摘要条件下的 2 个事件,读下来是有依据的设计观点,不是猜的。
差别只剩成本和性格:mask 零成本、零延迟;Jev 每次压缩多几个 2 秒的请求,挑选还不如随机;pi 的 LLM 摘要每次要为一整段历史付一次全量 LLM 调用,约 $0.015、分钟级延迟,33 次里还失败过一次。
免费的规则和花钱花时间的 LLM summary,行为回放下分不出胜负。所以说,我们在做 summary 的时候,一直在一边浪费时间,一边浪费 token。
尾声:一下午的账
从下午五点到晚上十点,五个实验,总花费约 $3.8,五美元额度没用完。一开始的问题是"Jev 挑得准不准”,最后的答案是"没什么好挑的”,顺带把 LLM summary 也拉下了水。
回头看,这个下午真正反复出现的角色不是 Jev,是尺子。208 个"有用"是标签的回声;46% 的重取率是 cd 前缀的杰作;“模型卡住了"是 max_tokens 的截断;排序能力 0.81 的"大结果优先"基线是标签偏爱大文件的混淆;连"全保留也丢 9%“都量出了产品里一个真实的截断 bug。每一次意外都先当测量误差查,查完还站着的才配当结论。这句话大概是这 $3.8 里最值钱的部分。
最后把结论的边界说清楚:这是一个人的工作流、一个模型版本(typesafe/jev-1.13)、单步的回放;“没测到差别"不等于"没有差别”,能排除的只是明显的那档。但量级很难翻转了。工具输出看着像需要精心管理的记忆,其实是个随取随用的外置硬盘。马尔克斯说以往的一切春天都无法复原。在 agent 的上下文里,只要调用记录还在,春天随时可以重新执行一遍。
评测代码在 pi-jev 的 eval/ 目录,跑在本地 session 上,不随 npm 包发布;所有数字都能用缓存的答案免费复现。