Coding Agent Harness 拆解 · 会话持久化:仅追加的 JSONL 会话树(八)
本文是《Coding Agent Harness 拆解》系列的一篇。整个系列逐个拆开一个真实在用的终端 AI 编程 agent(codebot),以及它底下的执行内核 agentcore,看清一个"能每天用的 coding agent"到底由哪些子系统拼成。本篇单看一件事:codebot 怎么把一次对话存到磁盘,让它崩溃可恢复、可回到任一节点分叉重来。
关掉终端,明天还能接着聊;聊崩了,还能回到某一步重来。这两件事,是把一个"会跑循环的玩具"变成"每天敢用的工具"的分水岭。一个只在内存里跑循环的 agent,进程一退历史全无;想从半路重来,只能靠人肉复制粘贴。这篇讲 codebot 怎么补上这一格。
先说结论,也是本篇最反直觉的一点:codebot 的会话是一棵"仅追加(append-only)的 JSONL entry 树",不是一个消息数组。一个文件,一行一个 JSON 对象,每行都指向它的父行。理解了这句话,恢复、分支、回放就都是同一个数据结构的自然推论。
为什么不是一个消息数组
把会话存成数组最省事:messages = [...],序列化一把梭。但它会在三个地方崩:
| 需求 | 数组存法的麻烦 | 树存法的答案 |
| --- | --- | --- |
| 崩溃可恢复 | 每轮都要重写整个数组,写一半崩了就全丢 | 只在文件尾追加一行,写完即落盘 |
| 回到某步重来 | 要在数组里"截断",改动历史 | 换一个"叶子指针"指向旧节点即可,旧数据一行不删 |
| 记录的不只是消息 | 模型切换、压缩、计划状态塞进消息数组很别扭 | 每种事件都是一种 EntryKind,平等地挂在树上 |
仅追加意味着历史永不被改写,只会长出新枝;树意味着"当前对话"只是从某个叶子回溯到根的那一条路径,路径之外的枝叶静静躺在文件里,不碍事也不丢失。这就是它比数组强的全部理由。
Entry:一行 JSONL 的形状
每一行都是一个 Entry(internal/storage/session_types.go):
// Entry is a single JSONL line in the session file.
type Entry struct {
Kind EntryKind `json:"kind"`
ID string `json:"id"`
ParentID string `json:"parent_id,omitempty"`
Timestamp time.Time `json:"timestamp"`
Data json.RawMessage `json:"data"`
}
五个字段,各司其职:
| 字段 | 作用 |
| --- | --- |
| Kind | 这行是什么种类的事件(见下表) |
| ID | 本行的唯一标识,generateID() 生成 4 字节随机数的 8 位十六进制串 |
| ParentID | 指向父行的 ID,树的"边"就藏在这里 |
| Timestamp | 写入时刻 |
| Data | 载荷,json.RawMessage 惰性存放,不同 Kind 对应不同结构体 |
注意 Data 是 json.RawMessage:扫描文件时不必急着把每行解成具体类型,先当原始字节收着,等需要某一行时再按 Kind 解码。长会话里这能省下大量无谓的反序列化。
EntryKind:九种事件平等地上树
Kind 决定了 Data 怎么解。codebot 定义了九种(session_types.go):
| EntryKind | 常量 | Data 结构 | 记录什么 |
| --- | --- | --- | --- |
| header | EntryHeader | Header | 会话头:version / session_id / cwd / created,永远是第一行 |
| message | EntryMessage | agentcore.Message | 一条对话消息(用户 / 助手 / 工具结果 / 思考块…) |
| model_change | EntryModelChange | ModelChange | 中途切换 provider / model |
| reasoning_effort_change | EntryReasoningEffortChange | ReasoningEffortChange | 切换推理强度 |
| compaction | EntryCompaction | Compaction | 一次上下文压缩:摘要 + 保留的尾部消息 |
| session_info | EntrySessionInfo | {"name": ...} | 给会话改名 |
| plan_state | EntryPlanState | PlanStateEntry | 计划模式的阶段流转 |
| goal_state | EntryGoalState | GoalStateEntry | /goal 目标状态,供恢复 |
| llm_call | EntryLLMCall | LLMCallEntry | 单次模型响应的可观测性元数据 |
关键设计:消息只是九种 entry 之一。模型切换、压缩、计划状态和一条用户消息在树里地位完全平等,都是一行、都有父指针、都参与回溯。这样"状态"和"对话"走同一条时间线,恢复时不需要在两套存储间对齐。
其中 llm_call 值得一提:它记录本轮的 provider / model / token 用量 / 缓存命中 / 延迟,和助手消息分开写。这样消息载荷保持精简,而诊断信息(缓存为什么掉、延迟多少)又能事后单独查,不必回放整条消息。缓存命中率异常掉落时,它还会带上一个 CacheBreakInfo 说明大概是哪块前缀变了,这条线索会在《上下文管理》里派上用场。
父链接:parentID、leafID、appendChained
树的"边"不是画出来的,是靠三样东西维系的。
leafID 是 Store 内存里的一个字段:当前这棵树的"树梢",也就是最近一次写入那行的 ID。新建会话时它等于 header 的 well-known ID h0。
appendChained 是写入的统一入口(internal/storage/session_store.go)。所有 Append* 方法(AppendMessage、AppendModelChange、AppendCompaction…)最终都汇到它:
// appendChained appends an entry linked to the current leaf and advances the leaf pointer.
func (s *Store) appendChained(kind EntryKind, data json.RawMessage) error {
id := generateID()
s.mu.Lock()
defer s.mu.Unlock()
if err := s.appendEntry(Entry{
Kind: kind,
ID: id,
ParentID: s.leafID, // link to current tip
Timestamp: time.Now(),
Data: data,
}); err != nil {
return err
}
s.leafID = id // advance the tip
return nil
}
两步:新行的 ParentID 指向当前 leafID,写成功后把 leafID 前移到新行。一行接一行,边就是这么长出来的。文件名形如 2006-01-02_<id>.jsonl(日期前缀便于按时间排序,id 保证唯一)。
::: tip 写入永远只是"追加 + 移动树梢"
appendEntry 只做一件事:json.Marshal 成一行,末尾加 \n,写到文件尾。它从不回头改任何已写的字节。这就是“仅追加”的全部含义,也是它崩溃安全的根源:进程随时被杀,最坏只丢最后没写完的一行,前面的历史分毫不动。
:::
真实的几行长这样
一个刚开完头、改了个函数名的会话,磁盘上大致是这样(为可读性换行,实际每条一行):
{"kind":"header","id":"h0","timestamp":"2026-07-14T09:00:00Z",
"data":{"version":3,"session_id":"a1b2c3d4","cwd":"/home/me/proj","created":"2026-07-14T09:00:00Z"}}
{"kind":"message","id":"7f3a1c22","parent_id":"h0","timestamp":"2026-07-14T09:00:12Z",
"data":{"role":"user","content":[{"type":"text","text":"把 main.go 里的 foo 改名为 bar"}]}}
{"kind":"message","id":"b8e14d0a","parent_id":"7f3a1c22","timestamp":"2026-07-14T09:00:15Z",
"data":{"role":"assistant","content":[{"type":"tool_use","name":"edit","input":{...}}]}}
{"kind":"llm_call","id":"c2d9f731","parent_id":"b8e14d0a","timestamp":"2026-07-14T09:00:15Z",
"data":{"provider":"anthropic","model":"...","input_tokens":1820,"output_tokens":64}}
{"kind":"message","id":"e5017a9c","parent_id":"c2d9f731","timestamp":"2026-07-14T09:00:16Z",
"data":{"role":"tool","content":[{"type":"tool_result","text":"已改 3 处"}]}}
第一行必是 header(ID 恒为 h0)。往后每行的 parent_id 都指向上一行的 id,串成一条链。此刻 leafID = "e5017a9c"。
从磁盘重建:scanEntries + BuildSnapshot
恢复分两步,对应两个函数。
第一步 scanEntries(session_store.go):把整个文件顺序读一遍,产出一个 map[string]Entry(按 ID 索引)和 leafID。leafID 取"最后一行有非空 ID 的 entry"。这一步宽容到近乎固执,它的注释把兜底策略写得很清楚:
- 解不出 JSON、或
ID 为空的行,跳过;
ID 重复的行,后写覆盖先写(last-write-wins),不报错;
- 连
scanner.Err() 都忽略,最常见的触发是某一行超过 1MB 缓冲(read/grep/bash 吐出的巨大工具结果)。与其让整个 resume 失败,不如返回已扫到的部分,让下游用能救回来的东西继续。
只有"文件根本打不开"才是硬错误,其余一律容忍。因为让用户被一行坏数据锁在门外,是最糟的产品体验。
open() 打开会话时会顺手调一次 scanEntries,把结果缓存在 Store.openEntries 上,好让紧接着的第一次 BuildSnapshot 不必把同一批字节再读一遍;任何一次写入(appendEntry)都会把这个缓存置 nil,保证写后再读一定走新扫描,不会读到陈旧视图。
第二步 BuildSnapshot(internal/storage/session_context.go):从 leafID 出发,顺着 ParentID 一路回溯到 h0,得到"当前这条路径",再反转成时间正序,逐行 reduce 成运行时状态:
flowchart TB
H["h0 · header"] --> M1["7f3a1c22 · message(user)"]
M1 --> M2["b8e14d0a · message(assistant)"]
M2 --> L1["c2d9f731 · llm_call"]
L1 --> M3["e5017a9c · message(tool) ← leafID"]
M3 -. 回溯 leaf→root .-> H
回溯途中带一个 visited 集合防环,走不到 h0 就报"chain does not reach session header"。reduce 时按 Kind 分派:message 累进消息列表,model_change / reasoning_effort_change 记住最后一次选择,plan_state / goal_state 记住最新状态,compaction 则把此前所有消息一笔勾销,换成摘要加保留的尾部消息(压缩 entry 怎么产生,见《上下文管理》)。最后交给 agentcore.RepairMessageSequence 修补序列(比如工具调用缺了结果),产出 ContextSnapshot:
type ContextSnapshot struct {
Messages []agentcore.AgentMessage
Provider string
Model string
ReasoningEffort string
PlanSlug string
PlanPhase string
PlanPreMode string
Goal GoalStateEntry
}
恢复一个会话,就是拿这个 ContextSnapshot 去初始化 Session。消息、模型、推理强度、计划、目标,全从那一条 leaf→root 的路径上"放电影"放出来。
为什么这构成"树",而不只是链
到目前为止磁盘上还是一条直链,因为 appendChained 永远挂在当前 leafID 后面。让它成为树的,是重建端的那句"从 leaf 回溯":
BuildSnapshot 只认从当前叶子能回溯到的那些节点,路径之外的 entry 一律不参与。这意味着:只要把 leafID 指到某个较早的旧节点,再从那儿继续 appendChained,就会长出一条新枝;旧枝的行还原封不动躺在文件里,但因为不在新叶子的回溯路径上,重建时被自然略过。
flowchart TB
H["h0"] --> A["用户: 改个函数名"]
A --> B["助手: 走了 X 方案"]
B --> C["结果不理想"]
A --> D["助手: 改走 Y 方案 (新枝)"]
D --> E["更好的结果 ← 新 leaf"]
style C stroke-dasharray: 4 4
style B stroke-dasharray: 4 4
图里虚线那条旧枝并没有被删除,它只是不再在活动叶子的回溯路径上。这就是"分支"和"回放"背后同一套机制:不改历史,只挪叶子。这也是仅追加设计最优雅的地方:回退无需删除,分叉无需拷贝。scanEntries 对重复 ID 的"后写覆盖"容忍,为这种非线性写入留了余量。
::: tip 结构支持 vs. 当前写入路径
需要说清楚:Store 并没有导出一个显式的 Fork 方法。“分叉”是这套数据结构与重建逻辑天然具备的结构性能力(父指针 + leaf→root 回溯 + 略过旁支)。当前常态的写入路径是线性的:每次都挂在 leafID 后面,所以磁盘文件多数时候是一条主干。"把叶子挪回旧节点再写"这一步属于会话编排层的职责,具体在什么时机、由谁触发,见《Session 编排层》。
:::
Manager:列出 / 打开 / 新建
单个文件由 Store 管,而"一个目录下的许多会话"由 Manager 管(internal/storage/session_manager.go)。它只有一个字段 Dir,方法都很直白:
| 方法 | 做什么 |
| --- | --- |
| List() | 扫目录里所有 .jsonl,各读一次头部信息,按 Updated 倒序(最近的在前) |
| MostRecent() | List() 的第一个,就是"继续上次会话"要打开的那个 |
| Open(id) | 按文件名后缀 _<id>.jsonl 找到文件并 open() |
| OpenPath(path) | 直接按路径 open() |
| Create(cwd) | 在 Dir 下 create() 一个新会话文件 |
List 背后是 readSessionInfo:它只为"列表展示"服务,所以扫得比 scanEntries 更轻:读出会话名、消息数、第一条用户消息(截断到 80 字,作为列表标题)、以及最新时间戳(用于排序)。有个细节:取第一条用户消息时,它跳过 metadata.injected == true 的消息,并且取最后一个文本块,因为 reminder 是前置注入的,用户敲进去的那句话总在最后一个文本块里。这个"reminder 前置"的约定,也是《上下文管理》的主题之一。
几处工程兜底
真实代码里总有几处"为边界条件而存在"的细节,知道它们为什么在就够:
trimThinkingForStorage:写消息前,把思考块截断到 maxStoredThinkingRunes(200 runes)再落盘,让会话文件保持紧凑。截断只针对存储,内存里这轮消息仍保有完整思考,因为 agentcore 在同一 agent_end 生命周期里可能还要用。无需截断时原样返回,不做多余分配。
currentVersion = 3:header 里带版本号。open 和 readSessionInfo 都会校验,版本不符直接拒绝,宁可明确报错,也不拿旧格式去猜。
- well-known
h0:header 的 ID 恒为 h0,所以任何一次回溯都有一个确定的终点,不必靠"第一行"这种脆弱假设去定位根。
三问回顾
- 这个机制解决了什么痛点? 让会话崩溃可恢复、可回到任一节点分叉重来,且对话与状态(模型 / 压缩 / 计划 / 目标)走同一条可追溯的时间线。
- 如果没有它,会在哪里崩? 关掉终端历史即失;想重来只能改写数组、破坏历史;巨大的工具结果或一行坏数据就能把整个会话锁死。
- 它和前后怎么接上? 向上,
BuildSnapshot 产出的 ContextSnapshot 喂给会话编排层做恢复;向下,它承接的是消息与事件流写进来的 message entry;而 compaction entry 的产生则属于上下文管理。
延伸阅读