Appearance
第 14 章 状态与会话
本章要解决的问题
第 13 章解决了“长对话还能给模型看什么”,但被剪裁或压缩后的上下文只是一份模型可见视图,不能单独当作恢复依据。程序还可能在第 9 章留下的敏感边界——“审批通过以后、工具返回结果以前”——崩溃。此时只看内存或压缩摘要,都无法判断操作没有开始、已经完成,还是结果未知。
本章讨论怎样把“发生了什么”可靠地保存下来:会话日志记录什么、损坏后怎样修复、断线重连与重试有什么区别,以及 fork 会话到底复制什么。
14.1 恢复时要分别保存哪些状态?
把所有状态都塞进 session.json 很省事,也很快会失控。一个可恢复的 Agent 至少有六层状态:
| 层 | 典型内容 | 能否作为恢复依据 | 恢复方式 |
|---|---|---|---|
| 会话事件日志 | 用户消息、完整回复、工具请求/结果、turn 边界 | 是:记录“发生过什么” | 校验并重放日志 |
| Goal/任务控制状态 | 目标、验收条件、阶段、预算、阻塞原因、revision | 是:决定“还要不要继续” | 单独加载,重新判断执行授权 |
| 模型上下文 | system prompt、当前 messages、压缩摘要、工具 schema | 否:是根据记录组装给模型看的输入 | 从日志、Goal 与配置重新组装 |
| UI 状态 | 流式文本、工具卡片、审批框、进度、连接状态 | 否:是根据记录计算给人看的当前状态 | snapshot + 事件重放;实时 delta 可丢弃 |
| 长期记忆 | 用户偏好、项目约定、已验证经验 | 独立知识库 | 按适用范围检索并带来源注入 |
| 运行统计 | 延迟、token、错误率、一次任务经过模型、工具和重试的调用记录(trace) | 否:只用于观察 | 丢失不应影响正确恢复 |
工作区文件、远端 API、数据库等还构成第七个边界:外部世界。会话日志能证明 Agent 发起过一次写操作,却不能仅凭日志断定写操作一定成功或一定失败。
最常见的混淆有四个:
- 聊天还在 ≠ 任务可继续:Goal 可能已经暂停、预算耗尽或等待用户。
- UI 显示 running ≠ 工具仍在运行:进程可能已经随宿主崩溃。
- 摘要里写着“已完成” ≠ 已通过验收:摘要会丢信息,不是完成凭证。
- 会话可搜索 ≠ 有长期记忆:搜索历史只回答“以前说过什么”,不会自动处理可信度、过期和遗忘。
14.2 为什么会话日志不能只是 append JSONL?
14.1 分清了哪些状态是恢复依据,哪些只是可重建视图。其中最关键的事实源是会话事件日志;但只选定 JSONL 文件格式,还没有解决事件怎样定位、配对、去重和区分重试。
14.2.1 每条事件需要哪些共同字段?
生产事件至少要能回答四个问题:属于哪次会话、发生在哪一轮、顺序是什么、这是第几次尝试。可以用下面这些共同字段表达:
| 字段 | 作用 | 通常是否必需 |
|---|---|---|
schemaVersion | 识别日志格式,避免新代码误读不兼容的旧记录 | 是 |
sessionId | 标识事件属于哪次会话 | 是 |
seq | 会话内连续递增,用于排序、去重和发现缺口 | 是 |
time | 记录发生时间,用于展示和排查;不能代替 seq 排序 | 是 |
type | 区分消息、工具、审批、回合边界等事件 | 是 |
turnId / stepId | 标识事件属于哪轮任务和哪一步 | 按事件需要 |
attemptId | 把重试输出与上一次尝试分开(第 4 章 4.5.4) | 发生重试时需要 |
toolCallId | 把工具请求、开始记录和最终结果配对 | 工具事件需要 |
第 20 章会说明这些 ID 怎样避免前端把不同尝试、工具和审批状态混在一起。
源码中还常用 durable 和 transient 两个词。durable 表示服务重启后仍能恢复的记录;transient 表示只服务当前连接、丢失后不影响正确恢复的实时事件。
不是所有实时事件都必须永久保存。先定义恢复承诺,再定持久化策略:
| 事件 | 通常应持久化 | 原因 |
|---|---|---|
| 用户消息、完整 assistant message | 是 | 重建模型 transcript |
| 工具请求、已开始标记、最终结果 | 是 | 判断能否重试,避免重复副作用 |
| turn 开始/结束及原因 | 是 | 确定恢复边界 |
| Goal、预算、plan 的最新有效更新 | 是 | 长任务跨重启 |
| token delta、实时 stdout 小片段 | 可选 | 通常只影响现场 UI,可批量或截断保存 |
| 审批请求与决定 | 是 | 断线后不能默认允许 |
| hover、spinner、连接状态 | 否 | 纯 UI 状态 |
| 性能 trace、埋点 | 另存 | 不是恢复依据 |
是否保存 delta 取决于恢复承诺。若产品承诺“浏览器重连后连中断中的每个 token 都原样出现”,delta 也必须持久化;若只承诺恢复最终消息,保存完整消息即可。不要在没有明确需求时把所有实时事件都写进同一份日志。
14.2.2 codex:哪些事件写入 rollout,哪些只发给当前客户端?
codex 恢复会话时以 rollout 为准——rollout 是 codex 给持久会话日志起的名字。它的 rollout 写入策略明确区分两类事件:
- 模型消息、reasoning、工具调用/结果、compaction、turn 开始/完成/中止、Goal 更新等写入持久日志。
- 文本 delta、reasoning delta、执行 begin/end、审批交互、连接期错误等大量实时事件是 transient,只服务当前客户端。
恢复时,codex 的会话重建逻辑会处理压缩替换历史、回滚的 turn 与 turn context,而不是简单把 JSONL 每行塞回 messages。
新式 thread store(thread ≈ 会话)还维护 SQLite 物化视图,也就是预先算好并保存的查询结果。它出错时可以重建;rollout 才是会话恢复时的唯一可信记录。
写入也不是“多个进程一起 append 就行”。这个存储层用 OS 文件锁保证一个 thread 同时只有一个写入者,再通过队列依次写入。调用方可以等待缓冲内容真正保存后再继续。
这种做法可以恢复完整消息,但不保证精确还原崩溃前最后一帧 UI。
14.2.3 grok-build 恢复时为什么读取 updates.jsonl?
崩溃恢复时面对多种文件,必须先确定哪一份可信。grok 每个 session 目录里有多种文件:
text
summary.json 元数据/列表索引
updates.jsonl 恢复使用的 ACP(Agent Client Protocol,Agent 与客户端的通信协议)更新流
chat_history.jsonl 发给模型的历史,可从 updates 重建
plan.json 计划状态
rewind_points.jsonl rewind 标记
signals.json token/turn 等信号
compaction_checkpoints/ 压缩检查点xai-sqlite-journal 不是会话主日志,只是替索引/缓存类的 SQLite 选择 journal 模式的组件;会话恢复应以 updates.jsonl 为准。
Grok 的 JSONL 层仍有不少容易漏掉的工程件:
- 每个文件用
.jsonl.lock保护文件末尾检查和 append,并在需要时确认内容已经可靠保存。 - append 前若发现末尾缺换行,先补换行,把损坏限制在一条记录内;读取时跳过 torn/unparseable 行。
- 普通 append 只 flush;durable append 还会同步文件与父目录;整文件重写采用临时文件 + rename。
- durable append 把失败分为三态:
NotCommitted(没写进,可直接重试)、Committed(写进但收不到回执)、AcknowledgementLost(结果未知)。第二种数据其实已在盘上,应视为成功并补走后续流程,不要盲目重写;第三种必须先查updates.jsonl或外部状态确认是否已提交。 chat_history.jsonl是可重建缓存,损坏或过期时从updates.jsonl重建。
这三个错误状态比一个 throw Error("write failed") 更有价值:网络断开或进程崩溃时,调用方必须分别处理“没写进去”“写进去了但后处理失败”和“是否写入未知”。
14.2.4 dsh 怎样记录事件并补齐崩溃时未完成的工具调用?
崩溃后最麻烦的不是丢一段日志,而是悬空的工具调用:日志里能看到 assistant 请求了工具,却不知道它执行到了哪一步——直接截断会丢掉已完成的结果,盲目重试又会重复副作用。dsh 的做法是先把发生的事记录清楚,恢复时再给悬空调用补一个明确结局。
dsh 的 session header 记录格式版本 version、会话 id、fork 依赖的 parentSession/seedLength 等字段;事件有连续 seq,并把 turn/start、原始 assistant/chunk、完整 assistant/message、tool/call、tool/result、请求 header/context 等分开记录。
悬空的调用分两种,处理方式完全不同:
- assistant 提出了调用,但没有
tool/call:返回TOOL_NOT_STARTED,仍需要时可重新调度。 - 已有
tool/call,但没有已持久化的 result:返回TOOL_OUTCOME_UNKNOWN,可能已经产生副作用,必须检查外部状态或询问用户,不能盲重试。
持久层有默认 zstd JSONL 与 SQLite 两个后端:
- 新事件可声明
ignorable;未知且必需的事件必须拒绝,避免虽然解析成功,却恢复出错误状态。 - 同一 session 的操作串行化;JSONL 初次发布先写同步后的临时文件再原子落位,避免并发创建者互相覆盖。
- JSONL append 后同步;写入或同步失败会 truncate 回旧长度。SQLite 后端则把批量 append/repair 放进事务。
- 当前状态缓存只是可丢弃的加速层:落后可以从日志追尾,内容错误则必须丢弃重建。
dsh 不会在崩溃后直接截回最后一个完整 turn。它的会话修复模块保留文件末尾已经完整写入的事件,再追加确定性的合成事件,补齐悬空工具、step 和 turn/end(interrupted),让前面两种悬空调用得到确定的结局。
“模型可见即可重放”还不够
“发给模型的内容都写入日志”能重建对话,但不会自动解决多个写入者、格式升级、文件末尾写到一半、外部副作用、UI 读取位置和状态计算规则升级。
按事件记录只说明“发生过什么”;并发写入、崩溃恢复和版本兼容仍要分别实现。
14.2.5 pi 为什么在一份 JSONL 里保存会话树?
pi 当前 Coding CLI 的 session 文件不是只能向前走的线性 transcript。每条记录带稳定 id 和 parentId,当前对话是从叶子沿父指针回到根的一条路径;/tree 可以切换分支,/fork 从旧节点产生新未来,/clone 则复制成独立会话。模型输入只根据当前分支重建,其他分支仍保留在同一 JSONL 中。
这种结构让“尝试另一条路”不需要删除旧记录,但它没有改变外部世界。切到编辑前的分支后,磁盘上的文件仍可能已经被改过;后台进程和网络请求也不会自动撤销。若两条分支要并行写代码,仍需独立 worktree、容器或文件版本检查。
pi 仓库还包含更新一代的 AgentHarness 会话抽象,支持 lane、operation record、suspended/crash/deferred 状态以及 JSONL/SQLite 后端。它代表更一般的持久运行方向,但当前 Coding CLI 仍主要使用既有 SessionManager 路径;阅读源码时不要把两套保证混写成同一个已上线协议。
14.3 append 之后还要做什么才能安全恢复?
14.3.1 怎样避免多个写入者把事件写乱?
appendFile() 只保证一次系统调用的意图,不保证多个进程写出的记录不交错。生产实现至少选择一种:
- 一个 session 只允许一个写入任务,其他调用进入队列;前提是写入都发生在同一进程,不适合多进程场景。
- OS 文件锁/lock file,跨进程串行 append;还要处理持锁进程崩溃后的锁回收。
- 数据库事务 + revision/CAS。CAS(compare-and-swap)指写入前确认版本仍和读取时一致,不一致就拒绝覆盖;代价是要引入版本号基础设施。
每条事件带连续 seq;读取时发现重复可去重,发现 gap 则停止恢复并请求完整快照,不要猜缺失内容。seq 是会话内顺序,全局排序另用 session/turn 标识,不要依赖机器时钟。
14.3.2 新代码怎样安全读取旧日志?
仅有 TypeScript 类型不够——旧日志由新代码读取时没有编译器保护。需要同时定义:
- 共同字段的 schema version;
- 未知事件是
required还是ignorable; - 迁移链或明确的拒绝策略;
- 当前状态计算规则的版本,防止旧 cache 被新 reducer 误读。
原则是不兼容就拒绝恢复:少展示一段历史比悄悄构造错误的模型上下文安全。可忽略事件只能包含展示或运行统计信息,不能改变模型收到的内容或权限。
14.3.3 flush、fsync 与 checkpoint(检查点)有什么区别?
text
写入进程缓冲区 → flush 到 OS → fsync 文件 → fsync 父目录越往右越耐崩溃,成本也越高。checkpoint 指可供恢复继续的已知安全位置;它不等于一次磁盘同步。无需每个 token fsync,但在不可逆操作前,必须先确认依赖的记录已经可靠保存:
- 发起下一次 LLM 请求前,先持久化这次请求所依赖的历史。
- 执行有副作用的工具前,先持久化 tool requested/started。
- 向用户宣告 turn 完成前,先落最终结果和 Goal 更新。
dsh 的 checkpoint policy 就要求记录保存成功后,才允许 LLM dispatch 与顶层工具执行;保存失败则拒绝继续。实用折中是:delta 批量写,工具/turn/Goal 边界强制同步。
14.3.4 日志最后一条写到一半时怎样修?
先从字节上找到“最后一个完整记录”,再检查 turn、step 和 tool pairing 是否完整。两项检查不能互相替代:
text
字节检查:丢弃最后一段不完整 JSON/zstd frame
事件结构检查:保留完整事件,为悬空结构追加 interrupted closer直接回退到最后一个完整 turn 会丢掉用户消息、已完成工具结果等有价值记录;直接保留文件末尾的完整事件,又可能留下 provider 不接受的悬空 tool call。正确做法是保留完整记录,再合成明确的中断结果;合成事件也要保存,确保下一次加载得到同样结果。
14.3.5 哪份记录可信,哪些当前状态可以重建?
模型 history、会话列表摘要、UI store、全文索引都应被视为根据日志计算出的当前状态。它们可以用检查点加速恢复,但必须携带:
- 对应日志的 revision/lastSeq;
- reducer 及其计算规则版本;
- checksum 或其他一致性校验。
恢复时先加载缓存,再读取缓存之后的日志;校验失败就重新读取全部日志。不要同时写两份都声称可用于恢复的数据。Grok 的 updates.jsonl → chat_history.jsonl 和 dsh 可丢弃的当前状态缓存都采用了这一做法。
14.4 断线、重放、重试和继续执行有什么区别?
14.2、14.3 解决了“记录里有什么,并且能否安全读回来”。恢复器读到这些事实后,还必须决定只恢复界面、重新发起某次尝试,还是重新授权任务继续执行。这四个动作不能因为都发生在“恢复以后”就混为一件事。
| 操作 | 含义 | 是否重执行工具 |
|---|---|---|
| reconnect | 传输断开后重新连接,补 UI 事件 | 否 |
| replay | 从日志重建模型输入/UI 当前状态 | 否 |
| resume | 在同一 session 上开启新 turn | 只有新 turn 决定后才执行 |
| retry | 再做一次失败操作 | 可能,必须判断幂等(重复执行结果是否相同)与提交状态 |
| restart | Runtime/进程重启并重新加载已持久化状态 | 不应自动重执行工具 |
| rewind/rollback | 对话记录回到较早位置 | 不应自动反做文件副作用 |
| fork(会话分叉) | 复制历史前缀,从此产生另一个未来 | 新分支独立决定 |
UI “恢复成功”只说明界面状态已重建。Agent 要继续跑,还应加载 Goal,检查预算、权限、工作区变化和上一个工具的提交状态,再显式获取执行权。对会产生副作用的产品,安全默认值是恢复为 paused,而不是宿主一启动就继续执行。
日志最后留下不同状态时分别怎么办?
| 日志最后的状态 | 恢复动作 | 禁止动作 |
|---|---|---|
| assistant 只有 delta,无 complete | 标记该 attempt interrupted;新建 attempt | 把两次 attempt 文本拼成一个回复 |
| 有 tool requested,无 started | 若 Goal 仍需要,可重新调度 | 假装工具已经成功 |
| 有 tool started,无 result | 标记 outcome unknown;检查文件/API/数据库或询问用户 | 盲目重试非幂等工具 |
| result 已写入日志,UI 没收到 | 读取已保存的结果并更新 UI | 再执行一次工具 |
| approval pending | 恢复同一 approvalId,或按策略过期并拒绝 | 默认允许、重复弹出多个可提交审批 |
| job 记为 running,进程不存在 | 标记 orphaned,保留日志与重启入口 | 一直显示“运行中” |
compaction/start 无 end | 回滚未完成的状态更新或完成确定性修复 | 使用半份摘要覆盖原历史 |
| 当前状态检查点落后 | 从 lastSeq 追尾;校验失败则全量重建 | 把 cache 当成唯一可信记录 |
| seq 重复/乱序 | 幂等去重或拒绝 | 按到达时间猜顺序 |
| seq 出现 gap | 请求 tail/snapshot;仍不连续则全量重放 | 跳过缺口继续执行 |
Grok 的 ACP replay 给每条可重放更新附 eventId,客户端可以带读取位置增量恢复。如果该位置之后的事件无法可靠定位,就重新读取完整记录。
浏览器也可以使用 snapshot(lastSeq) + subscribe(afterSeq),再用稳定的 item ID 避免重复更新。第 20 章会继续讨论流式 UI 的渲染与重连。
14.5 fork(分叉)到底复制了什么?
14.4 说的 resume 是继续同一个会话未来。另一种需求是保留现有路径,同时从某个安全的历史边界开始另一个未来,这才是 fork。典型场景有两个:想并行验证两种方案,让两条分支各跑一条路互相对照;或者最近几步走错了,想回到几个 turn 之前换条路重试,又不毁掉现有记录。
text
resume: 继续同一个未来(会话 ──────→)
fork: 复制一段过去,产生新身份(会话 ──┬──→ A
└──→ B)对话 fork 会选择一个安全、已完成的事件前缀,为 child 创建独立 session/rollout,之后各自追加。不要从 tool call 与 result 中间、压缩事务中间或开放 turn 中间分叉。
| 实现 | 当前做法 | 成本/限制 |
|---|---|---|
| codex | thread fork 创建新 thread 与独立 rollout,记录 forked_from_id | 历史与工作区隔离是两个决策;不能假设 fork 会撤销或复制外部副作用 |
| grok-build | /fork [--worktree|--no-worktree] 复制当前对话,可选新 worktree | 当前不支持 --at <turn>;底层 rewind 支持全部回退、只回退对话、只恢复文件三种模式,当前 pager 的 /rewind 明确选择只回退对话 |
| dsh | sessions.fork 复制 seed 前缀,header 记录 parent/seedLength/origin/depth | 当前 slice + 逐事件快照 + 持久化,O(前缀长度),不是 O(1) |
| pi | 同一 JSONL 中用 id/parentId 选择当前分支;也可 clone 为独立会话 | 分支切换便宜且保留其他路径;文件和进程副作用仍共享 |
对话是否分叉、文件是否隔离,是两个需要分别决定的问题:
| 文件共享 | 文件 fork(worktree/隔离目录) | |
|---|---|---|
| 对话共享 | 普通单 Agent | 同一讨论在隔离环境执行,较少见 |
| 对话 fork | 适合只讨论不同方案;并行写会互相干扰 | 适合多个 Agent 并行改代码,成本更高但边界清楚 |
是否需要文件 fork 取决于副作用,不是会话 API。多个分支并行写代码时,优先使用 worktree/容器/快照;只做阅读、审阅和方案对比时,共享工作区通常更省。
本章小结
- 恢复要分别保存:模型可见的会话日志、任务控制状态、外部世界状态——三者丢失后果不同
- 会话记录应是可审计事实源:codex/grok/dsh 侧重线性事件与修复,pi 用
id/parentId在一份 JSONL 中保存分支树;模型上下文只是根据记录生成的当前视图 - 断线重放、失败重试、继续执行是三件不同的事,日志尾部状态决定走哪条路
- fork 或切换分支改变的是对话未来,不自动分叉或回滚文件系统;文件是否隔离要单独决定
会话日志让同一任务能够恢复,但不应该把所有旧日志都注入未来会话。下一章讨论怎样只保存仍有价值、适用范围明确且可以更新或删除的长期记忆。