---
title: 大量对话导入的内存预算：隔离样本测量再分批
description: 解释 sql.js、来源解析、数据库保存和队列如何共同决定导入峰值，用可复现的测量方法安排批次而不是套用固定内存数字。
image: "https://zengliangyi.com/og/blog/conversation-import-memory-budget.png"
---

大量导入时，内存峰值来自多个阶段叠加：sql.js 把 ChatCrystal 数据库保持在内存中，来源适配器还要打开原始文件，保存数据库时又会生成导出副本。不要用“对话条数 × 固定 KB”预测所有机器；先测量自己的来源、消息长度和运行方式。

本文依据固定提交 [`7642768`](https://github.com/ZengLiangYi/ChatCrystal/tree/764276814e5c5a33ecbfeae63754f74b9261a3a8) 的 [导入循环](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/import.ts)、[数据库实现](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/db/index.ts)、[远程导入](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/remoteImport.ts) 和 [队列](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/queue/index.ts)。

## 先区分常驻内存和瞬时峰值

sql.js 初始化时读取数据库文件并在 WASM 数据库中维护它；数据库越大，常驻内存越高。`saveDatabase()` 调用导出，再写入文件，导出期间会出现额外的字节数组和 Buffer。自动保存默认每 30 秒触发，但保存时的峰值与数据库实际大小有关。

`importAll()` 会先收集启用适配器返回的全部元数据，然后逐条解析、事务写入；本地路径不会把所有完整对话一次解析进数组。循环末尾显式保存数据库，运行中的自动保存也可能提前保存一部分；中途终止仍可能丢失上次保存后尚未落盘的数据。`importAll()` 本身不负责自动生成摘要，当前文件监听器也只触发导入；需要另行确认摘要任务是否已提交。实际运行的摘要和向量任务会再占用内存，封面中的 Queue 表示这个可选的后续阶段。

## 不同来源的峰值不同

- Claude Code 和 Codex 的 JSONL 解析器使用流和 readline 逐行读取，但会累积整条会话的解析后消息数组；内存并非只保留一行，单个超长记录还会形成瞬时大对象。

- Cursor 和 Trae 需要用 sql.js 打开来源 SQLite 数据库，解析时 ChatCrystal 数据库和来源数据库会同时存在。

- Copilot 的 JSONL 快照路径只读取快照首行；JSON 文件路径仍需完整读取和解析。

这些是实现路径，不是“任何大小都安全”的承诺。先按来源分别测一次，才能知道哪一类文件主导峰值。

## 队列限制的是处理峰值，不是导入速度保证

通过队列提交的摘要与其他后台任务共用 p-queue，当前并发为 1、每秒最多启动 1 个队列任务；遇到包含 `429` 的错误会退避重试，最多额外重试 3 次。串行执行减少这些任务同时持有的转录和向量对象，但同步 API 和其他进程不受这个队列统一串行化；它不能保证固定完成时间或消除并发写入风险。

远程导入路径先把所有解析后的上传对象收集到数组，再按每批最多 25 条、单条 8 MiB、序列化批次 20 MiB 的限制筛选和分批。传输批次有限，并不意味着收集阶段的内存有限；本地 `importAll()` 的元数据收集和保存逻辑又不同。

## 用隔离样本测量服务端进程

RSS 是进程当前驻留在物理内存中的大小。以下方法限定 Linux、本地 CLI 服务和 Claude Code JSONL，不把 Electron、上传客户端或外置模型的内存混进同一个数值。需要已安装的 `crystal`、Node 24 以上、Python 3 和 curl；确认 13731 端口空闲，把 `/replace/with/sample.jsonl` 换成一份可正常解析的样本副本。保留其中的会话元数据，目录结构为 `claude/sample/session.jsonl`。

`DATA_DIR` 只隔离应用数据库；五个来源路径也须分别指定。脚本将其他来源指向空目录，并在服务启动后只启用 Claude Code。固定版本重启时会重新补齐 enabledSources，所以每轮都要重新设置，不能只依赖之前保存的单来源配置；见 [运行时路径](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/runtime/paths.ts) 与 [配置加载](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/config.ts)。



```bash
set   -e  
  sample_root  =  $(  mktemp   -d  )  
  mkdir   -p   "  $sample_root  /data"   "  $sample_root  /claude/sample"   "  $sample_root  /empty"  
  cp   /replace/with/sample.jsonl   "  $sample_root  /claude/sample/session.jsonl"  
  DATA_DIR  =  "  $sample_root  /data"   CLAUDE_PROJECTS_DIR  =  "  $sample_root  /claude"   \  
    CODEX_SESSIONS_DIR="  $sample_root  /empty"   CURSOR_DATA_DIR="  $sample_root  /empty"   \  
    TRAE_DATA_DIR="  $sample_root  /empty"   COPILOT_DATA_DIR="  $sample_root  /empty"   \  
    CHATCRYSTAL_CLOUD_MODE=  false   CHATCRYSTAL_API_TOKEN=''   \  
    crystal   serve   --port   13731   >  "  $sample_root  /server.log"   2>&1   &  
  server_pid  =  $!  
  trap   'kill -TERM "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true'   EXIT  
  ready  =  false  
  for   attempt   in   $(  seq   1   20  );   do  
    kill   -0   "  $server_pid  "   ||   exit   1  
    if   curl   --fail   --silent   http://127.0.0.1:13731/api/health   >  /dev/null  ;   then  
      ready  =  true  
      break  
    fi  
    sleep   0.5  
  done  
  [   "  $ready  "   =   true   ]   ||   exit   1  
  curl   --fail   --silent   --show-error   --request   POST   \  
    --header   'Content-Type: application/json'   \  
    --data   '{"enabledSources":["claude-code"]}'   \  
    http://127.0.0.1:13731/api/config   >  "  $sample_root  /config.json"  
  python3   -   "  $sample_root  /config.json"   <<  'PYCONFIG'  
  import json, sys  
  assert json.load(open(sys.argv[1]))['success'] is True  
  PYCONFIG  
  ps   -p   "  $server_pid  "   -o   pid=,args=  
  python3   -   "  $server_pid  "   "  $sample_root  /rss.csv"   "  $sample_root  /sampler.ready"   <<  'PYSAMPLE'   &  
  import csv, sys, time  
  from pathlib import Path  
  status = Path('/proc') / sys.argv[1] / 'status'  
  with open(sys.argv[2], 'w', buffering=1) as out:  
      writer = csv.writer(out)  
      writer.writerow(['unix_seconds', 'rss_kib', 'process_peak_kib'])  
      ready = Path(sys.argv[3])  
      while status.exists():  
          try:  
              fields = dict(line.split(':', 1) for line in status.read_text().splitlines() if ':' in line)  
              writer.writerow([time.time(), fields['VmRSS'].split()[0], fields['VmHWM'].split()[0]])  
              out.flush()  
              if not ready.exists():  
                  ready.write_text('sample recorded')  
          except (FileNotFoundError, KeyError, ProcessLookupError):  
              break  
          time.sleep(0.05)  
  PYSAMPLE  
  sampler_pid  =  $!  
  for   attempt   in   $(  seq   1   100  );   do  
    kill   -0   "  $sampler_pid  "   ||   exit   1  
    [   !   -s   "  $sample_root  /sampler.ready"   ]   ||   break  
    sleep   0.05  
  done  
  [   -s   "  $sample_root  /sampler.ready"   ]   ||   exit   1  
  printf   'import_start %s\n'   "$(  date   -Is  )"   >  "  $sample_root  /phases.log"  
  curl   --fail   --silent   --show-error   --request   POST   \  
    http://127.0.0.1:13731/api/import/scan   >  "  $sample_root  /import.json"  
  python3   -   "  $sample_root  /import.json"   <<  'PYRESULT'  
  import json, sys  
  result = json.load(open(sys.argv[1]))  
  assert result['success'] is True  
  print(result['data'])  
  PYRESULT  
  printf   'import_end %s\n'   "$(  date   -Is  )"   >>  "  $sample_root  /phases.log"  
  kill   -TERM   "  $server_pid  "  
  wait   "  $server_pid  "  
  wait   "  $sampler_pid  "  
  trap   -   EXIT  
  python3   -   "  $sample_root  /rss.csv"   <<  'PYVERIFY'  
  import csv, sys  
  rows = list(csv.DictReader(open(sys.argv[1])))  
  assert rows, 'No RSS samples recorded'  
  print('samples:', len(rows), 'max_rss_kib:', max(int(row['rss_kib']) for row in rows))  
  PYVERIFY  
  printf   '%s\n'   "  $sample_root  "
```



确认 `ps` 输出是本轮 `crystal serve` 对应的 Node 进程，保留日志、配置、导入结果、Node/应用版本与样本大小。新数据库可能有内置示例数据，应记录这个基线。每 50 毫秒从进程外采样一次 RSS，单位 KiB；`VmHWM` 是该进程自启动以来的累计 RSS 高水位。更换小、中、大样本时使用新的临时目录和新进程，避免把前一轮基线带入下一轮。

开始/结束标记仅圈定整轮导入，不区分扫描、单文件解析和同步导出的峰值。50 毫秒采样可能漏掉瞬时峰值；高水位能补充进程级峰值，但也不能证明峰值发生在保存阶段。[Linux 内核文档](https://docs.kernel.org/filesystems/proc.html) 说明 RSS 的异步记账可能不够精确，精细快照可另用较慢的 `smaps`。若要归因到数据库导出，需在隔离副本的 `saveDatabase()` 前后加带时间戳的诊断标记，结合外部采样复测，不能把导入结束日志当作导出开始标记。

远程模式须分别采集上传 CLI 和服务端 PID。本地 Electron 的服务嵌在主进程中，两者共享 PID；另测实际存在的 renderer、GPU 等子进程。连接远程时另测服务端，远程导入 worker 也须单独记录；本地模型单独测量，避免把共享页重复相加。本文未提供这些模式的统一峰值，也没有实测内存数字。如果观测峰值接近可用内存，减少批次、关闭不需要的来源或错开摘要任务，并为系统、其他进程和采样误差留出余量后复测。

## 分批和恢复策略

先在设置中只启用一个来源完成扫描，确认数据库保存成功，再加入下一来源。本地 CLI 的 `--source` 当前没有传入导入入口，不能据此保证只扫描一个来源。对 Cursor/Trae 这类 SQLite 来源，可在保留必要元数据的隔离副本中按工作区组织测量；对超长 JSONL，先找出异常大的单文件。导入期间避免同时启动多个共享数据目录的实例。

保留每轮扫描的 JSON／SSE 结果和服务日志，从中核对导入、替换、跳过和错误数；`crystal status` 只提供当前总量，笔记列表也不能恢复某次导入的四类计数。中断后结合这些记录和会话详情判断是否重跑。文件大小和修改时间相同的已导入记录会跳过；内容变化会计算 hash，必要时替换旧会话并为旧笔记安排向量清理。不要用“队列为空”推断所有摘要和 embedding 都已完成。

必要时正常停止并重启服务以释放进程内对象：



```bash
crystal   serve   stop  
  crystal   serve   --daemon  
  crystal   status
```



恢复后用一条已知对话和一条长对话验收，检查原文、笔记状态和向量状态。性能结论只有在记录样本、配置和测量过程后才有意义。

分享：

##  相关文章

[##  AI 对话格式差异：事件、快照与跨库消息定位

对照五个固定适配器，区分 JSONL 容器、事件语义与 SQLite 键布局，用两个内存数据库观察消息索引、孤立数据和未知版本。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/ai-conversation-format-divergence)    [##  AI 对话管理方案：四种存储路径与迁移验收

比较 Claude Code、Cursor、Copilot、Trae 的固定适配器输入，区分原始副本、可读导出与知识库，并用合成清单验证身份和信息保留。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/ai-conversation-management-options)    [##  五种 AI 对话解析器：字段保留与损失边界

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵，检查工具标记、思考文本、顺序与快照覆盖范围。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#TypeScript

#Node.js](/blog/ai-conversation-parser-boundaries)

##  从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录，欢迎交流实现细节。

[访问我的 GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "大量对话导入的内存预算：隔离样本测量再分批",
  "description": "解释 sql.js、来源解析、数据库保存和队列如何共同决定导入峰值，用可复现的测量方法安排批次而不是套用固定内存数字。",
  "url": "https://zengliangyi.com/blog/conversation-import-memory-budget/",
  "image": "https://zengliangyi.com/images/blog/conversation-import-memory-budget.svg",
  "datePublished": "2026-10-03T00:00:00.000Z",
  "dateModified": "2026-10-03T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/blog/conversation-import-memory-budget/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "首页",
      "item": "https://zengliangyi.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "文章",
      "item": "https://zengliangyi.com/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "大量对话导入的内存预算：隔离样本测量再分批",
      "item": "https://zengliangyi.com/blog/conversation-import-memory-budget/"
    }
  ]
}
```
