Skip to content
Go back

会话管理:持久化与恢复

Published:  at  02:00 AM

转载声明

本文转载自 WaytoAGI 社区,原作者 Kevin(X:@htt_keyboarder),原文标题《会话管理(二):持久化与恢复》。

原文链接:https://waytoagi.feishu.cn/wiki/P9j2wxAfUiEOfokVuIYcqO4Un0e

版权归原作者所有,转载请注明出处。

当本地 Coding Agent 被关闭或意外中断之后,之前的工作还能不能接着做?这就是会话持久化与恢复要解决的问题。

很多介绍把这件事讲成”保存聊天记录,再把聊天记录读回来”。但 Agent 不只是聊天工具——它会读取和修改文件、执行命令、运行测试,甚至启动进程。因此,会话恢复至少要区分三件事:

这三件事不能混为一谈。


一、一个具体场景

假设你在 /project_A 中使用 Agent,让它排查 login.py 的登录问题。工作到一半,你按下 Ctrl+C,进程结束。

程序都退出了,内存也释放了,为什么第二天执行 resume,昨天的对话还能重新显示出来?

原因很简单:这些内容在昨天运行时,就已经被不断写到磁盘了。用户发一条消息保存一条,模型回复保存一条,调用工具、返回结果也继续保存。所以进程退出后,内存中的 Session 消失了,但磁盘里的会话记录还在。

第二天执行 Resume,本质就是:

找到这个 Session
    ↓
从磁盘读取它之前保存的记录
    ↓
重新恢复聊天界面
    ↓
恢复工作目录、执行状态等信息
    ↓
等待用户继续

这就是会话持久化与会话恢复最基本的意义:持久化负责把内存中的重要状态保存下来,恢复负责在程序重启后把这些状态读回来。

再考虑一个更麻烦的情况:你按 Ctrl+C 时,Agent 正准备执行一次写文件操作。恢复会话后,程序发现有 ToolCall 记录,却没有对应的 ToolResult——这次写入到底成功没有?该怎么安全地继续?这个问题后面会专门讲。


二、四个必须先分清的概念

2.1 Session:一项长期任务的容器

Session 表示一段可以多次继续的任务或对话。例如”修复 project_A 的登录问题”就是一个 Session。它通常保存:

会话的完整生命周期可以包含创建、运行、等待、完成、失败、取消、归档等状态。不过本地 Agent 的初版不必设计得过于复杂,通常只需要 active、completed、archived,再记录最近一次 Run 是执行中、成功、失败还是中断,我认为足够了。

2.2 Run:Session 中的一次执行

同一个 Session 可以包含多次用户触发的执行:

Session S1:修复登录问题
├── Run R1:先检查代码
├── Run R2:继续修改
└── Run R3:运行测试

用户每发送一次新要求,Agent 从接收要求到给出本轮结果,可以视为一次 Run。Run 通常记录 run_id、session_id、开始时间、结束时间、执行状态和错误信息。

2.3 Event:Run 中实际发生的一件事

一次 Run 内部会发生很多事件,例如:收到用户消息、模型产生回复、模型请求使用工具、工具返回结果、本轮执行结束。这些按时间排列的记录就是事件流水(Event Log)。

2.4 Context:本次真正发给模型的内容

Event 保存的是完整历史,Context 则是某一次调用模型前,从历史中挑选、整理出来的内容。例如一个会话已经产生十万字的事件记录,本次可能只发送:

一句话总结:Event Log 不等于模型上下文。读取历史只是本地读文件,只有把选出的 Context 发给模型时才会消耗输入 Token。

概念含义关键字段
Session一项长期任务的容器session_id、user_id、workspace、status、created_at/updated_at
RunSession 里的一次执行run_id、session_id、起止时间、状态、错误
EventRun 内发生的一件事类型、时间、关联的 run_id/call_id
Context本次真正发给模型的内容摘要 + 最近几轮 + 进度 + 必要工具结果

三、为什么 Session 必须保存 workspace

workspace 不是提醒模型”你在哪个项目”,而是让 Agent 的运行框架把工具绑定到正确目录。

如果恢复时直接使用当前终端目录 workspace = os.getcwd(),那么你在 /project_B 中恢复 /project_A 的会话后,模型发出的 read_file("login.py") 可能实际读到 ~/projects/project_B/login.py——这就是串线。

Resume 时程序需要知道这个 Session 上一次在哪里工作,并据此决定本次该继续在哪个目录工作:

