一次 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: hoisted、autoInstallPeers: 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 给源码构建版用户的建议
- 优先改 src 而非 lib:lib 会在下次 pnpm run build 时被覆盖。src 的修改会随构建持久化。
- 每次更新源码后重新验证:git pull 可能覆盖你的 src 修改。建议记录修改位置,或用 git stash / 补丁文件管理。
- 关注官方修复进展:这个 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 而非直接崩溃,以及检测并警告核心包重复。这些防护目前尚未落地。
六、参考资料
- [Discussion #3751] “Bug: TOOL_RUNTIME_SCHEDULER symbol mismatch crashes every subagent tool call (fix included)” — 最精确的根因分析,明确指出了多入口 chunk 分裂机制和 Symbol.for 修复。
- [Discussion #2078] “Fix: reading ‘prepare’ crash from duplicate @deepseek-ai/dsh-tools copies (Symbol-key mismatch)” — 首次将 Symbol.for 作为修复方案提出的讨论,包含了诊断方法和机制解释。
- [Discussion #2814] “dsh on Windows: every tool call fails with Cannot read properties of undefined” — 与本案例环境高度吻合的 Windows 用户报告,包含了初步的”双实例”怀疑。
- [Discussion #3033] “Plugin-bundled duplicate @deepseek-ai/dsh-tools breaks all tool calls” — 从插件污染角度描述的同类故障,包含了 dsh-plugin-doctor 等诊断工具的信息。
- [Discussion #1515] “tool calls crash after profile plugin install (dual dsh-tools copy)” — 讨论了 profile 侧 pnpm install 导致副本 materialize 的机制。
- [Discussion #1959] “An assistant message with ‘tool_calls’ must be followed by tool messages” — 讨论了”prepare 崩溃”的后遗症:会话日志留下孤儿 tool_calls,导致后续请求被 API 以 400 INVALID_REQUEST 拒绝。
本文基于真实排障过程整理。如果你也在用 dsh 源码构建版并遇到相同问题,希望这份记录能帮你少走弯路。