Appearance
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 / NotebookEdit | file_edit(文件路径、变更范围) | P1 Critical |
Bash(含 git 命令) | git_action(分支、commit、操作类型) | P2 High |
TodoWrite | task_change(任务状态变更) | P1 Critical |
Agent | subagent(子代理任务和结果) | P3 Normal |
| 任意工具出错 | error(错误类型、消息) | P2 High |
UserPromptSubmit | user_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 后端:
| 优先级 | 条件 | 后端 | 原因 |
|---|---|---|---|
| 1 | Bun 运行时 | bun:sqlite | 内置,无原生插件 |
| 2 | Linux + Node ≥ 22.13 | node: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 在这个场景有两个优势:
- 标签名即上下文:
<file path="...">比{"type": "file", "path": "..."}更紧凑 - 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,相比工具本身的执行时间可以忽略。