第 35 章:上下文压缩

"满了就摘要一下"——可摘要本身也是一次模型调用,它得塞进你刚刚撑爆的那个窗口。


⏱️ 快速通道(5 分钟掌握核心)

  1. 三道闸门,不是一道:主动 0.90、Preflight 0.95、Provider 报错后 Reactive
  2. 阈值从 token 余量倒推,不要取整数百分比
  3. 保留开场 + 摘要 + 有硬下限的近期尾部;切尾部要修复工具配对边界
  4. 给摘要器自己的输入设上限;Reactive 只准重试一次,标志永不复位
  5. 每次重写都让该点之后的缓存失效——边界必须由稳定输入推导

10 分钟路径:35.1-35.3 → 35.6 → Kocoro OSS Lab


公开源码快照:实现细节取自 Kocoro 公开仓库 origin/main4ec6772 提交,复核日期为 2026-07-27。章节讲的是不变量;具体常量应以当前源码为准。

35.1 从第 91 分钟死掉的那一轮说起

你的 Agent 已经重构了 90 分钟。读了 30 个文件,跑了四轮测试,整个 diff 计划都在上下文里。第 34 轮,它又读了一个文件:

[iter 34] file_read  path=build/generated/schema.pb.go
          result: 412 KB
[iter 34]  组装请求... 203,417 tokens
[iter 34]  provider: context_length_exceeded

那就压缩一下重试呗。很自然。

但看清楚你现在站在哪儿。你是在已经超限的状态下做压缩,而压缩本身是一次模型调用——它同样得塞进你刚刚撑爆的那个窗口。如果摘要这次也失败,就没有下一招了:丢掉这一轮,连同积累了 90 分钟的状态。

杀死这次运行的不是那个 412 KB 的文件,是你只有一道防线。

35.2 三道闸门,坏的方式各不相同

解法不是把阈值调得更准,是不止一道闸门——而它们之所以有用,恰恰因为失效原因彼此独立

主动压缩在窗口的 0.90,走的是正常路径:保存持久知识、摘要、重塑历史、继续。它跑在 token 估算上,而估算是近似的。一个 412 KB 的结果完全可以在两次检查之间一头冲过去。

Preflight 在 0.95,接的就是这一手。它紧贴请求发出之前再查一次——这是压缩还算便宜、而不是狼狈的最后一刻。

Reactive 在 Provider 返回上下文超限时触发。它存在,是因为你的估算可能朝着最要命的方向出错,也因为「到底装不装得下」这件事,只有 Provider 说了算。

只做第一道,你会拿到第 91 分钟。只做第三道,每个长会话都要白付一次报错往返。这三道不是冗余——每一道都在接住上一道骗你的那种具体方式。

35.3 为什么是 0.90,而不是 0.85

阈值这种数字,写成百分比时看着都差不多,换成 token 才看得出差别。

200K 的窗口,0.85 意味着悬崖在 170K,0.90 意味着悬崖在 180K。中间那 10K 里住着谁?是那批峰值刚好落在 170K–180K 的会话——大约 5%——它们现在一次都不用压。省下的不只是一次摘要调用,还有一次不可逆的信息损失。

那为什么不干脆推到 0.95,省得更多?因为压缩要留的不是「装得下这一轮请求」的空间,是「装得下这一轮请求加上它的响应」的空间。定得太高,你压完了还是塞不进去——摘要的钱花了,什么也没换到。

让 0.90 敢用的,是它上面还有一道 0.95 的闸门。把兜底那道门拿掉,0.90 就不是激进,是鲁莽。 于是得到一条可以带到任何系统里的规则:阈值从你真正需要的 token 余量倒推,然后说出剩下那截由谁接住。说不出接住的人,就说明阈值定高了。

这也解释了第 32 章里那个更早的单闸门 Harness。更低的触发点加一道闸门并不算错——它是同一条曲线上的另一个点,选在第二道闸门还不存在、因而更高的触发点还不安全的时候。

