练习 11:会话持久化

练习 3 说过一句话:"对话是幻觉,幻觉的维护者是你"。真相更准确的版本是: 幻觉的维护者是进程的内存history 数组活在一次运行里,进程退出, 数组跟着一起消失——不是模型忘了,是根本没人再把这段话喂给它。

这一章把 history 写到磁盘上。进程可以死,对话不用死。

敲进去

在练习 10 的代码上继续写。新增一个 session 类型、一套读写函数, 外加改造 main 认识 -c 参数。完整文件在 exercises/ex11/

先是类型和 ID 生成:

// ---- 会话层:把 history 写到磁盘上 ----

// sessionDir 是会话文件存放的地方,跟 .trash 一样就在工作目录底下。
const sessionDir = ".sessions"

// session 是一次对话的全部状态:一个 ID,加上完整的 History。persisted
// 记录 History 里前多少条消息已经写盘——save 只补写 persisted 之后新增的
// 部分,不是每次都把整个文件重写一遍。这是这一章的核心账本:存盘的代价
// 只跟"这一轮新增了多少条"有关,跟"这场对话已经聊了多久"无关。
type session struct {
	ID        string
	CreatedAt time.Time
	History   []message
	persisted int
}

// sessionRecord 是 JSONL 里的一行。meta 只在文件开头出现一次;
// 之后每条消息各占一行——练习 3 的 history 数组,这一章有了持久版本。
type sessionRecord struct {
	Type      string    `json:"type"` // "meta" | "message"
	ID        string    `json:"id,omitempty"`
	CreatedAt time.Time `json:"created_at,omitempty"`
	Message   *message  `json:"message,omitempty"`
}

// newSessionID 生成 时间戳-随机后缀 形式的 ID:时间戳让它天然按时间排序、
// 人眼可读;随机后缀避免同一秒内两个会话撞名。
func newSessionID() string {
	now := time.Now()
	var b [4]byte
	_, _ = rand.Read(b[:])
	return now.Format("20060102-150405") + "-" + hex.EncodeToString(b[:])
}

func sessionPath(id string) string {
	return filepath.Join(sessionDir, id+".jsonl")
}

新建会话——写一个 meta 头,往后就是纯追加:

