跳转到内容

20. 上下文为什么会溢出,以及如何安全压缩

阶段 20 · 压缩模型视图,不改写历史

上一章的结论:跨子系统的事实统一到共享 Meta 和四类受限 Hook。

本章要动的那一块:前 19 章一直在往历史里加东西——消息、工具调用、结果。而模型的输入窗口是固定的。这堵墙一定会撞上。

三种直觉做法,各自破坏一条不变量

Section titled “三种直觉做法,各自破坏一条不变量”

删掉最旧的消息

最简单。但开头往往是最重要的约束——用户的目标、限制条件、「不要碰生产环境」。删掉它们,模型会开始违反自己早已同意的约束。

丢失约束

原地改写历史

把旧消息替换成摘要,省空间。但审计、请求重放和恢复全都依赖原始历史(第 8 章)——改写之后再也无法验证摘要是否忠实。

破坏事实来源

原文不动,另建投影

消息保持 append-only;另建一份摘要,只改变发给模型的视图。

Half-Pi 的选择

核心区分:历史是事实,发给模型的内容是一个视图。压缩改的是视图,不是事实。

flowchart TD
    RAW["原始消息 1…N<br/>append-only,永不改写"] --> RANGE["选一段完整安全前缀"]
    RANGE --> SUM["摘要模型生成候选"]
    SUM --> CHECK["校验:预算 / 版本 / 环境摘要 / CAS"]
    CHECK --> ACTIVE["活动 Context Summary"]
    ACTIVE --> VIEW["Provider 视图<br/>摘要 + 未覆盖的尾部"]
    RAW --> VIEW

一个具体的坑:如果压缩范围的末端刚好切在一次工具调用和它的结果之间,会得到一段协议上非法的历史——一个有调用没结果的 assistant 消息。多数模型会直接报错,或者行为异常。

这是 第 2 章 那条「工具调用和结果必须配对」在压缩时的回响。所以压缩范围只能跨越完整的消息和完整的 tool batch,不能拆开语义单元。

走读一次压缩:它要校验什么才敢提交

Section titled “走读一次压缩:它要校验什么才敢提交”

压缩不是「生成摘要然后存下来」。从开始到提交,中间世界可能已经变了:

校验项防的是什么
source generation压缩期间又有新消息追加进来了
环境 digestSkill 重载、工具目录变化、系统提示改了(第 9 章)
请求 fingerprint候选是否对应当前这次请求的环境
预算压完之后是否真的低于目标水位
CAS 提交有没有另一个操作已经提交了别的摘要

举个具体的失败例子:压缩开始时 Skill 库有 3 个技能,摘要生成花了 5 秒,这期间用户执行了 /skill reload 加载了第 4 个。这份摘要是基于旧环境生成的——提交它就等于让后续请求基于一个不再成立的环境。Engine 拒绝提交,由后续操作重试。

最后的提交用 compare-and-swap,摘要和 lifecycle outbox 原子写入。冲突时不覆盖新事实。

用高低水位而不是单一阈值:到达高水位触发压缩,压到低水位为止。

如果只有一个阈值,压完之后马上又会越过它,于是每加一条消息都触发一次压缩——每次都要花一次模型调用。高低水位之间的空间就是「压一次能撑多久」。

还有一道更硬的防线:如果上下文已经超过硬预算,在向 Provider 发请求前就 fail closed。

为什么不直接发出去让 Provider 自己截断?因为截断方式不可控:它可能砍掉系统提示、可能砍掉工具定义、可能把一次 tool batch 切成两半。一个明确的错误比一次行为异常的成功调用更好排查。这又是「不静默降级」(第 3 章)。

摘要不是免费的,也不是无损的

Section titled “摘要不是免费的,也不是无损的”

诚实地说清代价:

  • 每次压缩要额外花一次模型调用(时间和钱)
  • 摘要是有损的。它会丢细节,可能丢掉后来才发现重要的东西
  • 摘要本身由模型生成,因此可能出错或遗漏

所以原始历史必须保留——它是唯一能在事后重新压缩(rebase)的依据。摘要兼容时可以增量扩展;不兼容或需要修正时走显式 rebase。

检查点:压缩成功后,把被覆盖的原始消息删掉省空间,可以吗?

不能。原始历史是审计、恢复和未来 rebase 的事实来源。摘要只改变发给模型的视图,删掉原文之后就再也无法验证摘要是否忠实,也无法用更好的策略重新压缩。第 8 章的 append-only 约束在这里兑现。

检查点:摘要生成期间用户重载了 Skill,这份摘要还能提交吗?

不能。环境 digest 已经变化,这份候选代表的是旧环境。提交它会让后续请求建立在一个不再成立的环境假设上。Engine 拒绝提交,后续操作会重试——宁可多花一次模型调用,也不引入一个静默的不一致。

检查点:上下文已经超过硬预算了,直接发给 Provider 让它自己截断行不行?

不行。截断行为不可控,可能砍掉系统提示、工具定义,或把一次 tool batch 切成两半——后者会产生协议非法的历史。Core 在准入前停下来,尝试按配置压缩,或返回明确错误。可诊断的失败优于不可预测的成功。

严格的版本、环境和并发校验意味着压缩可能失败并重试,而且每次都要额外花一次模型调用。换来的是它不会悄悄改写事实。

到这里,21 章的结构全部就位。最后一章把它们串成一次完整的请求——以及所有主要的异常路径。

证据证明什么
compact/engine.go范围选择、候选校验、CAS 提交与失败事件
compact/engine_test.go增量摘要、环境变化与限流行为
store/compact_test.goappend-only 投影与原子提交