35.4 你选择丢掉什么

压缩天然有损,所以真正的设计问题只有一个:你挑哪些损失。

活下来的是三段。开场——最前面那几条消息,装着用户最初的诉求和系统设定。丢了它,Agent 会非常自信地去解一个别的问题。摘要——被压缩的中段,由一次模型调用产出。近期——尾部原样保留,这样「刚才那个方案」才有东西可指。

公开 Runtime 默认保留 20 组近期对话对,即使在预算压力下也留 3 组的下限。下限比默认值重要得多。压力一大,本能是继续削尾巴;但少于几轮之后,Agent 会彻底丢失线索,开始重新推导二十分钟前就做完的事——这比你省下的那点 token 贵多了。

还有一个上了生产才会咬人的细节:近期切片是从尾部按位置取的。切得不小心,就会切在 tool_use 和它配对的 tool_result 中间,留下一个孤立的半对,Provider 直接拒收。任何保留尾部的实现都得做边界修复,而这种 bug 只在长会话里现形。

35.5 摘要器也会失败

这是大多数设计跳过的部分,也正是第 91 分钟真正变得不可挽回的地方。GenerateSummary 和别的模型调用没有区别:它会失败,会返回空,会超时,在小模型档上还会因为自己的输入而溢出。

所以先给它的输入设上限。交给摘要器的转录被限制在 540,000 字符,头尾保留、中间省略并留下明确标记。跳过这一步,超大会话的紧急压缩就会把一个小档模型处理不了的输入丢过去——你的恢复路径会因为和原请求完全一样的原因死掉。

再给重试划界。Reactive 路径会置一个在整个 Run 生命周期内永不复位的标志:

reactiveCompacted = true // never reset — prevents infinite reactive loops

紧急重试只有一次。再失败就干干净净地失败。一个压缩、重试、溢出、再压缩的 Agent,是在花真钱回到原地。

最后,记下断的是哪一道。Runtime 为主动、Preflight、Reactive、Emergency、ForceStop 各条路径发出不同的 Phase Tag,并把摘要失败和持久知识写入失败分开——这个快照上一共 10 个。凌晨三点,compaction_failed 不能让你做任何决定;reactive_summary_no_prior 直接告诉你断在哪一级、当时有没有前一份摘要可以退守。

35.6 压缩和缓存是对着拉的

Prompt Cache 奖励字节稳定的前缀,而压缩要重写历史。每一次重写都会让该点之后的缓存失效——这两个系统正面冲突,而冲突会直接体现在账单上。

公开源码里记着做错的代价。一条超大用户消息,按当前消息切片推导出的预算做截断。随着历史增长,截断点每一轮都在动。于是每一轮都在被截断的那条消息处打断缓存前缀,把整条消息当作全新的 cache creation 重新计费:每个追问轮大约 0.67 美元,就为了一条消息,而且什么好处也没换到。

修法是别再让边界依赖会变的东西。取每会话稳定目标的一个固定比例——0.80——而不是取一个由「此刻恰好有多少历史」算出来的值。同样的截断,稳定的边界,缓存前缀完好。

这可以推广成一条值得贴墙上的话:任何边界依赖当前历史长度的截断,都会把缓存打散。 边界要从不动的输入推导。第 39 章讲的就是这套完整纪律。

也留意这里的缩放选择:比例作用在上下文窗口上,而不是一个固定字符上限——这样 200K 时代的尺寸设定,就不会在你换到 1M 上下文模型族的那一刻悄悄开始过度截断。

35.7 快照证据

观察4ec6772 源码位置
主动触发 0.90,附带 cliff 换算window.go L23
Preflight 闸门 0.95loop.go L38
ShouldCompact 估算检查window.go L76
ShapeHistory 开场/摘要/近期整形window.go L91
保留 20 组近期对话对,下限 3window.go L50
摘要器输入上限 540,000 字符summarize.go L25
Reactive 压缩不重复loop.go L3847
分级失败遥测loop.go L185
为缓存安全而稳定的截断边界window.go L47

