Skip to content

Context Mode 的会话持久化系统是一套基于 SQLite 的事件追踪机制。它记录每次文件编辑、git 操作、任务状态、错误和用户决策,构建 23 种事件类别的完整审计日志。当 AI 助手触发上下文压缩(Compaction)时,系统从 SQLite 读取事件,按 4 级优先级构建 ≤2 KB 的 XML 快照,通过 SessionStart Hook 注入回对话。本文从源码角度拆解这套系统的设计思路。

问题根源

AI 编程助手的上下文窗口是有限的(Claude 约 200K tokens)。当对话足够长时,系统会触发 Compaction——把早期对话"压缩"成摘要以腾出空间。问题是:压缩会丢失细节。AI 忘了你让它改哪个文件、用了什么方案、拒绝过什么方案。

Context Mode 的解法:在压缩前把关键信息备份到 SQLite,压缩后恢复回来。

架构总览

┌──────────────────────────────────────────┐
│               对话上下文                   │
│                                          │
│  User: "把登录改成 OAuth"                 │
│  AI: Edit("src/auth.ts") → 修改完成       │
│  AI: "已改好,要用 Google OAuth 还是…"     │
│                                          │
│  ┌─────────────────────┐                 │
│  │ PostToolUse Hook     │                │
│  │ → 提取事件           │                │
│  └──────────┬──────────┘                 │
└─────────────┼────────────────────────────┘

┌─────────────────────────────────────────┐
│            SQLite (SessionDB)            │
│                                         │
│  events:                                │
│  ├── file_edit: src/auth.ts (P1)       │
│  ├── task_change: "改登录" (P1)         │
│  ├── decision: "选 OAuth 方案" (P2)     │
│  ├── error: TS2345 类型不匹配 (P2)      │
│  └── user_prompt: "用 Google" (P1)      │
│                                         │
└──────────────────┬──────────────────────┘

         ┌─────────┴─────────┐
         │ Compaction 触发    │
         └─────────┬─────────┘

┌─────────────────────────────────────────┐
│     Snapshot Builder (snapshot.ts)       │
│                                         │
│  1. 读 SQLite events(全部)             │
│  2. 按优先级排序                          │
│  3. 生成 XML(≤ 2 KB)                   │
│  4. 写入 session_resume 表              │
└──────────────────┬──────────────────────┘

┌─────────────────────────────────────────┐
│     SessionStart Hook (恢复)             │
│                                         │
│  检测 source: "compact"                 │
│  → 读 session_resume                    │
│  → 注入 <session_knowledge> 指令        │
│  → AI 继续工作                          │
└─────────────────────────────────────────┘

事件提取(extract.ts)

事件触发点

事件通过 PostToolUse Hook 在每次工具调用后自动提取:

工具调用提取的事件优先级
Edit / Write / NotebookEditfile_edit(文件路径、变更范围)P1 Critical
Bash(含 git 命令)git_action(分支、commit、操作类型)P2 High
TodoWritetask_change(任务状态变更)P1 Critical
Agentsubagent(子代理任务和结果)P3 Normal
任意工具出错error(错误类型、消息)P2 High
UserPromptSubmituser_prompt(用户输入摘要)P1 Critical

提取逻辑

extract.ts 从 Hook 的 stdin JSON 中解析工具名称和参数,按工具类型匹配提取规则:

typescript
// 简化示意
function extractEvent(toolName: string, input: any, output: any): SessionEvent {
  switch (toolName) {
    case 'Edit':
      return {
        type: 'file_edit',
        priority: 'P1',
        data: { file: input.file_path, old: input.old_string.slice(0, 100), new: input.new_string.slice(0, 100) }
      };
    case 'Bash':
      if (input.command.startsWith('git')) {
        return { type: 'git_action', priority: 'P2', data: { command: input.command } };
      }
      return { type: 'mcp_tool', priority: 'P3', data: { command: input.command.slice(0, 200) } };
    // ...
  }
}

关键设计:只提取摘要,不存原始数据。文件变更存路径和前 100 字符,不存完整 diff;git 命令存命令本身,不存输出。

事件存储(db.ts)

数据库结构

每个项目一个 SQLite 数据库,路径按平台不同:

~/.claude/context-mode/sessions/<project-hash>.db   (Claude Code)
~/.cursor/context-mode/sessions/<project-hash>.db   (Cursor)
~/.gemini/context-mode/sessions/<project-hash>.db   (Gemini CLI)

核心表:

sql
CREATE TABLE events (
  id INTEGER PRIMARY KEY,
  session_id TEXT NOT NULL,
  type TEXT NOT NULL,          -- 23 种事件类别
  priority TEXT NOT NULL,      -- P1/P2/P3/P4
  data TEXT NOT NULL,          -- JSON 格式的事件数据
  created_at INTEGER NOT NULL  -- Unix timestamp
);

CREATE TABLE session_resume (
  session_id TEXT PRIMARY KEY,
  snapshot TEXT NOT NULL,      -- XML 快照
  created_at INTEGER NOT NULL
);

SQLite 后端选择

Context Mode 自动选择最优 SQLite 后端:

