Zero 架构 · 第三课

上下文压缩:主动 vs 被动,守住模型窗口

第一课那条「只追加的磁带」有个天生的麻烦:它只会变长。跑上十几个回合、读几个大文件、 执行几次 shell,messages 迟早撑爆模型的上下文窗口。压缩就是让这条磁带 不至于爆掉,同时又不丢近期上下文的机制。

对齐 mission:你的目标之一是「解释上下文如何守在窗口内 —— 主动 vs 被动压缩」。 这一课就交付它,并回到第一课里那两行你当时略过的分支(loop.go:161:186)。

核心洞察:压缩就是「掐掉中段,留头留尾」

纯函数 Compact 定义了唯一的形状: compaction.go:166

[system...,  summaryAsUser,  preservedSuffix...]
   ↑留verbatim   ↑把中段总结成一条user消息   ↑最近N条留verbatim
一个容易被忽略的正确性细节 保留后缀的起点会向前推,直到它落在一条 assistant 消息上 (safeSuffixBoundary,compaction.go:241)。 因为总结是以 user 角色注入的,若后缀以 tool/user 开头,就会和它形成连续同角色回合 —— Anthropic 这类严格 provider 会直接拒绝。压缩必须产出一段合法可重放的对话。

另外,中段里的结构化状态(当前 plan、已加载的 skill)会被原样提取拼进摘要, 而不是被散文「意译」掉(appendPreservedState,compaction.go:217)。散文会模糊,状态不能模糊。

两个触发时机

同一个 Compact,被两条路径调用。这正是第一课里那两行分支:

主动(proactive):在发请求之前

每个回合开头,maybeCompact 先估算 messages + 工具定义的 token 数 (工具定义每次请求都要带上,所以一起算)。一旦超过阈值 = 窗口 × 0.8 就压缩。 compaction.go:338compaction.go:29

但它还有两个精妙的省钱/防抖设计:

被动(reactive):请求失败之后

估算不总是准的 —— 有些 provider 直到你真的发出去才报「上下文超限」。这时 recover 兜底:如果错误串看起来像上下文超限 (isContextLimitError 做大小写无关的子串匹配,容忍各家措辞差异, compaction.go:258), 就压缩一次并重试同一个回合compaction.go:400

关键护栏:每次运行最多被动压缩一次(reactiveAttempted)。否则一个 压缩后仍反复报超限的 provider 会无限循环。但注意一个考究之处 —— 如果历史太小、压缩其实 没缩小任何东西,它消耗这一次性预算(返回「未重试」让原始错误浮现),好让历史 以后长大了还能再救一次。 compaction.go:429

回到第一课的循环

还记得这两行吗?现在它们有名字了:

messages = compactor.maybeCompact(ctx, provider, messages, exposed)      // 主动:loop.go:161
...
if compacted, retried, _ := compactor.recover(ctx, provider, ...); retried {  // 被动:loop.go:186
    messages = compacted   // 用压缩后的历史重试本回合
}

两条路径共用同一个纯 Compact;区别只在时机触发条件。而且整个 压缩过程对用户不可见 —— 摘要调用故意不转发 OnText,但仍转发 OnUsage,因为它烧的 token 得照常计入用量和预算。 compaction.go:319

为什么这么设计 压缩是纯函数 + 两个调用点:Compact 不做任何 provider I/O, 只接收一个 Summarize 闭包,失败就原样返回历史、绝不丢消息。把「怎么压」和 「何时压」分开,让这段最容易出正确性 bug 的逻辑(重放合法性、防无限循环)可以被单元测试 钉死 —— 这也是为什么 compaction_test.go 有 600+ 行。

动手回忆

压缩后,一段对话的形状是?

主动压缩和被动压缩的本质区别是?

为什么保留后缀的边界要「向前推到 assistant 消息」?

接下来该读的一手源码

less +166 ../zero/internal/agent/compaction.go   # Compact 纯函数:形状
less +338 ../zero/internal/agent/compaction.go   # maybeCompact 主动 + 省钱/防抖
less +400 ../zero/internal/agent/compaction.go   # recover 被动 + 一次性预算

读的时候抓三个词:阈值(窗口×0.8)、lowWaterMark(防每回合重压)、 reactiveAttempted(防无限重试)。这三个数字/标志就是整套策略。

我是你的老师 —— 随时问我。 适合现在追问: 「pruneStaleToolOutput 具体裁掉什么、怎么判断哪些工具结果算『陈旧』?」、 「token 是怎么估算的(estimateTokens)?」,或者 「摘要那次 provider 调用为什么要 tool-less?」