Skip to content

第 14 章 状态与会话 ​

本章要解决的问题

第 13 章解决了“长对话还能给模型看什么”,但被剪裁或压缩后的上下文只是一份模型可见视图,不能单独当作恢复依据。程序还可能在第 9 章留下的敏感边界——“审批通过以后、工具返回结果以前”——崩溃。此时只看内存或压缩摘要,都无法判断操作没有开始、已经完成,还是结果未知。

本章讨论怎样把“发生了什么”可靠地保存下来:会话日志记录什么、损坏后怎样修复、断线重连与重试有什么区别,以及 fork 会话到底复制什么。

14.1 恢复时要分别保存哪些状态? ​

把所有状态都塞进 session.json 很省事,也很快会失控。一个可恢复的 Agent 至少有六层状态:

层典型内容能否作为恢复依据恢复方式
会话事件日志用户消息、完整回复、工具请求/结果、turn 边界是:记录“发生过什么”校验并重放日志
Goal/任务控制状态目标、验收条件、阶段、预算、阻塞原因、revision是:决定“还要不要继续”单独加载,重新判断执行授权
模型上下文system prompt、当前 messages、压缩摘要、工具 schema否:是根据记录组装给模型看的输入从日志、Goal 与配置重新组装
UI 状态流式文本、工具卡片、审批框、进度、连接状态否:是根据记录计算给人看的当前状态snapshot + 事件重放;实时 delta 可丢弃
长期记忆用户偏好、项目约定、已验证经验独立知识库按适用范围检索并带来源注入
运行统计延迟、token、错误率、一次任务经过模型、工具和重试的调用记录(trace)否:只用于观察丢失不应影响正确恢复

工作区文件、远端 API、数据库等还构成第七个边界:外部世界。会话日志能证明 Agent 发起过一次写操作,却不能仅凭日志断定写操作一定成功或一定失败。

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 类型不够——旧日志由新代码读取时没有编译器保护。需要同时定义:

  1. 共同字段的 schema version;
  2. 未知事件是 required 还是 ignorable;
  3. 迁移链或明确的拒绝策略;
  4. 当前状态计算规则的版本,防止旧 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再做一次失败操作可能,必须判断幂等(重复执行结果是否相同)与提交状态
restartRuntime/进程重启并重新加载已持久化状态不应自动重执行工具
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 中间分叉。

实现当前做法成本/限制
codexthread fork 创建新 thread 与独立 rollout,记录 forked_from_id历史与工作区隔离是两个决策;不能假设 fork 会撤销或复制外部副作用
grok-build/fork [--worktree|--no-worktree] 复制当前对话,可选新 worktree当前不支持 --at <turn>;底层 rewind 支持全部回退、只回退对话、只恢复文件三种模式,当前 pager 的 /rewind 明确选择只回退对话
dshsessions.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 或切换分支改变的是对话未来,不自动分叉或回滚文件系统;文件是否隔离要单独决定

会话日志让同一任务能够恢复,但不应该把所有旧日志都注入未来会话。下一章讨论怎样只保存仍有价值、适用范围明确且可以更新或删除的长期记忆。

基于 MIT 协议发布,欢迎转载与贡献。