// newSessionFile 开一个新会话:建目录、写 meta 头,返回可以继续追加的 session。
func newSessionFile(history []message) (*session, error) {
	if err := os.MkdirAll(sessionDir, 0o755); err != nil {
		return nil, err
	}
	s := &session{ID: newSessionID(), CreatedAt: time.Now(), History: history}
	f, err := os.OpenFile(sessionPath(s.ID), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	enc := json.NewEncoder(f)
	if err := enc.Encode(sessionRecord{Type: "meta", ID: s.ID, CreatedAt: s.CreatedAt}); err != nil {
		return nil, err
	}
	for i := range history {
		if err := enc.Encode(sessionRecord{Type: "message", Message: &history[i]}); err != nil {
			return nil, err
		}
	}
	s.persisted = len(history)
	return s, nil
}

// save 只追加 History[persisted:]。没有新消息时是个空操作——
// 一轮里模型只回了一句话、没有工具调用,这一次 save 就什么都不写。
func (s *session) save() error {
	if len(s.History) == s.persisted {
		return nil
	}
	f, err := os.OpenFile(sessionPath(s.ID), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
	if err != nil {
		return err
	}
	defer f.Close()
	enc := json.NewEncoder(f)
	for i := s.persisted; i < len(s.History); i++ {
		if err := enc.Encode(sessionRecord{Type: "message", Message: &s.History[i]}); err != nil {
			return err
		}
	}
	s.persisted = len(s.History)
	return nil
}

再是恢复——重放记录,同时防着文件写到一半就被杀的情况:

// loadSession 读一份 JSONL,把 meta 和 message 记录重放回 History。
// 最后一行如果不完整(进程写到一半时被杀),就连同它一起丢掉——
// 半条消息比没有消息更危险:模型会把它当成一条完整的历史来读,
// 而它实际上什么都不是。
func loadSession(id string) (*session, error) {
	data, err := os.ReadFile(sessionPath(id))
	if err != nil {
		return nil, err
	}
	if n := bytes.LastIndexByte(data, '\n'); n >= 0 {
		data = data[:n+1]
	} else {
		data = nil
	}

	s := &session{ID: id}
	sc := bufio.NewScanner(bytes.NewReader(data))
	sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
	for sc.Scan() {
		line := sc.Bytes()
		if len(line) == 0 {
			continue
		}
		var rec sessionRecord
		if err := json.Unmarshal(line, &rec); err != nil {
			return nil, fmt.Errorf("会话文件损坏: %w", err)
		}
		switch rec.Type {
		case "meta":
			s.CreatedAt = rec.CreatedAt
		case "message":
			if rec.Message != nil {
				s.History = append(s.History, *rec.Message)
			}
		}
	}
	if err := sc.Err(); err != nil {
		return nil, err
	}
	s.persisted = len(s.History)
	return s, nil
}

最后改 main:认识 -c <session-id>,新建或恢复出一个 sess, 把原来局部的 history 换成 sess.History,每轮跑完都 sess.save()

	args := os.Args[1:]
	var resumeID string
	if len(args) >= 2 && args[0] == "-c" {
		resumeID, args = args[1], args[2:]
	}
	// ...解析出 task 之后:

	var sess *session
	if resumeID != "" {
		loaded, err := loadSession(resumeID)
		if err != nil {
			fmt.Fprintln(os.Stderr, "错误: 恢复会话失败:", err)
			os.Exit(1)
		}
		sess = loaded
		fmt.Fprintf(os.Stderr, "[恢复会话 %s,已有 %d 条消息]\n", sess.ID, len(sess.History))
	} else {
		s, err := newSessionFile([]message{{Role: "system", Content: basePrompt}})
		if err != nil {
			fmt.Fprintln(os.Stderr, "错误: 创建会话文件失败:", err)
			os.Exit(1)
		}
		sess = s
		fmt.Fprintf(os.Stderr, "[新建会话 %s]\n", sess.ID)
	}
	sess.History = append(sess.History, message{Role: "user", Content: task})

循环体里,把每处 history = append(history, ...) 换成 sess.History = append(sess.History, ...),并在每轮工具调用结束、 以及任务结束时各调一次 sess.save()。完整改动看仓库里的文件, 这里不再逐行贴。

别忘了 import ("crypto/rand"; "encoding/hex")。先别问为什么。 敲完,跑起来,我们再回头讲。

跑起来

go build -o ex11 .

第一步,开一场新对话:

./ex11 "我叫小明,记住这个名字"

记下打印出来的会话 ID,然后在另一次调用里恢复它——注意这是一个全新的 进程,没有任何内存状态从上一次调用带过来:

./ex11 -c <上一步的会话 ID> "我叫什么名字?"

第三步,模拟一次崩溃:把会话文件从中间截断,假装进程在写到一半时被杀, 再试着恢复:

SID=<会话 ID>
wc -c .sessions/$SID.jsonl                       # 看一下总字节数
head -c <总字节数减去几十> .sessions/$SID.jsonl > /tmp/crash.jsonl
cp /tmp/crash.jsonl .sessions/$SID.jsonl
./ex11 -c "$SID" "确认一下,我叫什么名字?"

你应该看到什么

第一步,新建会话——DeepSeek:

[新建会话 20260801-090720-56492195]
记住了,小明。有什么需要帮忙的吗?

[共 1 轮 · 最后一轮输入 927 tokens(命中缓存 896)· finish_reason=stop]
[会话 ID: 20260801-090720-56492195,用 -c 20260801-090720-56492195 继续]

第二步,全新进程里恢复:

[恢复会话 20260801-090720-56492195,已有 3 条消息]
你叫小明。

[共 1 轮 · 最后一轮输入 945 tokens(命中缓存 896)· finish_reason=stop]

它记得——不是因为进程还活着,进程根本是新的一个。是因为磁盘上那份 .jsonl 文件被原样读回来,重新塞进了发给模型的请求里。

第三步,模拟崩溃后恢复:

[恢复会话 20260801-090720-56492195,已有 4 条消息]
你叫小明。😊

[共 1 轮 · 最后一轮输入 952 tokens(命中缓存 896)· finish_reason=stop]

截断前文件里本来有 5 条消息(system、user、assistant、user、assistant), 截断精确切在最后一条 assistant 回复的中间。恢复后只有 4 条——最后那条 不完整的记录被整个丢弃了,但前面四条完好无损,"小明"这个名字仍然在 第二条消息里,模型照样答对。

发生了什么

"对话是幻觉"这句话,这一章有了另一半。 练习 3 讲的是:模型不记得你, 是你把话重新发了一遍。这一章讲的是:进程也不记得你——sess.History 这个数组,本质上只是磁盘上那份记录的一份内存视图,进程重启,这份视图 消失,但记录本身没有跟着消失。真正持久的不是内存里的状态,是磁盘上 那些追加写下去的字节。

为什么是追加,不是每次都整个重写。 save 只写 History[persisted:], 存盘的代价只跟"这一轮新增了多少条"有关,跟"这场对话已经聊了多久" 无关。如果每次都重写整个文件,聊得越久存盘就越贵——这跟练习 3 那句 "聊得越久、每轮越慢越贵"是同一种代价曲线,只是这次贵在磁盘 IO, 不是贵在 token。全量重发/全量重写,天然是 O(总量);只有增量才是 O(新增量)。

JSONL(一行一条记录)不是随手选的格式,是"能追加"这件事的前提。 如果整份历史存成一个大 JSON 数组,往里加一条消息就得把结尾的 ] 挪到新的位置——这意味着要读出整个文件、改动、再整个写回去, 根本没有"只追加"这个选项。一行一条独立的 JSON,新记录直接拼在 文件末尾,前面一个字节都不用碰。格式的选择,决定了"增量存盘" 是不是可能。

丢弃不完整的最后一行,是故意的,不是将就。 练习 7 讲过"不声明的 截断是撒谎"——那一次的做法是截断了但留一个标记,让模型知道自己看到的 不是全部。这一次更狠:不完整的记录直接整条扔掉,一个字都不留。 原因是场景不一样:bash 输出截断,剩下的半份内容仍然是有意义的部分 事实;但一条写到一半的 JSON 记录不是"半个事实",它可能是一句话说 到一半、一个字段值被腰斩——半条消息比没有消息更危险,因为它看起来 像是完整的,会被无声无息地当成真的历史读进去。

这一章故意没做的东西: 真实的 octo 还会在某些时刻整份重写文件 (比如上下文被压缩之后,练习 13 会讲),也会把标题、绑定的入口、 心跳锁这些额外字段一起持久化。这一章只做了"消息本身能不能追加、 能不能在崩溃后干净地恢复"这两件事——够用就好,其余的等用得上再说。

常见问题

  • 为什么每条 message 记录里都有一个 "created_at":"0001-01-01T00:00:00Z" 这种奇怪的字段:Go 的 encoding/json 里,omitempty 对结构体类型的 字段不生效——time.Time 是个结构体,不是指针、切片或 map,包不认为 它的零值"空",所以哪怕从没赋值,它也会被序列化出来。这是个无害的 噪声,loadSession 读的时候压根没用这个字段,直接忽略了。
  • 我可以手动删掉 .sessions/xxx.jsonl:可以,删了这场对话就是 彻底没了。这里和练习 10 的 .trash 不是一回事——会话文件没有"删前 自动备份",这一章的重点是"追加式持久化",不是"误删保护", 两件事分属两章,别指望这里也有退路。
  • 恢复一个不存在的会话 ID 会怎样loadSessionos.ReadFile 直接返回错误,main 打印"恢复会话失败"然后退出——不会静默创建 一个空会话,也不会崩溃。

加分练习

  1. 加一个 -list 模式,列出 .sessions/ 目录里所有会话的 ID 和 消息条数——不用读完整个文件重放,只需要数一数有多少个 message 类型 的行。
  2. 故意手动改坏某一条完整的 message 记录(比如删掉中间一个引号, 但保留末尾的换行符),再试着恢复,看报错信息和"丢弃不完整的最后 一行"那种情况有什么不同——这是两类损坏,程序该不该用同一种方式 应对,想清楚再看代码怎么处理的。
  3. sess.save() 的调用位置改成只在整个任务彻底结束时调一次(去掉 每轮工具调用后的那次),然后故意在一次多轮任务跑到一半时手动 kill 掉进程,对比两种存盘频率各自能恢复出多少内容——这道题在 帮你理解"多久存一次盘"本身也是一种设计决定,不是免费的。