跳到正文
曾亮懿 ZengLiangYi Blog

批量导入 1000 条对话:成本拆分与性能验收

从固定导入链路区分解析、事务、WASM 导出与摘要队列,并用临时合成输入检查流式保留范围与数据等价性。

曾亮懿

曾亮懿

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

批量导入的成本来自多个阶段:发现来源、读取和解析、归一化、SQL 写入、数据库导出;摘要、嵌入和关系发现又是另一条链路。先分开计时,才能知道该减少解析对象、导出次数,还是模型请求。本文保留“1000 条”作为合成样本规模,不把原稿的个人机器耗时、OOM 和优化倍数当作已验证结果。

固定实现已经做了什么

server/src/services/import.ts 先对启用的适配器依次 detect/scan,把全部元数据收集到 allMetas,然后逐条检查、解析和写入。大小与修改时间相同可在解析前跳过;元数据变化但内容哈希相同只更新元数据。它不会因为已经记录 parserVersion 就强制重解析,见适配器内部链路。

每篇解析结果仍是完整 messages 数组。写入用单篇事务和预编译语句插入消息;替换会失效旧的自动笔记并安排向量清理,同时保留符合条件的用户编辑笔记或拒绝状态。批次结束调用 saveDatabase。server/src/db/index.ts 的自动保存仍由服务启动,没有在 importAll 中临时关闭;“每条都导出、导入时自动停保存、已经流式 parseAndInsert”都不是这份提交的行为。

导入返回 imported、replaced、skipped、errors 以及具体 ID 和 summarizationCandidateIds。候选 ID 不是已完成摘要。server/src/services/summarize.ts 才会执行经验筛选、摘要、嵌入和关系发现;摘要可被 filtered,嵌入失败也不一定使摘要失败。

给各阶段定义可核对的成本

阶段 同时记录 先验证的条件
detect/scan 来源、文件数、元数据数、耗时 可见文件与预期来源一致
parse/hash 消息数、输入字节、最大单篇大小 内容字段和顺序保持一致
SQL/事务 新增、替换、跳过、失败 ID 失败篇不留半条会话
export/save 导出字节、调用次数、重开结果 内存提交与文件落盘分开验证
摘要/嵌入/关系 入队与最终状态、请求数、错误 队列收据不等于完成或质量

Node 的 memoryUsage 文档区分 RSS、V8 heap 和 external;arrayBuffers 已包含在 external 中,不要重复相加。定时采样可能漏掉同步 export 的短峰值,末尾快照更不是整次峰值。记录采样方法、Node/依赖版本、硬件、样本和冷暖缓存条件;不要用对话数量推导固定内存门槛。

用 1000 条合成输入比较保留范围

在独立目录用 Node 24 保存下面文件为 performance.mjs,先运行 npm install --ignore-scripts --save-exact sql.js@1.14.2,再运行 node performance.mjs。程序只创建临时归一化 JSONL 和独立 WASM 数据库:一条路径保留全部输入对象,另一条用 readline 逐行插入;两者都是每篇事务、每篇预编译消息语句、最后一次导出并重开检查。

它比较教学输入的对象保留范围和最终数据,不运行 ChatCrystal 的 detect/scan、厂商解析器、自动保存或模型;更不表示当前适配器已经支持边读边插入。计数中的 1000 与 1 是显式保留的归一化对象数,不是缓冲区大小、整个数据库内存或进程峰值。计时及内存快照会随机器和运行顺序变化。

import assert from 'node:assert/strict';
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createInterface } from 'node:readline';
import { performance } from 'node:perf_hooks';
import initSqlJs from 'sql.js';

const SQL = await initSqlJs();
const dir = await mkdtemp(join(tmpdir(), 'import-fixture-'));
const file = join(dir, 'normalized.jsonl');
const count = 1000;
const rows = Array.from({ length: count }, (_, i) => ({
  id: `fixture-${i}`,
  messages: [
    { role: 'user', content: `question ${i}` },
    { role: 'assistant', content: `answer ${i}` },
  ],
}));
await writeFile(file, rows.map(JSON.stringify).join('\n') + '\n');
rows.length = 0;

