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

结果和第三课内存里 Compact 的形状一模一样: [摘要一条, 保留的近期事件...]。同一套心智模型,一个在内存、一个在持久层。

「盖住了哪些」的两种判定 —— 向前兼容 判断一个事件是否被压缩盖住,优先用摘要载荷里显式记录的事件 ID 集合 (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 能先给用户一个预览。

做摘要 prompt 时:塑形 + 脱敏 + 限长

buildCompactionPrompt 把每个待压缩事件转成一行喂给模型,但绝不原样倒进去 replay.go:298:

三课在此合流 第一课:内存里那条只追加的 messages 磁带。第七课:把它落盘成 只追加的 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?」