# 17. Session:会话管理、任务执行、工具调用、状态持久化

# 17.1 领域模型与生命周期

推荐把状态拆成以下层级,而不是把所有内容塞进一个 messages 数组:

Session / Thread                                                // 表示可跨连接恢复的顶层会话实体,是所有运行状态的顶层容器
├─ immutable: session_id, principal, created_at, lineage        // 保存不可变身份、创建时间和父子来源关系,作为审计与幂等基础
├─ mutable config: model, cwd, permission profile, tools, environment // 保存可按 Turn 更新的模型、工作目录、权限和执行环境配置
├─ Turn[N]                                                      // 一个会话包含多个用户目标处理周期,每个 Turn 有独立生命周期
│  ├─ user inputs / queued steering                             // 记录本轮初始输入以及运行中排队等待安全点消费的用户指导
│  ├─ Step[N]: context snapshot + model response                // 每个 Step 固化一次模型调用所用上下文快照和对应响应
│  ├─ ToolCall[N]: proposal → approval → execution → result     // 工具调用显式经历提议、审批、执行和结果持久化状态
│  └─ terminal outcome: complete / aborted / failed             // Turn 最终必须落入完成、主动中止或失败之一的终态
├─ Task graph / plan versions                                   // 关联可调度任务图以及每次审批或重规划产生的计划版本
└─ Event log / rollout / artifacts                              // 用追加事件保存发生顺序,并通过引用关联大体积执行产物

其中 Thread 更接近可恢复的长期对话实体,Session 可理解为一次活跃连接或运行实例,Turn 是一次用户目标处理,Step 是一次模型调用。不同产品命名可能不同,但必须明确 ID、生命周期和持久化边界。

# 17.2 状态机与一致性

会话状态至少要处理:并发输入、取消、审批等待、断线恢复、进程崩溃和重复事件。

建议采用“内存状态 + 追加事件日志 + 可重建投影”:

  • 内存状态服务当前低延迟执行。
  • 追加日志保存事实顺序,是恢复和审计的来源。
  • SQLite/索引/列表页是投影,可以从事件修复,不应成为唯一真相。
  • 大文件、图片和完整命令输出存入执行产物存储区(Artifact Store),事件只保存引用、哈希和摘要。

写入顺序通常是:先给事件分配稳定 ID 和递增事件序号,再持久化关键状态,最后向客户端确认。界面上的临时进度可以允许偶尔漏显,但 ToolResult、审批结果和文件修改必须能够去重和重放。

# 17.3 Claude Code 源码映射

Claude Code 的本地 Transcript 是按项目目录存放的 JSONL,每条主链消息通过 uuid/parentUuid 形成可分叉链。工具进度被明确视为临时 UI 状态,不写入 JSONL、也不参与父子链,否则恢复时会产生孤儿分支:sessionStorage.ts:132sessionStorage.ts:148sessionStorage.ts:202

写入侧不是每条消息都立即写入持久化存储:Project 为每个文件维护有序队列,默认每 100ms 合并一批 JSONL,单块上限为 100MB;flush() 会取消定时器、等待正在处理的写入任务,再清空剩余队列:sessionStorage.ts:549sessionStorage.ts:606sessionStorage.ts:645sessionStorage.ts:841。这是写入吞吐量与崩溃时可能丢失的数据范围之间的明确取舍。

恢复侧从多个叶节点中选择最新的非 sidechain 节点,再沿 parentUuid 回溯主链;随后恢复计划、文件历史和 Skill 状态,清理未完成 ToolUse,并运行 resume hook:conversationRecovery.ts:409conversationRecovery.ts:459conversationRecovery.ts:542conversationRecovery.ts:559

# 17.4 Codex 源码映射

Codex Session 同时持有 active_turn、等待进入主循环的用户消息、InputQueue,以及 MCP、Agent 和 Environment 等服务;SessionConfiguration 保存模型、指令、审批策略、权限配置、环境选择、Session 来源和 Thread 的父子来源关系:session.rs:45session.rs:68

新建、Fork 和 Resume 使用不同的 InitialHistory 分支。初始化时解析 thread_id/session_id/forked_from_id/parent_thread_id;非临时会话创建或恢复 LiveThread 持久化,临时会话跳过:session.rs:548session.rs:569session.rs:626session.rs:685

执行侧 RegularTask 发出 TurnStarted 后反复调用 run_turn;如果运行期间又有用户输入,就用空的初始输入继续下一次循环,让队列在安全边界被消费:regular.rs:38regular.rs:49regular.rs:73

每条 Conversation Item 先更新内存 state.history,再追加到持久化 Rollout,并向客户端发送 raw item:session/mod.rs:2953。协议事件同样先按持久化策略写 Rollout,再进入 Trace 和事件通道:session/mod.rs:2043

# 17.5 可恢复会话伪代码