async function run(retainAll) {
  const db = new SQL.Database();
  db.run(
    'CREATE TABLE messages (conversation TEXT, position INTEGER, role TEXT, content TEXT, PRIMARY KEY (conversation, position))'
  );
  let peakRetained = 0;
  let imported = 0;
  const started = performance.now();
  const insert = (item) => {
    assert.equal(item.messages.length, 2);
    db.run('BEGIN');
    const stmt = db.prepare('INSERT INTO messages VALUES (?, ?, ?, ?)');
    try {
      item.messages.forEach((m, i) => stmt.run([item.id, i, m.role, m.content]));
      db.run('COMMIT');
      imported++;
    } catch (error) {
      db.run('ROLLBACK');
      throw error;
    } finally {
      stmt.free();
    }
  };
  try {
    if (retainAll) {
      const items = (await readFile(file, 'utf8')).trim().split('\n').map(JSON.parse);
      peakRetained = items.length;
      items.forEach(insert);
    } else {
      const lines = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
      try {
        for await (const line of lines) {
          if (!line.trim()) continue;
          peakRetained = Math.max(peakRetained, 1);
          insert(JSON.parse(line));
        }
      } finally {
        lines.close();
      }
    }
    const values = db.exec(
      'SELECT conversation, position, role, content FROM messages ORDER BY conversation, position'
    )[0].values;
    assert.equal(imported, count);
    assert.equal(values.length, count * 2);
    const bytes = db.export();
    const reopened = new SQL.Database(bytes);
    try {
      assert.equal(reopened.exec('SELECT count(*) FROM messages')[0].values[0][0], count * 2);
    } finally {
      reopened.close();
    }
    return {
      values,
      peakRetained,
      elapsedMs: performance.now() - started,
      exportedBytes: bytes.length,
      memory: process.memoryUsage(),
    };
  } finally {
    db.close();
  }
}

try {
  const retained = await run(true);
  const streamed = await run(false);
  assert.deepEqual(streamed.values, retained.values);
  assert.equal(retained.peakRetained, count);
  assert.equal(streamed.peakRetained, 1);
  const { values: a, ...first } = retained;
  const { values: b, ...second } = streamed;
  console.log(
    JSON.stringify(
      {
        scope: 'synthetic normalized JSONL; not ChatCrystal importAll',
        retained: first,
        streamed: second,
      },
      null,
      2
    )
  );
} finally {
  await rm(dir, { recursive: true, force: true });
}

优化前先让验收失败一次

将逐行路径的 peakRetained 改成 count,保留范围断言应失败;只插入 user 消息,消息数量和等价性验收应失败。恢复代码再运行。程序输出 elapsedMs 和末尾 memory,仅作本次观察;两条路径在同一进程顺序执行,不能据此宣布速度或 RSS 获胜。

对真实导入另做故障样本:坏 JSON、超长单篇、重复/改写内容、不可写输出和落盘重开。流式读取减少某类输入对象,不会让 WASM 数据库变成外存数据库。先保证事务、内容哈希、替换语义和可恢复性,再决定是否值得增加异步流式接口,参见WASM 持久化边界。

摘要队列和监听器分别验收

server/src/queue/index.ts 的并发为 1,intervalCap 为 1、interval 为 1000ms;这是队列任务启动节奏,不是所有 Provider HTTP 请求的统一限速。内部 SDK 重试和任务重试可能增加请求;外层只在错误字符串包含 429 且仍有机会时指数等待,默认最多四次任务尝试。不能用入队数估算真实请求数或完成时间。

server/src/watcher/index.ts 在文件稳定等待之外再防抖 3 秒,同一 watcher 内已有导入时置 pending,结束后延迟重跑。该布尔量属于 watcher,不是所有 HTTP/CLI 导入共享的全局锁;云模式由 watcherPolicy 禁止监听。批量测试应固定入口并观察任务状态,避免把文件事件、解析次数和模型请求混成一个吞吐数字。

分享:

相关文章

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

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

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

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

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

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

sql.js WASM 持久化:内存、导出、外键与保存点

对照 ChatCrystal 的数据库生命周期,运行 sql.js 1.14.2 样本,区分 SQL 提交、二进制导出、文件写入、外键恢复和嵌套回滚。

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

从文章到代码

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