DeepSeek Harness 报 request extension 失败:一个坏插件包拖垮官方线路

故障排查发布于 2026-10-03作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSHREQUEST_EXTENSIONdeepseek-officialplugin-package-inventory-deepseekinitProfileprofile manifestllm-pi-ai
DSH 用官方线路每轮都报 request extension preparation failed,切到 pi-ai 却正常且无请求到达网络:准备阶段采集 dsh_plugin_packages 时,任一活跃包解析不出可读 package.json 或 manifest 有 name 无 version 就抛错。

症状很有辨识度:只有 DeepSeek 官方线路(deepseek-official/*)的每一轮都失败,报 DeepSeek request extension preparation failed(REQUEST_EXTENSION),切到 pi-ai 承载的路由(例如 opencode-go)却完全正常——而且没有任何请求到达网络。根因与网络、模型、鉴权都无关:在请求准备阶段采集 dsh_plugin_packages 这个纯诊断字段时,只要有一个活跃插件包解析不出可读的 package.json,或某个被向上找到的 manifest「有 name 却缺 version」,解析器就会抛错;这个错误经注册表的 Promise.all 放大,变成整轮请求失败,发生在 fetch 之前。两条现实触发路径分别是「插件目录被更新器掏空」和「initProfile() 生成的 profile manifest 天生缺 version」——后者让新用户第一次用官方线路就会中招。

先分诊:这不是网络问题,是请求准备阶段被本地元数据拦下

结论先行:先用「零网络流量」这一个特征把范围缩小。 REQUEST_EXTENSION 抛出的位置在适配器里、fetch 之前,所以出现这个错误码时,网络、代理、证书、鉴权、配额全都可以先排除——请求根本没发出去(#6497)。

确认它属于这一类,按下面四步:

  1. 确认错误码是 REQUEST_EXTENSION,错误文案是 DeepSeek request extension preparation failed。这是适配器把注册表准备阶段的拒绝包装后的结果。
  2. 确认只有官方线路受影响。同一个 profile 下换到 pi-ai 承载的路由就正常——因为 llm-pi-ai 根本不消费这个注册表,只有 llm-deepseek 注册并依赖它。
  3. 确认它是每轮都失败、而不是只在第一轮。整条链上没有结果级缓存,失败的解析也不会被缓存,所以每一轮都会重新抛一次(#7678)。
  4. 去读 cause。真正可操作的包名 / manifest 路径 / 该补哪一行都在 LlmError.cause 里,只是它没有被任何界面渲染出来——这一步往往需要你直接看宿主日志或临时加打印。

完整的失败链(每一环都有对应的源码位置):

某一轮请求
  └─ adapter 在 fetch 之前 await 注册表的 prepare()
       └─ prepare() 用 Promise.all 并发跑所有贡献者
            └─ dsh_plugin_packages 贡献者调用 collectActivePluginPackages()
                 └─ 逐包 resolver.resolve(entry) —— 没有 per-package try/catch
                      └─ 某个包解析失败 → throw
   ↑ 这个 throw 让 Promise.all 整体拒绝
  └─ adapter 包成 LlmError(..., 'REQUEST_EXTENSION', { cause })
  └─ fetch 从未发生 → 每一轮都失败

值得单独点出的一处设计错配:注册表在**接受(acceptance)阶段本来用的是「全部 settle、互不影响」的宽容语义(acceptAll),但准备(prepare)**阶段却用了 Promise.all——一个贡献者拒绝就拖垮整批。有确认者认为,把准备阶段改成与接受阶段一致才更自然(#6497)。

触发路径一:任意活跃包解析不出可读的 package.json

只要有一个活跃插件包无法在磁盘上解析出可读的 package.json,PackageIdentityResolver.resolve 就会抛错;collectActivePluginPackages 在循环里没有 per-package try/catch,于是整批采集失败,进而整轮请求失败。 这不是偶发环境问题,而是已经发生过的一次真实事故。

问题代码的形态(packages/llm/plugin-package-inventory-deepseek/src/index.ts):

ts
const packageName = barePackageName(entry.options.name)
let manifest: string | undefined
if (packageName !== undefined) {
  manifest = barePackageManifest(packageName, anchors)
  if (manifest === undefined) {
    throw new Error(`plugin-package-inventory-deepseek: cannot resolve active package ${JSON.stringify(packageName)}`)
  }
}

barePackageManifest() 用 existsSync 在 Node 的包搜索路径里找 package.json;找不到就抛上面那句。同样的 resolve 还会在「找到了但 manifest 不合法」时抛错:JSON 无法解析,或缺少非空的 name 与 version(#6497)。

真实事故的完整样子(这比理论更值得记住):某部署上的第三方插件更新器对 @memtensor/memos-local-plugin 执行了「先 rm -rf、再 cp」,而 cp 在复制 better_sqlite3.node 时被运行中的宿主占用、以 EPERM 中止。结果是这个包只剩一个空目录、没有 package.json。从那一刻起,每一条 deepseek-official 轮次都失败,LlmError.cause 精确点名了那个包。把包从源目录恢复后,不需要重启宿主——因为失败的解析结果从未被缓存,下一个请求就恢复了(#6497)。

一个容易忽略的细节:并不是所有挂载形态都会触发这个 throw。 相对路径、绝对路径、file URL 入口会向上寻找「最近的 manifest」,找不到时是静默省略,不会抛;只有 bare-name(或包内子路径)入口才会走到上面那个 throw,另外加上「已有的 manifest 畸形或缺少 name/version」这一种(#6497)。

这也解释了为什么这个行为长期没被当成 bug:它是文档写明的契约,不是回归。docs/subsystems/llm-streaming.md 写着「准备、冲突与接受失败使用 REQUEST_EXTENSION 并让模型请求失败」,插件 README 也写着「畸形的包元数据会让请求准备失败」;仓库自己的测试 inventory.spec.ts 还把「畸形元数据 → 拒绝」钉成期望行为。所以修复它必须同时改文档和测试,这也是它能一直留在这里的原因。

触发路径二:initProfile() 生成的 profile manifest 缺 version

第二条路径更严重,因为它不是「你弄坏了什么」,而是「开箱即坏」:initProfile() 创建 profile 时写出的 package.json 没有 version 字段;只要该 profile 里存在相对路径模块入口,采集器向上找到的「最近 manifest」就是这个 profile 自己的 package.json,于是立刻命中「有 name 必须有 version」的校验。

initProfile() 写出的 manifest 形态(packages/boot/app-boot/src/profile.ts):

js
const manifest = {
    name: `dsh-profile-${basename(dir)}`,
    private: true,
    dependencies: {},
    dsh: { profile: { bundles: [...bundles] } }
};

没有 version。 对应到采集器的校验:

js
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`);

这里有一个不对称,正是问题的关键:采集器对 loose module 分支传了 allowAnonymous: true,于是「完全没有 name」的 manifest 被容忍(返回 undefined、不贡献 identity);但「有 name 却没有 version」不在容忍范围内,会直接抛错。而相对路径入口(例如 name: './x.mjs')恰恰走 loose module 分支、恰恰会向上落到 profile 的 manifest 上——profile 里唯一的那份 manifest,正好落在容忍范围之外(#7678)。

用四步最小复现(不需要任何第三方插件):

  1. 初始化一个全新 profile,让 initProfile() 生成 manifest:

    bash
    dsh headless --help
    cat ~/.dsh/profiles/headless/package.json
    # => { "name": "dsh-profile-headless", "private": true, ... }   没有 version
    
  2. 在该 profile 的 cordis.patch.yml 里加一个相对路径模块入口:

    yaml
    - insert:
        - id: my-plugin
          name: './my-plugin.mjs'
    
  3. 用该 profile 的 DeepSeek 官方线路发一条消息。

  4. 观察结果:本轮运行失败 / DeepSeek request extension preparation failed。

一个特别有说服力的对照实验(同一台机器上的两个 profile):

profilemanifest相对路径入口结果
~/.dsh/profiles/web有 "version": "0.0.0"./eli-host2.mjs不触发
~/.dsh/profiles/desktop无 version新加一个本地 ./x.mjs加完立刻每轮 REQUEST_EXTENSION

两者喂给 identityFromManifest() 的唯一差别就是 version:有则返回 identity,无则落到 throw。补上 version 后不需要重启,下一个请求就恢复——因为 throw 位于 cache.set 之前,失败的 key 从未被缓存(#7678)。

还有三点容易误判的细节:

  • 改成绝对路径没用。 barePackageName() 对 ./x.mjs 和 /abs/x.mjs 都返回 undefined,两者都会走 nearestManifest(),从入口所在目录向上找。
  • 桌面端不会自愈。 桌面端的 DesktopProjectManager.applyRelease() → createPluginProfile() → initProfile(),而 initProfile() 只在 !existsSync(manifestPath) 时才写 manifest,整条路径没有任何补 version 的迁移。所以只改 initProfile() 只能救「以后新建」的 profile,存量 profile 必须手工改。
  • 这是家族里的第八例。 同一种形态(某个活跃入口解析失败 → 整轮请求失败)此前已有六种不同成因:pnpm 隔离布局(#5172 / #5173 / #5196)、过期的 fallback 符号链接(#5439)、干净构建(#5683)、绝对文件路径模块(#5844)、相对目录模块(#6064)——而 initProfile() 这一例的不同之处在于,它把这条链从「历史残留才会中招」推进成了「首次运行即坏」(#7678)。

三条修复与本地绕过

修复的优先顺序很明确:真正能关闭这个问题族的是「采集器逐包容忍 + 告警」;给 initProfile() 补 version 是正确的配套,但它只救新建的 profile;在此之前的当务之急是让存量用户有一条立刻可用的恢复路径。

修法一(推荐,窄改):采集器逐包 warn-and-omit。 在 collectActivePluginPackages 里把 resolver.resolve(activeEntry) 包进 try/catch,记一条 ctx.logger.warn(...) 然后 continue,让其余可解析的包照常采集。仓库内已有同类先例(受控监听器模式)。采用这条修法时,需要同步把 inventory.spec.ts 里「畸形元数据 → 拒绝」的断言改成「告警 + 省略」,并软化 README 那句表述。上报者按这个补丁在本地验证过:一个坏掉的 bare-name 入口现在只产生一条告警并被省略,其余包仍被采集(例如仍能收集到 ["dsh-better-sidebar@0.19.1"])(#6497)。

修法二:注册表级隔离。 把 prepare() 的 Promise.all 改成 Promise.allSettled,把被拒绝的贡献者的字段省略掉(可选加告警)。好处是让准备阶段与接受阶段语义一致。代价是这是文档写明的严格契约,且会改变所有贡献者(包括 dsh_session_log)的行为,被多个测试钉住——真要动,应当做成「按贡献者显式选择加入」,而不是一刀切地全局放宽(#6497)。

修法三(配套):让 initProfile() 写入 version: "0.0.0"。 这是问题链最上游的一环,属一行改动:

diff
 const manifest: ProfileManifest & { private: boolean } = {
   name: `dsh-profile-${basename(dir)}`,
+  version: '0.0.0',
   private: true,
   dependencies: {},
   dsh: { profile: { bundles: [...bundles] } },
 }

并加断言 expect(manifest.version).toBe('0.0.0')。但要注意它只对之后新建的 profile 生效——这正是「修法一必须排在修法三之前」的理由(#7678)。

当下立刻可用的三条绕过:

  1. 给 profile 的 package.json 补一行版本号(立刻生效、无需重启):

    json
    {
      "name": "dsh-profile-web",
      "version": "0.0.0",
      "private": true
    }
    
  2. 把本地插件放进自己的子目录,并给它一份带 name + version 的 package.json(例如 profiles/desktop/my-plugin/{package.json,index.mjs},入口写 ./my-plugin/index.mjs)。因为 nearestManifest() 先看入口自身所在目录,这样就永远不会落到 profile manifest 上。注意这只是绕开解析器,不是修复——给 profile manifest 补 version 仍然是更正确的做法(#7678)。

  3. 在 cordis.patch.yml 里关掉采集器(丢掉整个字段的取舍):

    yaml
    - id: plugin-package-inventory-deepseek
      name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek'
      config:
        enabled: false
    

    这一行在基础 bundle 里默认是开启的;关掉它是绕行而不是修复,真正需要这个诊断字段的人不应选它。

排查与注意事项

  • 报错不给你包名,是当前最大的可用性缺口。 终端、会话记录、Web UI 三处都只显示最外层那句 DeepSeek request extension preparation failed,而 cause 里才有包名与 manifest 路径。桌面端尤其麻烦:DesktopHostProcess 对宿主 stderr 只做 slice(-65536) 累积,并且只在宿主退出/失败时上报——请求级失败永远不会触发上报,所以「把 cause 渲染出来」只能落在会话 / Web UI 侧,宿主 stderr 不是可用通道(#7678)。
  • 修好之后通常不需要重启。 解析失败的结果不会被缓存,所以修好目录或补上 version,下一个请求就恢复。唯一的例外是「把一个已缓存的包 identity 换成另一个版本」——那需要重启。
  • 别把「相对路径入口」当成免疫。 相对 / 绝对 / file URL 入口只在「找不到 manifest」时静默省略;一旦向上找到的 manifest 存在但畸形(含「有 name 无 version」),照样抛错。这也是为什么 profile 自己的 manifest 会成为元凶。
  • 排查顺序建议:先确认错误码与「零网络流量」→ 再看是不是只有官方线路 → 再检查最近有没有插件被更新 / 删除 / 目录被掏空 → 最后检查 profile 的 package.json 有没有 version。
  • 社区已有一个针对这个接缝的 stopgap 插件(@argszero/cordis-plugin-request-extension-guard):它包住每个已注册 provider 的 prepare,把「一个字段抛错 = 整批失败」改成「该字段在本次请求中被跳过、并把它自己的消息按原样记录一次」;可选项 repairProfileManifest: true 会给缺失的 version 做纯追加式补写并在同一次请求内重试,让字段不是「幸存」而是「回来」。它明确声明这不等于修复:被跳过的字段确实缺席于那次请求,持久修复仍应在 harness 内完成(#7678)。
  • 顺带纠正一个常见误解:cordis.patch.yml 对已存在入口的 config 是整体替换、不是深合并。共享实现 applyEntryPatches 对非 insert 补丁就是逐键覆盖,所以只写 defaultPreset 会让基础 bundle 里整张 presets 表消失。这是有意的设计(深合并会改变「刻意替换整份 config」的行),但头注释里「id-targeted config overrides」的措辞容易让人以为会合并——写补丁时记得把整份 config 写全。

来源:


这类「明明报的是请求失败、其实是本地元数据不合法」的坑,最麻烦的地方在于报错把可操作的线索吞掉了。如果你想让插件环境本身变得可查,可以装一个 DSH Plugin Hub——DeepSeek Harness 桌面端内置的官方插件市场,除了浏览、安装、卸载与更新插件,还有「自定义安装」让第三方来源一目了然,装完能立刻在已安装列表里核对版本,避免出现「目录被掏空却没人发现」的情况:

DSH Plugin Hub 自定义安装:NPM 包、GitHub 源码、DSH 命令行三条通道,每条都有格式校验提示

把插件来源与版本集中管起来,比在报错里猜哪个包坏了要省事得多。

常见问题

为什么切到别的路由就正常,只有 DeepSeek 官方线路每一轮都失败?

因为出问题的是官方线路独有的一个请求扩展贡献者。deepseek-official 的唯一路由由 llm-deepseek 注册,它会在发请求前采集 dsh_plugin_packages 这个诊断字段;而 llm-pi-ai 完全不消费这个注册表。所以同一个坏插件包只会毒掉官方线路,pi-ai 承载的路由(例如 opencode-go)照常工作。

报错信息里根本没写是哪个包坏了,怎么定位?

真正可操作的信息在 LlmError 的 cause 里,但目前终端、会话记录、Web UI 三处都只渲染最外层那句 DeepSeek request extension preparation failed,把 cause 丢掉了——这是上报里被反复抱怨的一点。可以按报错形态反推:plugin-package-inventory-deepseek: cannot resolve active package "<name>" 指向一个 bare-name 入口解析不到包;… must declare non-empty name and version 指向某个被向上找到的 manifest(很可能是 profile 自己的 package.json)缺 version;JSON 解析错误会带 manifest 路径。

我什么都没改,为什么某天开始突然每一轮都失败?

最常见的是「插件目录被某个更新器掏空」:先 rm -rf 再 cp,而 cp 因为文件被占用(例如 EPERM on better_sqlite3.node)中途失败,于是这个包只剩一个空目录、没有 package.json。从上报的实际事故看,那一刻起每一条官方线路的轮次都失败,LlmError.cause 精确点名了那个包;把包从源目录恢复后,**下一个请求就恢复**,不需要重启宿主——因为失败的解析结果从未被缓存。

为什么新建的 profile 一上来官方线路就是坏的?

因为 initProfile() 写 profile 的 package.json 时没有写 version,而采集器要求「有 name 的 manifest 必须同时声明非空 version」。profile 里只要有相对路径模块入口(例如 ./x.mjs),采集器会从入口向上找「最近的 manifest」,落到的正是这个 profile 自己的 package.json,于是当场命中校验。这已不是「历史残留」,而是一条开箱即坏的首次运行路径;桌面端尤其明显,因为桌面 profile 就是由它创建的。

官方修了吗?我现在能做什么?

截至本文所引报告,master 上这条链仍在,而且被仓库自己的测试与文档钉住(inventory.spec.ts、adapter.spec.ts、registry.spec.ts 与 llm-streaming.md 都把严格失败当作既定契约)。当下可按优先级做三件事:① 给 profile 的 package.json 补 version: "0.0.0"(立刻生效、无需重启);② 把坏掉的插件包修好,或在组合里把它的插件行移除;③ 实在要立刻解封,就在 cordis.patch.yml 里把 plugin-package-inventory-deepseek 这一行设为 enabled: false——但这会丢掉整个诊断字段,是绕行不是修复。

相关术语

REQUEST_EXTENSION
LLM 适配器上「请求扩展准备失败」的错误码。适配器在发起 HTTP 之前会 await 注册表的 `prepare()`,任何拒绝都会被包成 `LlmError('DeepSeek request extension preparation failed', 'REQUEST_EXTENSION', { cause })`。因为发生在 `fetch` 之前,所以这类失败的典型特征是「零网络流量」——用它可以把网络问题、鉴权问题一次性排除。— https://github.com/deepseek-ai/deepseek-harness/discussions/6497
dsh_plugin_packages
每次请求前采集的**诊断性**扩展字段,列出当前活跃插件包的标识。它由 `plugin-package-inventory-deepseek` 贡献,本意是给模型一点环境上下文。讽刺之处正在这里:一个纯诊断字段,却因为「采集失败会抛错」而具备了让整轮对话硬失败的能力。— https://github.com/deepseek-ai/deepseek-harness/discussions/7678
loose module 与 allowAnonymous
无法归到某个具名包的模块入口(相对路径、绝对路径、file URL)称为 loose module。采集器在解析时对这条分支传 `allowAnonymous: true`,因此「完全没有 `name`」的 manifest 会被容忍、返回 `undefined`(不贡献 identity);但「有 `name` 却缺 `version`」不在容忍范围内,会直接抛错。这个不对称正是 #7678 的成因。— https://github.com/deepseek-ai/deepseek-harness/discussions/7678
nearestManifest
采集器为某个模块入口寻找「最近的 package.json」的规则:从入口自身所在目录开始向上逐级查找。它决定了相对路径入口会落到哪个 manifest 上——也正因如此,profile 自己的 `package.json` 会成为「最近的那一个」。反过来,把本地插件放进自己的子目录并配一份带 `name` + `version` 的 `package.json`,就能让解析器不再落到 profile manifest 上。— https://github.com/deepseek-ai/deepseek-harness/discussions/7678

来源