Agent 平台

客户机上什么都没有,连运行库都没有

客户机器不一定有 Node、Python、VC++ 运行库,交付物必须自带整个运行环境。解决方案的分层设计与三个隐藏的坑。

客户拿到 U 盘启动时,不能假设其机器已装好任何运行时。纯净 Win10 上双击启动不能直接闪退,这意味着 Node、Python、FFmpeg、VC++ Redistributable 这些依赖全都要打进交付物里。问题不止于此——这个自包含包要在任何盘符运行、处理可选模块的平台差异、防止构建漂移、应对 Windows 的路径长度陷阱。

分层打包:系统层 vs 应用层

离线环保需要预置的东西分成两个层级,放在 U 盘的不同位置。

**系统层(Windows 运行库)**在 prereq/ 目录:

  • WebView2 Evergreen Standalone Installer(约 170 MB)
  • VC++ Redistributable 2015-2022 x64 installer(约 25 MB)
  • vcruntime 四个 DLL 备份(vcruntime140.dll、vcruntime140_1.dll、msvcp140.dll、concrt140.dll,共约 3 MB)

为什么要预置这些?Node 24 依赖 vcruntime140.dllmsvcp140.dll;Python build-standalone 同样依赖;FFmpeg 也需要。纯净 Win10 不保证这些 DLL 存在——试图运行缺依赖的 exe 会直接闪退,没有清晰的错误提示。WebView2 在 Win11 预装,但 Win10 某些纯净版没有,而 Tauri desktop 应用的 GUI 层必须依赖它。

**应用层(运行时)**在 runtime-extra/ 目录:

  • Python 3.11(python-build-standalone,约 50 MB)
  • FFmpeg 7.x static build(约 100 MB)
  • Git 2.46+(MinGit,约 50 MB,可选)
  • Node 24.2.0 保留在 tar.zst 内,不在 runtime-extra(这是打包阶段的历史决策)

应用层为什么单独打包?第一,Node 已经在冷缓存 tar.zst 里;第二,Python 和 FFmpeg 这种大运行时(50-100 MB 级别)分开打包能让冷缓存保持小巧,也便于未来独立更新某个运行时;第三,Git 标记为可选,允许某些配置完全不需要版本控制。

启动序列的检查与回退

launcher.exe 启动时的第一件事是预检——在任何主业务代码执行前,就要确认依赖齐全。

对于 WebView2 和 VC++,检查逻辑走这条路:

  1. 注册表查询是否已装(HKLM 的三处位置)
  2. 若已装,版本号对比(WebView2 要 ≥ 90;VC++ 要满足 manifest 里写的最低版本)
  3. 若版本不足或缺失,尝试系统级安装(需要管理员权限)
  4. 若系统级安装失败(用户拒绝 UAC、或权限不足),回退到 per-user 安装(WebView2)或 app-local DLL 旁置(VC++)

这个分支设计背后的约束是客户的权限矩阵不可控。家长控制的账户可能完全无管理员权限,硬要等系统级安装会卡住。允许 per-user 和 app-local 回退意味着代码路径多一倍,但这是在异构客户环境下的必要权衡。

对于应用层运行时(Python/FFmpeg/Git),检查轻得多:每个可执行文件存在否、sha256 是否匹配、版本命令输出是否符合预期。缺失 required 的运行时是致命错误;缺失 optional 的(如 Git)则记录降级状态,允许启动继续,只是后续依赖 Git 的 skill 会被标红。

检测 VC++ 运行库的程序,自己不能依赖 VC++ 运行库

launcher.exe 的职责之一是检测并安装 VC++ 运行库。但如果它自己用默认方式编译,二进制就动态链接了那套运行库——在一台从没装过 VC++ 的机器上,launcher 连启动都做不到,更谈不上去装。

这是个自举悖论:负责解决依赖缺失的程序,自己不能有那个依赖。

解法是 launcher 用 +crt-static 编译,把 C 运行时静态链进二进制。代价是体积变大,换来的是它在任何一台干净的 Windows 上都能起来。这个决定必须在项目最早期做——等到发现问题时再改编译方式,前面所有的构建产物都要重来。

退出码 0 不等于装上了

安装器返回 0,通常意味着装好了。这套流程里不能这么认。

装完必须重读注册表确认。原因是杀毒软件可能拦掉了注册表写入,而安装器进程自己仍然正常退出、返回 0。只信退出码,就会得到一个"装成功了但检测不到"的状态,然后下一个 stage 在莫名其妙的地方失败。

还有一个更细的坑:这里说的退出码,必须是通过 GetExitCodeProcess 取到的真正 Win32 子进程退出码,不是 ShellExecuteW 那个 HINSTANCE-like 的返回值。后者只反映"这个进程能不能被启动起来",不反映它干成了什么。把这两个混为一谈,会得到一个永远成功的安装流程——所有安装都"成功",因为进程确实都启动了。

第三条规则和版本有关:如果检测到已装的 WebView2 版本过低,不重装

