
字节笔记本
2026年10月24日 · 约 19 分钟读完
崩溃安全的会话持久化:JSONL 方案

本文是《从 0 写 Agent》系列第 9 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「FileSession:用 JSONL 做崩溃安全的持久化」。
上一章我们讲了 Session 接口。这一章深入最常用的后端实现,
FileSession。它用 JSONL 文件存对话历史,看起来简单,但代码里藏着大量"崩溃安全"的工程细节,临时文件 + fsync + rename 的原子替换、per-path 锁、坏行不销毁。每个决策都有清晰的反例驱动,"如果不这么做,什么情况下会出 bug"。 这是纯文件系统工程的精华。
引子:为什么选 JSONL
Session 要存的是"一组对话 item"。可选的存储格式:
| 格式 | 优点 | 缺点 |
|---|---|---|
| 单个 JSON 文件 | 一次读写,简单 | 每次写要重写整个文件;并发写危险 |
| JSONL(每行一个 JSON) | 追加高效;天然支持流式读 | 解析要逐行 |
| SQLite | 事务、查询、并发 | 要数据库依赖(虽然 agents-go 的 sessions 包用了) |
| 自定义二进制 | 紧凑 | 不可读、不可手动修复 |
FileSession 选了 JSONL,每行一个 JSON 对象,整个文件就是对话历史:
{"type":"message","role":"user","content":"我叫张三"}
{"type":"message","role":"assistant","content":"好的"}
{"type":"function_call","name":"get_weather","arguments":"{\"city\":\"北京\"}"}
{"type":"function_call_output","call_id":"call_1","output":"晴,26度"}JSONL 的核心优势是追加高效,加一条历史,只要往文件末尾 append 一行,不用重写整个文件。对话是"只增不改"的(除了压缩和撤回),append-only 最合适。
而且 JSONL 可读,你可以直接 cat sessions/user-123.jsonl 看历史,出问题手动编辑修复。对调试和运维,这点极其重要。

