Zero 架构 · 第三课
第一课那条「只追加的磁带」有个天生的麻烦:它只会变长。跑上十几个回合、读几个大文件、
执行几次 shell,messages 迟早撑爆模型的上下文窗口。压缩就是让这条磁带
不至于爆掉,同时又不丢近期上下文的机制。
对齐 mission:你的目标之一是「解释上下文如何守在窗口内 —— 主动 vs 被动压缩」。 这一课就交付它,并回到第一课里那两行你当时略过的分支(loop.go:161和:186)。
纯函数 Compact 定义了唯一的形状:
compaction.go:166
[system..., summaryAsUser, preservedSuffix...]
↑留verbatim ↑把中段总结成一条user消息 ↑最近N条留verbatim
[Summary of earlier conversation]。preserveLast 条(默认 8)逐字保留 ——
近期上下文最关键,绝不总结。
compaction.go:25safeSuffixBoundary,compaction.go:241)。
因为总结是以 user 角色注入的,若后缀以 tool/user 开头,就会和它形成连续同角色回合 ——
Anthropic 这类严格 provider 会直接拒绝。压缩必须产出一段合法可重放的对话。
另外,中段里的结构化状态(当前 plan、已加载的 skill)会被原样提取拼进摘要,
而不是被散文「意译」掉(appendPreservedState,compaction.go:217)。散文会模糊,状态不能模糊。
同一个 Compact,被两条路径调用。这正是第一课里那两行分支:
每个回合开头,maybeCompact 先估算 messages + 工具定义的 token 数
(工具定义每次请求都要带上,所以一起算)。一旦超过阈值 = 窗口 × 0.8
就压缩。
compaction.go:338、
compaction.go:29
但它还有两个精妙的省钱/防抖设计:
pruneStaleToolOutput —— 把老的大工具结果
的正文裁掉(模型早就基于它行动过了),零 token、零延迟。若这一步就压回阈值以下,
就跳过要花钱的摘要调用。
compaction.go:366
估算不总是准的 —— 有些 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?」