摘要先备好,别急着替对话换脑子

题记:摘要什么时候生成,与对话什么时候开始依赖摘要,是两个问题。
1. 压缩不只是把长文本变短⌗
一种直接的上下文压缩方案是:对话太长,生成摘要,用摘要替换旧历史,然后继续。
这条流水线容易理解,却把三个不同决定绑在了一起:
| 决定 | 真正在问什么 |
|---|---|
| 准备摘要 | 什么时候读一遍长上下文比较合算? |
| 应用摘要 | 什么时候值得放弃原始上下文? |
| 恢复原文 | 用户发现摘要不够用时,怎么办? |
读原文便宜的时候,不一定应该马上丢掉原文。用户回来时,也不一定应该先等系统临时写一篇摘要。
这次落地的关键,是补齐缓存失效后的后台切代,并把摘要状态和恢复入口接进界面。它建立在已有的“温热缓存上准备摘要、下一回合检查是否应用”的机制上,不是重新发明摘要算法。
我认为值得借鉴的地方也在这里:优化对象不再只是一段文本的长度,而是长对话从温热、冷却到重新使用的整个生命周期。
2. 趁便宜备货,但不立刻换货⌗
准备阶段复用线程最后一次请求的冻结前缀:系统说明、工具列表、消息视图和思考级别等,沿用该线程自己的模型继续请求,而不是另造一份大致相似的输入。
目的很具体:尽量沿着已有提示缓存读取上下文,而不是为了压缩,先支付一次完整的冷读取。
代码通过已有的分钟节拍,在缓存接近配置 TTL 时考虑三种动作:准备摘要、保活,或者什么都不做。选择参考输入与缓存价格、摘要输出成本、恢复概率等配置,也有上下文窗口占比触发条件。
这些是决策估计,不是已经观察到的节省。没有可用 TTL、价格或缓存条件,不能凭一句“后台总归更便宜”就启动。
摘要准备成功后,只保存为 prepared,原会话不切代。更有意思的是,生成摘要本身又触碰了缓存:
最后一次正常调用
│
▼
临近 TTL:沿冻结前缀准备摘要
│
├── 摘要保存为 prepared
├── 原始上下文继续有效
└── 缓存计时重新开始
所以,“已经花钱生成摘要”并不构成立刻使用它的理由。那笔钱已经花了;继续使用温热原文是否更好,是下一笔账。
3. 后台应用,不能还等用户来按开关⌗
此前,准备好的摘要主要在下一回合开始前检查并应用。这次增加的 applyExpired(),让分钟节拍也能完成同一件事:找到当前活动会话中已准备的摘要,在空闲且满足失效条件时切换到新一代。
因此,用户还没发下一句话,界面就能呈现“当前已经在使用摘要”的状态。应用阶段消费现成摘要,不再调用模型。
这里有两个容易写错的时钟。
第一,不能从上次用户发言开始计时。
代码以摘要准备时间与会话最新模型调用时间的较晚者为基准:
最后触碰时间 = max(摘要准备时间, 最新模型调用时间)
可应用 = 已到配置 TTL,或下一回合使用了不同 provider
用户在缓存仍温热时继续工作,新的调用会推迟应用。不能因为后台早早备好了摘要,就在对话恢复之后照原闹钟强行换上下文。
第二,配置 TTL 是失效判断依据,不是服务端缓存探针。
实现根据时间和 provider 判断是否可应用,并没有实时向服务端查询这个前缀到底还在不在缓存里。因此本文说的“缓存失效”,在机制上是按这套配置判定的失效,不是对远端实际命中率的保证。
分钟节拍也不是精确到毫秒的定时器:符合条件后,在后续节拍应用;若线程正在执行,就继续等。回合入口仍保留检查,以处理恢复工作和 provider 变化。
4. 摘要有覆盖边界,不是永久通行证⌗
准备好的摘要可能暂时用不上。原会话继续增长,摘要只覆盖过去的一段历史。
系统保存了摘要覆盖到的消息序号。应用时,新一代由摘要和原文尾部组成,覆盖范围之后的消息仍要保留;不能因为它们比摘要“晚出生”,就一起被扫掉。
另一方面,旧摘要也不能无限期携带越来越多的新历史。代码在后续测量的输入增长超过尾部预算时,将该摘要丢弃为 resumed-warm。
这里的增长检查依据是测得的输入 token 差值,不是对所有新增消息逐条重新精确分词。它是一项实现中的有效性判断,不能解释成摘要语义质量的检测。
整个过程更像一个有条件的状态转换:
准备中 → prepared
│
├── 缓存仍温热 → 原文继续,摘要待命
├── 新历史增长超预算 → discarded
├── 原会话已被别的切代替代 → discarded
└── 满足失效条件且空闲 → applied
相比“生成完就替换”,这套设计多了待命与作废状态。代价是真实存在的:摘要可能白准备,调度和状态维护也更复杂。收益则是不用为了兑现一次后台工作,牺牲仍然可用的原始上下文。
5. 两条应用路径,只能产生一个后继⌗
有了后台应用,就会出现真实的竞争:分钟节拍发现摘要可用,恰好下一回合也发现它可用。
这不是理论上天边的竞态,而是两个正常入口会做同一项状态转换。代码没有让它们各自随便创建一代。
后台路径在查询准备状态前后检查运行状态,再调用带 requireIdle: true 的切代流程;忙碌与旧代已被替换的错误被当作让步处理,留给正确的路径继续。
测试把几个边界写得很直白:
| 测试情形 | 断言 |
|---|---|
| 恰好到达配置 TTL | 切到新一代,不新增模型调用 |
| 重复执行节拍 | 不再次应用同一份摘要 |
| 后续调用刷新时间 | 原 TTL 到点时仍不切代 |
| 内存运行标记或持久化在飞记录存在 | 保持 prepared |
| 查询就绪状态期间开始新回合 | 后台不抢走当前会话 |
| 节拍与回合同时应用 | 数据库中只有一个后继会话 |
这些测试证明的是状态转换约束,而不是摘要质量或生产环境节省比例。把两者分清,文章会少一点气势,系统会少一点玄学。
6. 没有测量值,不代表没有状态⌗
后台切代带来一个细小但重要的界面问题:新一代还没调用模型,却已经开始依赖摘要。
如果上下文状态接口坚持“没有 token usage 就什么也不返回”,用户会在最应该知道切换发生的时候,看不到任何提示。
这次接口把两件事拆开了:
- 是否有本代模型调用的用量测量:
hasUsage。 - 当前是否来自可恢复的空闲压缩:
idleCompaction。
因此可以出现一个完全合法的组合:
当前使用摘要:是
当前代的调用用量:尚无
上下文占用百分比:不显示
恢复原文入口:可显示
前端测试明确要求:没有测量调用时,显示摘要标记,但不捏造上下文占用百分比。
这比把空值涂成 0% 更诚实。零是一个数,“尚未测量”是一种知识状态。UI 把两者混成一个,监控看着整齐,用户理解却错了。
时间线里的摘要使用统一压缩卡片,正文默认折叠;输入区附近也有摘要标记和说明。它们展示的是同一个上下文事实,而不只是后台任务完成通知。
7. 恢复原文,不是把时间倒回去⌗
恢复按钮写的不是“撤销”,而是“用原上下文继续”,并展示大约需要重读的 token 量。
这个措辞对应实际机制:
A:原始会话
│ 空闲压缩
▼
B:摘要 + 原文尾部 + 后续回合
│ 选择用原上下文继续
▼
C:A 的完整记录 + B 的新增记录 + 切回说明
A 和 B 都不被改写。系统新建 C,把原始会话记录带过去,再带上摘要阶段产生的本代记录,排除压缩摘要事件,并增加说明切换发生的框架事件。
这不是简单把当前指针拨回 A。否则 B 中已经发生的新对话会被抛下。它也不是把摘要和原文都塞进 C,让模型自己猜哪份算数。
恢复测试同时检查了原始代与摘要代未被改写、恢复后的回放顺序,以及重复恢复被拒绝。运行中的回合不能恢复;旧代或只读界面保留卡片,但不显示恢复按钮。
这里也需要两条边界说明。
一是按钮上的 token 数来自压缩前记录的上下文规模,经取整显示,是重读量提示,不是下一次请求的精确账单。后续新增记录和请求形状还会影响实际用量。
二是恢复输入,不等于撤销结果。已经在摘要条件下生成的回答会保留,已经执行的外部动作也不会因为重读原文而自动消失。
它恢复的是接下来推理可用的材料,不是删除中间发生的历史。
8. 自动化应该省掉等待,而不是藏起取舍⌗
相较于“超过阈值再现场压缩”,这套设计的进步并非摘要更聪明,而是把几个原本纠缠的责任分开:
| 责任 | 合适的位置 |
|---|---|
| 何时读长上下文生成摘要 | 缓存仍温热时的后台准备 |
| 何时改变模型输入 | 满足失效条件后的空闲边界,或回合入口 |
| 如何保留摘要之后的新记录 | 覆盖序号与原文尾部 |
| 用户如何知道自己在用什么 | 摘要卡片、状态标记与独立的用量状态 |
| 摘要不够用时怎么办 | 显式选择重读原文,并保留中间历史 |
它没有证明压缩可以无损,也没有证明预备摘要在所有价格和恢复模式下都更省钱。它做的是更朴素、也更有工程价值的事:便宜时先准备,不该换时别换,换了以后让人看见,想重读时有路可走。
摘要是有损表示。真正成熟的自动化,不是假装损失不存在,而是给损失一个清楚的发生时刻,以及一个诚实的恢复选择。
代码依据:落地版本 95ffd2d 的 src/memory/idle-compactor.ts、src/memory/idle-compaction.ts、上下文状态接口,以及 IdleCompaction、CompactCard、ModelSelector 和线程状态更新代码;证据来自对应的空闲压缩、接口与界面测试。本文描述的是源码机制和测试断言,未另行运行性能、缓存命中率或摘要质量评测。