跳到主要内容

消息模型与会话持久化:Log、TOML 与事件日志

30 秒导读: gptme 的每一条对话消息都是一个冻结的数据类 Message,一整段对话是一个不可变的 Log——所有"修改"都返回新对象。落盘时按会话目录写成 conversation.jsonl(一行一条消息),另有一份 events.jsonl 事件流做崩溃恢复的兜底。这一章只讲"数据长什么样、怎么存、怎么恢复",不碰主循环和工具(那是 0102)。

本章是 gptme 的数据底座。上层的主循环(01)、提示词准备(04)都建立在这个模型上:它们操作的每一个对象,都是这里定义的 MessageLog


1. 这是什么(零基础也能懂)

一句话定义: 这是 gptme 存"聊天记录"的那一层——把一次人和 AI 的对话,变成内存里能操作、磁盘上能恢复的数据。

它解决什么问题: 假设你在终端里和 gptme 聊了两小时,改了十几个文件,中途机器崩了。重开后你希望对话还在、能接着聊——甚至希望 AI 直接从"你刚问的问题"继续回答,而不是让你重打一遍。这一层就是为此存在的:让对话既能可靠落盘,又能在崩溃后精确复原到"该谁说话"的那一刻。

它由三样东西组成:

部件白话文件
Message一条消息(谁说的、说了什么、什么时候)gptme/message.py
Log一串消息组成的对话,不可变gptme/logmanager/manager.py
LogManager管家:负责追加、落盘、加载、恢复gptme/logmanager/manager.py

用起来什么样: 你几乎不直接碰它——你在终端里打字、AI 回话,背后每一轮都在往同一个 LogManagerappend 消息,同时静默写盘。你能感知它的地方是:Ctrl-C 打断后重开,对话还在;或者你想手改历史时,把日志导成 TOML、编辑、再读回来。

一句话直觉:Log 当成一本只能往后翻、不能涂改的记事本——你永远只能在末尾添一页;想"改历史",就是誊一本新的记事本出来。这种"不可变 + 追加"的设计,是它能安全恢复、能分叉(branch)的根本原因。


2. 顶层全景(一条消息的一生)

先看一条消息从"生成"到"落盘"再到"崩溃后复活"走的完整路径。这张图从上到下是时间顺序,左边是内存对象,右边是磁盘文件。

内存(Python 对象) 磁盘(<logdir>/)
─────────────────── ────────────────────

Message(role, content, …) ← frozen,不可变

