- 人工智能
- AI Agent
- AI 应用
- 大模型
- 交互助手
- 工具调用
- Agent 编排
- 桌面应用
【免费下载链接】OpenMausBot
Open-source Grok Bot alternative with a virtual machine that bots can use
OpenMausBot 的本地服务器以 SQLite(node:sqlite)作为消息与事件日志的持久化底座。本文基于仓库验证文档 docs/verification/persistence-performance.md 展开,讲清楚三件事:如何设计一个可复现的合成持久化基准(pnpm bench:persistence)来测量事件循环延迟;如何将/api/search的扫描从同步 SQLite 查询迁移到惰性读只 worker 而不改变任何持久化契约;以及这次改动用哪些测试与真实测量来证明“扫描期间服务器仍然可用”。读完后你将掌握 Node 侧“同步 I/O 阻塞主线程”问题的定位方法、worker 队列边界与 503 可重试语义的实现细节,以及一份可直接复制运行的基准命令集。
定位:并发加固,而非放弃 SQLite
原文档开宗明义:这是一项增量式并发加固(incremental concurrency-hardening)工作,不是从 SQLite 的迁移,也不宣称所有持久化操作都是非阻塞的。其动机来自一个具体的响应性问题:历史消息的全文扫描(lower(text) LIKE ?子串扫描)在主线程同步执行时,会卡住事件循环,使健康检查、SSE 推送等其他 HTTP 处理排队等待。
改动的边界被刻意收窄为:只有/api/search的扫描进入 worker。Agent recall、消息提交(message commits)、事件日志(event logging)以及报表/备份等路径的行为不做隐性变更。这个“只动一处”的范围声明后文会用源码逐一印证。
可复现合成基准:pnpm bench:persistence
基准命令定义在 package.json 中:
pnpm bench:persistence pnpm bench:persistence --worker pnpm bench:persistence --checkpoint-seed --paced pnpm bench:persistence --checkpoint-seed --paced --worker脚本本体是 scripts/bench-persistence.ts,其工作方式与文档描述一致:
数据隔离:每次调用先
mkdtempSync创建临时目录并设置OMB_DATA_DIR,之后再importserver/message-db.ts——文件第 1 行注释明确写着 “Never import config before isolating data”,保证不触碰真实配置与用户数据。种子规模:通过
db.importThread种入 50 个线程,每线程 1,000 条约 1 KB 的消息(共 50,000 行):for (let thread = 0; thread < 50; thread++) { const messages = Array.from({ length: 1000 }, (_, i) => ({ id: `seed-${i}`, at: now + i, role: "user" as const, kind: "text" as const, text })); db.importThread(`thread-${thread}`, messages, "seed-999"); }负载模式:依次测量 1、10、50 个“会话”的突发写入。每个会话写一条消息,每批发布 10 个 canonical text delta 事件(
bus.publish,底层走同步appendFileSync,即真实的 event bus);混合场景还会执行 5 次 absent-query 子串扫描(search布尔交替开/关)。批与批之间通过await yieldLoop()让出事件循环。事件循环测量:使用
monitorEventLoopDelay({ resolution: 2 }),并在同步工作开始之前lag.enable()、结束后lag.disable(),这样最后一笔同步卡顿也能被采样到。脚本只报告最大值——因为空闲态的 setup/drain 采样会稀释百分位数,所以早期诊断用的 p99 被移除,只保留eventLoopMaxMs。不涉及任何真实引擎、provider、账号或用户数据,输出 JSON 中会显式标注
scope: "Real SQLite and event bus, synthetic messages/events. Not model throughput or full HTTP capacity."。
两个可选开关的精确语义
--checkpoint-seed:在计时区之外对 fixture 构造做 checkpoint。实现上打开 scripts/bench-persistence.ts 中的messages.db执行PRAGMA wal_checkpoint(TRUNCATE),然后await delay(1000)。它不改变生产环境的 checkpoint 策略,只是把“种子构造的 I/O 尾巴”排除出被测区间。
--paced:改为 100 个批次、批间 20 ms 间隔,每批每会话 1 个 canonical delta + 1 个 native delta(通过appendNative写入native/目录),每 50 批提交一条消息。搜索仍执行 5 次且被await等待,因此其耗时会延长整个负载——文档特别强调:这测量的是事件循环响应性,不是固定速率下的吞吐量。raw canonical append 的最大值被单独报告(canonicalAppendMaxMs),与整个日志批次的耗时区分开。
对比方法纪律:baseline 与--worker跑的是同一套同步 SQL 搜索实现,worker 模式只是把该查询放进新 worker 执行;SQL 耗时、单操作最大值与事件循环延迟是三个互不替代的度量。比较必须串行、在空闲主机上进行,不要与构建或其他基准并行。两种模式都不注入磁盘争用,也不能证明主机空闲。
2026-09-26 实测数据:worker 释放事件循环,但不加速 SQL
以下结果来自原文档记录的测量环境:macOS arm64、Node 26.7.0、基线d1d6bc701叠加该改动,每组为三次串行 baseline/worker 对比,种子 50,000 条消息。
混合负载(每批 10 个 canonical delta,含 5 次扫描)
| Mixed workload | 三次最大事件循环延迟的中位数,synchronous | Worker |
|---|---|---|
| 1 session | 70.84 ms | 3.22 ms |
| 10 sessions | 73.14 ms | 9.77 ms |
| 50 sessions | 90.11 ms | 25.90 ms |
扫描本身的耗时保持在约 65–85 ms 不变——这次改动让扫描期间服务器“解放”出来了,但没有让 SQL 变快。普通写入的 p95 在该主机上通常为 0.11–0.19 ms。
不隐藏离群值:尾部卡顿依然存在
原文档特意保留了不利数据:50 会话混合负载中,baseline 出现 492.04 ms、worker 出现 292.55 ms 的最大延迟;纯写入突发(未被此补丁触及)也偶发数百毫秒级卡顿。后续归因测量显示 455.55 ms 发生在 10 次事件发布内部;一次带 GC tracing 的运行测得 1,267.99 ms 的单条消息 append 与 417.27 ms 的事件批次,但没有对应的长 major-GC 暂停。结论是:磁盘/OS 层面的可变性仍然重要,这些数字不能证明所有卡顿都源于 SQLite,也不是生产环境回归的证据。
分离 fixture 构造与 paced 负载
三组额外的串行 baseline/worker 对比使用--checkpoint-seed --paced,负载同时包含 native 日志与 canonical 事件:
| Paced mixed workload | 最大事件循环延迟的中位数,synchronous | Worker |
|---|---|---|
| 1 session | 99.81 ms | 3.47 ms |
| 10 sessions | 102.11 ms | 9.04 ms |
| 50 sessions | 107.22 ms | 25.99 ms |
在这六次运行中,write/log 场景的事件循环最大值为 4.97–30.41 ms;最大单条消息 append 为 6.17 ms,最大日志批次 22.64 ms。此前数百毫秒级的卡顿在此 paced/checkpoint 负载下没有复现。文档对此保持克制:pacing 与 seed checkpointing 同时变化,无法单独隔离二者效应,也不能证明先前卡顿只是 setup I/O——但可以强化“搜索是主线程卡顿来源”的结论,且不足以支持“全面重写为异步 writer/logger”。
范围与不变量(Scope and invariants)
原文档列出的约束,逐条都能在源码中找到对应:
- 只有
/api/search扫描进入 worker。Agent recall、消息提交、事件日志、报表/备份路径不变。 - 一个惰性读只 worker;至多 8 个未完成搜索;30 秒 deadline。忙碌/被中断/失败的搜索返回可重试的 503,绝不回退到阻塞调用,也绝不在失败后返回“空的成功”。
- 字面子串匹配、通配符转义、snippet 生成、排序与 thread 作用域共享既有查询实现,无 schema 或数据格式变更。
- 授权与 bot 可见性在异步扫描之后再检查一遍;已删除的会话对照当前 Store 解析。
- 维护/关闭流程会等待 worker 终止;每次扫描开/关自己的读只连接,避免跨 workspace 替换持有陈旧文件 inode。
- 消息提交与 branch-head 事务仍然先于 ack(确认)完成。
源码深潜:worker 化搜索的完整链路
入口:searchMessagesAsync
server/message-db.ts 同时保留同步与异步两个入口:
export function searchMessages(query: string, limit = 40, threadId?: string): SearchHit[] { return searchMessagesInDatabase(db(), query, limit, threadId); } export function searchMessagesAsync(query: string, limit = 40, threadId?: string): Promise<SearchHit[]> { if (!query.trim()) return Promise.resolve([]); db(); // The owner initializes/migrates the schema before any read-only scan. searchWorker ??= new MessageSearchWorker(DB_FILE()); return searchWorker.search(query, limit, threadId); }注意db()这一行:主线程必须先完成 schema 初始化/迁移,worker 才拿到只读文件——worker 永不负责建库或迁移。closeMessageSearch()(server/message-db.ts)在维护替换数据库或关闭释放租约前被await,保证 worker 终止后再动数据库文件。
队列边界与超时:MessageSearchWorker
server/message-search-worker.ts 是核心。关键设计:
search(query: string, limit: number, threadId?: string): Promise<SearchHit[]> { if (this.pending.size >= 8) return Promise.reject(new SearchUnavailable("Search is busy. Try again shortly.")); if (!this.worker) { const worker = new Worker(existsSync(source) ? source : new URL("./message-search.worker.js", import.meta.url), { workerData: { file: this.file }, execArgv: [], // Do not inherit test loaders or inspector ports into the worker. }); ... } const worker = this.worker; worker.ref(); const id = ++this.sequence; return new Promise((resolve, reject) => { const timer = setTimeout(() => { void this.close(); }, this.timeoutMs); // 默认 30_000 ms this.pending.set(id, { resolve, reject, timer }); try { worker.postMessage({ id, query, limit, threadId }); } catch { void this.close(); } }); }要点:
- 惰性启动 +
unref:worker 在第一次搜索时才创建,且execArgv: []防止把测试 loader 或 inspector 端口继承进 worker;pending 清空后worker.unref(),空闲时不阻止进程退出。 - 拒绝在入口发生:第 8+1 个请求在
postMessage之前就以SearchUnavailable(status = 503)被拒绝,没有任何排队等待。 - 超时即销毁:
setTimeout(() => { void this.close(); }, this.timeoutMs)触发close(),它会terminate()worker 并把所有pending 请求以 “Search was interrupted. Try again shortly.” 拒绝——没有部分成功、没有阻塞回退。 - 失败信息防泄漏:worker 出错时只回传
failed: true布尔,因为 “Database errors can contain private paths or query text”(见 server/message-search.worker.ts)。
每次扫描独立的读只连接
worker 端 server/message-search.worker.ts 对每个请求:
db = new DatabaseSync(workerData.file, { readOnly: true }); const hits = searchMessagesInDatabase(db, request.query, request.limit, request.threadId); parentPort!.postMessage({ id: request.id, hits }); // finally: db?.close();顶部注释点明了原因:“Closing after each scan keeps no old database inode open across a workspace restore.”——workspace 备份/恢复可能整体替换messages.db,逐次开关连接保证下一次扫描一定看到当前文件。readOnly: true也意味着 worker 永远无法写库,更无法为缺失的文件创建数据库(后者有专门测试覆盖)。
共享的查询实现:转义、作用域与 snippet
server/message-search-query.ts 是被主线程与 worker 共同调用的单一实现:
const needle = query.trim().toLowerCase(); const pattern = `%${needle.replace(/([\\%_])/g, "\\$1")}%`; const scope = threadId ? "thread_id = ? AND " : ""; const statement = database.prepare( "SELECT thread_id, id, at, role, kind, text, json_extract(json, '$.tool.name') AS tool_name, json_extract(json, '$.from.name') AS from_name FROM messages " + `WHERE ${scope}((kind = 'text' AND text IS NOT NULL AND lower(text) LIKE ? ESCAPE '\\') ` + " OR (kind = 'activity' AND tool_name IS NOT NULL AND lower(tool_name) LIKE ? ESCAPE '\\')) " + "ORDER BY at DESC LIMIT ?", );\\、%、_三个字符被转义并配合ESCAPE '\\',使 “10%_\” 这类查询按字面子串匹配而非通配符;- 命中范围同时覆盖
kind = 'text'的正文与kind = 'activity'的工具名(json_extract); - snippet 截取命中点前 60 字符、命中词后 90 字符,空白折叠,
matchStart/matchLength基于折叠后的 needle 定位,供前端高亮。
HTTP 层:等待之后重新鉴权
server/routes/threads.ts 的GET /api/search处理了这次异步化引入的安全问题:扫描可能在执行期间跨越权限变更。
limit被钳制到 1–100,缺省 40;非everything可见性的 viewer 会请求min(limit * 10, 1_000)条再截断到limit,保证过滤后仍有足够命中;await searchMessagesAsync(...)之后,重新执行resolveRequestAuth与visibleTo检查(“A scan may outlive a revocation or audience edit: authorize again after the await.”),并对threadId再次做 404 判定;- 每个命中再对照
store.activePath标注onActivePath,区分当前分支与分支历史消息; - worker 返回 503 时直接透传可重试错误,不会降级为主线程同步扫描。
验证命令与测试证据
原文档给出的验证命令:
pnpm exec vitest run server/message-search-worker.test.ts server/message-db.test.ts server/message-search.e2e.test.ts server/bot-visibility.e2e.test.ts server/workspace-backup.test.ts pnpm exec vitest run server/index.test.ts -t 'searches transcripts and exports' pnpm test:packaged-server各层测试的覆盖点:
- worker 单测(server/message-search-worker.test.ts):与同步实现的结果对等性(含 “10%_\” 通配符转义、中文、缺失线程)、已提交写入/删除无需重启 worker 即可见、队列边界(第 9 个请求拒绝 503,完成后容量释放)、
closeMessageSearch中断 pending 并换新数据后可见新库、读只打开失败时 503 且不创建缺失数据库、卡死 worker 在短 deadline(1 ms)后整体拒绝。 - 搜索 e2e(server/message-search.e2e.test.ts):使用标准隔离 launcher,仅 seed 其新数据库,通过
control:omb发送两个真实 fake-engine 回合;随后发起 16 个 HTTP 搜索,等待出现队列满 503(证明 8 个已被接纳);再验证健康检查响应先于所有已接纳扫描完成返回(completedAtHealth < completed);最后断言两个 bot 的 settled 回复与按 threadId 作用域的 active-branch 搜索结果均正确,并打印保留的证据 JSON 路径。 - 可见性 e2e(server/bot-visibility.e2e.test.ts):显式 preload 一个测试专用的结果屏障 server/testing/hold-search-result.ts——它 monkey-patch
MessageSearchWorker.prototype.search,在真实 SQLite 结果存在后、HTTP await 恢复前写入.ready文件并阻塞,直到 fixture 删除信号文件。测试借此等待 worker 返回一条私有命中,修改该 bot 的 audience,再放行投递并断言私有结果不逃逸。文件头注释强调它只在隔离 fixture 中通过--import加载,从不被打进产物。原文档还记录了一个负向自检:临时复用 await 之前的可见性集合会使该测试因泄漏私有 “Payroll” 命中而失败,恢复“新鲜集合”后通过——这个临时回归不在改动范围内。 - packaged smoke:在无
node_modules的可发布产物外调用搜索,验证打包路径。
fixture 构造的一个 CI 细节
原文档记录:e2e 中 50,000 行的历史 seed 每 1,000 次插入await setImmediate()一次(server/message-search.e2e.test.ts 的注释 “Slow CI disks can outlast HTTP keep-alive during setup”)。Windows CI 曾因 setup 阻塞暴露ECONNRESET:隔离复现中,bot 创建后阻塞客户端 8 秒即可复现同样错误;把该延迟拆成 50 ms 分块并让出事件循环后,后续 send 正常收敛。此调整只影响 fixture 构造,不改变生产请求、搜索负载、超时或断言,也没有为请求增加重试。
剩余工作(不属于本次搜索专属改动的范围)
原文档最后明确了四条后续方向,均不由本次改动负责:
- 在隔离磁盘争用下归因写入/日志尾部延迟,并测试真实混合 HTTP 流量、长历史 hydration、内存刷新与备份路径。
- 若未来改动日志路径:保留脱敏(redaction)、有界缓冲、失败标记、有序记录、检查/备份 flush 与“删除后不复活”等现有约束。
- 只有在剩余测量证明有必要时才移动消息写入;保持事务/回执/取消的持久化边界与有界队列,不得把 commit 变成 fire-and-forget 写入。
- Admin 需独立测量:分布式 desktop 不共享同一聊天数据库。
文档同时声明:本地 macOS 测量不隐含任何 live 部署结论、provider 加速、fleet 规模承诺,或 Windows/Linux 原生路径已验证。
小结
这份验证文档展示了 OpenMausBot 处理“同步 SQLite 扫描拖垮主线程”问题的完整方法论:范围最小化(只搬/api/search)、行为不变量先行(8 并发上限、30 s deadline、失败必 503、等待后重新鉴权)、可复现合成基准(隔离数据目录 +monitorEventLoopDelay最大值 + 明确的--worker/--checkpoint-seed/--paced开关)、不美化数据的实测表格(离群值全部保留),以及从单测到 e2e 到 packaged smoke 的三层验证。对需要在 Node 侧保留嵌入式数据库但要求服务器持续响应的读者,这套“worker 化只读扫描 + 事件循环直方图”的做法与测试设计是可以直接借鉴的工程范式。
- 人工智能
- AI Agent
- AI 应用
- 大模型
- 交互助手
- 工具调用
- Agent 编排
- 桌面应用
【免费下载链接】OpenMausBot
Open-source Grok Bot alternative with a virtual machine that bots can use
相关推荐
Cycle.js状态持久化实现:响应式应用的状态保存与恢复
Cycle.js状态持久化实现:响应式应用的状态保存与恢复 你是否遇到过这样的困扰:用户在你的Cycle.js应用中输入了大量数据,却因为不小心刷新页面导致所有
前端Web框架PPT Master:从零到原生可编辑 PPTX 的完整实战指南
PPT Master:从零到原生可编辑 PPTX 的完整实战指南 PPT Master 是一套运行在任意具备 Agent 能力的 AI 工具中的 PPT 生成工
AI 技能人工智能AI 应用国内用户 3 分钟装好 Folia 歌词动画播放器:网盘镜像加速下载与 AUR 一键安装指南
国内用户 3 分钟装好 Folia 歌词动画播放器:网盘镜像加速下载与 AUR 一键安装指南 Folia 歌词动画播放器官方安装包国内直连常限速甚至中断。本文给
音视频前端桌面应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考