一个 “Ne.async is not a function” 报错,牵出 AI 助手的整条版本链
一次从桌面版迁移到 npx 官方 CLI 的实战复盘。全程由 AI 编程助手协助排查,最终一句报错串起了一整条”版本错位 → 换引擎 → 老系统踩坑 → 可回滚迁移”的链路。
一、一个点开就崩的面板
前几天我用 DeepSeek Harness 干活,顺手点了一下侧边栏的「本会话费用明细」,想看看到底烧了多少 token。
结果页面白屏,控制台甩出来一句:
1 | Ne.async is not a function |
诡异的是:除了费用面板,其他功能全都好好的。插件能挂载、会话能跑、记忆能用,唯独这个统计页一碰就崩。
更让我心虚的是——在这之前,我其实已经收到过「风险级依赖不匹配」的警告,只是没当回事。 结果它就用这种方式找上门来了。
二、报错本身:一段”点开才加载”的代码
先看这个报错长什么样,再往下挖。
Ne.async 里的 Ne 不是什么神秘变量,它是打包工具把模块加载器 require 压缩后的短名。原代码长这样:
1 | require.async("./client.statistics.js") |
也就是说,费用统计插件(dsh-cost-meter)把「计费统计 / 价格 / 用量 / 会话费用明细」这些子页做成了代码分割(code splitting),拆成 14 个 client.*.js 分块文件。平时不加载,等你点开子页时才按需懒加载。
这本来是个很正常的优化——直到加载器里根本没有 async 这个方法。
require.async() 是 DSH 客户端模块加载器在 0.2.x 才新增的接口。 在 0.1.x 里,异步加载走的是另一个 loader.import(),传给插件的那个 require 函数压根没有 .async 属性。所以代码一执行到 Ne.async(...),立刻抛 TypeError,子页白屏。
一句话:插件在调一个”更高版本才存在”的 API,而宿主没提供。
三、真正的根因:版本错位
那为什么插件会调一个不存在的 API?答案藏在它的 package.json 里:
1 | "engines": { "dsh": ">=0.2.0-rc.1 <0.3.0-0" } |
插件的 README 也明明白白写着:Host floor: DSH 0.2.0、no longer supports any DSH 0.1.x。
而我这台桌面 App 内置的核心,是 @deepseek-ai/dsh@0.1.1-rc.2。
插件要求 ≥0.2.0,宿主实际是 0.1.1-rc.2。 这就是那批「风险级依赖不匹配」警告的来源——准确说是 11 条 belowMin(声明的版本下界高于实际解析到的版本)。
三个社区插件(上下文、费用统计、技能管理器)的 peerDependencies 都声明了 >=0.2.0-rc.x 的下界,但它们最终解析到的,是宿主的 0.1.1-rc.2。于是插件市场把它们标记成了 soft-incompatible。
四、一个关键认知:软警告 ≠ 无害
这是整件事里我最想强调的一点。
一开始,AI 助手给这批警告的定性是:「软警告,插件运行正常」。从服务端看,这个判断没错——插件 exit=0 hot=true,热挂载 live,加载器成功注册了它,没有任何启动失败。
但这次暴露了它在客户端的真实破坏:费用面板里所有懒加载子页,点开就崩。
所以「软」这个字,只代表”不阻止你安装、不阻止启动”,不代表”运行时不会炸”。
尤其像这种「跨版本边界调用新 API」的场景,服务端启动时压根不会走到那段懒加载代码,所以看着一切正常;一旦客户端触发懒加载,藏在分块里的 require.async 一执行,就现原形了。
排查这类”点开某个页面才崩”的报错,脑子里要有一个默认动作:先看它是不是代码分割 + 懒加载,再看懒加载的代码用了什么宿主能力。
五、为什么”升级桌面 App”这条路走不通
既然根因是版本错位,最干净的解就是「把宿主升到 0.2.x」。可问题来了:我这台桌面版根本升不动。
查证结果(几条都实锤):
- 桌面版没有自动更新。 我读了它的主进程源码,里面没有
electron-updater、没有autoUpdater,连更新元数据文件都没有。它的插件市场只管”插件”更新,不管 App 自己。 - GitHub Releases 通道是空的。 去官方仓库的 releases 接口拉,直接没返回数据。
- 它本质是个第三方 Electron 壳,内置 0.1.1-rc.2 核心,还靠 约 196 个软链把 profile 里的核心包死死钉在自己身上。
说到软链,这是理解”为什么必须换壳”的关键:
桌面 App 的 ~/.dsh/profiles/node_modules/@deepseek-ai/* 下,有近 200 个软链接,全部指向 App 安装包内置的 0.1.1-rc.2。这是刻意设计——让社区插件和宿主共用同一个 cordis / dsh-session 单例,避免重复加载。
但副作用是:你没法单独升级核心。 核心被软链钉死在 App 里,想升 0.2.x 就得连壳一起换。
六、换引擎:从桌面版到 npx
好消息是,官方 CLI(@deepseek-ai/dsh)就躺在 npm 上,npx 一条命令就能拉下来跑。
我先查了它的 dist-tags,纠正了自己一个错误假设:
1 | { alpha: '0.2.2-alpha.2', latest: '0.2.1-rc.5', next: '0.2.1-rc.5' } |
我之前担心「latest 可能还停在 0.1.x」,结果是错的——latest 已经直接是 0.2.1-rc.5。也就是说,不用锁 @next,npx @deepseek-ai/dsh 默认拉到的就是满足插件要求的版本。
桌面版和 npx 版,本质上跑的是同一套核心,桌面版只是套了层窗口 + 自动开浏览器:
1 | 桌面 App 实际执行的命令 ≈ node lib/bin.js --profile web --host 127.0.0.1 ... |
数据也都在 ~/.dsh 里,App 和 npx 共用,换壳不丢数据。对一台 2020 年的老 Intel Mac 来说,npx 版(纯 Node)比 Electron 壳还更省资源。
七、迁移路上的三个坑
真正动手前,得先把 Node 环境准备好。结果这一步连踩三个坑。
坑一:老 Mac 装 Node,brew 只能源码编译
2020 年的 Intel MacBook,macOS 版本已经掉出了 Homebrew 的官方支持名单(Tier 3)。
Homebrew 有个 Support Tiers 政策:只给「最新 macOS + 前两个大版本」提供预编译的 bottle,更旧的系统没有 bottle,就只能 --build-from-source 源码编译。
所以 brew install node@22 直接开始 ./configure && make install,编译了半个多小时还没完。
关键认知:这不是「Intel 芯片」的问题,是「macOS 版本太旧」的问题。换包管理器不治本,只要系统版本不变,底层就是缺预编译二进制。
坑二:root 所有的旧 npm 残留,link 失败
好不容易编译完,brew link 那一步又崩了:
1 | Error: The `brew link` step did not complete successfully |
一查,/usr/local/lib/node_modules/npm 是 root 所有,日期 2020-05-26——那是我装机时用官方 .pkg 装的旧 Node(Node 12 时代)留下的孤儿残留。brew 以普通用户身份跑,写不进 root 目录,于是 link 失败。
坑三:curl 走本地代理,但代理没开
决定改走 fnm 下载预编译二进制,结果安装脚本又报:
1 | curl: (7) Failed to connect to fnm.vercel.app:443 over proxy 127.0.0.1 |
不是 fnm 挂了,是我的 shell 配了指向 127.0.0.1 的本地代理(Clash / V2Ray 之类),但代理软件没开,所以 after 0 ms 直接连接被拒。
这类报错的特征很明显:over proxy 127.0.0.1 + after 0 ms,一眼就能认出是本地代理没跑,而不是目标服务器的问题。
最终解法:下载预编译二进制,零编译
综合三个坑,最优解是 fnm(一个 Rust 写的 Node 版本管理器):
- 直接去 nodejs.org 官方源下载预编译二进制,几十秒装完,不碰
/usr/local、无 sudo、无权限坑; - 以后升级永远下载二进制,不再源码编译。
这其实呼应了一个更通用的原则:老系统上装运行时,优先找”下载预编译二进制”的方案,别头铁跟源码编译死磕。
八、隔离验证:先不碰生产环境
环境就绪后,我没有立刻动正式的 profile,而是先做了一次隔离验证——这步是迁移里最值得保留的习惯。
思路很简单:把 profile 拷贝到 /tmp,移除副本里的 196 个软链,用一个独立的 DSH_HOME 起一个 npx 0.2.x 测试实例。这样无论成败,都不碰真实数据。
验证结果全绿:
| 验证项 | 结果 |
|---|---|
| 服务启动 | ✅ dsh web: http://127.0.0.1:3080,UI 正常响应 |
| 之前崩溃的费用插件 | ✅ 已加载,账本初始化正常 |
Ne.async 根因 | ✅ 0.2.x 的 dsh-client-modules 源码里确有 require.async |
| 插件 peerDeps | ✅ cordis / schemastery / dsh-session 全部满足声明下界 |
| 兼容警告 | ✅ 本次启动 0 条 warn |
迁移的本质,就是「移除那 196 个指向 App 的软链,让 npx 的 0.2.x 核心接管」。 隔离验证跑通了,正式迁移才敢动手。
九、可回滚的迁移方案
正式迁移最要紧的是可回滚。步骤拆成三步,每步都可逆:
1 | # 第 1 步:备份软链清单 + 关键配置(幂等) |
备份是”软链清单 + package.json + cordis.patch.yml”,回滚 = 按清单重建软链。所有破坏性操作前先留一份可执行的还原凭据,这是迁移里唯一能给你安全感的动作。
十、复盘:几个能带走的排查经验
这场从一句报错开始的迁移,最后沉淀成几条可以复用的经验:
版本错位看
resolved值,别看声明值。peerDependencies写的是”我希望的范围”,真正生效的是解析落到物理node_modules里的那个版本。belowMin 的排查,第一步就是确认实际解析版本。“软警告”要放到运行时场景里看。 服务端挂载成功 ≠ 运行时不会崩,尤其是代码分割 + 懒加载的代码,隐患全藏在”点开那一刻”。
“点开才崩”是代码分割的典型指纹。 见到
X is not a function且只发生在某个子页,优先怀疑懒加载分块里用了宿主没提供的 API。迁移先隔离验证,再动生产。 拷贝一份 + 独立环境跑通,成本极低,却能挡住 90% 的”迁移事故”。
老系统装运行时,优先预编译二进制。 源码编译慢不说,还容易撞上权限、依赖、Tier 支持的各种坑。
最后说句题外话:这次排查全程是我把报错丢给 AI 编程助手,它来读源码、定位、给方案,我负责在终端执行、回贴结果。整个链路——诊断、决策、隔离验证、可回滚迁移——几乎是一个”人 + Agent 协作排障”的标准样本。 这大概也是这类工具真正的价值:不是替你写几行代码,而是帮你在一个复杂系统里,把”为什么会这样”这条线捋清楚。
