Skip to content

第 18 章 会话与持久化 —— 状态的生与死

会话(Session)是 Coding Agent 的"工作现场"——它在内存中积累上下文、在磁盘上留下痕迹、在进程重启后决定哪些信息活下来、哪些永远消失。持久化策略的选择直接决定了 Agent 的"记忆力上限"和"遗忘速度"。

18.1 会话生命周期

状态机模型

一个会话从创建到销毁经历五个阶段:

mermaid
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 拼接

text
session-1719312456789-42851

结构是 session-{毫秒时间戳}-{进程号}。优点是简单、无外部依赖、文件名直接可排序。缺点是不同机器可能时钟偏移,同机同毫秒的不同进程极罕见但理论上 PID 可能重复(进程号复用)。Mini-Codex 使用这种方案:

rust
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

text
019709a3-4b8c-7def-8012-3456789abcde

UUID v7 的前 48 bit 是毫秒时间戳,后续 bit 是随机数。天然可排序、全球唯一、无 PID 依赖。DeepAgents 的 CLI 采用这种方案:

python
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 模式

会话恢复有两种交互模式:

模式触发方式行为
Selectresume 子命令列出同一 workspace 下的所有会话,用户选编号
Lastresume --last自动恢复最近的会话,无需交互

恢复的核心逻辑是按 workspace 过滤 + 按时间排序

rust
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 文件,每次状态变更时全量覆写。

数据结构

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 策略,保证崩溃时最多丢失一条消息:

rust
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

rust
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 设计是典型代表——三张核心表 + 两张辅助表:

sql
-- 会话表
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: 具体内容
);

这个设计有几个值得注意的点:

  1. session → message → part 三级结构:一条"消息"可以包含多个"部分"(文本、工具调用、工具结果)。这比 Mini-Codex 的扁平 entries 数组更灵活,支持流式追加单个 part 而不必重写整条消息。

  2. parent_id 字段:支持子会话(fork)。用户可以从某个消息点分叉出一条新会话,而不影响原会话。

  3. ON DELETE CASCADE:删除会话时自动清理所有消息和部分——数据库级联保证一致性,不需要应用层遍历删除。

数据库初始化时设置了性能关键的 PRAGMA:

typescript
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/ 目录结构:

text
~/.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:

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:

text
{timestamp}-{short_hash}-{slug}.md

例如 2025-01-15T14-30-00-a7Bx-fix_auth_bug.md。这种命名让 ls 按时间排序,短哈希避免冲突,slug 提供人类可读的上下文。

优点:人类可读、grep 友好、Git 可追踪、无数据库依赖。 缺点:事务性弱(目录不支持原子多文件更新)、查询能力有限。

方案四:LangGraph Checkpointer

框架内置的持久化抽象。LangGraph 的 Checkpointer 接口将图执行的完整状态(节点状态、消息历史、中间结果)自动序列化到后端存储。

python
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 自动重建完整的图状态:

python
# 恢复只需 thread_id
config = {"configurable": {"thread_id": thread_id}}
result = await graph.ainvoke(new_input, config)

删除会话则是删除对应 thread_id 的 checkpoint 和 writes 记录:

python
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)、调试需要专用工具。

取舍维度总结

mermaid
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 的记忆系统是迄今为止最精密的实现——它用两阶段流水线将会话历史转化为持久知识。

两阶段记忆流水线

mermaid
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 可用
  • 处理过程:
    1. 从 State DB 认领(claim)一批 eligible rollout job
    2. 加载每个 rollout 的完整内容
    3. 过滤掉 developer 角色的消息和不需要记忆的上下文片段
    4. 对敏感信息做 redact(API key、密码等)
    5. 并行发给模型(并发上限 8),要求生成结构化输出
    6. 将成功的结果写回 State DB

Phase 1 的模型输出是一个严格的 JSON schema:

json
{
  "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 抢锁实现)
  • 处理过程:
    1. 从 State DB 加载 top-N 的 stage-1 记忆(按使用频率 + 最近使用时间排序)
    2. 淘汰超过 max_unused_days 未使用的记忆
    3. 同步到文件系统:更新 rollout_summaries/ 目录和 raw_memories.md
    4. 剪除不再保留的旧 rollout summary 文件
    5. 生成整合 Agent 的 prompt,标注哪些记忆是"新增"、哪些是"保留"、哪些是"删除"
    6. 启动一个沙箱内的 subagent 来执行整合写入

整合 Agent 的约束极其严格:

rust
// 不允许递归生成记忆
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(水位线)来追踪"上次成功整合时看到的最新输入":

python
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 中:

rust
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_mdmemories/MEMORY.md
memory_summarymemories/memory_summary.md
raw_memoriesmemories/raw_memories.md
rollout_summariesmemories/rollout_summaries/*
skillsmemories/skills/*

每次 Agent 通过 shell 命令(catgrep 等)读取这些文件时,系统发射对应的 metrics 事件。这些遥测数据反馈到 Phase 2 的选择算法中——使用频率高的记忆优先保留,长期未使用的记忆被淘汰。

记忆引用与溯源

当 Agent 在回答中使用了记忆内容时,系统支持 citation(引用溯源):

xml
<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 提供了显式的会话删除命令:

bash
opencode session delete <sessionID>

底层是级联删除——删除 session 行时,SQLite 自动通过 ON DELETE CASCADE 清理所有关联的 message、part、todo 行。

DeepAgents 则是按 thread_id 清理 checkpoint:

python
# 删除 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

text
User: 帮我配置一下,key 是 sk-proj-abc123def456...

这条消息会被原样写入 history 文件 / 数据库。如果 history 文件在 Git 仓库中(.tasks/ 目录就是这种情况),API Key 就进了版本历史。

场景二:工具输出包含环境变量

Agent 执行 env | grep KEYcat .env,输出中包含的密钥会作为 tool result 持久化。

场景三:记忆系统放大风险

最危险的是场景一和场景二经过记忆系统的放大效应。一条包含密钥的消息不仅存在于当前会话的 history 中,还可能被 Phase 1 提取到 raw_memory 中,再被 Phase 2 整合到 MEMORY.md 中,从而在所有未来会话中持续暴露。

Codex 的应对措施是在 Phase 1 的输出上做 secret redaction

rust
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 角色的消息和被标记为"记忆排除"的上下文片段:

rust
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 提供了一个原子清理操作,用于重置整个记忆目录:

rust
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/ 是一个符号链接(可能指向共享存储或其他敏感位置),清理操作会拒绝执行。这是一个防御性设计,避免意外删除链接目标处的数据。

最佳实践清单

  1. .gitignore 覆盖:将 history 文件(*.session.json)、数据库文件(*.db*.db-wal*.db-shm)、记忆目录(memories/)加入 .gitignore
  2. 环境变量隔离:Agent 的工具沙箱不应继承宿主进程的全部环境变量。只传递白名单中的变量(PATHHOMELANG),敏感变量(*_API_KEY*_SECRET*_TOKEN)不进沙箱。
  3. 定期 GC:对长期运行的实例,设置自动清理策略——例如 Codex 的 max_unused_days 参数自动淘汰过期记忆,prune_stage1_outputs_for_retention 批量清理过期的 Phase 1 输出。
  4. 加密敏感字段:如果持久化到数据库且包含可能的敏感内容,考虑对 data 列做 application-level 加密(AES-GCM),密钥从用户的 keychain 获取。
  5. 审计日志分离:把"谁在什么时候做了什么操作"的审计日志和"对话内容"分开存储。审计日志保留更久(合规要求),对话内容按用户意愿尽早删除。