第 18 章 会话与持久化 —— 状态的生与死
会话(Session)是 Coding Agent 的"工作现场"——它在内存中积累上下文、在磁盘上留下痕迹、在进程重启后决定哪些信息活下来、哪些永远消失。持久化策略的选择直接决定了 Agent 的"记忆力上限"和"遗忘速度"。
18.1 会话生命周期
状态机模型
一个会话从创建到销毁经历五个阶段:
stateDiagram-v2
[*] --> Created : 用户启动 / API 创建
Created --> Active : 首条消息 / 首次交互
Active --> Suspended : 用户离开 / 显式暂停
Active --> Compacting : token 超限
Compacting --> Active : 压缩完成
Suspended --> Active : resume 恢复
Active --> Ended : /exit / Ctrl-D / 超时
Suspended --> Ended : 超时清理 / 手动删除
Ended --> [*]Created:会话元数据已生成,但还没有任何对话条目。此时 session ID 已分配,history 文件已落盘(或 DB 行已插入),但 entries 为空。
Active:用户正在交互。每一轮对话(user → assistant → tool → assistant)都在实时追加到持久化存储中。
Compacting:活跃 token 数逼近上限时触发的中间态。系统让模型生成一段"历史摘要",然后用摘要替换完整历史。这是会话的一次"部分遗忘"。
Suspended:用户关闭终端或切换到其他会话,但 history 文件仍在磁盘上。随时可以恢复。
Ended:会话正式结束。在某些实现中,ended 不等于删除——history 文件可能保留数天甚至永久,直到用户手动清理或自动 GC。
session ID 设计
session ID 的设计需要满足三个约束:全局唯一(多进程不冲突)、可排序(最近的会话排最前)、可读(人能看出大致时间)。
实际产品中有两种主流风格:
时间戳 + PID 拼接:
session-1719312456789-42851结构是 session-{毫秒时间戳}-{进程号}。优点是简单、无外部依赖、文件名直接可排序。缺点是不同机器可能时钟偏移,同机同毫秒的不同进程极罕见但理论上 PID 可能重复(进程号复用)。Mini-Codex 使用这种方案:
fn load_or_create_session(...) -> Result<(PathBuf, HistoryFile)> {
// ...
let history = HistoryFile {
session_id: format!("session-{}-{}", now_millis(), std::process::id()),
// ...
};
// 文件路径:{sessions_root}/{session_id}.json
}UUID v7:
019709a3-4b8c-7def-8012-3456789abcdeUUID v7 的前 48 bit 是毫秒时间戳,后续 bit 是随机数。天然可排序、全球唯一、无 PID 依赖。DeepAgents 的 CLI 采用这种方案:
from uuid_utils import uuid7
def generate_thread_id() -> str:
return str(uuid7())OpenCode 则使用自定义的 opaque ID(内部 SessionID.make() 生成),存入 SQLite 的 text 主键列。
选型建议:如果持久化到文件系统(文件名即 ID),选时间戳 + PID,因为人类可读、ls 即可排序。如果持久化到数据库,选 UUID v7,因为索引友好且跨进程安全。
resume 模式
会话恢复有两种交互模式:
| 模式 | 触发方式 | 行为 |
|---|---|---|
| Select | resume 子命令 | 列出同一 workspace 下的所有会话,用户选编号 |
| Last | resume --last | 自动恢复最近的会话,无需交互 |
恢复的核心逻辑是按 workspace 过滤 + 按时间排序:
fn list_sessions(workspace_root: &Path) -> Result<Vec<SessionSummary>> {
// 读取所有 session JSON 文件
// 过滤 workspace_root 匹配的
// 按 last_active_at_ms 降序排列
}恢复后,系统将历史条目原样回放到终端,让用户看到之前的对话上下文,然后进入正常的 Active 状态。
一个微妙的细节:恢复不会重新执行之前的工具调用,只是把它们的结果展示出来。这意味着恢复后的 Agent 看到的是"冻干状态"——它知道之前做了什么、结果是什么,但当前文件系统可能已经被外部修改了。好的 Agent 会在恢复后主动 inspect 当前状态,而不是盲目假设环境没变。
18.2 持久化方案对比
方案一:JSON 文件
最直接的方案。每个会话一个 JSON 文件,每次状态变更时全量覆写。
数据结构:
{
"version": 3,
"session_id": "session-1719312456789-42851",
"workspace_root": "/home/user/project",
"last_active_at_ms": 1719312789000,
"total_input_tokens": 15420,
"total_output_tokens": 8930,
"total_tokens": 24350,
"entries": [
{
"kind": "user",
"content": "帮我重构这个函数",
"estimated_tokens": 48
},
{
"kind": "assistant",
"content": "我来看看当前的实现。",
"tool_calls": [
{
"id": "call_abc123",
"name": "shell_tool",
"arguments": "{\"command\": \"cat src/main.rs\"}"
}
],
"estimated_tokens": 320
},
{
"kind": "tool",
"tool_call_id": "call_abc123",
"tool_name": "shell_tool",
"content": "command: cat src/main.rs\nworkdir: /home/user/project\nsuccess: true\n\nfn main() { ... }",
"estimated_tokens": 640
}
]
}关键实现细节:
每次追加条目后立即 save_history()——这是 write-through 策略,保证崩溃时最多丢失一条消息:
fn run_turn(&mut self, user_input: String) -> Result<()> {
self.history.push_user(user_input);
self.save_history()?; // 立即落盘
self.run_agent_loop() // agent loop 内部每步也 save
}save_history() 本身是 serde_json::to_string_pretty + fs::write:
fn save_history(&mut self) -> Result<()> {
self.history.last_active_at_ms = now_millis();
let text = serde_json::to_string_pretty(&self.history)?;
fs::write(&self.history_path, text)?;
Ok(())
}优点:人类可读、cat 即可调试、无外部依赖、Git 友好。 缺点:每次全量覆写,O(n) 写入;并发不安全;文件变大后性能下降。
适用场景:单进程交互式 CLI(Mini-Codex)、Task System 的 .tasks/ 目录(第 10 章)。
方案二:SQLite + ORM
用关系数据库替代裸文件,通过 ORM 层操作结构化数据。
OpenCode 的 Schema 设计是典型代表——三张核心表 + 两张辅助表:
-- 会话表
CREATE TABLE session (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES project(id) ON DELETE CASCADE,
workspace_id TEXT,
parent_id TEXT, -- 支持子会话树
slug TEXT NOT NULL,
directory TEXT NOT NULL,
title TEXT NOT NULL,
version TEXT NOT NULL,
share_url TEXT, -- 分享链接
time_created INTEGER,
time_updated INTEGER,
time_compacting INTEGER, -- 上次压缩时间
time_archived INTEGER, -- 归档时间
permission TEXT -- JSON: 权限规则集
);
-- 消息表
CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES session(id) ON DELETE CASCADE,
time_created INTEGER,
time_updated INTEGER,
data TEXT NOT NULL -- JSON: 消息元信息
);
-- 消息部分表(细粒度)
CREATE TABLE part (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL REFERENCES message(id) ON DELETE CASCADE,
session_id TEXT NOT NULL,
time_created INTEGER,
time_updated INTEGER,
data TEXT NOT NULL -- JSON: 具体内容
);这个设计有几个值得注意的点:
session → message → part 三级结构:一条"消息"可以包含多个"部分"(文本、工具调用、工具结果)。这比 Mini-Codex 的扁平 entries 数组更灵活,支持流式追加单个 part 而不必重写整条消息。
parent_id字段:支持子会话(fork)。用户可以从某个消息点分叉出一条新会话,而不影响原会话。ON DELETE CASCADE:删除会话时自动清理所有消息和部分——数据库级联保证一致性,不需要应用层遍历删除。
数据库初始化时设置了性能关键的 PRAGMA:
db.run("PRAGMA journal_mode = WAL") // Write-Ahead Logging
db.run("PRAGMA synchronous = NORMAL") // 非 FULL,换性能
db.run("PRAGMA busy_timeout = 5000") // 5秒锁等待
db.run("PRAGMA cache_size = -64000") // 64MB 页缓存
db.run("PRAGMA foreign_keys = ON") // 启用外键约束journal_mode = WAL 是最关键的一行——它让读写可以并发进行。在 JSON 文件方案中,读和写必须互斥(否则读到写了一半的文件),WAL 模式下 SQLite 可以边写边读。
优点:事务保证、结构化查询("列出最近 10 个会话"一行 SQL 搞定)、并发安全、级联删除。 缺点:引入 SQLite 依赖、不可直接 cat 调试(需要 sqlite3 CLI)、migration 管理。
适用场景:需要多会话管理、子会话 fork、结构化查询的产品级应用。
方案三:文件系统目录
介于 JSON 文件和数据库之间的折中方案。Codex 的 .codex/ 目录结构:
~/.codex/
memories/
MEMORY.md # 高层摘要
memory_summary.md # 压缩版摘要
raw_memories.md # 原始记忆合集
rollout_summaries/ # 每个 rollout 一个文件
2025-01-15T14-30-00-a7Bx-fix_auth_bug.md
2025-01-16T09-15-22-k3Mq-add_pagination.md
skills/ # 学到的技能每个 rollout summary 文件本身就是结构化的 Markdown:
thread_id: 019709a3-4b8c-7def-8012-3456789abcde
updated_at: 2025-01-15T14:30:00Z
rollout_path: /path/to/rollout
cwd: /home/user/project
git_branch: fix-auth
... rollout summary content ...文件名编码了时间戳 + 短哈希 + 可选 slug:
{timestamp}-{short_hash}-{slug}.md例如 2025-01-15T14-30-00-a7Bx-fix_auth_bug.md。这种命名让 ls 按时间排序,短哈希避免冲突,slug 提供人类可读的上下文。
优点:人类可读、grep 友好、Git 可追踪、无数据库依赖。 缺点:事务性弱(目录不支持原子多文件更新)、查询能力有限。
方案四:LangGraph Checkpointer
框架内置的持久化抽象。LangGraph 的 Checkpointer 接口将图执行的完整状态(节点状态、消息历史、中间结果)自动序列化到后端存储。
from langgraph.checkpoint.memory import MemorySaver
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
# 开发/测试:内存 checkpointer
checkpointer = MemorySaver()
# 生产:SQLite 持久化
async with AsyncSqliteSaver.from_conn_string(str(db_path)) as checkpointer:
graph = create_deep_agent(
checkpointer=checkpointer,
# ...
)Checkpointer 的核心是两张表:
| 表名 | 内容 |
|---|---|
checkpoints | 每个 thread_id 的完整状态快照(序列化后的图状态) |
writes | 增量写入记录,用于 replay |
恢复会话时,只需提供 thread_id,Checkpointer 自动重建完整的图状态:
# 恢复只需 thread_id
config = {"configurable": {"thread_id": thread_id}}
result = await graph.ainvoke(new_input, config)删除会话则是删除对应 thread_id 的 checkpoint 和 writes 记录:
async def delete_thread(thread_id: str) -> bool:
async with connect() as conn:
cursor = await conn.execute(
"DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)
)
deleted = cursor.rowcount > 0
await conn.execute(
"DELETE FROM writes WHERE thread_id = ?", (thread_id,)
)
await conn.commit()
return deleted优点:零样板代码(图定义 + checkpointer = 自动持久化)、支持 Human-in-the-Loop 中断恢复、后端可插拔(内存 / SQLite / PostgreSQL)。 缺点:与 LangGraph 框架强绑定、序列化格式不透明(JsonPlusSerializer)、调试需要专用工具。
取舍维度总结
graph LR
A[JSON 文件] -->|"可读性 5/5"| Z[选型决策]
B[SQLite + ORM] -->|"查询能力 5/5"| Z
C[目录结构] -->|"灵活度 4/5"| Z
D[LangGraph Checkpointer] -->|"集成度 5/5"| Z| 维度 | JSON 文件 | SQLite + ORM | 目录结构 | Checkpointer |
|---|---|---|---|---|
| 人类可读性 | 极高 | 低 | 高 | 低 |
| 事务保证 | 无 | 完整 ACID | 弱 | 由后端决定 |
| 查询能力 | 遍历文件 | SQL 全能力 | grep + ls | 按 thread_id |
| 并发安全 | 不安全 | WAL 模式安全 | 单文件安全 | 安全 |
| 崩溃恢复 | 最多丢一条 | WAL 回滚 | 最多丢一个文件 | checkpoint 回放 |
| 框架耦合 | 无 | ORM 层 | 无 | LangGraph |
| 适用规模 | 单会话 | 多会话产品 | 中等规模 | 图执行引擎 |
Event-sourcing vs Snapshot 是另一个关键抉择:
Event-sourcing(事件溯源):存储每一条消息/操作作为独立事件,状态通过回放事件重建。Mini-Codex 的
entries数组本质就是 event log。优点是完整审计轨迹,缺点是恢复时间随历史长度线性增长。Snapshot(快照):只存储最新状态。LangGraph Checkpointer 的
checkpoints表是纯快照。优点是恢复 O(1),缺点是丢失中间过程。
实际产品往往混合使用:event log + 定期快照。Codex 的 compaction 机制就是一种"有损快照"——用 LLM 生成的摘要替代完整历史,然后从摘要继续。
18.3 记忆系统
从"会话历史"到"持久记忆"
会话历史和记忆是两个不同层次的概念:
| 会话历史 | 持久记忆 | |
|---|---|---|
| 生命周期 | 单会话 | 跨会话 |
| 粒度 | 每条消息 | 提炼后的知识片段 |
| 来源 | 用户输入 + 模型输出 | 从历史中自动提取 |
| 大小 | 随对话线性增长 | 控制在固定范围内 |
Codex 的记忆系统是迄今为止最精密的实现——它用两阶段流水线将会话历史转化为持久知识。
两阶段记忆流水线
flowchart TD
A[会话结束 / rollout 生成] --> B{Phase 1: 提取}
B --> C[对每个 rollout 并行提取]
C --> D[raw_memory + rollout_summary]
D --> E[存入 State DB]
E --> F{Phase 2: 整合}
F --> G[从 DB 加载 top-N 原始记忆]
G --> H[同步到文件系统]
H --> I[生成 MEMORY.md / memory_summary.md]
I --> J[写入 memories/ 目录]Phase 1(提取):
- 触发时机:每次根会话启动时,作为后台任务异步执行
- 前置条件:非 ephemeral 会话、memory 功能已启用、非 subagent 会话、State DB 可用
- 处理过程:
- 从 State DB 认领(claim)一批 eligible rollout job
- 加载每个 rollout 的完整内容
- 过滤掉 developer 角色的消息和不需要记忆的上下文片段
- 对敏感信息做 redact(API key、密码等)
- 并行发给模型(并发上限 8),要求生成结构化输出
- 将成功的结果写回 State DB
Phase 1 的模型输出是一个严格的 JSON schema:
{
"type": "object",
"properties": {
"rollout_summary": { "type": "string" },
"rollout_slug": { "type": ["string", "null"] },
"raw_memory": { "type": "string" }
},
"required": ["rollout_summary", "rollout_slug", "raw_memory"],
"additionalProperties": false
}三个字段各有用途:raw_memory 是详细的 Markdown 记忆体;rollout_summary 是一行式摘要,用于路由和索引;rollout_slug 是可选的短标签,用来生成人类可读的文件名。
Phase 2(整合):
- 全局锁:同一时刻只有一个 Phase 2 在运行(通过 DB 抢锁实现)
- 处理过程:
- 从 State DB 加载 top-N 的 stage-1 记忆(按使用频率 + 最近使用时间排序)
- 淘汰超过
max_unused_days未使用的记忆 - 同步到文件系统:更新
rollout_summaries/目录和raw_memories.md - 剪除不再保留的旧 rollout summary 文件
- 生成整合 Agent 的 prompt,标注哪些记忆是"新增"、哪些是"保留"、哪些是"删除"
- 启动一个沙箱内的 subagent 来执行整合写入
整合 Agent 的约束极其严格:
// 不允许递归生成记忆
agent_config.memories.generate_memories = false;
// 不需要用户审批
agent_config.permissions.approval_policy = AskForApproval::Never;
// 禁用协作和记忆工具(防止递归)
agent_config.features.disable(Feature::SpawnCsv);
agent_config.features.disable(Feature::Collab);
agent_config.features.disable(Feature::MemoryTool);
// 沙箱:仅本地写入,无网络
let sandbox = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![codex_home],
network_access: false,
// ...
};这些约束确保整合 Agent 只能读写 memories/ 目录下的文件,不能上网、不能递归调用自己、不能请求用户审批。
选择性淘汰:Watermark 机制
Phase 2 使用 watermark(水位线)来追踪"上次成功整合时看到的最新输入":
watermark = max(claimed_watermark, max(source_updated_at for all inputs))每次成功完成 Phase 2 时,这个 watermark 存入 DB。下次 Phase 2 启动时,它比较当前最新输入的时间戳和上次 watermark:
- 如果相等:说明没有新记忆产生,跳过整合(
skipped_not_dirty) - 如果更大:说明有新记忆,需要执行整合
这种"脏检查"避免了不必要的重复整合。
记忆的读路径
写路径是 Phase 1 + Phase 2,读路径则简单得多——在会话启动时将 memory_summary.md 的内容注入到 developer instructions 中:
async fn build_memory_tool_developer_instructions(codex_home: &Path) -> Option<String> {
let memory_summary = fs::read_to_string(
memory_root(codex_home).join("memory_summary.md")
).await.ok()?;
// 截断到 5000 token 以内
let memory_summary = truncate_text(
&memory_summary,
TruncationPolicy::Tokens(5000),
);
// 渲染成 developer instructions 模板
// ...
}读路径有一个 token 预算约束(默认 5000 token),防止过长的记忆挤占主对话的上下文窗口。
记忆使用追踪
系统还追踪 Agent 实际读取了哪些记忆文件,分为五种类型:
| 类型 | 路径模式 |
|---|---|
memory_md | memories/MEMORY.md |
memory_summary | memories/memory_summary.md |
raw_memories | memories/raw_memories.md |
rollout_summaries | memories/rollout_summaries/* |
skills | memories/skills/* |
每次 Agent 通过 shell 命令(cat、grep 等)读取这些文件时,系统发射对应的 metrics 事件。这些遥测数据反馈到 Phase 2 的选择算法中——使用频率高的记忆优先保留,长期未使用的记忆被淘汰。
记忆引用与溯源
当 Agent 在回答中使用了记忆内容时,系统支持 citation(引用溯源):
<citation_entries>
path/to/file.py:10-25|note=[函数签名变更]
</citation_entries>
<rollout_ids>
019709a3-4b8c-7def-8012-3456789abcde
</rollout_ids>解析器从 Agent 输出中提取这些标记,建立"记忆 → 源 rollout"的追踪链。这既帮助用户理解"Agent 为什么知道这个",也为记忆淘汰提供信号——被频繁引用的记忆更有保留价值。
18.4 隐私与数据生命周期
会话数据的生命周期管理
不同持久化方案的数据生命周期差异巨大:
| 方案 | 默认生命周期 | 清理方式 |
|---|---|---|
| JSON 文件(临时目录) | 随 OS tmpdir 策略(通常重启或数天后) | 自动 GC / 手动删除 |
| JSON 文件(项目目录) | 永久(直到用户删除) | rm / .gitignore |
| SQLite(用户目录) | 永久 | CLI 命令删除 / 直接删 DB 文件 |
| LangGraph Checkpoint | 永久 | 按 thread_id 删除 |
| 内存 Checkpointer | 进程结束即消失 | 无需清理 |
OpenCode 提供了显式的会话删除命令:
opencode session delete <sessionID>底层是级联删除——删除 session 行时,SQLite 自动通过 ON DELETE CASCADE 清理所有关联的 message、part、todo 行。
DeepAgents 则是按 thread_id 清理 checkpoint:
# 删除 checkpoints 表中该 thread 的所有记录
await conn.execute("DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,))
# 删除 writes 表中该 thread 的所有增量写入
await conn.execute("DELETE FROM writes WHERE thread_id = ?", (thread_id,))
await conn.commit()敏感信息的意外持久化
会话持久化最大的隐私风险是敏感信息泄露到磁盘。三种典型场景:
场景一:用户在对话中粘贴了 API Key
User: 帮我配置一下,key 是 sk-proj-abc123def456...这条消息会被原样写入 history 文件 / 数据库。如果 history 文件在 Git 仓库中(.tasks/ 目录就是这种情况),API Key 就进了版本历史。
场景二:工具输出包含环境变量
Agent 执行 env | grep KEY 或 cat .env,输出中包含的密钥会作为 tool result 持久化。
场景三:记忆系统放大风险
最危险的是场景一和场景二经过记忆系统的放大效应。一条包含密钥的消息不仅存在于当前会话的 history 中,还可能被 Phase 1 提取到 raw_memory 中,再被 Phase 2 整合到 MEMORY.md 中,从而在所有未来会话中持续暴露。
Codex 的应对措施是在 Phase 1 的输出上做 secret redaction:
output.raw_memory = redact_secrets(output.raw_memory);
output.rollout_summary = redact_secrets(output.rollout_summary);
output.rollout_slug = output.rollout_slug.map(redact_secrets);redact_secrets 会识别常见的密钥模式(sk-、ghp_、AKIA 等前缀)并替换为 [REDACTED]。但这是正则匹配,无法覆盖所有格式的密钥。
此外,Phase 1 还过滤掉 developer 角色的消息和被标记为"记忆排除"的上下文片段:
fn sanitize_response_item_for_memories(item: &ResponseItem) -> Option<ResponseItem> {
// developer 消息完全过滤
if role == "developer" {
return None;
}
// user 消息中过滤掉标记为排除的片段
let content = content.iter()
.filter(|item| !is_memory_excluded_contextual_user_fragment(item))
.cloned()
.collect();
// ...
}清理 memories 目录
Codex 提供了一个原子清理操作,用于重置整个记忆目录:
async fn clear_memory_root_contents(memory_root: &Path) -> io::Result<()> {
// 安全检查:拒绝清理符号链接指向的目录
match tokio::fs::symlink_metadata(memory_root).await {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"refusing to clear symlinked memory root"
));
}
// ...
}
// 逐项删除子目录和文件
let mut entries = tokio::fs::read_dir(memory_root).await?;
while let Some(entry) = entries.next_entry().await? {
if entry.file_type().await?.is_dir() {
tokio::fs::remove_dir_all(entry.path()).await?;
} else {
tokio::fs::remove_file(entry.path()).await?;
}
}
Ok(())
}注意 symlink 检查——如果 memories/ 是一个符号链接(可能指向共享存储或其他敏感位置),清理操作会拒绝执行。这是一个防御性设计,避免意外删除链接目标处的数据。
最佳实践清单
.gitignore覆盖:将 history 文件(*.session.json)、数据库文件(*.db、*.db-wal、*.db-shm)、记忆目录(memories/)加入.gitignore。- 环境变量隔离:Agent 的工具沙箱不应继承宿主进程的全部环境变量。只传递白名单中的变量(
PATH、HOME、LANG),敏感变量(*_API_KEY、*_SECRET、*_TOKEN)不进沙箱。 - 定期 GC:对长期运行的实例,设置自动清理策略——例如 Codex 的
max_unused_days参数自动淘汰过期记忆,prune_stage1_outputs_for_retention批量清理过期的 Phase 1 输出。 - 加密敏感字段:如果持久化到数据库且包含可能的敏感内容,考虑对
data列做 application-level 加密(AES-GCM),密钥从用户的 keychain 获取。 - 审计日志分离:把"谁在什么时候做了什么操作"的审计日志和"对话内容"分开存储。审计日志保留更久(合规要求),对话内容按用户意愿尽早删除。