第 35 章:上下文压缩
"满了就摘要一下"——可摘要本身也是一次模型调用,它得塞进你刚刚撑爆的那个窗口。
⏱️ 快速通道(5 分钟掌握核心)
- 三道闸门,不是一道:主动 0.90、Preflight 0.95、Provider 报错后 Reactive
- 阈值从 token 余量倒推,不要取整数百分比
- 保留开场 + 摘要 + 有硬下限的近期尾部;切尾部要修复工具配对边界
- 给摘要器自己的输入设上限;Reactive 只准重试一次,标志永不复位
- 每次重写都让该点之后的缓存失效——边界必须由稳定输入推导
10 分钟路径:35.1-35.3 → 35.6 → Kocoro OSS Lab
公开源码快照:实现细节取自 Kocoro 公开仓库
origin/main的4ec6772提交,复核日期为 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.95 | loop.go L38 |
ShouldCompact 估算检查 | window.go L76 |
ShapeHistory 开场/摘要/近期整形 | window.go L91 |
| 保留 20 组近期对话对,下限 3 | window.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 分钟上手)
- 读
window.go,找到那段用 180K 悬崖推出 0.90 的注释。注意:换算过程写在注释里,不只是一个数字。 - 追
loop.go里compressOldToolResults的三处调用点,指出每一处属于哪一道闸门。 - 写下你自己的系统该发什么标签来替代
compaction_failed——先列出可能失败的五条路径,再检查你今天的遥测分不分得清它们。
划重点
- 三道闸门,不是一道。 主动看估算、Preflight 看调用前、Reactive 看 Provider 的真话。每一道都在接住上一道骗你的那种具体方式。
- 阈值来自换算。 从 token 余量倒推,说出谁接住剩下那截,并把推理写进注释里——那里它烂不掉。
- 摘要器是会失败的模型调用。 给它输入上限,把重试严格限制成一次,标志永不复位。
- 压缩和缓存对着拉。 任何由当前历史长度推导的边界都会打散前缀。边界要从稳定输入来。
- 先想外溢。 单个超大结果属于磁盘和一个指针,不属于你的转录。
下一章:第 36 章讲怎么把大结果整个挪出 Prompt,第 37 章讲怎么按年龄逐级降级旧结果,而不是一次性把全部摘要掉。