本文面向:需要一次性导入数百甚至上千条对话、关注内存占用和导入性能的 ChatCrystal 用户。 预计阅读时间:9 分钟
问题
当你积累了几个月的 Claude Code、Cursor 或 Codex 对话,首次使用 ChatCrystal 时需要一次性导入大量数据。这时候你可能会观察到:
- 服务进程内存占用迅速攀升,从几百 MB 涨到数 GB
- 导入过程中系统变得卡顿
- Electron 版窗口响应变慢,甚至出现无响应提示
- 导入后期速度明显下降
这些问题的根源不是 bug,而是 ChatCrystal 底层数据库和各数据源解析方式的固有特性。理解这些特性,才能有针对性地优化。
sql.js:整个数据库在内存中
ChatCrystal 使用 sql.js 作为数据库引擎。sql.js 是 SQLite 的 WebAssembly 移植版本,它的核心特点是:整个数据库文件会被加载到 JavaScript 堆内存中。
初始化时的流程是这样的:
typescript
// server/src/db/index.ts
const buffer = readFileSync(DB_PATH);
db = new SQL.Database(buffer);
readFileSync 把整个 .db 文件读成 Buffer,然后传给 new SQL.Database(buffer),sql.js 会在 WASM 内存中复制一份完整的数据库。这意味着你的数据库文件有多大,初始化时就会占用至少两倍的内存(一份 Node.js Buffer,一份 WASM 内存)。
更关键的是,sql.js 不像原生 SQLite 那样按需加载页面。所有的表、索引、数据行都常驻内存。每执行一次 INSERT,数据直接写入内存中的数据库结构,不会自动释放。
对导入场景来说,这意味着:你导入的每一条对话、每一条消息,都会实时反映在内存占用上。
内存估算
根据实际数据,一条普通的 Claude Code 对话(约 20 条消息)在数据库中大约占用 50-100 KB。1000 条对话就是 50-100 MB 的数据库体积,加上 sql.js 的内存开销,实际占用可能是这个数字的 2-3 倍。
流式解析 vs 全量加载
ChatCrystal 的五个数据源适配器在内存使用上有显著差异。
流式解析:Claude Code 和 Codex
Claude Code 和 Codex 的适配器使用 createReadStream + readline 逐行读取 JSONL 文件:
typescript
// 典型的流式解析模式
const stream = createReadStream(filePath, { encoding: 'utf-8' });
const rl = readline.createInterface({ input: stream });
for await (const line of rl) {
const entry = JSON.parse(line);
// 逐行处理,不需要把整个文件加载到内存
}
这种模式对内存友好。无论 JSONL 文件有多大(有些 Claude Code 对话可以到几 MB),同一时间只有一行在内存中。解析完一条对话后,对话内容被写入数据库,原始 JSON 字符串可以被垃圾回收。
全量加载:Cursor 和 Trae
Cursor 和 Trae 的适配器要面对的是 SQLite 数据库文件(state.vscdb)。它们必须先用 sql.js 打开整个 vscdb,然后通过 SQL 查询提取消息数据。这意味着源数据也会完整加载到内存中。
如果你的 Cursor 工作区有大量对话,vscdb 文件可能有几十 MB。再加上 ChatCrystal 自己的数据库,内存中同时存在两个 SQLite 实例,占用会翻倍。
最小读取:Copilot
Copilot 的 JSONL 适配器设计最省:它只读取 JSONL 文件的第一行(快照数据),而不是逐行扫描全部内容。.json 格式的对话文件则完整读取。这对内存几乎没有任何额外压力。
队列限速:为什么每秒只处理一个任务
ChatCrystal 的任务队列使用 p-queue,并发参数设置得非常保守:
typescript
// server/src/queue/index.ts
export const summarizeQueue = new PQueue({
concurrency: 1, // 同一时间只执行一个任务
intervalCap: 1, // 每个时间窗口最多放一个任务
interval: 1000, // 时间窗口 1 秒
carryoverConcurrencyCount: true,
});
这不是性能缺陷,而是有意为之的设计。原因有三:
1. 保护 LLM API 配额。 摘要和 embedding 生成都依赖外部 API(Ollama、OpenAI 等)。大多数 API 有速率限制,过快的请求会触发 429 错误。队列内置了指数退避重试:遇到 429 时等待 1000 * 2^attempt 毫秒后重试,最多 3 次。
2. 避免内存峰值。 如果同时处理 10 条对话的摘要,每条对话的转录文本(最多 32000 字符)都需要在内存中同时存在。10 条就是 320 KB 的纯文本,加上 LLM 响应和 JSON 解析的开销,峰值会更高。
3. 保护 vectra 索引一致性。 embedding 写入使用 beginUpdate() / endUpdate() 事务模式,并发写入可能导致索引损坏。concurrency=1 从根本上避免了这个问题。
批量导入的实际流程
理解 importAll() 的工作方式,有助于预判内存行为:
typescript
// server/src/services/import.ts --- 简化后的核心逻辑
for (const meta of allMetas) {
// 1. 去重检查:查数据库看是否已导入且未变更
const existing = db.exec(
"SELECT file_size, file_mtime FROM conversations WHERE id = ? AND source = ?",
[meta.id, meta.source]
);
// 2. 跳过未变更的对话
if (existing.length > 0 && sameSize && sameMtime) {
continue;
}
// 3. 解析对话(此时产生内存峰值)
const parsed = await adapter.parse(meta);
// 4. 写入数据库(事务保护)
withTransaction(db, () => {
insertConversation(db, parsed, meta);
insertMessages(db, parsed);
});
}
// 5. 全部完成后持久化到磁盘
saveDatabase();
关键观察:
- 逐条处理,不是批量加载。 循环内每次只解析一条对话,写入后才处理下一条。这意味着内存中同一时间只有一条解析中的对话数据。
- 去重基于文件大小和修改时间。 重复导入不会产生额外开销,已导入且未变更的对话会被直接跳过。
- 最后才持久化。 整个导入过程结束后才调用
saveDatabase(),将内存中的数据库写入磁盘。这意味着如果导入过程中断,本次导入的数据会丢失。
自动保存机制
sql.js 的 saveDatabase() 会调用 db.export(),生成一个包含完整数据库内容的 Uint8Array,然后写入磁盘文件。
typescript
// server/src/db/index.ts
export function startAutoSave(intervalMs = 30_000): void {
saveInterval = setInterval(() => saveDatabase(), intervalMs);
}
自动保存间隔为 30 秒。每次保存时,db.export() 会在内存中生成一份数据库的完整副本(Uint8Array),然后转成 Buffer 写入文件。在保存的那一瞬间,内存中会有三份数据库数据:
- sql.js WASM 内存中的数据库(常态)
db.export()产生的Uint8Array(短暂)Buffer.from(data)产生的 Node.js Buffer(短暂)
对于大型数据库,这个瞬间峰值可能相当可观。如果你的数据库文件有 200 MB,保存时的瞬时额外内存占用约 400 MB。
文件监听的防抖设计
ChatCrystal 使用 chokidar 监听所有数据源目录。文件监听本身不消耗多少内存,但防抖机制影响导入的触发时机:
typescript
// server/src/watcher/index.ts
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
function debouncedImport() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(runImport, 3000);
}
3 秒防抖意味着:当 Claude Code 在短时间内连续创建多个对话文件时(比如你在多个项目间快速切换),监听器会等到所有文件变更停止 3 秒后才触发一次导入。这避免了频繁的小批量导入,但也意味着第一次导入时可能会累积大量待处理文件。
此外,还有一个运行锁机制:
typescript
let isImporting = false;
let pendingImport = false;
async function runImport() {
if (isImporting) {
pendingImport = true; // 排队等待
return;
}
isImporting = true;
try {
await importAll();
} finally {
isImporting = false;
if (pendingImport) {
pendingImport = false;
setTimeout(runImport, 2000); // 等 2 秒再处理排队的导入
}
}
}
isImporting 锁确保同一时间只有一个导入任务在执行。如果你在导入过程中又触发了文件变更,新的导入会被排队,等当前导入完成后再执行。
实际建议
多少条对话需要关注内存
以下数据基于 Claude Code 对话(平均每条 20 条消息),实际值会因对话长度和消息内容而异:
| 对话数量 | 预估数据库大小 | 预估内存占用 | 是否需要关注 |
|---|---|---|---|
| < 100 | < 10 MB | < 100 MB | 不需要 |
| 100-500 | 10-50 MB | 100-300 MB | 关注即可 |
| 500-1000 | 50-100 MB | 300-600 MB | 建议优化 |
| > 1000 | > 100 MB | > 600 MB | 必须优化 |
优化策略
1. 分批导入。 不要一次性扫描所有数据源。在设置页面中临时禁用不需要的数据源,先导入最重要的一个,等摘要和 embedding 生成完毕后再导入下一个。
bash
# 先看当前有哪些对话
crystal status
# 手动触发导入(不会触发全部数据源)
crystal import --source claude-code
2. 让摘要和 embedding 自然消化。 导入完成后,摘要和 embedding 生成会自动排队。不要手动触发 crystal summarize --all,让队列按 1 秒一个任务的节奏自然处理。1000 条对话大约需要 17 分钟完成摘要,之后还需要 embedding 生成时间。
3. 监控队列状态。 导入后关注队列进度:
bash
crystal status
# 观察 queue 部分,确认任务在正常消费
如果队列中积累了大量任务,耐心等待即可。p-queue 的并发限制确保系统不会过载。
4. 关注 Cursor/Trae 数据源的体积。 如果你的 Cursor 或 Trae 工作区积累了大量对话,vscdb 文件可能很大。这些数据源在解析时会额外占用一个 sql.js 实例的内存。如果内存紧张,可以优先处理其他数据源。
5. 给 Electron 版留足内存。 Electron 版的内存包含 Chromium 渲染进程和 Node.js 主进程。如果同时运行大量导入和摘要任务,建议系统至少有 4 GB 可用内存。如果 Electron 窗口无响应,检查任务管理器中的内存占用。
6. 重启可以释放内存。 sql.js 不会自动压缩内存。长时间运行后,即使数据没有增加,内存占用也可能因为 JavaScript 堆碎片而偏高。如果内存占用异常,重启服务可以重置状态:
bash
crystal serve stop
crystal serve
导入后的行为
导入完成后,每条对话的状态会变为 imported,随后进入两个自动处理阶段:
- 摘要生成 --- 通过 LLM 提取标题、摘要、关键结论和代码片段,生成笔记。队列并发 1,每秒最多处理 1 条。
- Embedding 生成 --- 对笔记内容分块(每块 500 字符),调用 Embedding 模型生成向量,写入 vectra 索引。同样队列并发 1。
这两个阶段都会占用额外内存:摘要阶段需要 LLM 的上下文窗口,embedding 阶段需要维护 vectra 索引。好消息是 concurrency=1 确保了同一时间只有一个任务在运行,内存峰值是可控的。
排查流程总结
css
导入时内存暴涨
↓
crystal status → 查看对话总数和数据库大小
↓
> 500 条?
├── 是 → 分批导入,一次只启用一个数据源
│ ↓
│ 导入完成后等待队列自然消化
│ ↓
│ 内存仍然过高?
│ ├── Cursor/Trae 数据源过大?→ 暂时禁用,减少同时加载的 sql.js 实例
│ └── 自动保存峰值?→ 30 秒后自动回落,属正常现象
└── 否 → 正常范围,无需干预
下一步
- vectra 向量索引文件损坏怎么办 --- 导入过程中断可能导致索引损坏
- Ollama 本地部署:零成本跑通全流程 --- 本地模型可以避免 API 速率限制
- LLM 和 Embedding 不能混用 --- 确保 Embedding 模型配置正确