附录 A|压缩是追加一条替换声明,不是改历史
先别把压缩当成"改写消息数组"
第 05 章讲到表面层可以带"追加"或"替换"这两种操作时,把压缩的算法细节留给了这里。第一次要做"上下文太长,压一下"的人,几乎都会写同一个函数:找到最早的几十条消息,调一次模型生成摘要,从数组里删掉,塞进摘要。看着能跑。
这条路踩中的,恰好是这本书从头到尾在挡的那件事——制造第二份真相。第 05 章的钉子在这里变成一句话:压缩不是把旧的用户消息改短,也不是一份内存里"改写过"的消息数组。它是在只增不改的日志上追加一条带"替换"标记的表面事件,声明"这段投影作废"。
能带表面操作标记的事件类型是一个闭集,只有用户消息、助手消息、工具结果这三种。所以任何专门记录压缩过程的事件永远上不了表面层。一次成功的压缩走五步:
- 压缩开始(仅写日志)——抢一把锁
- 对这一段历史做摘要
- 摘要完成(仅写日志)——记录摘要内容、覆盖范围、被盖住的序号、消耗的 token 数、这次摘要请求本身的信封
- 追加一条用户消息,标记为"检查点"来源,带"替换"操作,指明覆盖的起止范围——这是唯一一次真正的表面层变更
- 压缩结束(仅写日志)——放锁
第四步发生在锁的保护范围内。所以如果进程恰好崩在"开始"和"结束"之间,留下的是一个没有对应"结束"记录的孤儿"开始"记录(这是可以被检测出来的脏锁状态),而不是一个谎称"压缩已完成"、但表面层根本没动过的"结束"记录。
投影之后,摘要会变成一条角色为用户的普通消息,被盖住的原始事件依然留在原始日志里,重放是确定性的。这就是第 05 章那句"不是改历史,是再追加一条声明:这段投影作废"的落地。
如果没有这一层会怎样?压缩变成原地改消息数组:界面已经画出来的旧对话还在,模型却读到了新的——第 05 章"人和模型读的是不同投影"这件事,直接破产。分叉一个会话,压缩前的状态再也回不来。重放一次请求,摘要和原文对不上。
1. 这是一条能力,不是循环里的一个条件分支
先给判断:压缩的"是什么"和"怎么做"拆在两个包,触发点挂在两个扩展点上,人工命令走第三条路。循环从头到尾不认识"压缩"这件事。
压缩这条能力家族由一个能力定义、一个摘要实现者、一个不调模型的免费修剪伙伴、一个人工命令消费者组成,全部是产品层的包。
能力定义本身只定义什么——决定历史何时太大、把旧的一段摘要成一个新的表面节点——不规定怎么做。三个角色分别是:能力定义拥有这个共享的键;一个实现者负责测量压力、管理 token 预算、真正调用模型做摘要;一个人工命令消费者对应用户输入的压缩指令。
这里有一个值得单独记一次的故意的偏差:一般的能力定义只依赖底层插件框架本身;压缩这条能力的定义却额外依赖了会话能力和模型能力——因为这份合同里的动词是定义在会话概念上的,输出格式借用了模型消息的内容块词表,不点名这两个依赖,合同就表达不出来。第 06 章说"能力定义拥有词表",这里的词表本身借用了别人的类型,属于"拆包要按变化速率来"这条纪律下的一个记录在案的例外。
2. 触发是真实压力,不是消息条数
压缩实现者的策略拆成五件各管一段的事:
- 测量:单独有一个专门的用量计量服务,给最新的请求信封加上当前表面层内容一起定价。所以每个 step 边界上的压力,算的是真实的系统提示词、工具描述、模型路由、模型返回内容、工具结果、插话内容的总量,不是猜一个"消息条数"。
- 保留:优先压最老的整段表面单元,留最近的尾巴,切割点要落在"配对完整的工具调用/工具结果"边界上,不让摘要把一个"还没答完的工具调用"从中间切开。
- 摘要:直接发起一次流式请求,把这个会话自己的系统提示词、工具描述、被覆盖那一段的消息逐字重放,压缩指令作为最后一条用户消息附加上去——这样可以复用模型侧的缓存,而不是先打断已有的前缀缓存再重新预热一遍。这次摘要请求本身不走循环里正常的请求事件。
- 触发点:一个"仅通知不可否决"的监听器,挂在准入检查这一步、在历史投影之前,检查当前压力;另一条针对"确认超限"的错误恢复,挂在请求出错这个检查点上,且只在表面层已经有过耐久进展之后才允许重试。
- 溢出恢复:如果是模型服务商明确报告的超限错误,不需要额外的容量估算,直接做一次最大化的头部削减。
这和第 03 章"压缩挂在准入检查、溢出恢复挂在请求出错"这条描述对上了——压缩不是循环里的一个"如果太长了"判断,是两个扩展点上的插件。第 04 章讲过准入检查是一条可否决的链、决定模型看见什么;压缩站在这条链上,在历史投影之前动手。
如果没有这一层会怎样?循环内部会长出一个"也许该压缩一下"的函数,测量、保留、摘要、重试全挤进去。换一个模型、换一种容量估算方式,就要改循环。现在这些都是两个独立服务各管一段的事,循环只负责在准入检查之前问一句。
3. 人工命令是人的命令,不是模型工具
压缩对应的斜杠命令通过命令注册表登记,不产生任何模型 turn。它的命令契约很简单:不带参数;没有可压缩的历史就报告"暂无可压缩内容";带了任何参数就报告用法说明。
它产生的"命令开始"和"命令完成"这对记录,是命令执行器自己拥有的仅写日志记录,不进模型历史;成功时,"命令完成"记录会指向这次压缩产生的"摘要完成"记录,好让界面把命令的生命周期直接折进这次检查点,而不必去解析一段自由文本。
第 10 章讲过命令平面;这个压缩命令是那个平面的一个具体住户——斜杠在字节 0 就被适配器截下,循环连这行字都看不见。而程序化调用压缩的那个入口,记的是一笔不依赖任何已经打开的 turn 的独立记录,跟人工命令用的是不同的记账单位。
这条切割线值得单独说一次:压缩这件事有三个入口——自动压力触发、人工斜杠命令、程序化调用——但只有一个真正执行压缩的能力动作。命令不复制压缩逻辑,它只是那个能力动作的一个轻薄消费者。第 06 章"消费者不拥有门后面的房间"在这里又兑现了一次。
4. 免费的半层:不调模型的修剪
在真正花钱摘要之前,还有一个不调用模型的"修剪"伙伴:它把超出预算的单条工具结果,换成"开头一段 + 固定的省略标记 + 结尾一段",原始事件依然留在日志里——又是一次替换,不是删除。
它不是压缩的替代品,而是一个可选的伙伴:真正的摘要实现者会先尝试问它要不要先修剪一下。顺序是先免费修剪(不调模型),修剪完重新测一次压力,还超才真正摘要(花钱,发起一次流式请求)。这和第 07 章"各站各的位"、第 06 章"一个上下文一个实现、键可以不在"是同一种品味。
如果没有这个免费的半层会怎样?一个几十万字符的工具结果会把整个会话直接逼进摘要流程,为了删掉中间绝大部分无意义的重复内容,白白烧掉一次模型调用,还可能丢失语义精确性。先修剪、再摘要,让模型只在真正需要"理解并浓缩"的时候才上场。
5. 结语:带走一句话
压缩不碰已经写下的字节,它在只增不改的日志上追加一条替换声明——原始日志不动、模型读投影、人读追加起源、重放读原始日志,四件事同时成立;触发是准入检查上的真实压力,不是循环里的一个条件判断。