转载声明
本文转载自 WaytoAGI 社区,原作者 Kevin(X:@htt_keyboarder),原文标题《会话管理(二):持久化与恢复》。
原文链接:https://waytoagi.feishu.cn/wiki/P9j2wxAfUiEOfokVuIYcqO4Un0e
版权归原作者所有,转载请注明出处。
当本地 Coding Agent 被关闭或意外中断之后,之前的工作还能不能接着做?这就是会话持久化与恢复要解决的问题。
很多介绍把这件事讲成”保存聊天记录,再把聊天记录读回来”。但 Agent 不只是聊天工具——它会读取和修改文件、执行命令、运行测试,甚至启动进程。因此,会话恢复至少要区分三件事:
- Agent 过去记录了什么;
- 下一次调用模型时,需要把哪些内容发给模型;
- 文件、Git、进程等外部环境现在是什么状态。
这三件事不能混为一谈。
一、一个具体场景
假设你在 /project_A 中使用 Agent,让它排查 login.py 的登录问题。工作到一半,你按下 Ctrl+C,进程结束。
程序都退出了,内存也释放了,为什么第二天执行 resume,昨天的对话还能重新显示出来?
原因很简单:这些内容在昨天运行时,就已经被不断写到磁盘了。用户发一条消息保存一条,模型回复保存一条,调用工具、返回结果也继续保存。所以进程退出后,内存中的 Session 消失了,但磁盘里的会话记录还在。
第二天执行 Resume,本质就是:
找到这个 Session
↓
从磁盘读取它之前保存的记录
↓
重新恢复聊天界面
↓
恢复工作目录、执行状态等信息
↓
等待用户继续
这就是会话持久化与会话恢复最基本的意义:持久化负责把内存中的重要状态保存下来,恢复负责在程序重启后把这些状态读回来。
再考虑一个更麻烦的情况:你按 Ctrl+C 时,Agent 正准备执行一次写文件操作。恢复会话后,程序发现有 ToolCall 记录,却没有对应的 ToolResult——这次写入到底成功没有?该怎么安全地继续?这个问题后面会专门讲。
二、四个必须先分清的概念
2.1 Session:一项长期任务的容器
Session 表示一段可以多次继续的任务或对话。例如”修复 project_A 的登录问题”就是一个 Session。它通常保存:
session_id:会话编号user_id:会话属于谁workspace:会话绑定的项目目录status:会话是否仍在使用created_at、updated_at:创建与更新时间
会话的完整生命周期可以包含创建、运行、等待、完成、失败、取消、归档等状态。不过本地 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 |
| Run | Session 里的一次执行 | run_id、session_id、起止时间、状态、错误 |
| Event | Run 内发生的一件事 | 类型、时间、关联的 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 → 添加一条 → 重新写回整个文件。
更合理的做法是:
session.json保存体积小、需要快速读取的会话信息;events.jsonl保存不断追加的详细历史。
拆分不会减少总数据量,真正的目的是让”会话列表等高频信息”和”庞大的历史流水”分开读取。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 等表,同时仍然只产生一个数据库文件。
两者没有绝对高下:
- 初版、重视简单和可读性:
session.json + events.jsonl; - 需要条件查询、事务和统计:SQLite。
真实案例:当前 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 只产生一次结果,可以进一步降低重复执行的风险。但两者作用不同:
call_id用来关联 Agent 内部的调用和结果;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 恢复机制检查了整个项目。
八、小结
把上面的内容压缩成一份速记清单:
- 三件事分开:内部记录、发给模型的上下文、外部世界状态,不能混为一谈。
- 四层概念:Session(容器)→ Run(一次执行)→ Event(流水)→ Context(本次发模型的内容)。
- 存储:
session.json(小而热)+events.jsonl(追加流水),统一emit()逐条落盘。 - 恢复靠 Harness 程序,不靠模型:校验 workspace,把没有
run_finished的 Run 标为interrupted。 - 不确定操作标
uncertain,并且必须写进下一次提示词告诉模型——这是最容易漏掉的一步。 - 外部状态按需查(依赖 / 修改 / 结果不明时才查);脏文件靠重读与版本、补丁校验发现。
参考链接:原文 《会话管理(二):持久化与恢复》(WaytoAGI 社区,原作者 Kevin,X:@htt_keyboarder)