function appendEvent(session, event):                            // 定义会话事件的持久化、状态更新和推送统一入口
    event.id = deterministicOrUUID()                             // 为可去重事件生成确定性 ID,否则生成全局唯一 ID
    event.ordinal = session.nextOrdinal()                        // 给 ordinal 字段分配递增事件序号,用于排序、补发和断点续传
    event.hash = hashCanonical(event.payload)                    // 对规范化载荷计算哈希,以检测篡改和重复但内容不一致的事件
    durableLog.append(event)                                     // 先追加到持久化日志,使其成为恢复与审计的权威记录
    inMemoryReducer.apply(event)                                 // 再根据事件更新内存状态,为当前执行提供低延迟查询结果
    clientStream.emit(event)                                     // 最后推送给客户端,确保已显示事件在断线后可以从日志补发

function resume(threadId):                                      // 定义从持久化 Rollout 恢复指定 Thread 的运行过程
    meta, events = rollout.loadAndValidate(threadId)             // 加载元数据和事件流,并验证顺序、校验和及版本兼容性
    state = reduce(events, emptyState(meta))                     // 从空状态依序重放事件,重建崩溃前的会话状态
    state = repairDanglingToolCalls(state)                       // 找出只有调用没有结果的工具,把它们标记为待确认或中断
    state = restoreArtifactsAndSkills(state)                     // 按引用恢复附件、计划、文件快照和已加载 Skill 状态
    if state.lastTurn == RUNNING:                                // 检查进程退出时是否仍有未正常结束的活跃 Turn
        state.lastTurn = INTERRUPTED                             // 将其显式改为中断,避免把旧运行状态误认为仍在执行
    return startRuntime(state, nextOrdinal=max(events.ordinal)+1) // 从最大已有事件序号的下一位启动 Runtime,避免序号冲突

function executeToolIdempotently(call):                          // 定义按稳定调用 ID 去重的工具执行包装器
    if resultStore.contains(call.id):                            // 恢复或重放时先检查该调用是否已有持久化结果
        return resultStore[call.id]                              // 已执行过则复用原结果,避免重复产生外部副作用
    mark(call.id, RUNNING)                                       // 在执行前记录运行状态,便于崩溃恢复识别只有开始记录、没有结果的调用
    result = toolRuntime.execute(call)                           // 通过受控 Runtime 真正执行已授权的工具调用
    atomicallyPersist(call.id, result)                           // 将调用 ID 与结果原子绑定持久化,防止部分写入造成重复执行
    return result                                                // 把已持久化的结构化结果返回给 Agent Loop 继续推理

注意:对写文件、支付、发消息等非幂等动作,仅靠重复 call_id 不够;需要下游支持 idempotency key,或在恢复时进入“结果未知,需人工确认”状态,不能盲目重试。

# 17.6 高频面试题

问:断线重连后如何避免重复显示和重复执行?

客户端重连时携带最后确认的事件游标;服务端按递增事件序号补发,前端状态更新器按 event id 去重。工具执行使用稳定的 call id 和结果表去重;对于无法确认结果的外部副作用,不自动重放。

问:为什么 JSONL/Rollout 比只存最终 messages 更适合 Agent?

Agent 的诊断对象包括采样、审批、工具、压缩和状态迁移。只存最终 messages 会丢失中间事实,无法解释失败、恢复半完成工具或做轨迹评测。

# 17.7 原理深化:Session 正确性取决于写入顺序与恢复语义

Session 的关键不是“能保存聊天”,而是明确哪些记录必须先写入持久化存储再对外可见。用户输入、审批结果、ToolCall 的开始意图和最终状态、计划版本及文件执行产物,都属于恢复所需的关键事件:应先获得稳定的递增事件序号并写入原始事件记录(Rollout),再更新可以重新生成的状态视图,最后通知客户端。工具进度可以只保存在内存并允许丢失,因为它不改变副作用是否已经发生。Claude Code 明确不把进度写入 JSONL,而对普通消息使用有序写队列和显式 flush(),体现的正是这两种持久化级别。

Codex Resume/Fork 初始化先恢复历史和有效配置,再持久化继承前缀并 flush;源码注释强调要把 copied prefix 与 child settings 放在同一追加序列中,避免进程重启恢复时只看到一半继承状态:session/mod.rs:1317session/mod.rs:1389session/mod.rs:1399。这说明恢复不是反序列化一个对象,而是按事件版本重建并修复中断边界。

恢复时 ToolCall 只能落入三类:已有持久化最终状态则复用;明确未执行则可按策略重试;执行结果未知则进入 interrupted/unknown 并查询下游幂等键或请求人工确认。把所有旧 running 直接标成 failed 会诱发重复副作用。Session 关闭也要先停止新提交、传播取消、等待/终止子任务、flush Rollout,再释放锁与临时资源,否则“进程退出成功”仍可能留下无法恢复的半条事件链。

最后更新: 2026/8/5 22:05:21