装新版有覆盖客户机上其他应用所依赖的 WebView2 引用的风险。那不是我的软件该替客户承担的副作用——为了让自己能跑,去动客户机上别的软件的运行时,越界了。这种情况直接走降级路径:禁用 GUI、保留 CLI 可用、记一条 version_too_old 事件,把决定权交回给客户。

每次启动都全量校验 170MB,是不行的

U 盘会经手客户,预置资产必须防篡改,手段是 sha256 加 ed25519 签名链。但资产有 170MB 以上,而预检阶段的验收要求是 100ms 以内——每次启动全量 hash 一遍,这个指标直接就废了。

所以校验分成两层,按"什么时候真的需要这个保证"来切:

Layer 1,每次启动必做,只做毫秒级的事。 定位 prereq/ 目录(取 current_exe() 的同级)、验 manifest 的 ed25519 签名、然后按 manifest 里的 size 字段把文件 stat 一遍。签名保证清单本身没被改过,size 对比能抓出明显的损坏和替换。

Layer 2,只在真要用某个资产之前做。 比如已经确定要装 WebView2 了,才对那个安装器文件算完整 hash。单次几秒,但一次启动里最多发生一两回。

这个划分的前提是信任链的方向:Layer 1 验的是"清单可信",Layer 2 才验"文件与清单一致"。顺序反过来就没意义了——拿一份可能已被篡改的清单去校验文件,校验通过也说明不了任何事。

同理,注册表检测也是每次启动都查,不写 sentinel 文件缓存结果。查一次注册表 50ms 以内,而 sentinel 会在客户手动卸载了运行库之后继续谎报"已装"。用便宜的实时检测换掉一个会说谎的缓存,这笔交易划得来。

manifest 与版本管理

U 盘根的 prereq/runtime-extra/ 各自配套一个 manifest 文件(JSON 格式),记录:

  • 每个组件的版本号
  • sha256 校验值(抽样,不是全文件 hash)
  • 最低版本要求(用于判断已装的版本是否满足)

为什么版本号要写进 manifest 而不是硬编码在代码里?因为这样可以让 U 盘中的系统层或应用层独立升级。比如 Microsoft 每季度推一个新的 WebView2 stable,打包脚本只需重新跑 download-webview2-installer.ps1,更新 manifest,重新签名,就能用新版本。不用重新编译 launcher.exe。

VC++ Redistributable 的版本策略更细:manifest 里不只记录当前版本,还记录 min_runtime_minor。因为 VC++ 14.x 系列内部版本(minor)是向后兼容的,14.44 的 DLL 可以被 14.50 替代。所以检测逻辑是"(major > min_major) OR (major == min_major AND minor >= min_minor)"。这避免了非必要的重装。但同时也意味着不能随意支持太老的版本——14.40 已于 2026-01-13 结束支持,manifest 里的 min_runtime_minor 需要设到 44 或更高,主动排除 EOL 版本的客户机。

Python 和 FFmpeg 不需要这么细的版本对标。launcher 启动时只用 version 命令验证前缀("Python 3.11." 或 "ffmpeg version 7."),足以说明大版本号匹配。

网络视角的隔离

这套方案的一个隐含的架构优势是运行时完全网络隔离。打包阶段在某个受控环境里把所有东西下下来、验证、签名;之后 U 盘交付出去,启动时完全不需要访问外网。这对政企客户特别有价值——某些机房里网络受严格管制,根本出不了外网。传统的"运行时现场下载"方案在这种环境里就彻底废了。

当然代价是 U 盘 size 不能太小。系统层 + 应用层合计约 200+ MB(不含 Node 的 tar.zst)。这对 U 盘不是问题,但对某些极老的机器(USB 3.0 普及前)读取速度会有感知。

自检与降级

launcher 做完预检后,启动过程还没有真正开始。此时会把检查结果写入 runtime-info.json,供后续 desktop-app 查询。如果某个 required 的运行时缺失或损坏,launcher 弹错误对话框直接退出。如果是 optional 的(目前只有 Git),就标记为 degraded,继续启动,然后 desktop-app 的技能管理页会给依赖该运行时的 skill 加个"环境受限"的灰色标记。

这个分层的降级策略源于一个现实:VC++ 缺失是致命的(几乎所有运行时都要它),但 Git 就不是——大多数 skill 完全不需要版本控制。对这两类依赖差别对待,能在保证基本可用性的前提下,最大化客户的体验。

离线预置的本质是把一个通常由宿主操作系统承担的职责下推到交付物本身。代价不只是包体积——更麻烦的是,操作系统原本替你处理的那些边界情况,现在全都归你了:权限不够怎么办、安装器撒谎怎么办、客户机上已有的版本比你需要的旧怎么办、而它又被别的软件依赖着。

这些分支里没有一个是"技术难题",它们只是琐碎、且必须逐个想清楚。写下这套设计的时候客户机还没到手,上面每一条规则都是预设的失败模式,还不是踩过的坑。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。