{
    "session_id": "xyz123",
    "cwd": "/home/user/projects/project_A"
}
session = load("xyz123")
print(session.cwd)
# /home/user/projects/project_A

随后 ReadFile、WriteFile、Bash 等工具都通过这个 tool_context 解析路径。也就是说:工具可以全局注册,但工具能访问哪个目录,应由当前 Session 的运行环境决定,不应在全局工具注册表里永久写死 /project_A。

你可能会问:为什么在 /project_A 下的会话,可以在 /project_B 中恢复?实际上这么做更好——用户完全可能把 /project_A 移动到其它目录,如果仅仅因为 cwd 不同就拒绝 resume,那这个被移动的 Session 就彻底废了。


四、存储设计:Session/Run/Event 不必是三个文件

Session、Run、Event 描述的是三种逻辑关系,不代表磁盘上必须存在 session.json、runs.jsonl、events.jsonl 三个文件。对一个本地 Coding Agent,最简目录可以只有:

~/.agent/sessions/
└── s_001/
    ├── session.json
    └── events.jsonl

session.json 保存少量、经常查询的会话信息:

{
  "session_id": "s_001",
  "user_id": "u_001",
  "workspace": "/home/user/projects/project_A",
  "status": "active",
  "created_at": "...",
  "updated_at": "..."
}

events.jsonl 保存详细过程:

{"type":"run_started","run_id":"r_001"}
{"type":"user_message","run_id":"r_001","content":"检查登录问题"}
{"type":"tool_call","run_id":"r_001","call_id":"c_001","tool":"read_file"}
{"type":"tool_result","run_id":"r_001","call_id":"c_001","status":"success"}
{"type":"run_finished","run_id":"r_001","status":"success"}

这里的 run_started 和 run_finished 已标出每个 Run 的边界,因此初版不需要单独建 Run 文件。只有当需要频繁查询”最近一百次 Run""所有失败的 Run""平均运行时间”时,才值得在 SQLite 中单独建 runs 表——此时 Run 表保存的是索引信息,详细过程仍由 Event 保存,两者并不冲突。

4.1 为什么不把所有内容都放进 session.json

可以放,但运行时间越长,代价越明显。假设一个会话产生了数百条消息、几百次工具调用和大量日志,如果全部放进一个大 JSON,每新增一条记录都可能需要:读取整个文件 → 解析整个 JSON → 添加一条 → 重新写回整个文件。

更合理的做法是:

拆分不会减少总数据量,真正的目的是让”会话列表等高频信息”和”庞大的历史流水”分开读取。Event 文件变大后,可以按需读取、分页、压缩、归档,也可以定期生成摘要;日常展示会话列表时,没必要扫描几百 MB 的历史记录。

4.2 持久化时机:不需要写很多 if/else

不是让模型判断哪个事件关键,而是只要运行框架产生了需要记录的 Event,就通过统一入口立即追加到存储中:

def emit(event):
    event_store.append(event)
    event_bus.publish(event)

这里 event_bus 解决的是:一个 Event 刚发生,程序内部哪些模块需要马上知道?

Agent Runtime
    ↓
产生 ToolCall
    ↓
EventBus
    ├── UI:显示“正在读取文件”
    ├── Logger:打印日志
    ├── Telemetry:统计工具调用次数
    └── SSE:推送给前端

收到用户消息、工具开始/结束、本轮完成,都走同一个通道:

emit(UserMessageReceived(...))

emit(ToolCallCreated(...))
result = tool.execute(...)
emit(ToolResultReceived(...))

emit(RunCompleted(...))

所以不是模型知道”现在发生了关键事件”,也不是在一个保存函数里堆很多 if/else,而是所有标准事件都走同一个 emit → append 通道。

如果等整个 Run 结束后才统一保存,那么运行十分钟后在第九分钟崩溃,前面的记录可能全部丢失。逐条追加可以把损失缩小到最后一个尚未成功写入的事件。

4.3 JSONL 还是 SQLite

JSONL 每行是一条独立 JSON,适合追加写,新增事件只需在文件末尾加一行,不必重写整个历史文件,也方便直接查看和调试。SQLite 则适合需要事务和查询的本地 Agent,可建 sessions、runs、events 等表,同时仍然只产生一个数据库文件。

两者没有绝对高下:

真实案例:当前 Codex CLI 会把 Session 的详细执行记录保存到:

~/.codex/sessions/
    YYYY/
        MM/
            DD/
                rollout-时间戳-SessionID.jsonl

除了 rollout-*.jsonl,还有 state_5.sqlite、session_index.jsonl 和 history.jsonl,其中 history 存了你在 Codex 中发送的所有消息。另一个工具 pi 的存储位于:

~/.pi/agent/sessions/
    --home-user-project_A--/
        timestamp_uuid.jsonl

这和本文说的 session metadata + event log 非常接近。讲再多都不如自己去本地目录翻一翻真实的存储文件。


五、恢复流程:主体是 Harness,而不是模型

恢复流程的主体是 Agent Harness,也就是我们编写的普通程序,而不是大语言模型。一个最小流程可以写成:

def resume_session(session_id):
    session = session_store.load(session_id)

    workspace = Path(session.workspace)
    if not workspace.exists():
        raise WorkspaceNotFound()

    events = event_store.load(session_id)
    recover_interrupted_run(events)

    context = context_builder.build(events)

    return AgentRuntime(
        session=session,
        workspace=workspace,
        events=events,
        context=context,
    )

再强调一次:“读取历史”和”把历史全部发送给模型”是两件不同的事。 前者只是本地读文件,不消耗模型 Token;后者才会产生模型输入费用。

5.1 如何判断上一 Run 被异常中断

假设事件流水最后只有:

{"type":"run_started","run_id":"r_015"}

却没有对应的:

{"type":"run_finished","run_id":"r_015"}

当前程序又已经重新启动。在”同一 Session 同时只允许一个主要 Run”的本地设计中,新的运行框架可以直接判断:r_015 没有正常收尾,应标记为 interrupted。

if last_run.status == "running":
    last_run.status = "interrupted"

在线系统中可能有多个工作进程,不能仅凭重新启动判断。此时可以为 Run 保存 worker_id、心跳或租约到期时间,超过期限仍未续约,才判断对应工作进程已经失联。无论哪种方式,都不需要模型检查操作系统进程,更不需要模型下令修改 Run 状态。

5.2 目标和任务进度从哪里来

不是每个 Agent 都必须单独维护一份任务状态。对于简单任务,事件历史中已经记录了用户要求、工具调用和模型回复,恢复时选择必要历史作为 Context 通常就足够了。

对于步骤较多的任务,可以额外保存结构化任务状态:

{
  "goal": "修复登录问题",
  "status": "in_progress",
  "completed_steps": ["检查 login.py"],
  "pending_steps": ["修改代码", "运行测试"]
}

它可能来自模型生成的计划,但生成后应由运行框架解析并保存。每完成一步,由任务管理代码更新对应步骤,不需要每轮都让模型重新总结全部历史。当历史过长时,也可以偶尔让模型生成阶段摘要或检查点,下次恢复时直接加载这份摘要,而不是重新把完整历史交给模型压缩。


六、三个最容易踩的坑

6.1 ToolCall 有记录、ToolResult 没记录

假设崩溃前的 Event Log 里有一条 tool_call,call_id 为 c123,操作是 write_file main.py,但你按下了 Ctrl+C,没有得到对应的 tool_result。这存在两种可能:

恢复时可以先找出”没有结果的调用”:

calls = find_tool_calls(events)
results = find_tool_results(events)

for call in calls:
    if call.id not in results:
        unresolved.append(call)

此时不能直接重试,因为写文件、部署、创建 Issue 等操作都可能产生重复副作用。运行框架应先把它记录为”结果不确定的操作”,等真正要处理它时,交给对应工具核对:

SessionManager
    → 发现 c123 没有结果
    → 标记为 uncertain

WriteFileTool
    → 根据目标内容或文件版本检查是否已写入

标记为 uncertain 就是追加一条恢复事件:

{
  "type": "tool_recovery",
  "call_id": "c123",
  "status": "uncertain"
}

但最关键的一步是:Harness 必须在下一次构建 Context 时告诉 LLM。 你辛辛苦苦给领导的花浇了水,结果领导没看见,一切都无济于事。大体流程:

SessionManager
发现 c123 没结果
        ↓
生成结构化 RecoveryState
        ↓
Context Builder
把结构化状态转换成模型消息
        ↓
加入下一次 Context
        ↓
LLM 看见

例如 Harness 得到:

recovery_state = {
    "interrupted_run": "R105",
    "uncertain_calls": [
        {
            "call_id": "c123",
            "tool": "write_file",
            "path": "main.py"
        }
    ]
}

Context Builder 把它格式化为一段提示词,放进下一次模型输入:

<recovery>
上一轮执行被意外中断。

以下工具调用没有得到明确执行结果:

- call_id: c123
- tool: write_file
- target: main.py