原理:崩溃安全的三层防御
"崩溃安全"是指,程序在任何时刻崩溃,文件都不会损坏。这听起来简单,实现起来全是坑。
想象这些崩溃场景:
- 写到一半进程被 kill → 文件可能是半截
- rename 到一半断电 → 文件可能不存在
- 多个进程同时写 → 内容可能交错
FileSession 用三层防御解决:
防御一:批量原子写(AddItems)
// agents/memory/file_session.go:120(简化)
func (s *FileSession) AddItems(_ context.Context, items []TResponseInputItem) error {
var buf bytes.Buffer
for i := range items {
data, _ := json.Marshal(items[i])
buf.Write(data)
buf.WriteByte('\n')
}
// 关键:先把所有内容 marshal 到内存 buffer,再一次 Write
f, _ := os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
f.Write(buf.Bytes()) // 单次 Write
f.Close()
}关键,不是"每个 item 写一次",而是"所有 item 先 marshal 到内存 buffer,再一次 Write 调用"。
为什么?考虑 marshal 失败的情况,如果边 marshal 边写,marshal 第三条时失败了,前两条已经写进文件了。这次 AddItems 部分成功,历史不一致。
先全部 marshal 到 buffer,再单次 Write:
- marshal 失败 → buffer 没填完,文件一点没动,干净
- Write 失败 → 整批失败,但不会"写了一半"
注释明说(file_session.go:117):"a marshal failure writes nothing and a crash cannot interleave half-written lines from this batch",marshal 失败什么都不写,崩溃不会交错出半行。
防御二:临时文件 + fsync + rename(writeLines)
AddItems 是追加,相对简单。但 PopItem(撤回最后一条)和压缩要重写整个文件,这时用更严格的原子替换:
// agents/memory/file_session.go:202(简化)
func (s *FileSession) writeLines(lines [][]byte) error {
// 1. 创建临时文件(在同一个目录)
tmp, _ := os.CreateTemp(filepath.Dir(s.path), ".session-*")
// 2. 写入新内容
tmp.Write(buf.Bytes())
// 3. fsync!把内核缓冲刷到磁盘
tmp.Sync() // 关键
// 4. 关闭临时文件
tmp.Close()
// 5. 原子 rename 替换原文件
return os.Rename(tmpName, s.path)
}这是 Unix 文件原子替换的经典套路,写到临时文件,刷盘,再 rename。为什么?
- 直接写原文件:写到一半崩溃 → 原文件半截,损坏
- 写临时文件再 rename:rename 是原子的(在同一文件系统内),要么成功(新文件完整替换),要么没发生(原文件还在)。任何时刻崩溃,要么看到旧文件,要么看到完整新文件,永远不会看到半截。
tmp.Sync() 是关键一步,它强制把内核的页缓存刷到物理磁盘。如果不 fsync:
- 写完临时文件,rename 成功
- 此时断电
- 重启后发现:rename 已经记录,但临时文件内容没真正落盘 → 原文件被替换成一个空文件或部分内容
注释明说(file_session.go:235):"Flush to disk before the rename so a crash cannot publish a truncated file",rename 前刷盘,防止崩溃发布截断文件。
防御三:坏行不销毁(GetItems 和 PopItem)
文件可能因为各种原因出现损坏的行(磁盘错误、写入异常)。FileSession 的策略是,跳过坏行,不销毁整个文件:
// agents/memory/file_session.go:100(简化)
items := make([]TResponseInputItem, 0, len(lines))
for _, line := range lines {
item, err := agents.UnmarshalInputItem(line)
if err != nil {
continue // 坏行跳过,不报错
}
items = append(items, item)
}为什么跳过而不是报错? 因为"部分历史可用"比"完全不可用"好。一条坏行不应该让整个对话历史失效。
PopItem 更讲究(file_session.go:149),先解码最后一行,再动文件:
func (s *FileSession) PopItem(_ context.Context) (*TResponseInputItem, error) {
lines, _ := s.readLines()
last := lines[len(lines)-1]
item, err := agents.UnmarshalInputItem(last)
if err != nil {
return nil, err // 坏行:报错,但不删文件
}
s.writeLines(lines[:len(lines)-1]) // 只有解码成功才删
return &item, nil
}如果最后一行是坏的,先报告错误,不动文件。否则可能"把坏行当最后一条 pop 掉,结果好的倒数第二条成了新的最后一条,但用户以为 pop 的是他想撤回的那条"。解码失败时不销毁,是保护数据完整性的关键。
源码对照:per-path 锁
并发是另一个坑,多个 goroutine 同时操作同一个 session 文件怎么办?
FileSession 用两层锁:
第一层:实例内的 mutex
// file_session.go:41
type FileSession struct {
mu sync.Mutex
path string
}每个 FileSession 实例有自己的 mutex。同一实例的方法调用串行化。
但这不够,如果两个不同的 FileSession 实例打开了同一个文件呢?(比如两个 goroutine 各自 OpenFileSession("sessions/user-123.jsonl"))
第二层:进程内的 per-path 锁
// file_session.go:26
var pathLocks sync.Map // key: 绝对路径, value: *sync.Mutex
func lockFor(path string) *sync.Mutex {
abs, _ := filepath.Abs(path)
actual, _ := lockFor_.Load(abs)
// ... 确保同一路径用同一个 mutex
}pathLocks 是一个进程级全局的 sync.Map,key 是文件的绝对路径。不管你 new 了多少个 FileSession 实例,只要它们指向同一个文件,就用同一把锁。
这解决了"多实例同文件"的并发问题。 但注意,它只保护同一进程内。跨进程(两个独立的服务进程)打开同一个文件,这个锁管不了。跨进程要用文件锁(flock),但 FileSession 目前没做,因为它假设单进程使用。
设计权衡:为什么不用数据库
有人会问,直接用 SQLite 不就行了?为什么还要 FileSession?
因为"零依赖"是个真实的价值。 FileSession 不需要任何外部依赖,不用装数据库、不用配连接串、不用管 schema 迁移。一个文件,能跑。
对比:
- FileSession:
go get完就能用,零配置,文件可读可手动修 - SQLSession:要引入
uptrace/bunORM + SQLite 驱动(modernc 纯 Go,无 CGO)+ 写 schema + 管迁移
对个人 CLI 工具、单机 Agent、快速原型,FileSession 的零依赖是巨大优势。 它让你写一个 Agent 工具,编译成一个二进制,扔到任何机器上,./myagent 就跑,不用"先装数据库"。
但 FileSession 也有局限,不能高效查询(要全文件扫描)、不支持多进程并发、不能做复杂索引。这些场景用 SQLSession(第 10 章)。
接口的威力在于,你可以在两种后端之间无缝切换,Agent 循环代码一行不改。
Go 1.24 的 bytes.SplitSeq
注意 readLines 用的 bytes.SplitSeq(file_session.go:191):
for line := range bytes.SplitSeq(data, []byte("\n")) {
...
}这是 Go 1.24 新增的迭代器版本的 Split。传统的 bytes.Split 返回 [][]byte(一次性分配所有切片),而 SplitSeq 返回迭代器,逐行产出,不一次性分配。
对大文件(几千行历史的 session),这能省内存,不用把所有行都装进一个 [][]byte,而是一行一行处理。
这是 Go 标准库的演进,越来越多 API 提供 Seq 版本,让"流式处理"成为默认。
实战对照:mini-claude-code session.go 的崩溃安全
mini-claude-code 的 Session.Append(session.go)就是本章防御一的落地,批量原子写:
// mini-claude-code/session.go 的 Append
func (s *Session) Append(msgs []Message) error {
s.mu.Lock()
defer s.mu.Unlock()
var buf []byte
for _, msg := range msgs { // 先全部 marshal 到内存
data, err := json.Marshal(msg)
if err != nil { return err } // marshal 失败:buffer 没填完,文件不动
buf = append(buf, data...)
buf = append(buf, '\n')
}
f, _ := os.OpenFile(s.path, O_APPEND, 0644)
defer f.Close()
_, err := f.Write(buf) // 单次 Write
return err
}对照本章的防御一,"marshal 到 buffer 再单次 Write"。mini-claude-code 完全照做了。防御二(临时文件+rename)和防御三(PopItem 解码失败不动文件)没做,因为 MVP 只追加不重写、不 pop,但防御一的逻辑是一致的。
Load 的坏行跳过也照做了,json.Unmarshal 失败时 continue 而非 return err。这正是本章"坏行不销毁"的落地。
小结:崩溃安全的工程哲学
读完这一章,你应该理解 FileSession 的设计哲学:
"在任何时刻崩溃,数据都不损坏",这不是口号,是每个写操作都要考虑的约束。
具体的三层防御:
| 防御 | 技术 | 防什么 |
|---|---|---|
| 批量原子写 | marshal 到 buffer 再单次 Write | 部分写入、marshal 中途失败 |
| 临时文件+fsync+rename | 写临时→刷盘→原子替换 | rename 中途崩溃发布截断文件 |
| 坏行不销毁 | 跳过/先解码再动 | 单行损坏不波及全文件 |
这些都是"反例驱动"的设计,每个决策都对应一个"如果不这么做会怎样"的具体 bug。 这是文件系统工程的核心思维,不假设正常运行,要为异常情况设计。
而这种思维的价值超出了 FileSession,任何要做持久化的系统,都要考虑崩溃安全。 你写自己的存储层时,这三层防御同样适用。
下一篇
第 10 章,对比看 SQLSession,SQLite/Postgres 后端。我们会讲它为什么是独立 module(保持核心零依赖)、为什么用纯 Go 的 SQLite 驱动(无 CGO)、复合索引设计、事务防并发 pop。和 FileSession 的对比能让你深刻理解"接口驱动多后端"的价值。
本文是《从 0 写 Agent》系列第 9 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。



