holaOS 桌面回合首字延迟(TTFT)优化实录:从双重 Node 子进程启动到进程内 pi 的演进
holaOS 桌面回合首字延迟(TTFT)优化实录:从双重 Node 子进程启动到进程内 pi 的演进
导读
本文以 holaOS 运行时团队的内部设计文档 runtime/docs/plans/2026-08-13-warm-harness-ttft-init.md 为主线,完整还原一次以"首字时间(TTFT,Time To First Token)"为目标的性能治理全过程:问题量化、根因定位、候选方案权衡、实测裁决,以及最终"进程内 pi"方案的落地与回滚边界。读完本文,你将理解 holaOS 桌面端每轮对话在调用模型之前那约 3 秒固定开销从何而来、为什么"预热 harness 进程池"方案会被实测数据否决、Phase 1 进程内方案如何在不牺牲崩溃隔离与会话恢复能力的前提下把固定初始化从约 2.5 秒压到约 1.25 秒,以及如何在量产环境中为这类高风险的进程拓扑变更设计验证闸门。
背景:为什么 CLI 快而桌面端慢
holaOS 的桌面端每一轮对话,在模型被真正调用之前,都要付出一个相对固定约 2.6~3.2 秒的初始化开销——这个数字与模型速度无关。而同一模型后端的 CLI pi harness 之所以"手起刀落"般快,是因为它在进程内运行 runPi(),启动成本只付一次,而非每轮都付。
从源码拓扑看,问题根因非常清晰:每轮对话要冷启动两个 Node 子进程,各自冷加载一个约 1.1 MB 的 bundle:
api-server(长驻进程)
└─ runner-worker.spawn( node ts-runner.mjs --request-file ) [每轮一次] ← ts_runner_load
└─ ts-runner: 编译计划 + 记忆召回 + sidecar(复用) + composio 预取 ← bootstrap
└─ ts-runner.spawn( node harness-host run-pi --request-stdin ) [每轮一次] ← harness_load
└─ harness-host: runPi → createSession(...) → sendUserMessage → NDJSON 输出 → process.exit() ← session_setup
其中 MCP sidecar 已经实现了跨轮复用(workspace_mcp_sidecar_reused,见 runtime/api-server/src/workspace-mcp-sidecar.ts),但 harness 进程没有。文档给出的原始 [ttft] 遥测(16 个真实桌面回合)按阶段拆解如下:
| 阶段 | 典型值 | 含义 |
|---|---|---|
ts_runner_load |
~270 ms | api-server 启动一个全新的 node dist/ts-runner.mjs,冷加载约 1.1 MB bundle + 解析请求 |
bootstrap |
~960 ms | ts-runner 进程内的请求构建(编译计划、记忆召回、资源重载;MCP sidecar 已复用 → mcp_connect ~1 ms) |
harness_load |
~1,100 ms(峰值 2.3 s) | ts-runner 再启动第二个全新的 node harness-host/dist/index.mjs run-pi,再次冷加载约 1.1 MB bundle |
session_setup |
~715 ms | harness 的 createSession;其中约 95% 是 composio_inline(~675 ms) |
| 固定初始化 | ~2.6–3.2 s | 上述全部,发生在模型调用之前 |
model_ttft |
3.2 s … 54 s | 纯模型侧(Ark)延迟,属于另一个问题 |
且该遥测遵循一个精确恒等式:total_ttft = ts_runner_load + bootstrap + harness_load + session_setup + model_ttft。不过文档也明确指出,这个"总和恒等"是同义反复——各阶段项是从同一组时间戳反推出来的(runtime/api-server/src/runner-worker.ts 第 887–898 行附近),并非独立交叉验证,因此任何单次样本中的数字都应视为"方向性"而非精确值。
候选方案(Levers)与实测裁决
针对这份固定初始化账单,团队排出了若干候选杠杆,按"杠杆 ÷ 风险"排序。随着 2026-08-18 之后的实测推进,方案排序发生了两次重大修正,最终形成"0b → 1 → 重新测量 → 停止"的既定路线。
Lever 0:验证 composio 缓存是否真的命中——答案:命中
每轮对话中,Composio 的 inline 工具列表会被两个进程各自抓取一次:
- ts-runner 的 bootstrap 阶段:
fetchComposioInlineToolRefs,约 773 ms; - harness-host 的会话初始化阶段:
resolveComposioInlineTools,约 675 ms(占session_setup的 95%)。
2026-08-15 提交 2f6d97f 加入了 runtime/harnesses/src/composio-inline-cache.ts——一个文件后端、跨进程的缓存。逐行追踪代码后发现两处抓取命运截然不同:bootstrap 的预取会写入缓存并被 await(await_composio_inline_tools),之后才以相同的 workspace_dir 启动 harness-host,因此 harness 端的那次抓取在每一轮(含第一轮)都必然命中缓存——这是结构性命中,而非概率性命中。实测确认:session_setup 从 692 ms 降到 62 ms,composio_inline 从 653 ms 降到 1.4 ms。
该缓存的实现要点(composio-inline-cache.ts):
- 缓存文件位于
workspaceDir/.holaboss/state/composio-inline-tool-cache.json,按 workspace id 键控,携带原始响应体,两个调用方各自按自己的方式解析; - TTL 默认
DEFAULT_TTL_MS = 15 * 60_000(15 分钟),可用环境变量HB_COMPOSIO_CACHE_TTL_MS覆盖; - 可用
HB_COMPOSIO_CACHE=0|false|off完全关闭(composioInlineCacheEnabled,默认开启,仓库内没有任何地方显式关闭它); clearComposioInlineCache(workspaceDir)为 connect/install 流程提供显式失效钩子。
Lever 0b:接线缓存失效钩子并把 TTL 抬高——已在生产中验证
clearComposioInlineCache 早已导出并有单元测试(composio-inline-cache.test.ts,7 个用例覆盖往返、TTL 边界、跨 workspace 隔离、损坏文件与开关),文档注释也写明它是"connect/install 钩子"——但仓库中没有任何调用点。这意味着 120 秒 TTL 是唯一失效机制,同时造成两个坏结果:新连接的集成最多两分钟内不可见;TTL 不得不保持很短,导致用户思考超过两分钟后,bootstrap 那约 773 ms 的抓取必然 miss。
0b 的做法是把钩子接到 connect/disconnect/install 路径(共 3 个调用点:integration-broker.ts、integrations.ts 两处),随后把 TTL 从 120 秒提到 15 分钟。实测效果显著:bootstrap 从约 960 ms 降到 287 ms,session_setup 从约 715 ms 降到 50 ms(其中 composio_inline 仅 1–3 ms)。文档还留下一个值得验证的预测:若缓存命中完整生效,bootstrap 应进一步趋近 ~500 ms。
Lever 1:在 ts-runner 进程内运行 pi(合并双重启动)——已落地并成为默认
这是最终胜出的方案:不再冷启动第二个 Node 进程,而是在 ts-runner 已运行的 V8 中加载 pi 代码。省下的正是 fork + V8 初始化 + node bootstrap + 模块解析这整套第二进程启动开销。详细的实现与验证闸门见后文 Phase 1 章节。
Lever 2:预热 harness 进程池(镜像 MCP sidecar)——已搁置
计划中的"按活跃会话维护一个长驻 harness-host serve 进程,轮 2+ 同时跳过进程启动与 createSession"方案,在实测面前被否决:其预算本是 harness_load + session_setup ≈ 1.8 秒,而实测 session_setup 只有 62 ms,真正剩下的只有约 1,084 ms 的 harness_load——这恰好是 Phase 1 用低得多的风险就能移除的部分。Phase 2 需要热会话保留、serve 循环、进程池、取消通道、每轮 header 重解析,却只换来比 Phase 1 多约 62 ms,且其注册表无法在"每轮一个进程"的 ts-runner 中实现(进程内去重与空闲 TTL/LRU 定时器都需要常驻所有者)。因此它被搁置(shelved)而非延期(deferred),仅作为记录保留,若未来拓扑重访,其阻断分析依然有用。重启它的两个前提:新样本显示 session_setup 残留足够大,且 Phase 3 的所有权迁移先落地。
Lever 3:把预热查找上移到 api-server(连每轮的 ts-runner 启动也省掉)——仍为可选
目标是 ts_runner_load(~270 ms)加上可上移的 bootstrap(~960 ms 中的大部分),需把编译+记忆步骤缓存/上移出 ts-runner。文档明确:一个预热池必须有常驻所有者来承载进程内去重、空闲 TTL/LRU 定时器和孤儿清扫,而 ts-runner 是每轮进程,一个都承载不了——因此一旦要建池,Phase 3 必须先行。
Phase 1 深度拆解:进程内 pi 的实现与七个阻断点
打包阻断(Blocker 0):依赖方向必须反转
@holaboss/runtime-api-server 并不依赖 @holaboss/runtime-harness-host,而 runPi 位于 runtime/harness-host/src/pi.ts。ts-runner 目前只通过相对导入访问 runtime/harnesses/src/*(api-server 已携带其依赖)。harness-host 的 package.json 额外引入 mcporter、@anthropic-ai/claude-agent-sdk、@earendil-works/pi-ai、web-tree-sitter、tree-sitter-wasms、@napi-rs/canvas、openai——api-server 一个都没有,且 tsup 的 noExternal 只列了 channel-gateway。
更致命的是:harness-host 有 postinstall: node scripts/apply-pi-patches.mjs,只作用于它自己的 node_modules。若从 api-server 解析一份 pi 副本,跑的是未打补丁的代码,"CLI 已验证的保真度"便不成立。因此首选方案是反转依赖:进程内入口点放在 harness-host 内(依赖与补丁都在),api-server 跨包边界调用它。最终实现落在 runtime/harness-host/src/in-process.ts:runPiInProcess 从 harness-host 自己的入口再导出,pi 从 harness-host 自己的 node_modules 解析——正是 postinstall 打补丁的那份安装,做到"一份 pi 安装、一份补丁集"。
六项子进程"免费提供"的隐性保障
设计文档尖锐地指出:崩溃隔离只是"唯一的取舍",实际上子进程还静默提供了六项保障,进程内化时每一项都必须手工重建,否则都是静默失败:
-
terminalEmitted必须如实计算。调试 CLI(scripts/hola.mts 第 560–566 行)硬编码sawEvent: true, terminalEmitted: true。子进程路径用defaultRunHarnessHost通过监听TERMINAL_EVENT_TYPES如实计算(runtime/api-server/src/ts-runner.ts),ts-runner 以它为依据合成失败:第 2803 行if (harnessResult.terminalEmitted) { return; }守卫buildTsRunnerFailureEvent。若照抄 hola 的硬编码,任何非正常终止(OOM、工具内未捕获 rejection、原生 abort)都会被报告为"干净",run 挂到 30 分钟硬超时而非数秒内失败。进程内实现用有序 promise 链记录每个中继事件并同步登记sawEvent/terminalEmitted/lastSequence。 -
终态后的异常不能清空恢复指针。pi 先发出
run_completed,然后在finally中执行maybeCompactSessionOverThreshold()与handle.dispose()(pi.ts 第 4200、4253 行附近)。子进程路径下,压缩/销毁阶段的异常死在子进程里;进程内路径下,一轮成功之后的 finally 抛错会冒泡进 ts-runner 的外层 catch,中继run_failed,进而触发clearWorkspaceHarnessSessionId(ts-runner 第 2216 行附近,对向的是persistWorkspaceHarnessSessionId)——下一轮将新建 pi 会话,用户对话直接丢失。因此runPiInProcess显式区分preTerminalError(中继run_failed)与postTerminalError(仅记录,绝不触碰会话 id)。 -
不能走
plugin.run通用路径。HarnessHostPlugin.run声明为(request: unknown) => Promise<number>(runtime/harnesses/src/types.ts 第 312 行),pi 的实现没有 deps 参数(runtime/harness-host/src/harness-registry.ts 第 14 行),无处传入emitEvent。runPi于是回退到emitRunnerEvent,它直接process.stdout.write(...)——在进程内这就是 ts-runner 自己的 stdout,runner-worker 会把它当成事件流解析,绕过relayTsRunnerEvent,会话 id 持久化失效,且 ts-runner 因未通过自己的emitEvent看到任何事件而报告terminalEmitted: false,再叠加一层假run_failed。结论:要么给run加 deps 缝隙,要么直接调用runPi而让其他 harness 保持冷启动。 -
回合结束的压缩不能被 SIGTERM 杀死。
runner-worker.ts第 834 行在收到任何终态事件的瞬间调用killChildProcess(child, "SIGTERM"),且 runner-worker 不以detached方式启动 ts-runner(第 682 行附近),所以信号只打到 ts-runner 本身。子进程路径下,孙进程里的压缩照常完成;进程内路径下await runPi(...)尚未返回,SIGTERM 一到压缩每轮必死,长会话不再收缩直至爆上下文窗口。解决方案是 SIGTERM/SIGINT 延迟机制:IN_PROCESS_TERMINATION_GRACE_MS(默认 120,000 ms,可用HB_HARNESS_IN_PROCESS_GRACE_MS覆盖)内若回合仍在进行则延迟退出,让压缩在进程内完成;无回合进行时信号行为与从前完全一致。配套测试 runtime/api-server/src/in-process-termination.test.ts 还断言该宽限期必须 ≥ 实测最大压缩时长(MEASURED_MAX_COMPACTION_MS = 26,100 ms)的三倍。 -
60 秒首事件看门狗不能消失。
HARNESS_HOST_FIRST_EVENT_TIMEOUT_MS = 60_000(ts-runner 第 111 行)在defaultRunHarnessHost内武装(第 1989 行附近),对不发任何事件的子进程直接 SIGKILL——其注释点名的正是关键场景:mcporter 传输打开、app 生命周期探测等 bootstrap 步骤无限阻塞。进程内没有可杀的子进程,因此runPiInProcess用AbortController移植了看门狗(firstEventTimeoutMs参数,0 表示禁用):超时则 advisory abort、记录告警,让被卡住的运行可见而非静默挂起,定时器unref()不拖住进程。 -
硬崩溃隔离降级但可接受。子进程路径下 pi 崩溃只带走 harness-host 子进程,ts-runner 存活并合成
run_failed;进程内路径下硬崩溃(如原生 better-sqlite3 段错误)会连带带走 ts-runner——但 ts-runner 本身就是 api-server 的"每轮可抛弃"子进程,api-server 依然隔离,runner-worker 在子进程关闭时报告失败。残留回归仅是"一轮中途硬崩溃会跳过该轮 ts-runner 的回合后事件中继",因此方案默认关闭、A/B 通过后再翻转。
事件缝隙(Blocker 7):同步发射与异步中继的顺序问题
不在原始清单中的第七个阻断点:PiDeps.emitEvent 类型是 => void 且 pi 从不 await 它,但调用方的 emitEvent 是 => Promise<void> 并持久化 harness 会话 id。朴素的 fire-and-forget 会让后到事件的中继抢跑先到事件,并让 runPiInProcess 在持久化完成前返回——会话恢复会以"偶发丢对话"的方式被破坏。实现上(in-process.ts)所有事件进入一条有序 promise 链:逐个 emitEvent、前一个被拒不影响后续(catch 后继续链),返回前 await relayChain 排干整条链,中继失败被记录而非抛向 pi。
打包风险的兜底
若打包构建中 @holaboss/runtime-harness-host 解析失败,动态导入会抛错,路径自动回退到 defaultRunHarnessHost 并告警——发布问题降级为今天的既有行为,而不是烧掉用户的一轮对话。实现上 ts-runner 通过 harnessHostEntryPath() 得到 sibling 构建的入口文件,再 await import(pathToFileURL(entryPath)) 动态加载(ts-runner.ts 第 1988–1991 行):以"按路径解析"取代"声明为依赖",避免 bun 在 runtime 阶段把 harness-host 整棵树再拷贝一份(曾导致 473 MB / 14,734 文件重复、签名 macOS 包 EMFILE),同时保证进程内路径与启动路径使用同一份 harness-host 构建,不会漂移或拿到未打补丁的 pi。
为什么 ts_runner_load 没有按预期增长
计划预测"pi + mcporter + tree-sitter 现在加载进 ts-runner,ts_runner_load 会变大"。实测它没有——因为导入是动态的且在 flag 分支内部:静态导入会把 pi/mcporter/tree-sitter 拉进 ts-runner 每次启动的冷启动路径,等于把 harness_load 挪进 ts_runner_load 而非移除。实测 dist/ts-runner.mjs 仅增长 2.3 KB,并保留裸说明符供运行时解析。
实测闸门:A/B 对比与翻转
方案要求先在打包后的权威 [ttft] 行上做 A/B(它由事件到达 + run_started 推导,独立于实现细节),进程内实现必须填充 harnessSpawnToFirstEventMs / harnessSpawnToFirstTokenMs(否则 [ttft] 行会打出 harness_load_ms=n/a)。
2026-08-19 单机实测(7 次启动回合 vs 5 次进程内回合):
| 阶段 | spawn | in-process | delta |
|---|---|---|---|
ts_runner_load |
253 ms | 223 ms | −30 |
bootstrap |
287 ms | 204 ms | −83 |
harness_load |
1,335 ms | 787 ms | −548 |
session_setup |
50 ms | 47 ms | −3 |
| 固定初始化(我方) | 2,492 ms | 1,251 ms | −1,241 |
文档附了三条诚实限定:① ts_runner_load 并未增长(动态导入的代价落在 harness_load 栏,节省干净地留在该栏);② 前两轮进程内回合是预热(1350、1261,之后稳定在 ~775 ms),冷启动应用的前一两轮仍大致付出启动成本;③ 不要把 total_ttft 的 −1,870 ms 当作收益——model_ttft 在两个样本间也降了 315 ms,那是模型供应商的功劳,可辩护的声明是"我方固定初始化省约 1.2 秒"。
会话恢复被单独验证:因 Blocker 2 与 7 都威胁 harness 会话 id,四轮连续的进程内回合累积进一个 pi 会话文件(137,902 字节),状态指针持续跟踪,而非每轮派生新会话。
残留未观察风险:回合后压缩(Blocker 4)的 SIGTERM 延迟已实现并有单元测试,但只在会话越过压缩阈值时触发,尚未在生产中观测过。若长会话停止收缩,HB_HARNESS_IN_PROCESS=0 即为回滚开关。此外文档对 5 个真实桌面回合的活体重测还揭示两条值得记住的异常:一次全阶段同时 2–5× 膨胀(CPU 争用,非代码路径),以及一次 mcp_connect 3,677 ms 的冷 sidecar 启动(每 sidecar 生命周期一次,中位数里看不见,但撞上就是 3.7 秒等待)。
环境变量速查
| 变量 | 作用 | 默认 |
|---|---|---|
HB_HARNESS_IN_PROCESS |
进程内 pi 开关:1 开启;0 回退子进程 |
已翻转,默认开启(初始上线时默认关闭) |
HB_HARNESS_IN_PROCESS_GRACE_MS |
回合进行中的 SIGTERM/SIGINT 延迟宽限 | 120,000 |
HB_COMPOSIO_CACHE_TTL_MS |
composio inline 工具缓存 TTL | 900,000(15 分钟) |
HB_COMPOSIO_CACHE |
缓存总开关,0/false/off 关闭 |
开启 |
对应源码:harnessInProcessEnabled(ts-runner.ts)与配套测试 harness-in-process-default.test.ts(空值/空白即默认开启,仅显式 0/false/off 关闭);TTL 解析见 composio-inline-cache.ts 的 ttlMs。
未决风险与开放问题
- bootstrap 抓取的实际命中率取决于轮间间隔分布与 TTL 的关系,0b 让 TTL 上升后该问题基本失去意义;
- Phase 1 的净值——更胖的 ts-runner bundle 是否会吃掉
harness_load的收益,必须在翻转默认前实测(已测:净值 +548 ms); clearComposioInlineCache的接线须覆盖每一个变更点而非只覆盖 happy-path connect——漏掉一次 disconnect 会让"看似已连接"的工具在更长 TTL 内滞留于列表;- **非 pi harness(Claude Code / ACP)**保持冷启动,直到完成移植;
- RAM:若未来重建预热池,热会话会放大内存树膨胀,TTL/LRU 上限是承重墙而非可选项,且必须独立于
DEFAULT_MAX_CONCURRENCY单独设值; - 取消/中止:今天的中止即杀子进程,持久连接需要显式取消通道;
- 指纹完整性:宁可多包含字段(假阴性只多一次冷启动),但必须排除每轮身份(
x-holaboss-input-id),否则复用门永远过不去——这是 Phase 2 草案指纹 100% miss 的根因。
结论:一次"测量否决计划"的教科书式过程
这次优化的最终形态与最初计划大相径庭:最诱人的"预热 harness 进程池"被实测数据否决,胜出的是一个低风险、可回滚的进程内合并方案。文档留下的方法论同样值得借鉴——用权威遥测线做 A/B 闸门、把"总和恒等"当作同义反复而非佐证、明确区分可辩护声明与供应商变量。在当前数据下,进程拓扑手术的空间已所剩无几:model_ttft 已占等待的大头,我方固定初始化从 ~3 秒压到 ~1.25 秒后,剩余 ~1.25 秒里不值得再冒险。若未来真有必要继续推进,顺序必须是 Phase 3(所有权上移)先行,再谈任何预热池——这正是"测量优先、谨慎翻转"在运行时性能工程中的完整体现。