Zero 架构 · 第八课
/resume 的另一半 —— 塌缩压缩、而非照搬
第七课说 /resume 就是「读回 events.jsonl、重放成对话」。但那句话藏了一个陷阱:
如果一次长会话中途做过压缩(第三课),日志里既留着被压缩掉的原始老事件,
又留着那条摘要。天真地全部重放,等于把当初省下的几千 token 又原样倒回上下文 ——
压缩白做了。这一课讲 replay.go 怎么优雅地避开它。
对齐 mission:第三课的压缩是内存里的即时行为;而一次能被 /resume 的会话必须把
「我压缩过」这件事持久化下来,并让回放尊重它。这一课就是压缩的持久面。
入口 RehydrateEvents 出奇地简单 —— 从日志末尾往前找第一个(即最后发生的)
EventCompaction:
replay.go:233
for i := len(events) - 1; i >= 0; i-- {
if events[i].Type != EventCompaction { continue }
return rehydrateEventsWithCompaction(events, events[i], payload), nil // ← 命中即返回
}
return cloneEvents(events), nil // 从未压缩过 → 原样重放
rehydrateEventsWithCompaction 顺序走一遍事件,做三件事:
replay.go:262
insertedSummary 标志保证只插一次);event.ID == compaction.ID 时 continue),
它只以「插在老事件那个位置」的身份出现一次。
结果和第三课内存里 Compact 的形状一模一样:
[摘要一条, 保留的近期事件...]。同一套心智模型,一个在内存、一个在持久层。
CompactableEvents);当这个集合为空(更老/更精简的载荷没存 ID),就回退到
序列号截止:Sequence ≤ CompactedThroughSequence 即视为被盖住。
replay.go:269
两种判据让回放能读懂不同年代写下的压缩事件 —— 日志格式演进了,老会话仍能 resume。
压缩和倒带都遵循同一个手法:先产出一个纯粹、可检视的计划对象,再据此执行。
PlanCompaction
replay.go:145 就是第三课那条规则的持久版:
split := len(events) - preserveLast // preserveLast 默认 8 —— 和第三课同一个数
compactable := events[:split] // 老的中段:要总结
preserved := events[split:] // 近期:逐字保留
prompt, truncated := buildCompactionPrompt(compactable, maxPromptChars)
PlanRewind(replay.go:77)同理:
它先算出 KeptEvents / DroppedEvents 两张清单(可选 KeepTarget 决定目标事件本身留不留),
让调用方先看清「倒带会丢什么」,再决定要不要真的 ApplyRewind(第七课)。
决策与执行分离 —— 这让最容易出正确性 bug 的逻辑可以被单元测试钉死,也让 UI 能先给用户一个预览。
buildCompactionPrompt 把每个待压缩事件转成一行喂给模型,但绝不原样倒进去
replay.go:298:
shapedPayloadPreview):工具调用只留 id/name、权限事件只留
action/risk 等白名单字段,其余丢弃 —— 摘要不需要完整载荷。
replay.go:319redaction.RedactString,和第二课工具结果在 registry 边界统一
擦密钥呼应 —— 密钥不该混进摘要,更不该被写回日志或发给 provider。
replay.go:373[truncated],
不产生非法 UTF-8。replay.go:382messages 磁带。第七课:把它落盘成
只追加的 events.jsonl。本课:把日志回放成一条磁带 —— 但回放不是逆操作,
它要尊重压缩(第三课),否则 resume 会撤销掉当初的省钱。持久化不是简单的「存了再读」;
存的是事件,读的是塌缩后的派生状态。
回放时,为什么只需处理日志里「最后一次」压缩事件?
如果天真地把 events.jsonl 全部原样重放会怎样?
判断一个事件是否被某次压缩「盖住」,回放用什么依据?
less +233 ../zero/internal/sessions/replay.go # RehydrateEvents:从尾找最后一次压缩
less +262 ../zero/internal/sessions/replay.go # rehydrateEventsWithCompaction:跳过+插一次摘要
less +145 ../zero/internal/sessions/replay.go # PlanCompaction:preserveLast=8,和第三课同源
less +298 ../zero/internal/sessions/replay.go # buildCompactionPrompt:塑形+脱敏+限长
读的顺序:先看 RehydrateEvents 那个「从尾往前找压缩」的短循环,理解「只认最后一次」;
再看 rehydrateEventsWithCompaction 的 skip-set + 只插一次摘要;最后把 PlanCompaction
和第三课的 Compact 并排看 —— 你会发现 preserveLast=8、「留头留尾掐中段」是同一套设计,
只是一个作用于内存磁带、一个作用于持久事件流。
Compact)和持久压缩(RecordCompaction)是谁触发谁、怎么保持一致的?」、
「会话锁的两层(进程内 mutex + filelock_unix.go 文件锁)各防什么并发?」,
或者「EventSessionFork / EventSessionChild 怎么支撑 specialist 子会话和 /fork?」