跳到正文
曾亮懿 ZengLiangYi Blog

大量对话导入的内存预算:隔离样本测量再分批

解释 sql.js、来源解析、数据库保存和队列如何共同决定导入峰值,用可复现的测量方法安排批次而不是套用固定内存数字。

曾亮懿

曾亮懿

阅读约 6 分钟
目录 展开 收起

大量导入时,内存峰值来自多个阶段叠加: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

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

分享:

相关文章

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

对照五个固定适配器,区分 JSONL 容器、事件语义与 SQLite 键布局,用两个内存数据库观察消息索引、孤立数据和未知版本。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #Node.js #SQLite

AI 对话管理方案:四种存储路径与迁移验收

比较 Claude Code、Cursor、Copilot、Trae 的固定适配器输入,区分原始副本、可读导出与知识库,并用合成清单验证身份和信息保留。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #Node.js #SQLite

五种 AI 对话解析器:字段保留与损失边界

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵,检查工具标记、思考文本、顺序与快照覆盖范围。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

从文章到代码

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