不能确定该写入是否已经完成。
在继续依赖 main.py 之前,请先确认文件当前状态。
</recovery>

6.2 为什么 ToolCall 需要唯一编号

每次工具调用都应有唯一的 call_id:ToolCall call_123 → ToolResult call_123。恢复时,程序可以据此判断每个 ToolCall 是否找到了对应结果。

对于会产生外部副作用的操作,还可以增加 operation_id。如果外部服务支持幂等请求,多次提交同一个 operation_id 只产生一次结果,可以进一步降低重复执行的风险。但两者作用不同:

6.3 恢复内部记录,不等于扫描整个外部世界

文件内容、Git 分支、后台进程、端口、部署状态都可能在 Agent 退出后发生变化。但这不意味着每次恢复会话都要立刻扫描所有文件、进程、端口和外部服务。

更合理的原则是,只在以下三种情况下才查询当前状态:

例如,Agent 接下来准备修改 login.py,就在修改前重新读取或校验该文件;准备继续使用某个后台服务时,再检查服务是否仍然可用。

Session 恢复负责加载 Agent 的内部记录,工具负责在真正需要时观察外部世界。


七、Agent 如何发现文件被手动修改

一个具体情景:10:00 Agent 读了 main.py,10:05 用户手动改了 main.py,10:10 Agent 想改 main.py——Agent 并不知道 10:05 发生了什么。常见做法有三种。

7.1 再次读取

我们做一个约束:每次对文件执行写操作前,都必须先读文件。这样一来,就不是”因为知道变了,所以重新读”,而是”因为我重新读了,所以我知道变了”。

7.2 版本校验

读取文件时,工具同时返回内容哈希:

{
  "content": "...",
  "version": "abc123"
}

写入时带上这个版本:

write_file(path="main.py", expected_version="abc123", ...)

工具执行时做校验:

actual_hash = hash(file)

if actual_hash != expected_hash:
    return FILE_CHANGED

因此完全不需要 LLM 主动想到”我要重新读”,而是工具直接防止覆盖旧版本。

7.3 补丁无法匹配

补丁的机制是:找到一段旧内容,替换成新内容。假设原文件有一段 return a - b,Agent 使用 apply_patch 修改:

-    return a - b
+    return a + b

但如果用户已经手动改成 return sum([a, b]),那原来的 return a - b 已经不存在了,补丁工具就会:

Patch failed:
old context not found

于是 Agent 才知道:我之前看到的文件已经不是现在这个样子了,然后重新读取。

问题来了,补丁工具是怎么知道 Patch failed 的呢?实际上就是 apply_patch 读取磁盘里的文件,尝试找到 return a - b,但此时用户已经修改了,所以它肯定找不到,无法确认这个 Patch 应该应用在哪里。流程如下:

模型之前读取 main.py
        ↓
基于当时内容生成 Patch

-old code
+new code
        ↓
调用 apply_patch
        ↓
apply_patch 重新读取当前 main.py
        ↓
寻找 Patch 中描述的旧代码和上下文
        ↓
┌───────────────┬────────────────┐
│ 找得到        │ 找不到          │
│ 应用 Patch    │ Patch failed    │
└───────────────┴────────────────┘
                        ↓
                  告诉 LLM → 重新读取文件

所以补丁不是提前发现修改,而是修改时发现”我准备修改的旧内容已经不存在了”。

因此,Agent 在没有恢复会话的情况下也能发现脏文件,是因为它在正常运行时重新读取或校验了文件,而不是因为 Session 恢复机制检查了整个项目。


八、小结

把上面的内容压缩成一份速记清单:

  1. 三件事分开:内部记录、发给模型的上下文、外部世界状态,不能混为一谈。
  2. 四层概念:Session(容器)→ Run(一次执行)→ Event(流水)→ Context(本次发模型的内容)。
  3. 存储:session.json(小而热)+ events.jsonl(追加流水),统一 emit() 逐条落盘。
  4. 恢复靠 Harness 程序,不靠模型:校验 workspace,把没有 run_finished 的 Run 标为 interrupted。
  5. 不确定操作标 uncertain,并且必须写进下一次提示词告诉模型——这是最容易漏掉的一步。
  6. 外部状态按需查(依赖 / 修改 / 结果不明时才查);脏文件靠重读与版本、补丁校验发现。

参考链接:原文 《会话管理(二):持久化与恢复》(WaytoAGI 社区,原作者 Kevin,X:@htt_keyboarder)


Suggest Changes

Next Post
Spring MVC 拦截器实战:从 Filter 区别到登录、权限、日志与监控