以上描述的是一个有日期的实现快照,不是普遍契约。

35.8 什么时候压缩是错的工具

不是所有上下文问题都是压缩问题。反射性地伸手去拿它,你会白白付出本来不用付的质量代价。

如果会话根本够不到窗口上限,这套机制就是纯风险——用最小消息数把它关掉。如果问题是单个超大的工具结果,那就外溢到磁盘、留个指针,别把它摘要进转录里;第 36 章讲的就是这条路,而且它应该是你的第一反应,不是第二。如果内容本身就是交付物——用户要你产出的那份转录——压掉它就等于毁了产品。压缩塑造的是工作上下文,永远不是输出。

如果这次运行需要审计留痕,那就压缩模型看到的那一份,完整记录另外落盘。压缩是 Prompt 整形操作,不是留存策略;把它当留存策略用,你会丢掉本来必须留住的证据。

35.9 常见的坑

只有一个阈值、没有兜底。 野外最常见的设计,也是第 91 分钟的直接成因。

反复压缩摘要。 每多压一遍已经摘要过的区域,丢的信号更多、省的 token 更少。记录哪些压过了,别重复处理。

用刚刚溢出的那一档做摘要。 很省事,而且它会恰好在你最需要它的时候失败。把摘要器路由到更便宜的一档,并给它自己的输入上限。

接受空摘要。 摘要器返回 "" 却被照单全收,等于悄悄抹掉了对话中段。检查空值,保留上一份摘要。

让 guardrail 提示进入摘要输入。 上一轮那句「你好像在重复」不是对话内容。先把循环注入的消息剥掉,否则你的摘要会忠实地记下这个 Agent 挨过训。

相信估算。 基于字符启发式的 token 计数在边界上就是不准。这不是一个待修的缺陷——这恰恰是 Preflight 要复查、Reactive 要存在的全部理由。

Kocoro OSS Lab(10 分钟上手)

  1. window.go,找到那段用 180K 悬崖推出 0.90 的注释。注意:换算过程写在注释里,不只是一个数字。
  2. loop.gocompressOldToolResults 的三处调用点,指出每一处属于哪一道闸门。
  3. 写下你自己的系统该发什么标签来替代 compaction_failed——先列出可能失败的五条路径,再检查你今天的遥测分不分得清它们。

划重点

  1. 三道闸门,不是一道。 主动看估算、Preflight 看调用前、Reactive 看 Provider 的真话。每一道都在接住上一道骗你的那种具体方式。
  2. 阈值来自换算。 从 token 余量倒推,说出谁接住剩下那截,并把推理写进注释里——那里它烂不掉。
  3. 摘要器是会失败的模型调用。 给它输入上限,把重试严格限制成一次,标志永不复位。
  4. 压缩和缓存对着拉。 任何由当前历史长度推导的边界都会打散前缀。边界要从稳定输入来。
  5. 先想外溢。 单个超大结果属于磁盘和一个指针,不属于你的转录。

下一章:第 36 章讲怎么把大结果整个挪出 Prompt,第 37 章讲怎么按年龄逐级降级旧结果,而不是一次性把全部摘要掉。

引用本文 / Cite
Zhang, Wayland (2026). 第 35 章:上下文压缩. In AI Agent 架构:从单体到企业级多智能体. https://waylandz.com/ai-agent-book/%E7%AC%AC35%E7%AB%A0-%E4%B8%8A%E4%B8%8B%E6%96%87%E5%8E%8B%E7%BC%A9/
@incollection{zhang2026aiagent_35_-,
  author = {Zhang, Wayland},
  title = {第 35 章:上下文压缩},
  booktitle = {AI Agent 架构:从单体到企业级多智能体},
  year = {2026},
  url = {https://waylandz.com/ai-agent-book/%E7%AC%AC35%E7%AB%A0-%E4%B8%8A%E4%B8%8B%E6%96%87%E5%8E%8B%E7%BC%A9/}
}