大量导入时,内存峰值来自多个阶段叠加:sql.js 把 ChatCrystal 数据库保持在内存中,来源适配器还要打开原始文件,保存数据库时又会生成导出副本。不要用“对话条数 × 固定 KB”预测所有机器;先测量自己的来源、消息长度和运行方式。
本文依据固定提交 7642768 的 导入循环、数据库实现、远程导入 和 队列。
先区分常驻内存和瞬时峰值
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,所以每轮都要重新设置,不能只依赖之前保存的单来源配置;见 运行时路径 与 配置加载。
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 内核文档 说明 RSS 的异步记账可能不够精确,精细快照可另用较慢的 smaps。若要归因到数据库导出,需在隔离副本的 saveDatabase() 前后加带时间戳的诊断标记,结合外部采样复测,不能把导入结束日志当作导出开始标记。
远程模式须分别采集上传 CLI 和服务端 PID。本地 Electron 的服务嵌在主进程中,两者共享 PID;另测实际存在的 renderer、GPU 等子进程。连接远程时另测服务端,远程导入 worker 也须单独记录;本地模型单独测量,避免把共享页重复相加。本文未提供这些模式的统一峰值,也没有实测内存数字。如果观测峰值接近可用内存,减少批次、关闭不需要的来源或错开摘要任务,并为系统、其他进程和采样误差留出余量后复测。
分批和恢复策略
先在设置中只启用一个来源完成扫描,确认数据库保存成功,再加入下一来源。本地 CLI 的 --source 当前没有传入导入入口,不能据此保证只扫描一个来源。对 Cursor/Trae 这类 SQLite 来源,可在保留必要元数据的隔离副本中按工作区组织测量;对超长 JSONL,先找出异常大的单文件。导入期间避免同时启动多个共享数据目录的实例。
保留每轮扫描的 JSON/SSE 结果和服务日志,从中核对导入、替换、跳过和错误数;crystal status 只提供当前总量,笔记列表也不能恢复某次导入的四类计数。中断后结合这些记录和会话详情判断是否重跑。文件大小和修改时间相同的已导入记录会跳过;内容变化会计算 hash,必要时替换旧会话并为旧笔记安排向量清理。不要用“队列为空”推断所有摘要和 embedding 都已完成。
必要时正常停止并重启服务以释放进程内对象:
crystal serve stop
crystal serve --daemon
crystal status
恢复后用一条已知对话和一条长对话验收,检查原文、笔记状态和向量状态。性能结论只有在记录样本、配置和测量过程后才有意义。