▼ manager.append(msg)
┌─────────────────────────┐
│ Log = Log + [msg] │ ← 追加返回新 Log
│ (不可变,整个替换) │
└───────────┬─────────────┘
│ write()
├───────────────────────────► conversation.jsonl (主分支,一行一条)
│ branches/*.jsonl (其它分支)
│ views/*.jsonl (压缩视图)
│ _write_event_log()
└───────────────────────────► events.jsonl (追加事件 + 每50条一个checkpoint)

── 崩溃后重开 ──

LogManager.load(logdir) ◄─────────────── 读 conversation.jsonl → Log


_should_prompt_for_input(log) ← 看最后一条消息的角色

├─ 最后是 assistant → 等用户输入
└─ 最后是 user → 不问,直接让模型接着答(崩溃恢复)

怎么读这张图: 中间那根竖线是"一次 append"。它做两件事——把消息塞进不可变 Log(内存)、把 Log 和事件都写盘(磁盘)。图最下方是恢复:重开时读回 Log,再靠最后一条消息的角色决定接下来该谁说话。

各部件一句话职责:

部件干什么符号 · 位置
Message一条消息的不可变载体message.py:188 Message
Log不可变消息序列,append 返回新 Logmanager.py:68 Log
LogManager追加 / 落盘 / 加载 / 分叉的管家manager.py:118 LogManager
TOML 序列化把日志导成人可手改的格式再读回message.py:613 msgs_to_toml
事件日志追加式事件流 + checkpoint,崩溃恢复兜底eventlog.py:137 recover_messages
会话查询列会话、算 token/成本、取预览conversations.py:406 get_conversations

3. 核心原理之一:Message —— 一条冻结的消息

它要解决的小问题

一条消息不只是"文本"。它得记住:谁说的、什么时候说的、带了哪些文件、要不要固定在上下文顶部、这次调用花了多少 token。而且——它绝不能被人不小心改坏,因为整个对话的可信度都压在"历史不会被静默篡改"上。

思路:冻结 + 值相等

gptme 用一个 @dataclass(frozen=True, eq=False) 来表达这一点。frozen=True 意味着创建后任何字段都不能再赋值——想"改",只能用 replace() 造一个新对象。

@dataclass(frozen=True, eq=False)
class Message:
role: Literal["system", "user", "assistant"]
content: str
timestamp: datetime = field(default_factory=datetime.now)
files: list[FilePath] = field(default_factory=list)
...

真实定义见 message.py:188-221 Message。核心字段:

字段含义是否落盘
rolesystem / user / assistant
content消息正文
timestamp创建时间,默认 datetime.now()
files附件路径(如给视觉模型的图)有才写
file_hashes{路径: 内容哈希},附件按内容存储有才写
call_id工具调用关联 id有才写
pinned固定在上下文顶部,永不被裁剪有才写
hide从聊天输出里隐藏(但仍发给模型)有才写
quiet执行时不打印(resume 时会打印)不落盘
ephemeral_ttlN 个 assistant 轮后从上下文剔除有才写
metadatatoken 用量与成本有才写

注意 quiet 那一行——它只活在内存里,永远不写进日志文件。源码把这条约定写在字段文档里(message.py:201-202),to_dict() 里也确实没有它(message.py:284-317)。这区分很关键:hide/pinned 是消息的持久属性,quiet 只是"这一次要不要打印"的临时开关。

关键细节:相等只看 role + content

eq=False 关掉了 dataclass 自动生成的相等判断,换成手写的:

def __eq__(self, other):
if not isinstance(other, Message):
return False
return self.role == other.role and self.content == other.content

message.py:230-233 __eq__message.py:274-275 __hash__两条消息只要 role 和 content 一样,就算相等,时间戳、metadata 都不参与。这不是随手写的——分支 diff(manager.py:587 diff)靠它逐条比对两个分支从哪里开始分岔;undo 逻辑也靠内容前缀判断。

"修改"的唯一入口:replace

因为对象冻结,所有变体都走 replace()(message.py:280-282),它就是 dataclasses.replace 的薄封装——复制一份、覆盖指定字段、返回新对象。原对象纹丝不动。上层随处可见 msg.replace(content=...)

concat()(message.py:235-272)是它的一个应用:合并两条同角色消息时,内部也是 replace 出一个新对象,并把 pinned/hide/quiet运算(任一为真则保留)、ephemeral_ttl 取更短的那个。


4. 核心原理之二:两种序列化 —— JSON 落盘 与 TOML 手改

Message 有两条出磁盘的路,各有用途。

4.1 JSON:紧凑落盘(机器读写)

to_dict()(message.py:284-317)把消息转成 JSON 可序列化的 dict,Log.write_jsonl 再一行一条写进 conversation.jsonl。设计要点是紧凑:只有 role/content/timestamp 无条件写,其余字段有值才写——if self.pinned: d["pinned"] = True。空日志不背负一堆 null

反向读取见 _gen_read_jsonl(manager.py:844-882)。它有两处韧性设计值得留意:

  • 旧消息没有 timestamp → 用文件的 mtime 兜底,而不是 datetime.now(),免得老对话显示成"今天创建"(manager.py:864-868)。
  • 新版本写入的未知字段 → 直接丢弃并告警,而不是整行崩溃(manager.py:874-882),让旧版 gptme 仍能读新版日志(前向兼容)。

4.2 TOML:人可手改(编辑历史)

想手动编辑对话历史时,JSON 的一行长串没法看。to_toml()(message.py:354-412)把消息导成 TOML:content 用三引号多行字符串,pinned/hide 变成 flag = true,metadata 变成内联表。msgs_to_toml(message.py:613-619)把整段日志拼成多个 [[messages]] 块——你在编辑器里改完,toml_to_msgs(message.py:634-659)再读回来。

一个易被忽略的坑处理:TOML 三引号多行字符串会在闭合前多加一个换行。读回时 _fix_toml_content(message.py:622-631)用 removesuffix("\n") 精确删掉那一个换行,其余空白一律保留——因为对话内容里的空白可能是有意义的(比如代码缩进)。

下面这段展示的是导出后的 TOML 长相(内容本身含代码围栏,所以外层用四反引号包裹):

[[messages]]
role = "assistant"
content = """
好的,我来改这个文件:
```python
print("hi")
```
"""
timestamp = "2026-07-16T10:00:00"
pinned = true
metadata = { "model" = "claude-sonnet", "cost" = 0.005000, "usage" = { "input_tokens" = 100, "output_tokens" = 50 } }

4.3 全局开关:文本 or JSON 输出

序列化还有一层"给谁看"的全局开关。_output_format(message.py:34)默认 "text",可经 set_output_format("json")(message.py:37)切成 JSON。切成 JSON 后,print_msg(message.py:553)不再走 Rich 彩色渲染,而是往 stdout 吐逐行 JSON(message.py:567-593)——这是给程序化调用(比如 server 或脚本)消费用的。同一个 Message,给人看是彩色终端,给机器看是 JSONL。


5. 核心原理之三:LogLogManager —— 不可变追加 + 落盘

5.1 Log:只能追加的不可变序列

Log(manager.py:68-108)是 frozen=True 的 dataclass,内部就一个 messages: list[Message]。它把自己伪装成一个序列(__getitem__/__len__/__iter__),但没有任何原地修改方法。改动全靠返回新对象:

def append(self, msg: Message) -> "Log":
return self.replace(messages=self.messages + [msg])

def pop(self) -> "Log":
return self.replace(messages=self.messages[:-1])

manager.py:90-94append 不是 list.append——它构造一个全新的列表再包成新 Log。旧 Log 永远不变。这就是分支(branch)和撤销(undo)能安全实现的基石:保留旧 Log 引用即可回到过去。

5.2 LogManager:落盘的管家

Log 只管内存里的不可变演化;真正和磁盘打交道的是 LogManager(manager.py:118)。它一次 append(manager.py:397-422)做四件事,顺序固定:

append(msg):
1. _store_message_files(msg) # 附件按内容哈希入库,回填 file_hashes
2. self.log = self.log.append(msg) # 内存:换成新 Log
3. self.write() # 磁盘:写 conversation.jsonl
4. _write_event_log(APPEND) # 磁盘:追加一条事件
5. if not msg.quiet: print_msg(msg) # 终端:打印(quiet 则不打印)

第 5 步正是 quiet 的用武之地——静默消息照常入库、照常写盘,只是不打印。

5.3 会话在磁盘上的布局

一个会话是 <logs_dir>/<chat_id>/ 下的一个目录。logs_dirget_logs_dir()(dirs.py:48)决定(默认数据目录下的 logs/)。chat_id 就是目录名(manager.py:166)。目录里:

文件 / 目录内容写它的地方
conversation.jsonl主分支,一行一条消息writelogfile (manager.py:350-354)
branches/*.jsonl非主分支write (manager.py:466-478)
views/*.jsonl压缩视图(见 04)write (manager.py:481-486)
events.jsonl追加事件流 + checkpoint_write_event_log (manager.py:362)
conv-checkpoints.jsonl/backtrack 用的轻量标记conv_checkpoints.py:39
.lock进程独占锁(含 PID)_acquire_lock (manager.py:211)
workspace/该会话的工作目录workspace 属性 (manager.py:329)
config.toml会话配置(显示名等)ChatConfig

加载走 LogManager.load(manager.py:540-577):把 .jsonl 路径归一化到会话目录,读回 conversation.jsonlLog,再构造 LogManager__init__ 里还会扫 branches/views/ 把其它分支/视图一并载入(manager.py:189-203)。

锁的细节(顺带一提): _acquire_lock(manager.py:211)用 fcntl(Unix)/msvcrt(Windows)拿独占锁,并把自己的 PID 写进 .lock。若发现锁被别的进程持有,用 os.kill(pid, 0) 探活;进程已死则判为陈旧锁并回收(manager.py:255-270)。这防止两个 gptme 同时写坏同一会话。


6. 核心原理之四:事件日志 —— 崩溃恢复的兜底

它要解决的小问题

conversation.jsonl 每次 write整文件重写。如果进程恰好在写到一半时被杀,理论上可能留下半截文件。gptme 于是在旁边多存一份只追加、永不重写events.jsonl 作为更耐崩的真相来源。

思路:追加事件 + 周期性 checkpoint

每次 append/edit/undo,_write_event_log(manager.py:362-395)都往 events.jsonl 追加一条事件。事件有三类(eventlog.py:28-31):message_append(带这条消息)、message_edit(带当时的完整消息列表)、undo(带删除条数)。

纯靠追加,恢复时就得从第 1 条事件重放到现在——对话一长就慢。所以每 CHECKPOINT_INTERVAL = 50 条事件(eventlog.py:25),就写一个 checkpoint 快照当前完整消息列表(should_checkpoint,eventlog.py:116-123)。

恢复:从最近 checkpoint 起,重放其后的事件

recover_messages(logdir):
events = 读 events.jsonl
checkpoint = 最近一个 checkpoint 事件
messages = checkpoint 的消息快照 # 跳过前面所有事件
for 每条 checkpoint 之后的事件:
append → messages.append(该消息)
undo → messages.pop() × n
edit → messages = 该事件里的完整列表
return messages

真实实现见 recover_messages(eventlog.py:137-203)。它先找最近 checkpoint 作为起点(省去从头重放),再只重放其后的增量事件。返回 None 表示"没有事件日志",返回空列表表示"有日志但消息全被撤销了"——两者可区分。

另一层:会话级 checkpoint(/backtrack)

别把上面的事件 checkpoint 和 conv_checkpoints.py 混为一谈。后者是用户手动打的书签:save_conv_checkpoint(conv_checkpoints.py:39)往 conv-checkpoints.jsonl 追加一条 {index, label, timestamp},记录"当时对话有几条消息",供 /backtrack 把对话倒回某个已知好点。它只记消息数,不快照文件系统(文件系统快照是 gptme/checkpoint.py 的事,见 conv_checkpoints.py:8-9 的注释)。

机制文件存什么干什么
事件 checkpointevents.jsonl完整消息快照(每50事件)崩溃后加速恢复
会话 checkpointconv-checkpoints.jsonl消息数 index + label/backtrack 手动倒带

7. 崩溃恢复:靠"最后一条消息的角色"决定谁说话

前面把日志读回来了,但恢复的精髓在这一步:重开后到底该等用户输入,还是让模型直接接着答?

gptme 的答案朴素而巧妙——看最后一条消息是谁说的。逻辑在 _should_prompt_for_input(chat.py:449-480):

return (
not last_msg # 空对话 → 等输入
or last_msg.role == "assistant" # 最后是 AI 说的 → 等用户
or has_recent_interrupt_or_decline # 刚被打断/拒绝 → 等用户
or last_msg.pinned # 最后一条被 pin → 等用户
or not any(m.role == "user" for m in log) # 全无用户消息 → 等用户
)

正常一轮结束时,最后一条是 assistant → 重开后等你输入。但如果崩溃发生在你已提问、模型还没答完时,最后一条是 user → _should_prompt_for_input 返回 False,_get_user_input(chat.py:483-491)就不问你,直接让模型对着这条 user 消息生成回答。你重开就像什么都没发生,接着上次那句话继续。

这里的 has_recent_interrupt_or_decline:它从末尾往回扫,遇到 assistant 就停;途中若撞见内容等于 INTERRUPT_CONTENT("Interrupted by user")或 DECLINED_CONTENT("Execution declined by user")的消息(constants.py:50,53),就判定"刚被打断"。这样即便打断后有钩子又追加了别的消息,也不会误判成"该模型继续答"。

关键点:整个恢复决策不依赖任何额外状态,只读日志本身的最后几条消息。 日志即真相——这正是"不可变追加 + 忠实落盘"换来的红利。


8. pinned / hide / quiet 如何影响渲染与保留

这三个布尔字段常被搞混,但它们作用在完全不同的环节

字段影响落盘谁读它
pinned固定在上下文顶部,永不被裁剪;还让恢复时倾向等输入上下文准备(04)、_should_prompt_for_input
hide从终端输出里隐藏,但仍发给模型print_msg 渲染
quiet执行时不打印;但 resume(重放)时会打印LogManager.append

渲染侧: print_msg(message.py:553)遇到 hide=True 的消息且没开 show_hidden 就跳过,末尾统计"跳过了 N 条隐藏系统消息"(message.py:598-610)。quiet 则更早介入——在 append 那一步就决定"这条要不要打印"(manager.py:421-422)。

保留侧: pinned 的分量最重。上下文压缩(04)和 prune_ephemeral_messages(manager.py:701-731)都绝不丢弃 pinned 消息——即便某条 ephemeral_ttl 到期,只要它 pinned 就留下(manager.py:713-717)。所以系统提示、关键指令通常被 pin 住,保证永远在上下文里。

渲染彩蛋: format_msgs(message.py:482-550)给 system 消息按首行内容加 emoji——以 "saved"/"appended"/"success" 开头加 ✅,以 "error"/"failed" 开头加 ❌(message.py:536-548)。纯展示层的小巧思,不影响存储。


9. 通往模型:msgs2dicts

存储视角的终点,是把消息交给模型。msgs2dicts(message.py:662-664)把 Message 列表转成模型 API 要的最小 dict——只保留 role/content/files/call_id,其余(timestamp、metadata、pinned…)一律不发给模型。

但发之前还有一道加工:prepare_messages(manager.py:769-811)。它做上下文增强(RAG/新鲜文件)、reduce_log 压缩、prune_ephemeral_messages 剔除过期临时消息、limit_log 硬限长——这些"发送前变形"的逻辑属于 04 的范畴,这里只需知道:存储层保存的是"完整历史",发给模型的是"经准备的子集",两者刻意分离。 磁盘上永远是全量真相,裁剪只发生在去模型的路上。


10. 代码地图(导航索引)

主题文件符号
Message 数据类gptme/message.pyMessage
值相等 / 哈希gptme/message.pyMessage.__eq__ · Message.__hash__
不可变修改gptme/message.pyMessage.replace · Message.concat
JSON 序列化gptme/message.pyMessage.to_dict
TOML 导出 / 读回gptme/message.pyMessage.to_toml · Message.from_toml · msgs_to_toml · toml_to_msgs
TOML 换行修正gptme/message.py_fix_toml_content
metadata / 用量gptme/message.pyMessageMetadata · UsageData · _migrate_metadata
输出格式开关gptme/message.pyset_output_format · get_output_format · is_output_json
渲染gptme/message.pyprint_msg · format_msgs
发给模型的最小 dictgptme/message.pymsgs2dicts
不可变 Loggptme/logmanager/manager.pyLog · Log.append · Log.pop
JSONL 读写gptme/logmanager/manager.pyLog.read_jsonl · Log.write_jsonl · _gen_read_jsonl
追加 / 落盘 / 加载gptme/logmanager/manager.pyLogManager.append · LogManager.write · LogManager.load
目录布局gptme/logmanager/manager.pyLogManager.logfile · LogManager.workspace
进程锁gptme/logmanager/manager.pyLogManager._acquire_lock
发送前准备(→04)gptme/logmanager/manager.pyprepare_messages · prune_ephemeral_messages
事件流 / 恢复gptme/logmanager/eventlog.pyappend_event · recover_messages · should_checkpoint
会话级 checkpointgptme/logmanager/conv_checkpoints.pysave_conv_checkpoint · resolve_conv_checkpoint
会话查询 / 元数据gptme/logmanager/conversations.pyget_conversations · ConversationMeta
崩溃恢复决策gptme/chat.py_should_prompt_for_input
日志目录根gptme/dirs.pyget_logs_dir

上一章 05-hooks-extensibility.md · 返回 index.md