Jev 上下文压缩解剖:把 LLM 摘要换成不说话的判断模型
一句话总结:这个插件把「上下文快满了怎么办」从「让模型把整段对话重写一遍」改成了「逐条问一个不说话的模型——这条工具调用该不该留」。前者会丢掉用户的原话、会编、还要再跑一次大模型;后者只做删除和截断,一个字都不改。
00 先看结论:五句话
- 它的压缩是「减法」不是「改写」。 用户和助手的文本永远逐字不变,它只对工具调用(
tool_use)和工具结果(tool_result)动手。 - 删什么由一个概率决定。 对每个调用问两个「是非题」,Jev 返回 0~1 的概率,跟阈值
keepThreshold(默认 0.5)比大小,得到三种动作之一。 - 三种动作是三级瀑布: 整体保留 → 保留调用但把结果截成前 300 字加一行说明 → 调用和结果一起删。中间那一档是这套设计最值钱的地方。
- Jev 每次都看到整段对话。 状态里工具输出被换成一行
ok, 4213 chars (omitted),但要塞进 25k token 预算,于是有八级渐进的「缩身阶梯」。 - 失败就回退,永不变差。 缺 key、请求失败、格式不对、压缩率不到 25%,统统退回 Claude Code 官方的摘要式压缩。
一句话把整套设计讲透: 「可重放的东西可以删,不可重放的东西一个字都不能动。」工具结果是可重放的——重跑一次工具、重读一次文件就回来了,所以它敢删;人说的话和助手说的话不可重放,所以它永不碰。整篇源码都在这条边界上做文章。
01 问题:上下文里的「垃圾」在哪
一个跑久了的编码 Agent 会话,token 花在哪里:
- 真正的人类部分(需求、约束、偏好)——占比很小,通常几百到几千 token。
- 助手的推理文本(「我先看测试再改实现」)——中等,密度高,是决策链的痕迹。
- 工具调用与工具结果——压倒性的大头。一次
Read一个 800 行文件可能是 8000 token;一次npm test的失败日志可能 5000 token;一次Glob的文件树可能 3000 token。50 轮的会话里,这类内容轻松吃掉 70%~90% 的窗口。
而这些大块内容里,真正「当时有用、后来永远有用」的比例极低:
| 已经过期的大块 | 必须逐字留住的小块 |
|---|---|
| 读了一个最后证明无关的模块(legacy 实现、另一个包的同名文件) | 你定的约束:「不要改 src/generated」 |
| 同一段测试的中间态输出——修完 bug 后,那次 FAIL 的完整日志不再需要,只要知道「曾失败过、原因是什么」 | 准确的报错文本与堆栈位置(差一个字符就查错方向) |
一次 ls / Glob 的完整文件清单 | 最终通过的那次测试输出(证明「改对了」的证据) |
| 探索期的试错调用(先试 A 方案再试 B),决策已经做出 | 关键文件的真实内容;助手的结论性推理 |
传统做法是把旧的几轮喂给一个 LLM:「总结成一段」。三个问题:
- 不可验证。 摘要写完你没法检查它漏了什么——漏掉的恰恰是「当时不知道将来有用」的。
- 不可逆。 原文被替换掉了,想找回来只能重新读文件、重新跑命令。
- 粒度错。 摘要是「整段重写」,而真正的需求是「这块删、那块留」。
① 改写式压缩(主流)
[msg][tool_result 8k][msg][tool_result 5k]
--LLM 总结--> 一段新写的摘要
原文永久消失 · 无法核对漏了什么
② 删除式压缩(本项目)
[原文][保留并截断][原文][删除]
----------> 留下的部分一模一样,只是变少了
每个被删项都有理由 · 可逐条审阅 · 助手可重跑工具
赌注就在这:工具内容是可重放的,所以「删对了」的收益远大于「删错了」的代价;人类的文本不可重放,所以永不触碰。
02 底座:Jev 是什么,为什么它够用
TypeSafe 在 2026 年 9 月放出 Jev,自称 System One Model(取自卡尼曼的「系统一 / 系统二」)。它不生成文本:你发一个 state 和一串有类型的 questions,它回一串有类型的 answers,每个答案带概率和置信度。这个仓库的提交历史比 Jev 公开还早两天——它是最早一批吃这只螃蟹的用法之一。
问题有三种类型,这个项目全程只用其中一种:
| 类型 | 语义 | 返回 |
|---|---|---|
choice | 从最多 255 个选项里选一个 | choice + probabilities + confidence |
score | 按有序等级打分 | score + 概率分布 |
noul | 判断一条陈述是否为真 | 一个 0~1 的 noul 概率 |
为什么是 noul?因为这个插件的每个问题都是一个是非判断题,而不是分类题。与本项目相关的还有三个特性:
- 问题并行且互不影响。 多个问题对同一份 state 并行推理,官方说「加问题几乎不增加响应时间」——这直接决定了后面用批量提问而不是逐个问。
- 概率是校准过的。 所以可以当阈值用:
≥0.5 就留这种逻辑才成立。 - 不自由生成字符串。 所谓「零幻觉」指的是它写不出你没定义的答案,程序不用写解析兜底。
HTTP 契约长这样,一个请求里塞两样东西——state(状态)和 questions(问题):
POST https://api.typesafe.ai/v1/systemone
{
"model": "jev-latest",
"state": { "context": "...", "goal": "...", "history": [ ... ] },
"questions": {
"call_t3": {
"type": "noul",
"instructions": "Tool call t3 (Bash) should stay in the history: knowing this call was made, with its input, still matters for what the assistant does next"
},
"result_t3": {
"type": "noul",
"instructions": "The full output of tool call t3 (Bash, 4213 chars) should stay in the history verbatim: the assistant still needs its contents and re-running the tool would not do"
}
}
}
响应侧只有两个数字:
{
"model": "jev-latest",
"answers": {
"call_t3": { "type": "noul", "noul": 0.86 },
"result_t3": { "type": "noul", "noul": 0.93 }
},
"usage": { "input_tokens": 24118, "output_tokens": 0 }
}
洞察: Jev 把「压缩策略」这件模糊的工程决策,变成了一个可调的超参数。传统摘要方案的「压多少」是黑盒(你只能给一句「尽量详细」);这里压多少由
keepThreshold一个数字控制,而且每个决定都附带当时的概率,出问题时你可以逐条复盘:「这条被删是因为 result=0.12,是不是我把阈值调太低了?」
03 架构:三层与七个文件
LAYER 1 · 宿主 Claude Code 引擎(2.1.274+,需开 function hooks)
· 派发 session.compact / turn.complete
· 提供 $.http.fetch、$.ui.log/toast、$.settings.read
· 内置摘要式压缩作为回退
↓ session.compact(messages …)
↑ 返回 { messages } 顶替摘要,或 next(event) 回退
LAYER 2 · 适配器 hooks/fast-jev.ts(约 310 行)
· 读 userConfig → 找 key(option → env → settings.json)
· 换算 token → 调库 → 映射回 SessionMessage
↓ compact(messages, asker, options)
LAYER 3 · 库 types.ts · state.ts(状态拟合)· compact.ts(主流程)
request.ts · client.ts · index.ts
· 对外只暴露 compactMessages();也导出全部构件供他人组装
| 路径 | 作用 | 备注 |
|---|---|---|
src/ | 核心库,7 个文件约 700 行 | 零运行时依赖,只用原生 fetch |
hooks/ | Claude Code 适配器 + hooks.json | 唯一依赖宿主类型的地方 |
.claude-plugin/ | plugin.json(8 个 userConfig)+ marketplace 清单 | 仓库根即插件根,无需发布步骤 |
types/claude-code.d.ts | Claude Code 2.1.274 生成的类型快照 | 升级宿主后需重新生成并复核 |
tests/ | vitest 单测,含一个 fakeJev | 单测完全不联网;只有 npm run demo 打真实 API |
demo/JevDemo | macOS SwiftUI 小应用,把压缩流程演成动画 | 纯演示,不调 API,供录屏用 |
设计要点:库与宿主之间只靠一个接口解耦——
JevAsker。compact()只要求传入的对象有一个ask(state, questions)方法。于是同一个算法可以跑在原生fetch(JevClient)、Claude Code 的$.http.fetch(jevAsker)、或者测试里的一个纯函数上。这也是它敢把「库」和「插件」放在一个仓库里的原因——适配器的替换成本只是一次对象构造。
04 七步流水线,逐步拆源码
整个压缩的主函数不到 60 行,就是这七步的编排:
1 collectToolCalls → 2 fitState → 3 questionsFor → 4 batchCalls
↓
8 CompactResult ← 7 applyDecisions ← 6 decideCall ← 5 ask(并发)
贯穿全程的两条不变量:
pinned的消息(第一条 + 最新 N 条)永不进入候选集,因此第一条里的用户约束永远安全。- 结果永不先于调用消失:
drop_call时调用与结果一起走,drop_result时两者同步截断,绝不产生「孤儿结果」。
有个设计值得单独说:state 是「整段历史」,每批问题都要重复带一次。 为什么不把 state 切开放进各批?因为 Jev 需要看到全局才能判断「这条调用在后文里还被用到过吗」。代价是 state 越大部分越贵,所以 fitState 那一步才那么较真。
STEP 1 配对与「钉住」:把消息流变成可判定的调用单元
第一步不是压缩,而是建立可以被问问题的单位。原始 transcript 是一串消息,但工具调用是「两个消息里的两块内容」——assistant 消息里的 tool_use,和 user 消息里的 tool_result。它用 tool_use_id 把它们配成对,得到 ToolCall,带上 callIndex / resultIndex / resultChars / isError。
// src/state.ts:50-56 · "钉住"的定义
export function isPinned(index: number, total: number, preserveRecentMessages: number): boolean {
return index === 0 || index >= total - preserveRecentMessages;
}
为什么要 pin: 第一条消息永远是用户的初始任务描述——里面装着约束(「不要改 legacy/」、「保持 API 向后兼容」)。最新 N 条是当前推理的上下文,删了会断链。注意 pinned 是 callIndex 或 resultIndex 任一命中就成立——一次跨界的调用(调用在旧消息、结果在新消息)也不会被误删。
一个细节: 没有配到结果的调用不进候选集(if (!found) continue)。结果还没回来,没什么可删的;而且删掉一个「正在飞」的调用会破坏 API 的 tool_use ↔ tool_result 配对约束。
STEP 2 造状态:把整段对话压缩成「给判断模型的证词」
这一步有两个目标,而且它们在拉扯:要信息完整(Jev 判断得准)、要塞得进 25k token(请求有上限)。解法是「先尽量完整,装不下才按固定顺序弃车保帅」。
实际发出的 state 长这样:
{
"context": "A coding assistant conversation is being compacted to free context. history is the whole conversation so far, oldest first; tool outputs are replaced by a short result note and long texts may be abridged. ...",
"goal": "Fix the failing parser test in the checkout service.\nDo not touch legacy/. Keep the public parser API backward compatible.",
"history": [
{ "i": 0, "role": "user", "text": "Fix the failing parser test ..." },
{ "i": 2, "role": "assistant", "text": "",
"tool_calls": [ { "id": "t1", "tool": "Glob",
"input": "{\"pattern\":\"src/**/*.ts\"}",
"result": "ok, 63 chars (omitted)" } ] }
]
}
三个设计点:
- 工具结果只留统计,不留内容。
ok, 4213 chars (omitted)或error, 187 chars (omitted)。因为要不要留这个结果,主要取决于它在对话里的角色(哪个文件、哪条命令、成没成功、多大),而不是内容细节。让 Jev 读全部结果内容既贵又会淹没关键信号。 goal默认取最后 3 条用户提示,每条截到 500 字,并排除掉纯工具结果消息。等于自动注入一段「当前在干什么」——同一个Read,在「修 parser」和「重构日志」两个目标下要不要留,答案完全不同。- 空消息不进 history。 省掉一堆
{"text":""}的开销。
然后是八级缩身阶梯,顺序不是随便排的——从「损失最小的信息」开始牺牲:
| 顺序 | stage | 牺牲了什么 |
|---|---|---|
| 1 | full | 什么都没牺牲。工具输入最多 1000 字 × 文本全留 × 调用结构完整 |
| 2 | inputs<=200 | 工具输入的细节。大文件的 Write/Edit 输入最占地方,但判断「这个调用是什么」只要路径和命令 |
| 3 | inputs<=60 | 进一步:只剩调用的「名字与位置」 |
| 4 | texts abridged | 长文本变「头 400 字 + 省略标记 + 尾 150 字」,从最旧的非 pin 消息开始,最新的最后动 |
| 5 | old messages collapsed | 旧的纯文本消息整条变一行占位,只保留「这里曾有一大段话」 |
| 6 | old calls compacted | 旧的调用从结构化对象缩成一行:t12 Read file_path=src/a.ts → ok 480ch |
| 7 | old messages left out | 丢消息但不丢调用:不含任何调用的旧消息整条移出 |
| 8 | old calls merged | 连续的多条「纯调用消息」折叠成一条 history 条目:信封只付一次,调用行一字不少 |
| — | throw | 连折叠都塞不下 → 抛异常,交给上层回退。宁可不做,不做半成品 |
注意这里的取舍方向:先牺牲「细节」,最后才牺牲「消息的存在感」,而钉死的那几条消息永远在最后才被动。 这跟「按时间从旧到新一刀切」的粗暴做法完全不同——它是按信息密度从低到高地削。
最妙的一处: 第 7、8 级是「丢消息但不丢调用」。因为每条 history 条目都带 JSON 信封开销,折叠意味着信封只付一次,而调用行一字不少。测试里 40 次调用被折成 3 条 history,而 40 个调用行完整保留。
顺便,如果没显式给目标,它会把最近三条用户提问当成 goal 一起发给 Jev。这个细节很小但很关键:判断「这条调用还有用吗」,得先知道「我们到底在干什么」。
STEP 3 提问:两个问题,措辞本身就是提示词工程
// src/compact.ts:56-67
export function questionsFor(call: ToolCall): JevQuestions {
return {
[`call_${call.id}`]: {
type: 'noul',
instructions: `Tool call ${call.id} (${call.tool}) should stay in the history: knowing this call was made, with its input, still matters for what the assistant does next`,
},
[`result_${call.id}`]: {
type: 'noul',
instructions: `The full output of tool call ${call.id} (${call.tool}, ${call.resultChars} chars) should stay in the history verbatim: the assistant still needs its contents and re-running the tool would not do`,
},
};
}
两句大白话,就是实际发出去的两个问题:
工具调用 t7(Read)应该留在历史里:知道「当时发起过这个调用、输入是什么」,对助手接下来的动作仍然重要。
工具调用 t7(Read,3204 字符)的完整输出应该逐字留在历史里:助手仍然需要它的内容,重跑这个工具无法替代。
两个问题问的是两件正交的事,这是整套设计里最值得学的一招:
call_*调用本身该留吗 —— 问的是历史痕迹的价值:「知道发生过这个调用、以及它的输入是什么,对助手接下来做的事还有意义吗?」典型高价值场景:助手曾确认过legacy/parser.ts与故障无关,这个「查过」的记录防止它几轮后再去查一遍。result_结果该逐字留吗 —— 问的是内容的不可替代性:「助手还需要这些内容吗,而且重跑一次工具拿不到?」那句话里 re-running the tool would not do* 是关键约束:一个文件内容重读就有了;但一次已经过去的状态(某次测试在某个提交上的输出)重跑未必能复现。
为什么不分两次请求: 因为 Jev 的问题并行、互不可见。两个问题是独立判断,不会互相污染(不像让一个 LLM 连着回答会有上下文效应)。所以它们可以塞进同一个请求,代价只是几个 token。
STEP 4 分批:一个把预算算错就会崩的地方
// src/compact.ts:73-99
const budget = options.maxRequestTokens - stateTokens - REQUEST_OVERHEAD_TOKENS; // 30000 - state - 20
...
for (const call of calls) {
const tokens = estimateTokens(JSON.stringify(questionsFor(call)));
if (current.length > 0 && currentTokens + tokens > budget) { batches.push(current); current = []; currentTokens = 0; }
if (current.length === 0 && tokens > budget) {
throw new Error(`state leaves no room for questions (~${stateTokens} of ${options.maxRequestTokens} tokens)`);
}
current.push(call); currentTokens += tokens;
}
三条规则,缺一不可:
- 一次调用的问题对绝不拆开。 否则可能拿到
call_却没有result_,决策就残缺了。 - 批与批之间不留空档。
current.length > 0保证「装不下就立刻封批」,「单个超预算」由第 3 条兜底。 - 单个问题都塞不下就直接抛。 不静默丢问题。
用源码里的 estimateTokens 实跑一遍:一个 Read 调用的问题对约 124 token。所以当 state 吃满 25k 时,每批预算 30000 − 25000 − 20 = 4980,即每批约 40 个调用(80 个问题)。
| state token | 每批容量 | 200 次调用需要的请求数 | 总输入 token |
|---|---|---|---|
| 25 000 | 40 | 5 | 150 000(≈ $0.006) |
| 20 000 | 99 | 3 | 90 000 |
| 29 900 | 0 | 抛异常 | 回退官方摘要 |
STEP 5 并发提问与答案合并:全文重发是刻意的取舍
// src/compact.ts:117-133, 271-279
const answered = await Promise.all(
batches.map((batch) => askBatch(asker, state.state, batch)),
);
for (const map of answered) for (const [id, answer] of map) answers.set(id, answer);
每一批都重发同一份完整 state。这不是偷懒:
| 方案 | 换来什么 | 付出什么 |
|---|---|---|
| 全文重发(本项目) | 无状态:不需要维护会话 ID、不需要记住「上一批问到哪」、失败整批重试即可;每批判断题看到同样的证据,答案不会因批次而异 | 输入 token 随批数线性增长;历史接近上限时「每问几十个问题就付一次 25k」 |
| 增量续问(假设 API 支持) | 省 token | 需要状态管理、幂等、断线续传;而 Jev 的 API 本身就是「一次调用一份 state」的形态 |
作者自己写进 README 的风险:「The full state is repeated with every request, so a history near the state ceiling costs one request per handful of questions.」 换句话说:会话越长,压缩越贵。提高
maxStateTokens会挤掉问题空间;降低它又让 state 变糙、判断变差。25k / 30k 这两个数就是在这个张力下调出来的。
STEP 6 决策:三级瀑布与它的语义
整个压缩策略就这 15 行:
// src/compact.ts:101-115
export function decideCall(call, answer, options): CallDecision {
const base = { id: call.id, tool: call.tool, ...answer };
if (call.pinned) return { ...base, action: 'keep', reason: 'pinned' };
if (answer.keepResult >= options.keepThreshold) return { ...base, action: 'keep', reason: 'kept' };
if (answer.keepCall >= options.keepThreshold) return { ...base, action: 'drop_result', reason: 'result_dropped' };
return { ...base, action: 'drop_call', reason: 'call_dropped' };
}
钉死? ──── 是 ────> keep reason: pinned(永不入候选)
│否
结果概率 ≥ 0.5 ? ──是──> keep 调用 + 结果逐字保留
│否
调用概率 ≥ 0.5 ? ──是──> drop_result 调用留下,结果截成前 300 字 + 说明
│否
└──────> drop_call 调用与结果一起删
为什么 keepResult 先判? 因为「结果要留」是一个更强的条件:结果要留在逻辑上蕴含调用要留(不可能留一个没有调用的结果)。先判强条件,剩下的空间才交给「只留调用」这一档。
易错点: 判定顺序不是「先看调用再看结果」,而是先看结果。如果反过来写,一个
keepCall=0.9, keepResult=0.1的调用会被判成keep,中间那档就永远走不到了。这 15 行的顺序是经过推理的。
阈值 0.5 意味着什么? 不是「大概率要留」,而是「只要比抛硬币更可能有用就留」——整体偏保守。想更激进地压,抬到 0.7;想更安全,降到 0.3。
另外两个容易漏的边界:
- 钉死的调用直接返回 keep,连问都不问,理由记作
pinned。 - 没拿到答案的调用默认 keep(概率按 1 处理)。宁可留着,不可误删——这个默认值本身就体现了这份代码的性格。
STEP 7 重建:对象同一性不是优化,是协议
被 drop_result 的 4000 字工具输出会变成什么样:
// src/compact.ts:135-141
function truncatedResultText(text: string, isError: boolean, headChars: number): string {
if (text.length <= headChars + 120) return text; // 短结果不值得截,原样留着
const head = headChars > 0 ? `${text.slice(0, headChars)}\n` : '';
return `${head}[fast-jev-compaction truncated ${text.length - headChars} chars of this tool result${
isError ? ' (error)' : ''
}; re-run the tool if needed]`;
}
三个细节:
- 「前 300 字」是有信息量的。 文件开头的 import 列表、报错的第一行、命令的头部输出——头部密度远高于尾部。所以留头不留尾(
truncateHeadChars,默认 300;设 0 就只留说明)。 - 说明里写明数字与「可以重跑」,助手看到它知道「这里删了 3700 字」,而不是面对一个静默变短的结果发懵。
- 短结果直接放过。 原文只有 400 字时整条保留;删 100 字换来一条说明是负收益。
重建里有最漂亮的一处:
// src/compact.ts:161-168
for (const message of messages) {
const touched =
message.toolUses.some((tool) => actions.has(tool.tool_use_id)) ||
(message.toolResults ?? []).some((result) => actions.has(result.tool_use_id));
if (!touched) {
kept.push(message); // ← 同一个对象引用,不是拷贝
continue;
}
...
这段在库层面只是「少一次拷贝」,但在插件层面是功能性的。Claude Code 给每条 SessionMessage 盖了一个不透明的 handle,类型声明里写得很清楚:
「a message kept with its handle is the engine's own, whole; one without is read as built.」
带 handle 的原对象 = 引擎自己的,原封不动;没有 handle = 这是插件新造的,按新内容处理。
于是适配器用两个 Map 把「输入对象 → 引擎对象」映射回来:命中的直接返回引擎原对象(带 handle),重建过的就构造一个新的(无 handle)。对象同一性在这里就是通信协议。
所以代码里到处是 tool === message.toolUses[index] 这种引用相等的比较,看着啰嗦,其实是在省掉一整轮无谓的重建。两个收尾的不变量:
- 内容全丢的消息整条移除;但条件是
message.text.trim().length === 0——只要还有一句人话,消息就留着,哪怕工具块都清空了。 - 「看起来被碰过但实际没变」也返回原对象:重建后如果逐个元素与原来全等,仍然返回原引用,把对象同一性最大化。
还有一条判断特别隐蔽:如果一条消息里的工具调用全被判了 drop_call,那整条消息直接跳过。少写这一句,「结果减了多少」就算不对——因为空壳消息还在占位置。
它怎么数 token(全篇预算的地基)
所有预算都得先知道「有多大」,但 Jev 的计费口径你本地没有分词器。这个项目自己写了个估算函数,规则简单到可以背下来:
- 连续字母:1 个 token,之后每多 6 个字母加 1 个。
- 连续数字:长度的一半。
- 其他任何符号:每个 0.9。
写成正则是 /[A-Za-z]+|\d+|[^\sA-Za-z\d]/g。
有意思的是源码里那句注释:这个估法在真实对话上比 Jev 报的实际用量高 2% 到 18%,而「字符数除以固定值」那种土办法,在这种塞满 JSON 的 state 上会低估到 40%。 宁可高估不可低估——估低了,请求就会超预算被拒。
顺带一个对中文用户的实际影响:「其他符号」包含了汉字,所以每个汉字算 0.9 个 token。对一个中文为主的对话,这个数字是偏低的(真实分词器下中文往往更贵)。所以别拿它去算账单,它只是个「会不会超预算」的粗尺子。
05 交互实验室
上面四个实验台已经把源码搬到浏览器里了,它们不是示意图——驱动它们的 estimateTokens、fitState、collectToolCalls、decideCall、applyDecisions,都是从仓库源码逐行移植过来的 JavaScript:
| 实验台 | 在本文的位置 | 能玩什么 |
|---|---|---|
| 流水线剧场 | 04 开头 | 用 examples/demo.ts 的真实剧本端到端跑一遍七步,逐条看每个调用落进哪个分支 |
| 状态拟合台 | STEP 2 末尾 | 拖动 maxStateTokens,看八级阶梯怎么一级级启动、在哪一级抛异常 |
| 阈值决策台 | STEP 6 末尾 | 调 keepCall / keepResult / keepThreshold,看同一条调用在四个数字下变成 keep / drop_result / drop_call |
| 分批计算器 | STEP 4 之后 | 改 state 预算与调用数,算需要几次请求、总共多少输入 token |
这四个实验台只在网页版可用。点右下角「复制到公众号」时,它们会被自动换成一句文字说明,不会把一堆点不动的控件带过去。
不跑实验台也能看懂的读数
四个台子的输入都是仓库里可复现的固定剧本,所以结果也能写成静态表。下面是它们在网页版里的默认读数。
LAB 1 · 状态拟合台。 这份合成 transcript 的满状态是 12 861 token;把 maxStateTokens 从上往下拖,八级阶梯依次启动,最后一级是抛异常:
- 满状态:
stage=full、tokens=12861、history 57 条。 - 预算 9 600:
tokens=8397、stage=inputs<=200(牺牲工具输入、保住文本)。 - 预算 130 附近:抛
history too large for Jev (~1242 tokens after truncation, limit 130)。
这份 transcript 是合成出来的:18 轮 × 5 条 = 93 条消息、36 次工具调用,每轮含一段约 900 字的助手说明、一次 Read(输入很小)、一次 Edit(输入很大——old_string / new_string 各 40 行塞在 JSON 里)、两条工具结果。为什么要合成:真实剧本的工具结果虽然大,但结果内容根本不进 state(只换成一行 note),state 很小、压不出阶梯。这本身说明了一件事——状态预算是被「消息文本 + 工具输入」吃掉的,工具结果的体积只影响「删不删它」,不影响「问问题要花多少钱」。 而 Edit 的输入恰好是阶梯前两级唯一能触发的东西。
LAB 2 · 阈值决策台。 同一条 Bash 跑 vitest 的调用(结果 2443 字、isError=true),只改四个数字:
| keepCall | keepResult | keepThreshold | truncateHeadChars | action | 结果字符数 |
|---|---|---|---|---|---|
| 0.90 | 0.70 | 0.50 | 300 | keep | 2443(不变) |
| 0.90 | 0.20 | 0.50 | 300 | drop_result | 398(前 300 字 + 97 字说明) |
| 0.10 | 0.20 | 0.50 | 300 | drop_call | 0(调用与结果一起消失) |
| 0.90 | 0.20 | 0.50 | 0 | drop_result | 97(只剩一行说明) |
drop_result 留下的头部是有意义的:FAIL src/parser.test.ts 这一行还在,助手就知道「当时失败了、失败在哪」。
LAB 3 · 分批计算器。 见「STEP 4」那张表:预算 20 000 时每批能装 99 个调用、200 次调用只要 3 次请求;把预算顶到 29 900 反而每批装 0 个,直接抛异常回退。
LAB 4 · 流水线剧场。 仓库 examples/demo.ts 的真实剧本——修 src/parser.test.ts 的失败用例,约束是「不许动 legacy/、保持公开 API 向后兼容」,preserveRecentMessages=2:
| 调用 | 工具 | keepCall | keepResult | 决策 |
|---|---|---|---|---|
| t1 | Glob | 0.82 | 0.55 | keep |
| t2 | Read legacy/parser.ts | 0.24 | 0.12 | drop_call |
| t3 | Read src/parser.ts | 0.88 | 0.91 | keep |
| t4 | Bash vitest(FAIL) | 0.86 | 0.93 | keep |
| t5 | Edit src/parser.ts | 0.90 | 0.80 | keep |
| t6 | Bash vitest(PASS) | 0.90 | 0.24 | drop_result |
| t7 | Bash npm test | 0.94 | 0.20 | drop_result |
结果是 messages 21 → 19、字符数减少 52%、kept / resultsDropped / callsDropped = 4 / 2 / 1、state ~1131 token、1 次请求。52% 高于 minReductionRatio(0.25),所以这次压缩会被采纳、顶替官方摘要。两个值得注意的点:被 drop_call 删掉的是一次「读了无关文件」的调用,但「它无关」这个结论写在助手的文本里,完好无损——这正是「文本永不删」的价值;而两次 drop_result 其实一点字符都没省下来(结果只有 80 多字,headChars + 120 都超不过,于是整条放过),压缩主要来自那次 drop_call。
06 插件侧:钩子、手柄与三层回退
插件挂在两个钩子上,各管一件事:
| 钩子 | 触发时机 | 它做了什么 |
|---|---|---|
session.compact | 会话即将被压缩时(/compact、自动压缩、其他插件触发) | 把 Jev 方案跑一遍,返回 { messages } 顶替掉内置摘要;任何一步出错或收益不够就 return next(event) 交给官方摘要 |
turn.complete | 每一轮对话结束时 | 读 $.session.usage(),上下文占比到 compactAtPercent(默认 60%)就主动触发一次压缩 |
自动触发那半段有一个跨事件存活的并发锁:
export const register: Register = (on: On, options: PluginOptions) => {
const configured = resolveHookConfig(options);
let compacting = false; // 跨事件存活,就是一个并发锁
on('turn.complete', async ($, event, next) => {
if (compacting) return next(event); // 上一轮压缩还没结束,不叠加
try {
const { context } = await $.session.usage();
if ((context.percent ?? 0) < configured.compactAtPercent) return next(event);
compacting = true;
await $.session.compact(); // 主动触发,会再走一遍 session.compact
} catch (error) {
$.ui.log(`auto-compact skipped (...)`); // 自动压缩失败只记一行,不打扰用户
} finally {
compacting = false;
}
return next(event);
});
};
为什么要锁:turn.complete 主动调 $.session.compact(),而压缩本身又会派发 session.compact 事件。如果压缩期间又结束了一轮对话,没有守卫就会出现两个压缩同时在跑(各自打一遍 Jev、各自重建历史),结果取决于谁后写——典型的竞态。而且 compacting 是 register() 闭包里的变量,不是模块级单例,所以每个插件实例有自己的锁。
三层回退:一个「永不比现状更差」的设计
on('session.compact', async ($, event, next) => {
try {
const config = { ...configured, apiKey: await getApiKey($, configured) };
const { result, messages } = await compactSession(event.messages, config, httpFetch);
for (const line of decisionLogLines(result)) $.ui.log(line); // 逐条决策写进日志
if (reductionRatio(result) < config.minReductionRatio) { // ← 回退第二层:收益不够
notify($, `fallback to built-in summary (below ${percent(config.minReductionRatio)} minimum: ${summarize(result)})`);
return next(event);
}
notify($, `kept ${messages.length}/${event.messages.length} messages, no summary (${summarize(result)})`);
return { messages }; // ← 成功:顶替内置摘要
} catch (error) { // ← 回退第一层:任何异常
notify($, `fallback to built-in summary (${error instanceof Error ? error.message : String(error)})`);
return next(event);
}
});
| 回退层 | 触发条件 | 为什么这么设 |
|---|---|---|
| ① 异常回退 | 缺 TYPESAFE_API_KEY、网络失败、answers 缺失、答案不是数字、state 塞不进预算(too large)、单批放不下一个问题 | 错误信息都写进 toast,用户能看见原因。库的原则是该抛就抛,让调用方决定——库本身从不自己兜底 |
| ② 收益回退 | 字符压缩率 < minReductionRatio(默认 0.25) | 短会话本来就没多少可删的,用「删掉几条工具结果但不给摘要」去换掉官方摘要可能是净损失 |
| ③ 无候选 | 没有任何非 pinned 的工具调用 | 不调 API、不报错,直接走官方摘要(requests: 0) |
第三层「放弃」是良性的:如果压根没有候选(比如整段对话都在钉死的范围里),它一个请求都不发,直接返回原样。
可观测性。 成功时 toast 长这样:
fast-jev-compaction: kept 18/21 messages, no summary
(62% reduction; 4 kept, 3 results truncated;
state ~18420 tokens (inputs<=200) in 2 request(s))
括号里信息密度极高:压缩率、逐类决策计数、state 大小、命中了哪一级阶梯、几次请求。另外日志里会写一行:
decisions: t1:Glob:keep/call=0.82/result=0.55
t2:Read:drop_result/call=0.71/result=0.12
t3:Read:keep/call=0.88/result=0.91
这套日志设计的意义是:事后你能量化复盘「哪些被误删了」。 大多数压缩方案根本给不出这个——它只会告诉你「已总结」。
一个工程细节:决策日志按 4096 字符预算切行并标上 (1/3) 这样的序号。一次 200 调用的会话会产生接近 8KB 的决策日志,而 UI 单行有长度上限。这种「早期访问 API 的隐形约束」在真实集成里到处都是——纯库作者容易忽略。
找 key 的三级查找
async function getApiKey($, config: HookConfig): Promise<string | undefined> {
if (config.apiKey) return config.apiKey; // ① 插件 userConfig(敏感字段,安装时填)
const fromEnv = await $.env.get('TYPESAFE_API_KEY'); // ② 环境变量(推荐给开发用)
if (fromEnv) return fromEnv;
const settings = await $.settings.read(); // ③ settings.json 的 env 块
const env = settings['env'];
if (env && typeof env === 'object') {
const value = (env as Record<string, unknown>)['TYPESAFE_API_KEY'];
if (typeof value === 'string' && value) return value;
}
return undefined; // 都没有 → compactSession 抛错 → 回退
}
README 明确建议用环境变量而不是插件选项——插件选项会被写进配置文件,环境变量不会。库里也有一句硬话:「Never commit the key or put it in a source file.」
07 参数、成本与延迟实算
| 参数 | 默认 | 作用与调参直觉 |
|---|---|---|
keepThreshold | 0.5 | 压缩的总开关。抬高=更激进地删;降低=更保守。唯一你会经常动的参数 |
preserveRecentMessages | 6 | 最新多少条永不碰。太小 → 当前推理链断裂;太大 → 可压缩比例变小。「能省多少」的天花板由它决定 |
maxStateTokens | 25 000 | 状态预算。太大挤掉问题空间导致请求数暴涨,太小让判断变糙 |
maxRequestTokens | 30 000 | 单次请求上限,必须低于 Jev 的 32k 硬限制 |
truncateHeadChars | 300 | drop_result 时留多少原文。设 0 只留一行说明——最省,但失去「这个文件大概长什么样」的线索 |
goal | 最后 3 条用户提示 | 显式指定「当前任务」。多任务长会话里显式指定比自动推断准得多 |
compactAtPercent(插件专属) | 60 | 上下文占用到多少百分比自动触发。官方自动压缩一般更晚(80%+),提前到 60% 是为了避免压到一半没空间 |
minReductionRatio(插件专属) | 0.25 | 低于这个收益就放弃。防的是「用一次网络往返换来 3% 的收益」 |
成本与延迟(默认参数、state 吃满做上界估算):
| 项 | 值 |
|---|---|
| 每次请求 | 30 000 token 输入(25k state + 问题 + 信封开销) |
| 每批可问 | 约 40 个调用 → 80 个问题 |
| 200 次调用的会话 | 5 次请求 = 150 000 输入 token |
| Jev 价格 | $0.042 / 百万输入 token(输出免费) |
| 一次压缩的 API 成本 | 约 $0.006——不到一分钱 |
延迟上,5 次请求并发(Promise.all),而 Jev 官方标称端到端 70ms~500ms,所以整体大概率在一秒以内。stats.ms 也在结果里,可以自己量。
要小心的一个洞: 成本随「会话长度 × 调用次数」平方级增长——state 越长,每批装的问题越少,请求越多,而且每次都要重发长 state。极端情况:state 吃满 25k、候选调用 400 个 → 10 次请求 × 30k = 300k token ≈ $0.013,仍然是可忽略的量级。真正的成本不是钱是延迟——10 次并发请求串起来的等待,在自动压缩路径上用户能感觉到。
08 局限、风险与几个源码级发现
作者自己在 README 里写下的局限
- 只有工具调用和结果是候选,文本永不删。 输出里的文本消息既不删也不缩短(只在送给 Jev 的 state 里被截断)。所以「全是长文本、没有工具调用」的会话它一点也压不动。
- token 是估算,不是分词器。
- 校准在请求级,概率不是证明。 原话:「a probability is not a proof that a result is safe to delete」。缓解手段是「助手随时可以重跑工具」。
- 全文重发导致长会话请求数线性增长。
换成使用者视角,这几条意味着:
- 不许删的调用,它删不了。 你没给它「这条绝对不能动」的接口,只有首条和最近 6 条是自动钉死的。真有必须保全的中间调用,得靠把
preserveRecentMessages调大来覆盖。 - 中文对话的预算会被低估。 汉字按 0.9 token 算,中文占比高的时候实际开销会比估算更贵,可能提前撞上「装不下」。
- 判断质量取决于问题写得好不好。 那两句话本身就是提示词——它们陈述得越具体,Jev 的概率越有意义。想让它做得更好,改那两句比调阈值管用。
- 它不解决「该记什么」。 它只决定「什么该留」。真正需要跨轮次携带的关键约束,还是得写进系统提示或项目文件里。
读源码时发现的七件事
- 中文会话会被低估 token。 分词估算的正则是
/[A-Za-z]+|\d+|[^\sA-Za-z\d]/g,每个「非字母数字非空白」的字符(也就是每个汉字、每个标点)统统算 0.9 token。实测 160 字中文得 0.91 token/字,但真实 BPE 分词器对汉字通常 ≥1 token/字。作者的校准是针对英文 transcript 做的,中文会话下这个估算会偏小,可能导致 state 实际超出maxStateTokens、或者某批请求真的撞上 Jev 的 32k 硬限制。实践建议:用中文会话时把maxStateTokens从 25000 下调到 20000 左右。(这是我的推断,不是作者的说明。) - 没有任何答案的调用,默认是「全保留」。 兜底是
answers.get(call.id) ?? { keepCall: 1, keepResult: 1 }——「问不到答案」就保持原样,方向正确。好在答案解析对「答案存在但格式不对」会抛异常,所以只有「整个问题没被回答」才会静默走这条兜底。 drop_result会同时改两处文本。 assistant 消息里的tool_use.text(Claude Code 把调用结果也贴在了调用块上)和 user 消息里的tool_result.text会被同步截断成同一个字符串。如果只改一边,转录里同一个工具的输出会出现两个不一致的版本。- 「离开的」消息是跳过而不是重建。 那条
continue意味着被删除的消息从数组里彻底消失,不留占位。有意为之:留一个「[已压缩]」占位符反而浪费 token 并干扰模型。代价是消息索引会变——任何外部的「第 N 条消息」引用都失效了。 stats.charsBefore把「不可删的部分」也算进去了。 它统计整条消息的全部内容,包括永远不可能被删的text。所以reductionRatio是一个保守的(偏低的)压缩率。对minReductionRatio这个门槛来说这是好事:它不会因为分母里塞了一堆不可删的文本而虚高。REQUEST_OVERHEAD_TOKENS = 20是个「诚实的偷懒」。 请求信封(model字段、JSON 括号、state/questions键名)其实不止 20 token。常数 20 是「少算一点、留一点安全边际」的写法。真正的安全边际来自maxRequestTokens=30000相对 32k 硬限制的那 2000 token。- 三个「看起来能改但别随便改」的地方:
preserveRecentMessages只保护「消息」,不保护「调用」。 pin 区内的调用完全不参与判断——如果你在最近 6 条里读了 10 个大文件,它们全都不会进候选,压缩率会很难看。- Jev 的提问措辞是英文写死的。 没有国际化。想换措辞做 A/B 测试的话,记住它同时决定了
state里context那段旁白——两者要配套改。 estimateTokens在batchCalls和fitState里各算一遍,用的是同一个函数。 两处预算口径天然一致。如果给某一处换成真实分词器,另一处也必须换,否则会出现「state 说装得下、实际装不下」。
09 和其它压缩方案的正面比较
| 方案 | 机制 | 信息损失 | 可验证性 | 成本 | 最适合 |
|---|---|---|---|---|---|
| 滑动窗口 | 直接丢掉最旧的 N 条 | 整条整条地丢,包括用户约束 | 无(丢什么是写死的) | 零 | 短会话、无状态单次问答 |
| LLM 摘要 | 让模型把旧对话改写成一段话 | 不可控、不可逆;细节被自然语言重编码时蒸发 | 差(无法得知漏了什么) | 一次大模型调用,通常贵且慢 | 语义连贯性比细节保真更重要时 |
| 检索式记忆(RAG) | 旧内容向量化入库,按当前 query 召回 | 召回失败就完全丢失;需要额外基础设施 | 中(能看见召回了什么) | 嵌入 + 向量库 + 每轮检索 | 跨会话的长期知识、文档问答 |
| 结构化状态槽 | 把进展写进一个外部 JSON(待办、已改文件) | 只保留你预设的字段 | 好(字段就是契约) | 零(但需要精心设计 schema) | 流程固定的长任务 |
| 本项目(Jev 引导的删除) | 逐调用判断「删 / 截断 / 留」,从不改写 | 只损失可重放的工具内容;文本零损失 | 好(每个决定带概率 + 理由,可逐条审阅) | 一次会话几美分内,延迟 1 秒级 | 工具调用密集的编码 Agent 会话 |
最该记住的对比: 把它和「LLM 摘要」放在一起看,分界线很清楚——摘要解决的是「我不知道什么重要,你帮我挑」;删除解决的是「我知道什么可重放,你帮我判」。 前者把决策权交给模型且不可逆;后者把决策权变成阈值 + 概率,而且删错的东西有原路可回。
10 可迁移的三条工程思想
一、「判断」和「生成」分开
把一个需要「输出一段话」的任务,改写成若干「回答一个是非题」的任务,会同时得到三样东西:可缓存、可并行、可阈值化。
有意思的是这件事不需要 Jev 也能做——用一个普通 LLM 问「是 / 否」并要求它只输出一个数字也能近似实现(社区里就有人用 1B 小模型复刻了类似的并行判断)。这个仓库真正的贡献不是「用了 Jev」,而是把压缩问题拆成了正确的判断单元:两个正交的是非题 + 三级动作。
二、不可逆操作要有「边界对象」
这套系统明确划了一条线:可重放的(工具输出)可以删,不可重放的(人类与助手的文本)永不碰。 有了这条线,「自动删除」才敢上线——因为最坏情况是可恢复的。
推广开:任何自动化的破坏性操作,都应该先问「这个东西丢了能不能拿回来」,能拿回来的进自动化白名单,拿不回来的走人工确认。truncateHeadChars 保留前 300 字 + 写明「可以重跑」,就是这个思想的微观实现。
三、渐进降级,而不是一次降到底
八级缩身阶梯的顺序本身就是一份价值排序声明:工具输入的细节 < 旧消息的原文 < 消息的结构 < 消息的存在。系统按这个顺序一级一级地牺牲,只有前一级不够时才动下一级。
对比「直接把 state 截断到 25k」的做法:后者会把对话截成两半,中间断掉,而阶梯保证了无论预算多紧张,「最近发生了什么、有哪些工具调用」这两个骨架永远在。这是所有降级策略都该学的:先定义什么绝对不能丢,再决定丢的顺序。
附赠:失败路径的设计比成功路径更重要。 三层回退加上「库只抛错、调用方决定」的分工,让这个插件最坏结果等于没有它。这是所有「增强型中间件」该有的自觉。一个小证据:
compact()里对「没拿到答案」的调用默认判成keepResult=1——往「保留」的方向兜底,而不是往「删」的方向。
11 附录:文件清单与跑法
仓库与版本
github.com/tamaratran/fast-jev-compaction · MIT · 库 v0.2.0 / 插件 v0.3.0 · TypeScript(ESM,Node ≥ 18)。本文引用的行号对应主分支提交 e3f262a。
作为库使用
npm install fast-jev-compaction
export TYPESAFE_API_KEY=...
import { compactMessages, reductionRatio, type Message } from 'fast-jev-compaction';
const result = await compactMessages(transcript, { preserveRecentMessages: 4 });
console.log(result.messages, result.decisions, result.stats);
if (reductionRatio(result) < 0.25) {
// 不值得:保留原 transcript,或者走摘要
}
换成自己的传输层(库的核心解耦点)
import { compact, buildJevRequest, parseJevResponse, type JevAsker } from 'fast-jev-compaction';
const myAsker: JevAsker = {
async ask(state, questions) {
const req = buildJevRequest({ apiKey: myKey, model: 'jev-latest' }, state, questions);
const res = await myHttpClient.post(req.url, req.body, req.headers);
return parseJevResponse(res.status, res.ok, res.text);
},
};
await compact(transcript, myAsker, { keepThreshold: 0.6 });
整个库不依赖任何模型 SDK,只要求你有一个 ask(state, questions) 形式的函数,所以本地跑测试完全不需要 API Key——插件里就带了一个假 asker 的测试。
装成 Claude Code 插件
# 1. 打开 function hooks 并给 key(写进 ~/.claude/settings.json 的 env 块)
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
export TYPESAFE_API_KEY="<your key>"
# 2. 把仓库当 marketplace 装插件
claude plugin marketplace add tamaratran/fast-jev-compaction
claude plugin install fast-jev-compaction@fast-jev-compaction
# 3. 重启 Claude Code 或 /reload-plugins
# 之后 /compact 与自动压缩都会走 Jev,toast 显示 kept N/M messages, no summary (...)
# 短会话会印 fallback to built-in summary (below 25% minimum: ...)
# 不想装的话,从 checkout 直接跑:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir .
开发命令
| 命令 | 做什么 |
|---|---|
npm install | 装 devDependencies(typescript / vitest / tsx)——没有运行时依赖 |
npm run typecheck | 库 + 钩子两套 tsconfig 一起查 |
npm test | vitest,用 fakeJev,完全不联网 |
npm run build | tsc 出 dist/ |
npm run demo | 唯一一次真实网络调用,需要 TYPESAFE_API_KEY |
npm run validate:plugin | 跑 claude plugin validate 校验插件清单 |
demo/JevDemo/build.sh | 构建并启动 macOS 那个动画演示(仅限 macOS) |
建议的阅读顺序
types.ts(先看数据结构)→ compact.ts 的 compact()(主干七步)→ state.ts 的 estimateTokens + fitState(最复杂的部分)→ compact.ts 的 decideCall(策略核心,15 行)→ hooks/fast-jev.ts 的 toSessionMessages(最能体现宿主集成的技巧)→ 最后回头看 tests/,它是最好的规格说明。
仓库里还带了一个 examples/demo.ts 和一段可以直接跑的演示对话,是理解八级阶梯最快的方式:改一下 maxStateTokens,看它在第几级停下来。
最后回到那句话:证明模型能把对话总结得漂亮是一种本事,证明它知道哪些东西根本不该动,是另一种本事。 上下文压缩的真正难点从来不是「写得多好」,而是「忍住不写」。
Jev / System One 的公开信息来自 TypeSafe 官方介绍与公开报道;本文对设计动机的解读属个人分析,不是作者说明。交互实验室里的
estimateTokens/fitState/decideCall/applyDecisions均为源码的 JavaScript 移植,行为尽量逐字对齐。