优先级条件后端原因
1Bun 运行时bun:sqlite内置,无原生插件
2Linux + Node ≥ 22.13node:sqlite避免 V8 madvise 导致的 SIGSEGV
3其他环境better-sqlite3成熟原生插件

Linux 用户注意

Node.js V8 GC 的 madvise(MADV_DONTNEED) 可能覆盖 better-sqlite3.got.plt 段,导致每小时 1-4 次 SIGSEGV 崩溃。Node ≥ 22.13 的 node:sqlite 编译在二进制内,没有 .got.plt,不存在此问题。

快照构建(snapshot.ts)

触发时机

PreCompact Hook 在 Claude 压缩上下文前触发。Context Mode 从 SQLite 读取当前会话的所有事件,构建 XML 快照。

优先级分层

快照遵循"先保留重要的"原则:

P1 Critical:文件、任务、计划、规则、用户提示
  → 全部保留,不裁剪

P2 High:决策、Git、错误、约束、阻塞项、被拒方案
  → 全部保留,如果总量超标则截断最早记录

P3 Normal:MCP 工具、子代理、技能
  → 保留最近 N 条

P4 Low:意图、角色、数据引用
  → 仅在空间充裕时保留

XML 快照格式

xml
<session_knowledge>
  <files>
    <file path="src/auth.ts" action="edited" />
    <file path="src/types.ts" action="created" />
  </files>
  <tasks>
    <task status="in_progress">重构认证模块为 OAuth</task>
  </tasks>
  <decisions>
    <decision>选用 Google OAuth 2.0,不用 Passport</decision>
  </decisions>
  <errors>
    <error type="TS2345" file="src/auth.ts">类型不匹配:expected User, got Session</error>
  </errors>
  <git>
    <action>feature/oauth-rework (3 commits ahead of main)</action>
  </git>
</session_knowledge>

快照上限:≤ 2 KB。超出时按优先级从低到高裁剪。

为什么用 XML 而不是 JSON

这是一个有趣的工程决策。XML 在这个场景有两个优势:

  1. 标签名即上下文<file path="...">{"type": "file", "path": "..."} 更紧凑
  2. LLM 友好:XML 标签在 LLM 训练数据中大量出现,模型对 XML 结构的理解更稳定

恢复流程(sessionstart.mjs)

SessionStart Hook

当 SessionStart Hook 触发时,检查 source 字段:

javascript
// 简化逻辑
const source = input.source; // "startup" | "compact" | "resume"
if (source === "compact") {
  const resume = db.get("SELECT snapshot FROM session_resume WHERE session_id = ?", sessionId);
  if (resume) {
    output.additionalContext = resume.snapshot; // 注入回对话
  }
}

恢复效果

压缩前的对话:

User: 帮我把 App.tsx 的状态管理改成 useReducer
AI: 好的,我来分析一下当前的状态管理...
[读了 10 个文件,讨论了 3 种方案]
[Compaction 触发]

恢复后的对话:

<session_knowledge>
  <files>
    <file path="src/App.tsx" action="editing" />
  </files>
  <decisions>
    <decision>用 useReducer 替代 useState,reducer 按功能模块拆分</decision>
  </decisions>
  <tasks>
    <task status="in_progress">App.tsx 状态管理重构</task>
  </tasks>
</session_knowledge>

User: 继续
AI: 好的,我们正在把 App.tsx 的状态管理从 useState 改成 useReducer。
    之前决定按功能模块拆分 reducer。让我继续...

设计决策回顾

为什么不用 RAG 而用结构化快照

RAG(向量检索)的问题是不可控——检索结果可能遗漏关键上下文。会话恢复需要的是确定性:文件列表、任务状态、已做的决策——这些是结构化数据,用 SQL 查询比向量检索更可靠。

Context Mode 在知识库搜索(ctx_search)上用了 FTS5 + BM25 + RRF,但在会话恢复上用的是确定性 SQL 查询。这是两种不同的检索策略,各取所长。

为什么用 SQLite 而不是文件

  • 事务安全:Hook 可能并发触发,SQLite 的写锁保证数据一致性
  • 查询能力:按优先级、时间范围、事件类型过滤,SQL 比文件解析高效
  • 自动清理:删 session 直接 DELETE FROM events WHERE session_id = ?

为什么不把完整对话存下来

完整对话可能 100 KB+,存进 SQLite 只是换个地方占空间。Context Mode 的策略是只存关键事件的摘要(路径+变更类型,而非完整 diff),用空间换确定性。

FAQ

Q: 会话持久化支持多个项目同时工作吗?

支持。每个项目有独立的 SQLite 数据库(按项目路径哈希命名),不会互相干扰。

Q: 如果 SQLite 数据库损坏了怎么办?

删除对应的 .db 文件即可。下次会话启动时会自动创建新数据库。之前会话的恢复数据会丢失,但不影响新会话的正常记录。

Q: 事件记录对性能有影响吗?

影响极小。每次工具调用后写入一条 SQLite 记录,单次写入耗时 <1ms。PostToolUse Hook 的整体延迟增加约 10-30ms,相比工具本身的执行时间可以忽略。