DSH 桌面版用 DeepSeek 报 REQUEST_EXTENSION 的根因定位与修复
DSH 桌面版(DeepSeek Harness Desktop)里把模型切到 DeepSeek 后,每次发消息都失败:
本轮运行失败 DeepSeek request extension preparation failed,Tag 是REQUEST_EXTENSION。请求根本没发出去。本文从错误字符串一路翻到运行时源码,定位到一个 profile 清单缺字段引发的连锁报错,并给出零升级的本地修复方案和可复现的 A/B 验证。
一、现象
症状很干净,也很诡异:
- 桌面版默认模型是 MiniMax / 其他 provider 时,一切正常;
- 一旦在模型选择器里切到DeepSeek(本文实测是
deepseek-account/deepseek-flash),发送任意消息(哪怕只是「你好」)都是:
本轮运行失败 DeepSeek request extension preparation failed REQUEST_EXTENSION- 关键点:会话
assistant/attempt事件里,从request/header到报错只隔了~20ms。这说明不是网络超时、不是鉴权失败——它是在本地、在任何 HTTP 之前就炸了。
{"type":"request/header","seq":182,"time":1791868045205, "data":{"header":{"config":{"provider":"deepseek-account","model":"deepseek-flash","reasoningEffort":"high"}}}} {"type":"assistant/attempt","seq":184,"time":1791868045225, "data":{"turn":5,"step":1,"stream":[{"chunk":{"type":"finish", "reason":{"kind":"error","failure":{"message":"DeepSeek request extension preparation failed","code":"REQUEST_EXTENSION"}}}}]}}二、先搞清楚这个错误是谁抛的
DSH 的运行时是打进app.asar的。把dsh-llm-deepseek/lib/index.js抠出来,REQUEST_EXTENSION只有一处:
async function prepareRequestExtensions(body, options, prepare, onOmitted) { let extensions; try { extensions = await prepare({ body, ...options }); // ← 贡献者在这里跑 } catch (error) { throw new LlmError("DeepSeek request extension preparation failed", "REQUEST_EXTENSION", { cause: error }); // ← UI 只显示这层的 message } // ...再合并字段、序列化、派发 }也就是说:UI 上那句错误只是外壳,真正的cause被吞掉了。DeepSeek 官方适配器在发请求前会跑一个「请求扩展准备」阶段,把所有插件想往请求里注入的顶层字段准备好;任何一个贡献者抛异常,都会被包装成REQUEST_EXTENSION。
那谁在往 DeepSeek 请求里塞字段?在运行时里搜deepseekLlmApiExtensions.register,一共两个:
| 插件 | 注入字段 | 干什么 |
|---|---|---|
dsh-session-log-deepseek | dsh_session_log | 增量上传会话日志 |
dsh-plugin-package-inventory-deepseek | dsh_plugin_packages | 上报当前激活插件的包清单 |
而且这俩是@deepseek-ai/dsh-base这一层 bundle 的默认开启插件,所以每个 profile 都有。
# <runtime>/node_modules/@deepseek-ai/dsh-base/cordis.patch.yml - id: deepseek-llm-api-extensions name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' - id: session-log-deepseek name: '@deepseek-ai/dsh-session-log-deepseek' - id: plugin-package-inventory-deepseek name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek'三、根因:一个「相对路径插件」撞上「缺 version 的 profile 清单」
看dsh-plugin-package-inventory-deepseek的prepare,它会遍历所有激活的 loader 条目,解析每个条目的归属包:
ctx.deepseekLlmApiExtensions.register("dsh_plugin_packages", { prepare: async (request) => ({ value: { version: 1, packages: await collectActivePluginPackages(ctx, resolver, hostBaseUrl, request.sessionId) } }) });解析逻辑里,对「裸包名」和「相对/绝对路径模块」是两条路:相对路径的模块会就近向上找一个package.json,再用identityFromManifest()校验它:
function identityFromManifest(path, allowAnonymous) { const manifest = JSON.parse(readFileSync(path, "utf8")); if (allowAnonymous && manifest.name === void 0) return void 0; if (typeof manifest.name !== "string" || manifest.name.length === 0 || typeof manifest.version !== "string" || manifest.version.length === 0) { throw new Error(`plugin-package-inventory-deepseek: ${path} must declare non-empty name and version`); } return { name: manifest.name, version: manifest.version }; }现在看这台机器上的 desktop profile。它里面恰好有一个相对路径插件——就是之前为了修「Open In VS Code 打开失败」加的那个本地副本:
# ~/.dsh/profiles/desktop/cordis.patch.yml - id: open-in-app disabled: true - insert: - id: open-in-app-patched name: "./plugins/open-in-app.mjs" # ← 相对路径!于是plugin-package-inventory-deepseek解析这个条目时:
./plugins/open-in-app.mjs不是裸包名 → 走就近 manifest 逻辑;- 从
plugins/往上一层,找到最近的package.json=profile 自己的~/.dsh/profiles/desktop/package.json; - 而DSH 桌面版的 profile 初始化器生成的清单里没有
version字段:
// 桌面版 app.asar 内的 lib/main.js,initProfile() const manifest = { name: `dsh-profile-${basename(dir)}`, private: true, dependencies: {}, dsh: { profile: { bundles: [...bundles] } } // ← 没有 version };identityFromManifest走到typeof manifest.version !== "string"→抛错;- 异常从
prepare冒泡 → 被适配器包成REQUEST_EXTENSION。
于是链条闭合:只要 desktop profile 里挂着那个相对路径插件,任何一次 DeepSeek 请求都会在本地准备阶段必炸,跟模型、额度、网络、Cookie 统统无关。
两个很自然的问题也顺带解答了:
- 为什么换模型就好了?因为「请求扩展准备」只存在于 DeepSeek 适配器里,MiniMax / GLM 等 provider 根本不跑这段代码。
- 为什么之前改 web profile 没用?桌面版启动的是
desktopprofile(~/.dsh/profiles/desktop),不是web;改错对象了。
四、复现与验证(隔离 DSH_HOME + 桌面自带运行时)
桌面版自带一个 CLI 包装器,用应用内那份不可变运行时:
:: <install>\resources\runtime\cli\bin\dsh.cmd set "ELECTRON_RUN_AS_NODE=1" "%~dp0..\..\..\..\DeepSeek Harness.exe" --expose-internals ^ "%~dp0..\..\..\app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\cli.js" %*为了不污染真实环境,复制一份独立DSH_HOME,建一个最小 profile(dsh-base+dsh-headless两个 bundle),默认模型指到deepseek-official/deepseek-flash,然后只改一个变量:有没有那个相对路径插件、profile 清单有没有version。
| 组 | 相对插件./plugins/dummy.mjs | profilepackage.json | 结果 |
|---|---|---|---|
| A | ✅ | 无version | ❌REQUEST_EXTENSION: DeepSeek request extension preparation failed |
| B | ❌ | 无version | ✅ 正常返回「你好」 |
| C | ✅ | 加"version": "0.0.0" | ✅ 正常返回「你好」 |
三组用同一条命令,只有 A 复现了用户看到的报错;B 证明「跟缺 version 无关,是相对插件触发的」;C 证明「补上 version 就没事了」。
$env:DSH_HOME = "<隔离目录>" & "<install>\resources\runtime\cli\bin\dsh.cmd" --profile td "只回复两个字:你好" # A 组输出: dsh: REQUEST_EXTENSION: DeepSeek request extension preparation failed随后用真实 desktop profile 的补丁副本(含open-in-app-patched)重跑,也稳定拿到「你好」——修复在真实配置上成立。
五、修复:给 profile 清单补一个version
根因不在业务代码,而在 profile 清单少了一个字段。最小改动:
// ~/.dsh/profiles/desktop/package.json { "name": "dsh-profile-desktop", "version": "0.0.0", "private": true, "dependencies": {}, "dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"] } } }只加了"version": "0.0.0"一行,其余不动;open-in-app的本地补丁继续保留(那是修另一个问题的)。改完重启 DSH 桌面版(profile 在启动时读取),再发一次 DeepSeek 消息即可,旧会话里的失败记录不影响新请求。
为什么不改插件、也不禁功能?禁用
session-log-deepseek/plugin-package-inventory-deepseek也能绕过去,但那是关掉两个默认功能换来的「不报错」,属于治标;补version才是治本,且不损失任何能力。
六、给上游的建议
这其实是 DSH 运行时里的一处不一致:
initProfile()生成的是name有、version无的清单;identityFromManifest(..., allowAnonymous=true)只放宽了「没有 name」的情况,对「有 name 没 version」是硬抛。
合理修法二选一:初始化器给 profile 清单补version,或让 inventory 插件把「无 version 的 profile 自身清单」也按匿名模块跳过。在那之前,profile 里挂了相对路径插件的用户都会中招,~/.dsh/profiles/desktop/package.json加version就是最省事的兜底。
小结
REQUEST_EXTENSION只是外壳,真正原因藏在被吞掉的cause里:DeepSeek 发请求前的「请求扩展准备」阶段有插件抛异常。- 触发链:
desktopprofile 挂了相对路径插件./plugins/open-in-app.mjs→ 被解析到 profile 自身package.json→ 该清单缺version→plugin-package-inventory-deepseek抛错。 - 只有 DeepSeek 适配器跑这段准备逻辑,所以「换模型就好、用 DeepSeek 必炸」。
- 修复:给
~/.dsh/profiles/desktop/package.json补"version": "0.0.0",重启桌面版。 - 排查心法:报错只隔 ~20ms、且发生在 HTTP 之前 → 别怀疑网络和凭据,去翻「请求构造/准备」阶段的本地插件代码。