DeepSeek Harness Symbol 分裂真相

一次 DeepSeek Harness 工具调度崩溃的完整排查手记:从”双实例”误判到 Symbol 分裂真相

摘要:本文记录了一次真实的生产环境排障过程。报错信息 Cannot read properties of undefined (reading 'prepare') 指向工具调度器内部,但根因并非最初怀疑的”物理副本双实例”,而是更深层的 ESM 多入口构建导致的模块级 Symbol 分裂。修复只需一行 Symbol.for,但定位它花了数小时。

一、环境背景与故障现象

1.1 环境

  • DSH 版本:源码构建版(pnpm dsh web),版本线 0.1.0-rc 附近
  • 源码仓库路径E:\deepseek_harness\deepseek-harness(Windows,E 盘)
  • 数据目录C:\Users\<user>\.dsh\(C 盘)
  • 包管理器:pnpm,profile 配置 nodeLinker: hoistedautoInstallPeers: false
  • 安装方式:git clone 后 pnpm install && pnpm run build,日常通过 pnpm dsh web 启动

1.2 故障现象

任何新建会话,只要触发工具调用,立即崩溃:

本轮运行失败
Cannot read properties of undefined (reading ‘prepare’)
UNKNOWN

工具执行耗时 0ms,说明崩溃发生在调度阶段,而非工具执行阶段。模型输出本身正常,一旦决定调用工具就必崩。

1.3 初步误判

根据社区已知的”双实例”问题模式,第一反应是:~/.dsh/profiles 下出现了 @deepseek-ai/dsh-tools 的第二份物理副本,导致模块级 Symbol 不一致,ctx.tools[TOOL_RUNTIME_SCHEDULER] 返回 undefined,调用 .prepare() 时崩溃。

这个判断方向是对的,但具体定位路径走偏了。

二、第一轮排查:物理副本的排除

2.1 检查 profile 声明

Get-Content “$env:USERPROFILE.dsh\profiles\web\package.json”

输出:

{
“name”: “dsh-profile-web”,
“private”: true,
“dsh”: {
“profile”: {
“bundles”: [
“@deepseek-ai/dsh-base”,
“@deepseek-ai/dsh-web-app”
]
}
}
}

完全干净,没有任何第三方插件,也没有将核心包声明为 dependencies。这排除了”插件污染”路径。

2.2 搜索物理副本

Get-ChildItem “$env:USERPROFILE.dsh” -Recurse -Directory -Filter “dsh-tools” | Select-Object FullName

无输出。C 盘数据目录下没有任何 dsh-tools 实体目录或链接。

Get-ChildItem “E:\deepseek_harness\deepseek-harness” -Recurse -Directory -Filter “dsh-tools” | Select-Object FullName

输出:E:\deepseek_harness\deepseek-harness\packages\core\tools

只有源码树里这一份。从”物理副本”角度看,环境是干净的。

2.3 第一次误判的教训

社区大量讨论聚焦于”profile 里出现第二份 dsh-tools”,导致我最初把精力放在清理 ~/.dsh 上。但当 profile 本身没有第三方插件时,这条路径不成立。问题在别处。

三、第二轮排查:锁定 Symbol 定义

3.1 找到崩溃点的直接证据

在源码中搜索 TOOL_RUNTIME_SCHEDULER:

Select-String -Path “…\packages\core\tools\src\index.ts” -Pattern “TOOL_RUNTIME_SCHEDULER” -Context 0,2

输出:

src\index.ts:463:export const TOOL_RUNTIME_SCHEDULER: unique symbol = Symbol(‘@deepseek-ai/dsh-tools.scheduler’)
lib\types\index.js:51:export const TOOL_RUNTIME_SCHEDULER = Symbol(‘@deepseek-ai/dsh-tools.scheduler’)

关键发现:同一个 Symbol(‘@deepseek-ai/dsh-tools.scheduler’) 调用,在两个不同的构建产物文件中各出现了一次。

3.2 多入口构建的机制

dsh-tools 的 package.json 暴露了多个导出入口:

“exports”: {
“.”: { … },
“./invariant”: { … },
“./types”: { … },
“./presentation”: { … },
“./src/“: “./src/“,
“./package.json”: “./package.json”
}

构建工具(tsdown/rolldown)在打包时,将共享的模块级声明”内联”到了每个独立输出 chunk 中,而不是提取到共享模块。

这意味着:

  • lib/index.js(主入口)包含一份 Symbol(‘@deepseek-ai/dsh-tools.scheduler’) 的求值
  • lib/types/index.js(./types 入口)独立地包含另一份求值

即使磁盘上只有一份源码,构建产物里已经有两处独立的 Symbol 创建。

3.3 Symbol 身份分裂的后果

TOOL_RUNTIME_SCHEDULER 是一个 unique symbol,每次 Symbol() 调用返回全新的、互不相等的对象。

崩溃链路:

dsh-agent-loop 从 lib/index.js 导入 TOOL_RUNTIME_SCHEDULER (Symbol A)

ToolRuntime 实例在 lib/types/index.js 中被创建,其字段用 Symbol B 作为键

Agent Loop 执行 ctx.tools[Symbol A]

返回 undefined(因为字段实际挂在 Symbol B 下)

undefined.prepare(…) → TypeError

诊断方法:

Object.getOwnPropertySymbols(ctx.tools) // 查看实例实际拥有哪些 Symbol 键
Symbol.keyFor(实际的Symbol) // 返回 undefined → 证明是普通 Symbol,不是 Symbol.for

四、修复方案:一行 Symbol.for

4.1 为什么 Symbol.for 能解决

Symbol() 每次调用创建全新 Symbol;Symbol.for(key) 则查询全局 Symbol 注册表,相同 key 返回同一个 Symbol 对象。

修复前:

export const TOOL_RUNTIME_SCHEDULER = Symbol(‘@deepseek-ai/dsh-tools.scheduler’)

修复后:

export const TOOL_RUNTIME_SCHEDULER = Symbol.for(‘@deepseek-ai/dsh-tools.scheduler’)

无论模块被求值多少次(物理副本或多 chunk),Symbol.for 保证 key 身份一致。

4.2 实际执行

src 侧修改:

$root = “E:\deepseek_harness\deepseek-harness\packages\core\tools”
Get-ChildItem “$root\src” -Recurse -Filter “*.ts” | ForEach-Object {
$content = Get-Content $.FullName -Raw
$new = $content -replace “Symbol(([‘“”])@deepseek-ai/dsh-tools.“, ‘Symbol.for($1@deepseek-ai/dsh-tools.’
if ($content -ne $new) {
Set-Content -Path $
.FullName -Value $new -NoNewline
Write-Host “已修改: $($_.FullName)”
}
}

lib 侧同步修改(因为构建产物不会自动更新,且运行时加载的是 lib):

Get-ChildItem “$root\lib” -Recurse -Filter “*.js” | ForEach-Object {
$content = Get-Content $.FullName -Raw
$new = $content -replace “Symbol(([‘“”])@deepseek-ai/dsh-tools.“, ‘Symbol.for($1@deepseek-ai/dsh-tools.’
if ($content -ne $new) {
Set-Content -Path $
.FullName -Value $new -NoNewline
Write-Host “已修改: $($_.FullName)”
}
}

验证:

Get-ChildItem “$root\src”, “$root\lib” -Recurse -Include “.ts”,”.js” |
Select-String -Pattern “Symbol(([‘“”])@deepseek-ai/dsh-tools.“ |
Select-Object Path, LineNumber, Line

无输出表示全部替换成功。

重启 pnpm dsh web,工具调用恢复正常。

五、经验总结与注意事项

5.1 为什么”删了重建”没用

物理副本和构建产物分裂是两个独立的问题维度。

  • 删 ~/.dsh、重建 node_modules、重 clone 源码,解决的是物理副本问题。
  • 但 Symbol() 在 lib/index.js 和 lib/types/index.js 中的独立求值,是构建配置的问题。只要 pnpm run build 用同样的配置跑,分裂就会重新出现。

5.2 Symbol.for 修复的边界

社区讨论指出,Symbol.for 有一个剩余缺口:版本偏移(version skew)。如果两份 dsh-tools 来自不同版本线(如宿主 rc.7、插件携带 rc.6),Symbol.for 保证 key 一致,但两份的 scheduler 协议形状可能不同,导致”静默交叉连接”,比直接崩更隐蔽。

完整的修复需要叠加 TOOL_RUNTIME_SCHEDULER_PROTOCOL_VERSION 守卫。但就本案例而言,环境中只有一份源码构建产物,版本偏移风险不存在。

5.3 给源码构建版用户的建议

  1. 优先改 src 而非 lib:lib 会在下次 pnpm run build 时被覆盖。src 的修改会随构建持久化。
  2. 每次更新源码后重新验证:git pull 可能覆盖你的 src 修改。建议记录修改位置,或用 git stash / 补丁文件管理。
  3. 关注官方修复进展:这个 bug 已在社区多轮讨论中确认根因,官方分支已有 Symbol.for + protocol version guard 的修复方案。等官方合入后,可以回退手动修改。

5.4 报错信息为何如此难定位

Cannot read properties of undefined (reading ‘prepare’) 是一个典型的 JavaScript 通用错误,本身不携带任何 DSH 上下文。它出现的位置(ctx.tools[TOOL_RUNTIME_SCHEDULER].prepare)暗示了工具调度,但”为什么是 undefined”需要深入模块加载机制才能回答。

社区建议的改进方向包括:当调度器缺失时给出干净的合成 tool result 而非直接崩溃,以及检测并警告核心包重复。这些防护目前尚未落地。

六、参考资料

  1. [Discussion #3751] “Bug: TOOL_RUNTIME_SCHEDULER symbol mismatch crashes every subagent tool call (fix included)” — 最精确的根因分析,明确指出了多入口 chunk 分裂机制和 Symbol.for 修复。
  2. [Discussion #2078] “Fix: reading ‘prepare’ crash from duplicate @deepseek-ai/dsh-tools copies (Symbol-key mismatch)” — 首次将 Symbol.for 作为修复方案提出的讨论,包含了诊断方法和机制解释。
  3. [Discussion #2814] “dsh on Windows: every tool call fails with Cannot read properties of undefined” — 与本案例环境高度吻合的 Windows 用户报告,包含了初步的”双实例”怀疑。
  4. [Discussion #3033] “Plugin-bundled duplicate @deepseek-ai/dsh-tools breaks all tool calls” — 从插件污染角度描述的同类故障,包含了 dsh-plugin-doctor 等诊断工具的信息。
  5. [Discussion #1515] “tool calls crash after profile plugin install (dual dsh-tools copy)” — 讨论了 profile 侧 pnpm install 导致副本 materialize 的机制。
  6. [Discussion #1959] “An assistant message with ‘tool_calls’ must be followed by tool messages” — 讨论了”prepare 崩溃”的后遗症:会话日志留下孤儿 tool_calls,导致后续请求被 API 以 400 INVALID_REQUEST 拒绝。

本文基于真实排障过程整理。如果你也在用 dsh 源码构建版并遇到相同问题,希望这份记录能帮你少走弯路。