DeepSeek Harness 本地部署与第三方插件排障实录:从 fetch failed 到 preset not found

本文是一次完整真实 的排障复盘:在 Windows 上从零跑通 DeepSeek Harness(dsh), 再装上一个第三方 client UI 插件,然后把踩到的每一个坑连根因带修法全部拆开。 文中所有报错原文、版本号、字节数、耗时、进程树结论均来自真机实测,没有推测。 配套交付:一份排障手册 + 4 个只读排障脚本(见第六节)。


〇、先给结论:三句话

如果你正准备装 dsh 或它的第三方插件,这三条能省掉大部分试错:

  1. 插件报的错,多半不是你的配置错。 我遇到的两个故障------知识库页 fetch failed、 新建会话 preset not found------根因都在插件的发布包:一个把硬依赖写成了"可选", 另一个干脆漏打了一个目录。你的配置从头到尾没写错。

  2. 502 是插件的兜底错误码,不代表 dsh 挂了。 看源码就知道,整个路由 handler 的 catch 分支统一 return json(res, 502, ...)所有异常都是 502 。 而且它的接口分三类 行为,第三类会静默返回空------这是最容易误判的一条。

  3. 最隐蔽的坑不在安装,在"谁启动了服务"。 让 AI 助手在会话里帮你把服务跑起来, 服务会挂在会话的进程沙箱上:助手说"已就绪、路由全 200"是真的,过一会儿连不上也是真的。 想清理助手留下的后台任务又怕服务挂?根因在这里,修法在第六节。


一、环境与版本(可对照)

先把坐标系定死,本文所有结论都基于这套环境:

组件 实测版本 说明
操作系统 Windows 11 中文系统,代码页 936
DeepSeek Harness 0.1.5-rc.1latest 标签) 开发者预览版,官方明说会有破坏性变更
dsh web UI 127.0.0.1:3080 必须带 ?token= 完整 URL 访问
OpenViking 0.4.20 知识库服务,127.0.0.1:1933
llama-cpp-python 0.2.90(cp313 / win_amd64) 需官方 wheel 索引,PyPI 无 Windows 轮子
本地嵌入模型 bge-small-zh-v1.5-f16(GGUF,47,886,240 字节) hf-mirror.com 下载
第三方插件 dsh-client-ui-urban-planning-agent-workbench@1.1.0 城策工作台,城市规划业务 Agent 工作台

dsh 的版本标签:latest = 0.1.5-rc.1,next = 0.1.5-rc.2,alpha = 0.1.5-alpha.2。 跟着官方 quickstart 用 latest 即可。

dsh 的核心公式是 Model + Harness = Agent ,理念是"一切皆插件",底层是 Cordis 微内核。 理解这一点很关键------后面所有的坑,本质都是"插件组合"这一层的坑


二、装 dsh:三个拦路虎

2.1 npm 默认源打不通(E502)

企业内网环境常见的坑:npm 默认源指向内网 Nexus,装 dsh 会直接返回 E502安装时必须显式指定官方源

复制代码
"<node>" "<npm-cli.js>" install @deepseek-ai/dsh@latest \
  --prefix "<你的安装目录>" \
  --registry=https://registry.npmjs.org/ --no-audit --no-fund

实测约 555 个包 / 2 分钟

2.2 不要用 npm install -g

全局安装会污染环境,而且 dsh 的插件生命周期脚本要往用户目录写文件,全局装的权限和路径都容易出岔子。 统一装到固定目录,再写一个 .cmd 启动器暴露入口,这是更干净的形态。

2.3 别指望系统 Node 兜底

这一条值得单独说:实测用系统 Node 跑同一个 dsh 入口,返回空输出、退出码 0 ------ 不是报错,是静默失败,排查起来非常费劲。

所以启动器里必须写托管 Node 的绝对路径 ,并且不要清理那个运行时目录, 它是 dsh 能跑起来的前提。若该运行时被换版本,启动器里的路径要跟着改。

2.4 端到端验证:别只看"服务起来了"

Web UI 起来不等于模型链路通。最可靠的一条验证是真跑一次请求

复制代码
cd "<工作区>" && dsh --profile headless "只回复两个字:通了。不要调用任何工具。"

返回模型输出,说明 凭据 → 路由 → API → 回包 全链路正常。

一个容易吓到自己的细节 :裸访问 dsh 的根路径返回 401 authentication required, 这是正常的------必须用启动日志里打印的那一行带 ?token=... 的完整 URL 。 而且 token 每次启动都重新生成(实测连开三次得到三个不同值),所以旧链接必然失效, 别收藏它。


三、装第三方插件:官方 INSTALL.md 的三处误导

第三方插件的 INSTALL.md 通常是按 macOS + 全局安装写的,照抄会卡住。

3.1 ✗ npm install -g ------ 别用全局

插件自带的安装说明写的就是全局安装。两个问题:污染环境;生命周期脚本要往 ~/.agents/skills 写文件,全局装之后权限和路径都容易出岔子。

正确做法 :装进 profile 的 node_modules,再在 patch 层注册一行。 profile 用的是 nodeLinker: hoisted(扁平结构),所以 npm 直接装是兼容的:

复制代码
cd "<DSH_HOME>/profiles/web"
npm install "<插件包.tgz 的绝对路径>" --registry=https://registry.npmjs.org/ --no-audit --no-fund

官方路径 dsh plugin --profile web add <包> 转发给 pnpm ------没装 pnpm 会直接报 'pnpm' 不是内部或外部命令。不想为此引入 pnpm 就用上面的 npm 直装。

3.2 ✗ 装完包装了 ≠ 加载了 ------ 必须注册

这是最容易"以为装好了其实没生效"的一步。在 <DSH_HOME>/profiles/web/cordis.patch.yml 里加一行,否则包躺在 node_modules 里也不会被加载:

复制代码
- insert:
    - id: chengce-workbench          # 自定 id,随便起
      name: 'dsh-client-ui-urban-planning-agent-workbench'

然后重启 dsh web(新插件注册不是热重载)。

3.3 ✗ launchctl kickstart 是 macOS 的

INSTALL.md 给的重启命令是 macOS 的 launchctl。Windows / Linux 上直接重启 dsh 进程即可。

另外它没提:tgz 安装会在 profiles/web/package.json 留下脆弱引用 ,形如 "xxx": "file:../../../Downloads/xxx.tgz"------下载目录一清理就断链。 建议把 tgz 挪到固定位置,把依赖改成稳定相对路径,再 npm install 重解析一次。

3.4 ✓ 三层验证,缺一层都可能误判成功

怎么验 通过的样子
配置层 dsh --profile web --dump-config 配置树里出现注册的 id,stderr 干净
node half 请求任意插件路由,如 GET /chengce-knowledge/v1/skills 返回插件自己的 JSON,而不是 404
browser half 抓首页 HTML,搜 <包名>/client.js 出现在 boot 模块清单里

首页那串 boot 清单是最权威的浏览器端加载证据------它和内置插件并排列在 /plugins/... 序列中。

3.5 ✓ 确认内置 skill 装上了(8 个)

这类插件通常用 postinstall 把内置 skill 装到 ~/.agents/skills。本次实测装上了 u-policy + o-s1 系列 7 个,共 8 个 。被跳过时手动跑(脚本带 marker 文件保护, 不会覆盖你自己创建的同名 skill,可重复执行):

复制代码
node "<DSH_HOME>/profiles/web/node_modules/<包名>/scripts/install-bundled-skills.mjs"

顺带一个安装前的动作 :本地 tgz 一律先审计再装 。最小路径是 tarfile 列清单 → 读 package.jsonscripts(找 postinstall 这类自动执行钩子) → 读读写文件系统的那几个模块 → 扫 lib/client.jseval / new Function / child_process / 外部 URL。"0 个外部 URL"是最有价值的单一信号 。 本次这个插件 10MB 前端产物里 0 个外部 URL、无 eval / child_process,是干净的。


四、故障 A:知识库 / 技能管理页报 fetch failed(502)

4.1 先分清故障面:502 不等于 dsh 挂了

浏览器控制台看到 /chengce-knowledge/v1/* 返回 502 (Bad Gateway)。看源码 src/host.js,整个路由 handler 的 catch 是:

复制代码
} catch (error) {
  return json(res, 502, { ok: false, error: error instanceof Error ? error.message : String(error) });
}

所有异常统一返回 502。 所以看到 502,只说明"插件转发给 OpenViking 这一步失败了"。

而插件的接口其实分三类 ,行为完全不同------第三类是最容易误判的

接口 源码行为 OpenViking 挂掉时
/skills/expert-bindings/skill-configs 只读写本地文件 仍 200
/health/libraries 直接 await ov(...),无 catch 502 {"ok":false,"error":"fetch failed"}
/resources 每个 ov() 挂了 .catch(() => []) 仍 200,但内容静默为空

第三类的含义:"知识库页打得开、但里面永远是空的",同样可能是 OpenViking 没跑 ------ resourceRows() 把错误静默吞掉了。不要直接当成"我还没上传文档"。

一条命令看清三类(脚本见第六节),这是故障态下的实测输出:

复制代码
3. 插件路由 ------ 直接转发 OpenViking(OV 没跑就必然 502)
  FAIL GET /health      HTTP 502   {"ok":false,"error":"fetch failed"}
  FAIL GET /libraries   HTTP 502   {"ok":false,"error":"fetch failed"}

3b. 插件路由 ------ 静默失败型(源码带 .catch,恒 200,要看内容)
  OK   GET /resources   HTTP 200   {"ok":true,"resources":[]}     ← 静默为空

4. 插件路由 ------ 只读写本地文件(OV 在不在都该 200)
  OK   GET /skills            HTTP 200
  OK   GET /expert-bindings   HTTP 200
  OK   GET /skill-configs     HTTP 200

结论
  ▸ OpenViking 没跑,而插件依赖它 ------ 这就是「fetch failed / 502」的原因。

/skills 也失败(404 而不是 200),那说明插件根本没挂上------回到 3.2 节检查注册与重启。

4.2 为什么"技能管理"页也一起挂?

因为它和前端的加载逻辑有关。SkillsView.jsx 挂载时是:

复制代码
const [libraryValue, bindingValue, configValue] = await Promise.all([
  knowledgeApi.libraries(),                          // ← 唯一碰 OpenViking 的,挂了整页报错
  knowledgeApi.bootstrapExpertBindings(DEFAULT_BINDINGS),
  knowledgeApi.skillConfigs(),
]);

/skills /expert-bindings /skill-configs 单独请求都是 200,但 /libraries 一走 OpenViking 失败,Promise.all 就让整页进 catch。

所以插件作者的 INSTALL.md 把 OpenViking 写成"如需使用知识库功能,另行部署", 听起来是可选,实际是硬依赖。

而且前端是打包进 lib/client.js 的,没有构建链改不了 ------不能靠"给前端加容错"绕过, 唯一可行解就是把 OpenViking 跑起来

一个很有用的细节lib/index.js 只有一行 export { apply, inject } from '../src/host.js'------ 也就是说服务端代码就是 src/host.js 源码本身,可直接改;只有前端是打包的。 这个区分对"能不能自己打补丁"至关重要。

4.3 装 OpenViking

OpenViking(volcengine/OpenViking,AGPL-3.0,PyPI 包名 openviking,要求 Python ≥3.10) 是知识库的存储 + 检索引擎。装进独立 venv:

复制代码
"<python.exe>" -m venv "<venv 路径>"
"<venv>/Scripts/python.exe" -m pip install openviking    # 实测 0.4.20,约 200+ 包

入口是 <venv>/Scripts/openviking-server.exe

⚠️ 0.4.20 的 openviking-server 没有 init / doctor 子命令 ------官网文档写的是别的版本。 直接带参数起服务即可:openviking-server.exe --config <ov.conf>

4.4 两个"国内必踩"的依赖(最容易卡死的地方)

模型段在配置里全部可省,但服务端启动时会无条件创建 embedder ,默认用本地 GGUF bge-small-zh-v1.5-f16。缺任何一样,服务会在 lifespan 阶段直接退出:

复制代码
EmbeddingConfigurationError: Failed to download local embedding model
'bge-small-zh-v1.5-f16' from https://huggingface.co/... ConnectTimeoutError

① 模型文件huggingface.co 国内不通。用 hf-mirror.com 手动下到缓存路径 (代码里会先查 <用户目录>/.cache/openviking/models/<filename>,存在就跳过下载):

复制代码
curl -L -o "<用户目录>/.cache/openviking/models/bge-small-zh-v1.5-f16.gguf" \
  "https://hf-mirror.com/CompendiumLabs/bge-small-zh-v1.5-gguf/resolve/main/bge-small-zh-v1.5-f16.gguf"
# 校验:应 47,886,240 字节,文件头 4 字节为 'GGUF'

llama-cpp-python (本地嵌入的运行时,不在 openviking 的基础依赖里 ): PyPI 上没有 Windows 预编译轮子pip install "openviking[local-embed]" 会走源码编译 (需 CMake + MSVC,基本必失败)。用官方 wheel 索引拿预编译版:

复制代码
# 索引页(含 cp313 / win_amd64,可选 0.2.86 ~ 0.2.90):
#   https://abetlen.github.io/llama-cpp-python/whl/cpu/llama-cpp-python/
"<venv>/Scripts/python.exe" -m pip install \
  "https://github.com/abetlen/llama-cpp-python/releases/download/v0.2.90/llama_cpp_python-0.2.90-cp313-cp313-win_amd64.whl"
# 仅 3MB,秒装,无需编译

绕开这套的替代方案 :改配 API 型 embedding(openai / volcengine / dashscope / jina / glm / ollama / litellm...)。注意本地跑 Ollama 要另装,默认端口 11434。

4.5 配置 ov.confauth_mode 必须是 dev

复制代码
{
  "server": { "host": "127.0.0.1", "port": 1933, "auth_mode": "dev" },
  "storage": {
    "workspace": "<你的数据目录>/openviking/data",
    "agfs": { "backend": "local" },
    "vectordb": { "backend": "local" }
  }
}

为什么必须是 dev:看插件源码就明白了:

复制代码
// src/host.js
const response = await fetch(`${ENDPOINT}${path}`, {
  ...requestInit,
  headers: { 'X-OpenViking-Actor-Peer': 'deepseek-harness', ...(requestInit.headers || {}) },
});

插件转发时只发 X-OpenViking-Actor-Peer,不带任何 API Key。所以:

  • auth_mode: "dev"(仅监听本机、不要求 Key)✅ 正好匹配
  • 设了 root_api_key 会自动切到 api_key 模式 → 插件立刻 401

推论 :官方的远程 OpenViking 服务插件也接不上,除非对端开了免鉴权。

配置不允许未知字段,字段名写错会拒绝启动。

4.6 验收

复制代码
curl -s http://127.0.0.1:1933/health     # {"status":"ok","healthy":true,"version":"0.4.20","auth_mode":"dev"}
curl -s http://127.0.0.1:1933/ready      # {"status":"ready","checks":{...,"embedding":"ok"}}

实测冷启动约 18 秒 (首次加载 GGUF 模型);/ready 里看到 embedding: ok 才说明 GGUF 与 llama-cpp-python 都对上了。

最后建默认知识库(10 个) :最省事的做法就是刷新一次知识库页面 ------ 页面挂载时会自动发 Promise.all([knowledgeApi.bootstrap(DEFAULT_LIBRARIES), knowledgeApi.health()])

⚠️ {"libraries":[]} 一个库都不会建。 host.js 的 bootstrap 是遍历这个数组 逐个 mkdir 的------空数组只会创建根目录。这是我第一版手册里写错、后来实测纠正的地方。 正确定义是 10 条 ,写死在 KnowledgeView.jsx 里。

4.7 让它在重启后自动可用

服务是前台进程,开机不会自启 。做一个启动器 openviking.cmd, 再让 dsh 的启动器兜底拉起它

复制代码
netstat -ano | findstr /C:"127.0.0.1:1933 " | findstr /C:"LISTENING" >nul 2>&1
if errorlevel 1 start "OpenViking 知识库服务" /min "%~dp0openviking.cmd"
curl -s -o nul --noproxy "*" --max-time 2 "http://127.0.0.1:1933/health"

这样双击一个图标,两个服务一起起来。

两个 Windows 编码坑 (都是实测踩出来的): ⚠️ .cmd 里的中文必须用 GBK 写(cmd.exe 按系统代码页 936 读批处理), UTF-8 的中文会变乱码;文件开头加 chcp 936 >nul 2>&1。 另外 timeout /t N 在 stdio 被重定向时会直接报错退出 ,循环等待改用 ping -n N+1 127.0.0.1 >nul。 ⚠️ .ps1 里的中文必须用带 BOM 的 UTF-8 写(utf-8-sig), 否则 PowerShell 5.1 对无 BOM 的 UTF-8 按 ANSI 解析,中文串会破坏引号配对, 实测报「字符串缺少终止符」并整段不执行


五、故障 B:新建会话报 preset "..." not found

5.1 症状

在插件工作台里新建会话、选好专家和技能 、发出第一条消息时报:

复制代码
agent-presets: preset "up-renewal-policy" not found (available: standard, ptc, minimal, cordis)

这是插件的打包缺陷,不是你的配置问题。

5.2 根因:硬编码在前端,但包里没带

插件把 preset id 硬编码在前端

复制代码
// src/config/branding.js
export const AGENT = { id: 'U-E1', name: '政策咨询推送与解读专家',
                       preset: 'up-renewal-policy', skill: 'u-policy' };
export const BUSINESS_AGENTS = {
  urban: AGENT,
  overseas: { id: 'S1', name: '研判决策专家',
              preset: 'up-renewal-research', skill: 'o-s1' },
};

package.jsonfiles 字段是:

复制代码
"files": ["lib/", "src/", "skills/", "scripts/", "README.md", "INSTALL.md"]

没有任何 presets/ 换句话说:打包者本机有自己的 ~/.dsh/.agent-presets/, 但没打进包里INSTALL.md 也只交代了 Skills 与 OpenViking,这条链是断的。

调用点send() 里:

复制代码
const preset = summary?.projectionValues?.agentPreset;
if (sessionState.blank && preset !== agent.preset) {
  const selected = await ctx.remote.agentPresets.select(current, agent.preset);  // ← 这里抛
}

所以报错只发生在空白会话的第一条消息。已经固定成别的 preset 的会话会走另一条分支, 报「当前 Session 已固定为其他 Agent,请新建 DSH Session 后使用」。

排错时的一个坑 :变量名是驼峰 agentPreset用小写 preset 搜代码会漏掉调用点

5.3 修法:补建两个 preset(抄 standard,只换 persona)

dsh 的约定很清晰------目录名就是 preset id

复制代码
<DSH_HOME>/.agent-presets/
├── up-renewal-policy/          ← 目录名 = preset id,必须严格一致
│   ├── preset.yml              ← 只有 name / description / order
│   └── agent.cordis.yml        ← 组成文件(254 行)
└── up-renewal-research/
    ├── preset.yml
    └── agent.cordis.yml

关键做法 :以 随包交付、已验证可挂载的 standard preset 为底座,只替换 persona 那一段 。 本次实测生成的文件里,尾部 226 行与 standard 逐行一致(已 diff 核对)。

为什么这样做?因为 preset 就是一份插件组合清单 ------工具集(shell、文件读写检索、 Skills、网页检索、待办、提问、子代理、工作流、计划模式、上下文压缩)全在里面。 逐行照抄已验证可用的 standard、只换 persona,能一次到位且不引入任何挂载期未知数

persona 行的 schema 是定死的四个字段

复制代码
- id: persona
  name: '@deepseek-ai/dsh-persona'
  config:
    prefix: |-                  # 必填。用 | 字面块保留换行
      你是......                    #   (用 > 折叠块会把 bullet 列表挤成一整段)
    suffix: 当前工作目录:{{cwd}}。   # 可选
    # complete: true            # 可选:让 prefix 成为完整系统提示词(会压掉 suffix 与所有其他段)
    # includeRuntimeContext: false   # 可选:关掉动态运行上下文快照

{``{...}}严格插值 ,未注册的变量会报错({``{cwd}} 是有效的)。

5.4 为什么不用重启 dsh

dsh-agent-presets 的 discovery 不做记忆化list() / resolve() 每次调用都重扫根目录。 源码原话:

"Discovery re-reads the roots on every call so a preset authored while the process is running is visible without a restart."

而且 resolvedRoots 在构造期就无条件把 <DSH_HOME>/.agent-presets 拼进列表 (不检查目录是否存在),目录晚建也不影响。

补完 preset 直接刷新页面就生效,不需要重启 dsh web。 这条省掉一次瞎折腾。

5.5 验证:别只看文件在不在

文件在但组成非法一样会失败。权威做法是用 dsh 自己的 discovery 模块跑判定 (直接 import 它的 scanRoot / discoverPresets),覆盖 YAML 方言、行结构、 每个 name 指向的包能否解析:

复制代码
=== 断言:插件引用的 preset 是否都在 roster 里且健康 ===
  [PASS] up-renewal-policy    →  trust=user  显示名=城策 · 政策咨询与解读
  [PASS] up-renewal-research  →  trust=user  显示名=城策 · 海外项目研判

两个参数类型不一致,很容易写错scanRoot(root, harnessBase)root.path 要的是普通文件系统路径 (传 file:// URL 会被拼成乱路径、静默返回空列表 ); 而 harnessBase 反过来要的是 URL一定要带对照组:把随包的 4 个内置 preset 一起扫,它们也必须健康。 如果连内置的都报 broken,那是参数传错了,不是你的 preset 有问题。

⚠️ 但 discovery 判不了"挂载期抛错" :它能确认模块可解析 , 但插件加载后抛异常只会在真实创建会话时失败。所以"全部健康"是必要不充分条件。


六、最隐蔽的坑:服务挂在"启动它的那个会话"上

这一节是本文最有价值的部分------因为它平时不报错,只在"你以为一切正常"之后才咬你。

6.1 现象

如果你是在 AI 助手 / 终端的会话里把服务跑起来的(比如全程让助手帮你装、帮你启), 会埋一个很隐蔽的雷:

  • 助手明明说「服务已就绪、路由全部 200」,过一会儿却 fetch failed / 连接被拒
  • 想清理助手留下的后台任务,却不敢停------一停服务就没了

根因:AI 会话用 Windows Job Object 管理命令的子进程,一条命令结束,整棵进程树就被回收

我的实测现场就很典型------两个服务的处境完全相反

服务 父链顶端 结论
3080 dsh web explorer.exe(我自己双击快捷方式启的) ✅ 独立
1933 OpenViking 一路追到 WorkBuddy.exe --serve --session-id ... 挂在本会话沙箱上

实测把承载它的那个后台任务 kill 掉,1933 立刻释放。后台任务一停,服务就没了。

6.2 怎么判:别用 IsProcessInJob

❌ 别用 IsProcessInJob(h, NULL, &r) ------本机实测语义不可靠:对一个已经证明独立 的进程 (父链顶端是 explorer、且跨多轮工具调用存活),它仍返回 True,与父链结论直接矛盾。

✅ 可靠判据是「是否属于沙箱根的进程树」

复制代码
沙箱根 = sandbox-cli.exe  +  WorkBuddy.exe --serve --session-id ...
         (本机实测:前者后代 7 个、后者后代 17 个,并集 18 个进程)

在树内 → 命令结束 / 会话结束必被回收;不在树内 → 独立。

6.3 修法:让服务挂在资源管理器下

复制代码
import subprocess
subprocess.Popen([r'C:\Windows\explorer.exe',
                  r'C:\Users\<你>\.dsh\bin\openviking.cmd'],
                 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

explorer 会把请求转给已运行的 shell 实例去执行,新进程的父链顶端是 explorer.exe不再属于会话的 job 。之后停后台任务、关会话都不影响服务, 而且保留可见的控制台窗口------关窗口 = 停服务,符合直觉。

数据无损:data/vectordbdata/viking、sqlite 均落盘,WAL 自动重放,10 个知识库完好。

父链断 ≠ 有问题 :用 explorer 转发启动时,中间那个临时宿主 (explorer.exe /factory,{...} -Embedding)会很快退出,之后父链就查不到顶端了。 以「是否在沙箱进程树内」为准。

6.4 硬证据:心跳对照实验

判据给结论,心跳实验给证据------同一个脚本两种启动方式,跨两次工具调用看日志是否还在增长:

启动方式 结果(实测)
A explorer.exe <脚本> 5 行 → 29 行,仍在写 ✅ 存活
B 会话内 cmd /c start 5 行 → 5 行,时间戳停住 ❌ 被回收

关键点:必须跨越两次工具调用才能看出差别(同一条命令内两组都活着)。

另外记住:run_in_background 跑虽然能常驻,但服务仍是挂在会话沙箱上的------ 所以"助手留下的后台任务能不能清"这个问题,答案取决于服务当初是怎么起来的。


七、交付物:4 个只读排障脚本

光有结论不够,我把上面每一节的判定都固化成了脚本。全部只读、不改你的数据 , 且去掉了硬编码用户名路径 (改为 --dsh-home / 环境变量自适应),可以直接给别人用:

脚本 回答什么问题
chengce-doctor.py 知识库 / 技能管理页不通,是哪一环坏了
verify_presets.mjs preset not found我补的 preset 到底对不对
make_chengce_presets.py 帮我把缺的 preset 生成出来(幂等)
win-proc-tree.py 清理后台任务会不会把服务一起带走

chengce-doctor.py 是这个工具箱的入口:输出端口状态、OpenViking 直连、 三类插件路由、preset 与 skill 就位情况,最后直接给结论------ "OpenViking 没跑" / "服务端自身异常" / "全部正常" / "403 非同源"。

复制代码
python chengce-doctor.py                 # 服务 + 路由 + preset + skill 一次查完
python chengce-doctor.py --dsh-port 3080 --ov-port 1933 --dsh-home "D:/dsh"
node   verify_presets.mjs                # 只看 preset 是否真的可挂载
python win-proc-tree.py --list-sandbox   # 看沙箱根是谁、树里有多少进程

脚本内部显式禁用了系统代理ProxyHandler({})),这一点很关键: Windows 上 python urllib 会读注册表里的系统代理 (Clash 这类工具写的), 导致探测 127.0.0.1 被劫持成 502 / WinError 10061看起来像服务挂了,其实请求根本没发出去 。 顺带一提:curl 只读环境变量、Node 的 fetch 也不走系统代理------ 所以只有 python 探测需要这个处理

两个脚本都在正常态与故障态各跑了一遍做验证------故障态是主动 kill 掉 OpenViking 实测的 (冷启动 18 秒后恢复)。

📎 文件怎么拿 :CSDN 博客不支持附件下载,所以我把这 4 个脚本的完整源码内联在文末 附录 B 里 (4 段共 852 行),直接复制存成同名文件即可运行 ,无需任何改动。 依赖说明:chengce-doctor.pywin-proc-tree.py 只用 Python 标准库(后者零依赖, 仅用 ctypes);verify_presets.mjs 需要 Node(它要 import dsh 自己的 discovery 模块); make_chengce_presets.py 只用标准库。


八、以下三种情况"不是故障",别浪费时间

8.1 选某些专家提示「执行层尚未接入」

城策的 12 位专家里只有 2 位接了真实执行层availability: 'available' 且 preset 非 null), 其余 10 位是 plannedpreset: null。选其他专家时插件提示 「该专家的 DSH 执行层尚未接入」------这是设计如此

8.2 接口返回 403 仅允许本机同源访问

复制代码
function allowed(req) {
  const address = req.socket.remoteAddress;
  if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) return false;
  return req.headers['sec-fetch-site'] !== 'cross-site';
}

插件刻意只允许本机同源 访问。如果你通过反向代理、局域网 IP、或跨站嵌入访问 dsh, 所有接口都会 403。http://127.0.0.1:<port>/?token=... 直接访问即可。

8.3 知识库页能打开但列表是空的

两种可能,用体检脚本区分:

  • /libraries 返回 200 且有 10 条 → 只是还没上传文档,正常
  • /libraries 返回 502 ,或 /resources 返回 200 但 resources: []OpenViking 没跑(第三类"静默失败型")

九、复盘:可以带走的四条经验

  1. 区分"配置错"和"包错"。 我这次两个故障的根因都在发布包(漏打 presets/、 硬依赖写成"可选")。先读源码定位责任方,再动手改环境------否则会在自己的配置里 空转很久。读源码的成本,比乱试低一个数量级。

  2. 同一个错误码背后可能有三类行为。 502 统一兜底、/resources 静默吞错------ 如果只按"错误码"分类,就会把"页面能打开但列表为空"误判成"还没上传文档"。 按接口的源码行为分类,而不是按 HTTP 状态码分类。

  3. "能连上端口"不等于"服务独立"。 判断服务会不会被回收,必须看进程树归属 , 不能靠"我现在能访问"。而且 IsProcessInJob 这类 API 在真机上可能给出假阳性, 最终要用心跳对照实验拿硬证据

  4. 把排查结论固化成脚本,而不是文档。 文档会过期、会写错(我第一版手册就把 bootstrap 的用法写错了,实测才发现空数组不建库)。脚本每次跑都是最新的事实, 而且可以直接交给别人。


附录 A:路径与端口速查

默认位置
dsh web http://127.0.0.1:3080(必须带 ?token=...
OpenViking http://127.0.0.1:1933
插件安装位 <DSH_HOME>/profiles/<profile>/node_modules/<包名>/
插件注册 <DSH_HOME>/profiles/<profile>/cordis.patch.yml
内置 skill <用户目录>/.agents/skills/
知识库元数据 <DSH_HOME>/chengce-knowledge.json
用户 preset <DSH_HOME>/.agent-presets/(目录名 = preset id)
随包 preset <DSH_HOME>/profiles/node_modules/@deepseek-ai/dsh-agent-presets/presets/
OpenViking 配置 <用户目录>/.openviking/ov.conf
嵌入模型 <用户目录>/.cache/openviking/models/bge-small-zh-v1.5-f16.gguf

插件源码里硬编码的常量(改这些要同步改):

复制代码
const BASE      = '/chengce-knowledge/v1';                             // 路由前缀
const ROOT      = 'viking://resources/chengce';                        // OpenViking 里的库根
const META_FILE = join(homedir(), '.dsh', 'chengce-knowledge.json');   // ⚠️ 硬编码 .dsh
const SKILLS_DIR= join(homedir(), '.agents', 'skills');

⚠️ META_FILE 用的是 homedir() + '.dsh'不读 DSH_HOME 环境变量 。 如果你把 DSH_HOME 改到别处,插件的元数据仍会写到 ~/.dsh/------这是个已知的不一致点, 排查"配置写了但不生效"时要想到它。


最后一句 :dsh 目前是开发者预览版,官方明说会有破坏兼容性的变更。 本文的结论对 0.1.5-rc + 插件 1.1.0 + OpenViking 0.4.20 这组版本负责, 升级后请以实测为准------这也正是"把结论写成脚本"比"写成文档"更值钱的原因


附录 B:4 个排障脚本完整源码(复制即用)

CSDN 博客不支持附件下载 ,所以把源码直接内联在这里。 把下面 4 段分别存成同名文件即可运行,无需任何改动 。 路径全部做了回退(命令行参数 → 环境变量 → 默认值),没有硬编码用户名, 可以直接转给别人用。

顺序 文件 依赖 作用
B.1 chengce-doctor.py Python 标准库 一键体检,直接给结论
B.2 verify_presets.mjs Node preset 权威判定
B.3 make_chengce_presets.py Python 标准库 幂等补建 preset
B.4 win-proc-tree.py 零依赖(ctypes) 沙箱归属诊断

B.1 chengce-doctor.py

作用:城策插件一键体检:端口 → OpenViking 直连 → 三类插件路由 → preset / skill → 结论。

依赖 :只用 Python 标准库。最后一行直接给结论,不用自己读表格。

用法

复制代码
python chengce-doctor.py
python chengce-doctor.py --dsh-port 3080 --ov-port 1933 --dsh-home "D:/dsh"

完整源码(233 行 / 10689 字节):

复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""城策插件(dsh-client-ui-urban-planning-agent-workbench)一键体检。

为什么需要它:装完城策后常见两种报错长得完全不同、成因却都在"依赖没到位"------
  ① 知识库 / 技能管理页报 fetch failed(控制台 502)
  ② 新建会话发第一条消息报 preset "..." not found
本脚本用一条命令把 ① 的故障面切开,并顺带检查 ② 的两个 preset 是否就位。

用法:
    python chengce-doctor.py
    python chengce-doctor.py --dsh-port 3080 --ov-port 1933 --dsh-home "D:/dsh"

判定原理(来自插件源码 src/host.js):
    BASE = /chengce-knowledge/v1,所有分支的 catch 统一 `json(res, 502, {ok,error})`,
    所以 **502 永远只等于"插件转发给 OpenViking 失败"**,不代表 dsh 挂了。
    其中 /skills /expert-bindings /skill-configs 只读写本地文件,**不碰 OpenViking**;
    /health /libraries /resources /upload /search 才会转发 OpenViking。
"""
import argparse
import json
import os
import socket
import sys
import urllib.error
import urllib.request

try:                                     # 中文输出在重定向到文件时也别乱码
    sys.stdout.reconfigure(encoding='utf-8', errors='replace')
except Exception:                        # noqa: BLE001
    pass

# 本机没有 HTTP_PROXY 环境变量,但 urllib 在 Windows 上会读注册表里的系统代理
# (FlClash / Clash 这类工具写的),导致探测 127.0.0.1 被劫持成 502 ------ 必须显式绕过。
OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))

# 直接 await ov(...) 的接口:OpenViking 不在就抛错 → 插件统一 catch 成 502
NEEDS_OV = [
    ('GET',  '/health',    '服务健康(同时打 OV 的 /health + /ready)'),
    ('GET',  '/libraries', '知识库列表'),
]
# 源码里给每个 ov() 挂了 .catch(() => []):**恒返回 200**,OV 不在时静默给空数组。
# 所以它不能用来判断 OV 是否健康 ------ 反过来,"页面打得开但列表永远空" 可能正是 OV 挂了。
SILENT_ON_FAIL = [
    ('GET',  '/resources?libraryId=policy', '知识库内资源(OV 挂时静默返回空,仍 200)'),
]
# 只读写本地文件的接口(OV 在不在都该 200)
LOCAL_ONLY = [
    ('GET',  '/skills',          '读 <agentsHome>/skills'),
    ('GET',  '/expert-bindings', '读 <DSH_HOME>/chengce-knowledge.json'),
    ('GET',  '/skill-configs',   '读 <DSH_HOME>/chengce-knowledge.json'),
]

PRESETS = ['up-renewal-policy', 'up-renewal-research']
SKILLS = ['u-policy', 'o-s1', 'o-s1-01', 'o-s1-02', 'o-s1-03', 'o-s1-04', 'o-s1-05', 'o-s1-06']


def port_open(port, host='127.0.0.1'):
    sock = socket.socket()
    sock.settimeout(1.5)
    try:
        return sock.connect_ex((host, port)) == 0
    finally:
        sock.close()


def call(url, method='GET', body=None, timeout=15):
    headers = {'User-Agent': 'chengce-doctor', 'Accept': 'application/json'}
    data = None
    if body is not None:
        data = json.dumps(body).encode('utf-8')
        headers['content-type'] = 'application/json'
    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        with OPENER.open(req, timeout=timeout) as resp:
            return resp.status, resp.read(3000).decode('utf-8', 'replace')
    except urllib.error.HTTPError as exc:
        return exc.code, exc.read(3000).decode('utf-8', 'replace')
    except Exception as exc:                                  # noqa: BLE001
        return None, '%s: %s' % (type(exc).__name__, exc)


def line(mark, label, detail):
    print('  %-4s %-42s %s' % (mark, label, detail))


def probe_group(title, base, items, results):
    print()
    print('=' * 88)
    print(title)
    print('=' * 88)
    for method, path, why in items:
        code, text = call(base + path, method, None)
        flat = ' '.join(text.split())
        mark = 'OK' if code == 200 else 'FAIL'
        line(mark, '%s %s' % (method, path[:36]), 'HTTP %-5s %s' % (code, flat[:96]))
        print('       └ %s' % why)
        results.append((path, code, flat))
    return results


def main():
    ap = argparse.ArgumentParser(description='城策插件一键体检')
    ap.add_argument('--dsh-port', type=int, default=3080, help='dsh web 端口,默认 3080')
    ap.add_argument('--ov-port', type=int, default=1933, help='OpenViking 端口,默认 1933')
    ap.add_argument('--dsh-home', default=os.environ.get('DSH_HOME') or os.path.join(os.path.expanduser('~'), '.dsh'),
                    help='DSH_HOME,默认 ~/.dsh')
    ap.add_argument('--agents-home', default=os.environ.get('DSH_AGENTS_HOME') or os.path.join(os.path.expanduser('~'), '.agents'),
                    help='技能根目录,默认 ~/.agents')
    args = ap.parse_args()

    dsh = 'http://127.0.0.1:%d' % args.dsh_port
    ov = 'http://127.0.0.1:%d' % args.ov_port
    base = dsh + '/chengce-knowledge/v1'

    print('城策插件体检')
    print('  dsh web     %s' % dsh)
    print('  OpenViking  %s' % ov)
    print('  DSH_HOME    %s' % args.dsh_home)

    # ---------- 1. 端口 ----------
    ov_up = port_open(args.ov_port)
    dsh_up = port_open(args.dsh_port)
    print()
    print('=' * 88)
    print('1. 端口')
    print('=' * 88)
    line('OK' if dsh_up else 'FAIL', 'dsh web  127.0.0.1:%d' % args.dsh_port, '监听中' if dsh_up else '未监听')
    line('OK' if ov_up else 'FAIL', 'OpenViking 127.0.0.1:%d' % args.ov_port, '监听中' if ov_up else '未监听')

    # ---------- 2. OpenViking 直连 ----------
    print()
    print('=' * 88)
    print('2. OpenViking 服务端直连')
    print('=' * 88)
    if ov_up:
        for path in ('/health', '/ready'):
            code, text = call(ov + path)
            flat = ' '.join(text.split())
            mark = 'OK' if code == 200 else 'FAIL'
            line(mark, 'GET %s' % path, 'HTTP %-5s %s' % (code, flat[:96]))
    else:
        line('SKIP', '(服务未监听,跳过)', '先启动 OpenViking 再看这一节')

    if not dsh_up:
        print()
        print('  结论:dsh web 没在监听,先启动它(插件路由由 dsh 提供)。')
        return 1

    # ---------- 3. 插件路由 ----------
    needs = probe_group('3. 插件路由 ------ 直接转发 OpenViking(OV 没跑就必然 502)', base, NEEDS_OV, [])
    silent = probe_group('3b. 插件路由 ------ 静默失败型(源码带 .catch,恒 200,要看内容)',
                         base, SILENT_ON_FAIL, [])
    local = probe_group('4. 插件路由 ------ 只读写本地文件(OV 在不在都该 200)', base, LOCAL_ONLY, [])

    # ---------- 5. preset / skill / 元数据 ----------
    print()
    print('=' * 88)
    print('5. agent preset 与内置 skill')
    print('=' * 88)
    preset_root = os.path.join(args.dsh_home, '.agent-presets')
    preset_ok = True
    for pid in PRESETS:
        d = os.path.join(preset_root, pid)
        comp = os.path.join(d, 'agent.cordis.yml')
        meta = os.path.join(d, 'preset.yml')
        good = os.path.isfile(comp) and os.path.isfile(meta)
        preset_ok &= good
        line('OK' if good else 'FAIL', pid,
             'agent.cordis.yml + preset.yml 就位' if good else '缺文件(目录:%s)' % d)
    if not preset_ok:
        print('       └ 权威判定请另跑:node verify_presets.mjs')

    skills_root = os.path.join(args.agents_home, 'skills')
    hits = 0
    for sid in SKILLS:
        if os.path.isfile(os.path.join(skills_root, sid, 'SKILL.md')):
            hits += 1
    line('OK' if hits == len(SKILLS) else 'FAIL', '内置 skill %d/%d' % (hits, len(SKILLS)),
         skills_root if hits == len(SKILLS) else '缺少部分 skill,postinstall 可能被跳过')

    meta_file = os.path.join(args.dsh_home, 'chengce-knowledge.json')
    if os.path.isfile(meta_file):
        try:
            m = json.load(open(meta_file, encoding='utf-8'))
            libs = m.get('libraries') or []
            line('OK', 'chengce-knowledge.json', '%d 个知识库已登记,%d 位专家绑定'
                 % (len(libs), len(m.get('expertBindings') or {})))
        except Exception as exc:                              # noqa: BLE001
            line('FAIL', 'chengce-knowledge.json', '解析失败: %s' % exc)
    else:
        line('FAIL', 'chengce-knowledge.json', '不存在(知识库页首次打开会自动创建,也可 POST /bootstrap)')

    # ---------- 6. 结论 ----------
    needs_codes = dict((p, c) for p, c, _ in needs)
    local_codes = dict((p, c) for p, c, _ in local)
    lib_code = needs_codes.get('/libraries')
    # 静默型:返回 200 但 body 里列表为空 → 源码那层 catch 吞掉了 OpenViking 的错误
    silent_empty = any('"resources":[]' in t.replace(' ', '') for _, _, t in silent)

    print()
    print('=' * 88)
    print('结论')
    print('=' * 88)
    if not ov_up and lib_code == 502:
        print('  ▸ OpenViking 没跑,而插件依赖它 ------ 这就是「fetch failed / 502」的原因。')
        print('    修:启动 OpenViking 服务(见手册第 3 节),再刷新页面。')
    elif ov_up and lib_code == 502:
        print('  ▸ OpenViking 在监听,但转发仍失败 ------ 问题在服务端自身。')
        print('    查:OV 启动日志是否报 EmbeddingConfigurationError(缺本地嵌入模型或 llama-cpp-python)。')
    elif lib_code == 200 and all(c == 200 for c in local_codes.values()):
        print('  ▸ 插件路由全部正常 ------ 知识库 / 技能管理页应当可用。')
    elif lib_code is None:
        print('  ▸ 连不上 dsh 的插件路由 ------ 确认插件已注册到 profile 的 cordis.patch.yml 并重启过 dsh。')
    elif any(c == 403 for c in local_codes.values()):
        print('  ▸ 返回 403「仅允许本机同源访问」------ 你在通过代理 / 远程地址访问 dsh。')
        print('    修:用 http://127.0.0.1:<port>/?token=... 直接访问,别走反代。')
    else:
        print('  ▸ 状态混合,见上面各行的 FAIL。')

    if silent_empty and not ov_up:
        print('  ▸ 注意:/resources 仍是 200 但内容为空 ------ 它是"静默失败型"(源码里带 catch),')
        print('    所以"知识库页打得开、里面永远没东西"同样是 OpenViking 没跑的表现。')

    if not preset_ok:
        print('  ▸ 另有 preset 缺失 ------ 新建会话发第一条消息会报 preset "..." not found。')
        print('    修:见手册第 4 节,补建到 <DSH_HOME>/.agent-presets/ 下。')

    return 0


if __name__ == '__main__':
    sys.exit(main())

B.2 verify_presets.mjs

作用:preset 权威判定:直接 import dsh 自己的 discovery 模块,判定与运行时完全一致。

依赖 :需要 Node(因为要 import @deepseek-ai/dsh-agent-presets)。 会自动从插件源码里提取 preset id,插件升级换了 id 它也跟着变。

用法

复制代码
node verify_presets.mjs
node verify_presets.mjs --dsh-home "D:/dsh"

完整源码(125 行 / 5344 字节):

复制代码
// 验证城策插件硬编码引用的 agent preset 是否真的可用。
//
// 为什么不能只看文件在不在:preset 是「插件组合」,文件存在 ≠ 组成合法 ≠ 模块能解析。
// 所以这里不重新实现扫描规则,而是直接调用 dsh-agent-presets 自己的
// scanRoot / discoverPresets ------ 判定与运行时完全一致(涵盖 YAML 方言、行结构、模块可解析性)。
//
// 用法:
//   node verify_presets.mjs
//   node verify_presets.mjs --dsh-home "D:/dsh" --plugin "D:/dsh/profiles/web/node_modules/dsh-client-ui-urban-planning-agent-workbench"

import { pathToFileURL } from 'node:url';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

// ---------- 参数 ----------
const argv = process.argv.slice(2);
function arg(name, fallback) {
  const i = argv.indexOf('--' + name);
  return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
}
const DSH_HOME = (arg('dsh-home', process.env.DSH_HOME) || path.join(os.homedir(), '.dsh')).replace(/\\/g, '/');
const PLUGIN_NAME = 'dsh-client-ui-urban-planning-agent-workbench';

function firstExisting(candidates) {
  return candidates.find((p) => fs.existsSync(p));
}

// dsh-agent-presets 可能装在 profiles 根或某个 profile 下(hoisted / 非 hoisted 都可能)
const PROFILES = path.join(DSH_HOME, 'profiles');
const profileDirs = fs.existsSync(PROFILES)
  ? fs.readdirSync(PROFILES, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
  : [];
const PRESET_PKG = firstExisting([
  path.join(PROFILES, 'node_modules/@deepseek-ai/dsh-agent-presets'),
  ...profileDirs.map((p) => path.join(PROFILES, p, 'node_modules/@deepseek-ai/dsh-agent-presets')),
]);
if (!PRESET_PKG) {
  console.error('找不到 @deepseek-ai/dsh-agent-presets ------ 检查 --dsh-home 是否正确:' + DSH_HOME);
  process.exit(2);
}

// harnessBase 必须是 URL;要给「某个 profile 目录」,packageInstalled 会从它逐级向上找 node_modules
const HARNESS_DIR = firstExisting([
  path.join(PROFILES, 'web'),
  ...profileDirs.map((p) => path.join(PROFILES, p)),
]);
const HARNESS_BASE = pathToFileURL(HARNESS_DIR + '/').href;

// ---------- 找出插件实际引用的 preset id(不硬编码,跟着插件源码走)----------
const pluginDir = arg('plugin', firstExisting([
  path.join(PROFILES, 'web/node_modules', PLUGIN_NAME),
  ...profileDirs.map((p) => path.join(PROFILES, p, 'node_modules', PLUGIN_NAME)),
]));

const WANT = [];
if (pluginDir && fs.existsSync(pluginDir)) {
  for (const rel of ['src/config/branding.js', 'src/config/experts.js']) {
    const f = path.join(pluginDir, rel);
    if (!fs.existsSync(f)) continue;
    const text = fs.readFileSync(f, 'utf8');
    for (const m of text.matchAll(/preset:\s*'([^']+)'/gu)) {
      if (m[1] !== 'null' && !WANT.includes(m[1])) WANT.push(m[1]);
    }
  }
}

// ---------- 跑 dsh 自己的 discovery ----------
const d = await import(pathToFileURL(path.join(PRESET_PKG, 'lib/types/discovery.js')).href);

// 注意:scanRoot 的 root.path 要「普通文件系统路径」(内部 resolve + 展开 ~),传 file:// 会静默返回空;
// harnessBase 反过来要 URL。这两个参数类型不一致,是最容易写错的地方。
const roots = [
  { path: path.join(PRESET_PKG, 'presets').replace(/\\/g, '/'), trust: 'system' },  // 对照组:随包 4 个内置
  { path: path.join(DSH_HOME, '.agent-presets'), trust: 'user' },
];

console.log('=== discovery 常量 ===');
console.log('  USER_PRESET_DIR     =', d.USER_PRESET_DIR);
console.log('  SHIPPED_PRESET_ROOT =', d.SHIPPED_PRESET_ROOT);
console.log('  harnessBase         =', HARNESS_BASE);
console.log('  插件目录            =', pluginDir || '(未找到)');
console.log();

console.log('=== 逐根扫描 ===');
for (const root of roots) {
  const found = await d.scanRoot(root, HARNESS_BASE);
  console.log(`[${root.trust}] ${root.path}`);
  if (!found.length) console.log('   (无)');
  for (const p of found) {
    console.log(`   ${p.id.padEnd(22)} trust=${String(p.trust).padEnd(6)} order=${String(p.order ?? '-').padEnd(4)} name=${p.name ?? '(无)'}`);
    console.log(`      健康: ${p.broken === undefined ? 'OK' : 'BROKEN: ' + p.broken}`);
  }
  console.log();
}

const roster = await d.discoverPresets(roots, HARNESS_BASE);
console.log('=== 合并 roster ===');
console.log('共', roster.length, '个:', roster.map((p) => p.id).join(', '));
console.log();

if (!WANT.length) {
  console.log('未从插件源码里提取到 preset 引用,跳过断言。');
  process.exit(0);
}

console.log('=== 断言:插件引用的 preset 是否都在 roster 里且健康 ===');
let ok = true;
for (const id of WANT) {
  const hit = roster.find((p) => p.id === id);
  if (!hit) {
    console.log(`  [FAIL] ${id} ------ 不在 roster 中(新建会话会报 preset "${id}" not found)`);
    ok = false;
  } else if (hit.broken !== undefined) {
    console.log(`  [FAIL] ${id} ------ 存在但 broken: ${hit.broken}`);
    ok = false;
  } else {
    console.log(`  [PASS] ${id}  →  trust=${hit.trust}  显示名=${hit.name}`);
  }
}
console.log();
console.log(ok
  ? '结论:插件引用的 preset 均可被发现且组成合法。'
  : '结论:仍有问题,见上方 FAIL。补建方法见手册第 4 节。');
process.exit(ok ? 0 : 1);

B.3 make_chengce_presets.py

作用:幂等补建缺失的两个 agent preset(以随包的 standard 为底座,只替换 persona 行)。

依赖 :只用 Python 标准库。可重复运行------已存在的同名目录会被覆盖。

用法

复制代码
python make_chengce_presets.py
python make_chengce_presets.py --dsh-home "D:/dsh"

完整源码(192 行 / 9316 字节):

复制代码
# -*- coding: utf-8 -*-
"""补齐城策插件缺失的两个 agent preset(修 preset "..." not found)。

背景
----
城策工作台插件(dsh-client-ui-urban-planning-agent-workbench@1.1.0)的
src/config/branding.js 硬编码了 preset id:
    up-renewal-policy   ← U-E1 政策咨询推送与解读专家
    up-renewal-research ← S1 海外项目研判决策专家
但发布包 package.json 的 files 字段只有 lib/ src/ skills/ scripts/ ------
**没有任何 presets/**。这两个 preset 从未随包交付。

于是插件在空白会话首次发送时调用
    ctx.remote.agentPresets.select(sessionId, agent.preset)
服务端在 roster 里找不到该 id,抛:
    agent-presets: preset "up-renewal-policy" not found (available: standard, ptc, minimal, cordis)

修法
----
按 dsh 的既定约定(discovery.js 的 USER_PRESET_DIR = '.agent-presets',根为 <DSH_HOME>/.agent-presets)
在本机补建这两个 preset。组成文件以随包交付、已验证可挂载的 standard preset 为底座,
**只替换 persona 行**,保证工具集完整(shell / fs / 检索 / Skills / 网页 / 子代理 / 计划 / 压缩),
且不引入任何挂载期未知数。

用法
----
    python make_chengce_presets.py
    python make_chengce_presets.py --dsh-home "D:/dsh"

覆盖行为:已存在同名目录会被覆盖(幂等,可重复跑)。
"""

import argparse
import glob
import io
import os
import sys


# ── 两个 preset 的定义 ────────────────────────────────────────────────────────

POLICY_PERSONA = """\
你是「城策」工作台的城市更新政策咨询推送与解读专家,负责政策资讯的采集、去重、\
时效过滤、适用性解读,以及政策周报与重点政策汇报。

工作准则:
- 结论前置,先给判断再给依据,不要用铺垫开场。
- 每条政策结论都必须给出信息来源:发文机关、文号、发布日期、适用对象、有效期。缺哪项就写明缺哪项。
- 区分「已核实」与「待核实」。无法确认的字段标注 [待核实],不要补全成看似确定的表述。
- 时效优先:先确认政策是否现行有效,再判断适用性;已废止、已到期或被新文件替代的必须标注。
- 去重以「发文机关 + 文号」为主键;同一政策的多篇转载合并为一条,保留最权威的来源。
- 涉及金额、比例、期限、口径等关键内容时保留原文表述,不做换算、不做推断。
- 可调用 u-policy 技能,以及知识库检索与网页检索工具。引用知识库内容时给出库名与文档标识。
- 交付物默认使用简体中文与 Markdown;表格优先于长段落。"""

RESEARCH_PERSONA = """\
你是「城策」工作台的海外项目前期研判决策专家(S1),是项目前期第一道决策闸门。

工作准则:
- 结论前置:先给出「是否跟进」的明确判断,再给证据与不确定性。
- 六个研判面向必须逐个交代:机会、准入、市场、风险、价值、推进建议。缺证据的面向显式记为缺口,不臆测。
- 关键事实必须可追溯:给出信息来源与出处;多源冲突时并列呈现,并说明取舍理由。
- 严格区分「事实」「推断」「假设」三类内容,分别标注,不要混写成一种语气。
- 高风险项给出触发条件与应对建议,不做无依据的乐观陈述。
- 可调用 o-s1 系列技能(机会战略、宏观可行性、市场、风险、价值、推进建议)以及知识库检索与网页检索工具。引用知识库内容时给出库名与文档标识。
- 你负责备齐证据与草案,最终结论由主管或领导确认;请明确标出需要人工决策的节点。
- 交付物默认使用简体中文与 Markdown;表格优先于长段落。"""

PRESETS = [
    {
        'id': 'up-renewal-policy',
        'name': '城策 · 政策咨询与解读',
        'description': '面向城市更新的政策资讯采集、去重、时效过滤与适用性解读。'
                       '产出带来源的政策卡、政策周报与重点政策汇报。',
        'order': 10,
        'persona': POLICY_PERSONA,
        'banner': '城市更新政策咨询推送与解读专家(U-E1)',
    },
    {
        'id': 'up-renewal-research',
        'name': '城策 · 海外项目研判',
        'description': '海外项目前期机会、准入、市场、风险与价值研判,'
                       '输出是否跟进结论与 S2---S6 阶段路由建议。',
        'order': 11,
        'persona': RESEARCH_PERSONA,
        'banner': '海外项目前期研判决策专家(S1)',
    },
]

HEADER = """\
# 城策工作台的 agent preset:{banner}
#
# 本文件由脚本补建,因为城策插件(dsh-client-ui-urban-planning-agent-workbench@1.1.0)
# 硬编码了 preset id '{pid}',但发布包未携带该 preset ------ 装机后选择专家并在空白
# 会话发送消息,会报 agent-presets: preset "{pid}" not found。
#
# 组成与随包的 standard preset 等价(仅 persona 行不同),因此工具集完整:
# shell、文件读写与检索、Skills、网页检索、待办、提问、子代理、工作流、计划模式、上下文压缩。
#
# 位置:<dshHome>/.agent-presets/{pid}  ------ 目录名即 preset id。
# 本 preset 无需迁移数据;正在运行的会话停留在它启动时的组成上,新建会话才采用新版本。
"""

PERSONA_BLOCK = """\
- id: persona
  name: '@deepseek-ai/dsh-persona'
  config:
    suffix: 当前工作目录:{{{{cwd}}}}。
    prefix: |-
{prefix}

"""


def indent(text, spaces):
    pad = ' ' * spaces
    return '\n'.join(pad + line if line.strip() else '' for line in text.split('\n'))


def find_standard(dsh_home):
    """定位随包交付的 standard preset 组成文件。"""
    candidates = [
        os.path.join(dsh_home, 'profiles', 'node_modules', '@deepseek-ai',
                     'dsh-agent-presets', 'presets', 'standard', 'agent.cordis.yml'),
        os.path.join(dsh_home, 'profiles', 'web', 'node_modules', '@deepseek-ai',
                     'dsh-agent-presets', 'presets', 'standard', 'agent.cordis.yml'),
    ]
    candidates += glob.glob(os.path.join(
        dsh_home, 'profiles', '*', 'node_modules', '@deepseek-ai',
        'dsh-agent-presets', 'presets', 'standard', 'agent.cordis.yml'))
    for path in candidates:
        if os.path.isfile(path):
            return path
    return None


def build_composition(preset, standard_text):
    """以 standard 为底座,替换 persona 行。"""
    anchor = '- id: agent-instructions'
    idx = standard_text.index(anchor)          # 这一行之后全部照抄(含 agent-instructions 等)
    new_header = HEADER.format(banner=preset['banner'], pid=preset['id'])
    persona = PERSONA_BLOCK.format(prefix=indent(preset['persona'], 6))
    return new_header + persona + standard_text[idx:]


def main():
    ap = argparse.ArgumentParser(description='补齐城策插件缺失的两个 agent preset')
    ap.add_argument('--dsh-home', default=os.environ.get('DSH_HOME') or os.path.join(os.path.expanduser('~'), '.dsh'))
    args = ap.parse_args()
    dsh_home = args.dsh_home

    standard_path = find_standard(dsh_home)
    if not standard_path:
        print('找不到随包的 standard preset 组成文件,请确认 --dsh-home 是否正确:%s' % dsh_home)
        print('可找的位置:<DSH_HOME>/profiles[/<profile>]/node_modules/@deepseek-ai/dsh-agent-presets/presets/standard/agent.cordis.yml')
        return 1
    print('底座组成:%s' % standard_path)

    standard_text = io.open(standard_path, encoding='utf-8').read()
    user_root = os.path.join(dsh_home, '.agent-presets')
    os.makedirs(user_root, exist_ok=True)
    print('用户 preset 根目录:%s' % user_root)
    print()

    for preset in PRESETS:
        target = os.path.join(user_root, preset['id'])
        print('[%s] %s' % ('覆盖' if os.path.isdir(target) else '新建', target))
        os.makedirs(target, exist_ok=True)

        # preset.yml ------ 只放展示元数据;没有 id / trust 字段(那两个由目录名与所在根决定)
        meta = 'name: %s\ndescription: %s\norder: %d\n' % (
            preset['name'], preset['description'], preset['order'])
        with io.open(os.path.join(target, 'preset.yml'), 'w', encoding='utf-8', newline='\n') as fh:
            fh.write(meta)

        comp = build_composition(preset, standard_text)
        with io.open(os.path.join(target, 'agent.cordis.yml'), 'w', encoding='utf-8', newline='\n') as fh:
            fh.write(comp)

        print('    preset.yml        %6d bytes' % os.path.getsize(os.path.join(target, 'preset.yml')))
        print('    agent.cordis.yml  %6d bytes  (%d 行)'
              % (os.path.getsize(os.path.join(target, 'agent.cordis.yml')), comp.count('\n') + 1))
        print()

    print('完成。dsh-agent-presets 的 discovery 不做记忆化,会重扫根目录,')
    print('所以**不需要重启 dsh web** ------ 刷新页面即可生效。')
    print('权威校验:node verify_presets.mjs')
    return 0


if __name__ == '__main__':
    sys.exit(main())

B.4 win-proc-tree.py

作用:进程树 / 沙箱归属诊断:判断服务会不会随会话被回收,决定后台任务能不能放心清。

依赖零第三方依赖 ,只用 ctypes 调 Win32 API。含 --explain-heartbeat 打印心跳对照实验做法。

用法

复制代码
python win-proc-tree.py                      # 默认查 1933 与 3080
python win-proc-tree.py --list-sandbox       # 列出沙箱根进程及树规模
python win-proc-tree.py --explain-heartbeat  # 心跳对照实验的做法与实测结果

完整源码(302 行 / 11388 字节):

复制代码
# -*- coding: utf-8 -*-
"""win-proc-tree.py ------ Windows 进程树 / 沙箱归属诊断(无外部依赖,只用 ctypes)

## 解决什么问题

本机的 WorkBuddy 会话用 **Windows Job Object** 管理命令的子进程:一条命令结束时
整棵进程树被回收。被回收的服务表现为「刚才还通,下一条命令就 10061 / 连接被拒」。
所以日志里那个常驻服务,到底是能活、还是会掉,**只看端口通不通分不出来**。

## 判据(按可靠性排序)

1. **主判据:是否属于沙箱根的进程树**。
   沙箱根 = `sandbox-cli.exe` 和命令行含 `--serve` 的 `WorkBuddy.exe`。
   目标进程是它们的后代 → 会被回收;不是 → 独立。

2. **佐证:父链顶端**。顶端是 `explorer.exe` / `services.exe` → 独立。
   ⚠️ 父链会断:用 `explorer.exe` 转发启动时,中间那个临时 shell 宿主进程
   (`explorer.exe /factory,{...} -Embedding`)会很快退出,之后父链就查不到了。
   所以**父链断裂不等于有问题**,要以上面第 1 条为准。

3. **⚠️ 不要用 `IsProcessInJob(h, NULL, &r)`**。实测在本机语义不可靠:
   对 `explorer.exe` 启动、父链已证明独立的进程,它仍报 `True`(与父链结论矛盾)。

4. **最终的硬证据是心跳对照实验**(见 `--explain-heartbeat`):
   同一脚本分别用两种方式启动,跨工具调用看谁的日志还在增长。

## 用法

    python win-proc-tree.py                      # 默认查 1933 与 3080
    python win-proc-tree.py --port 1933
    python win-proc-tree.py --port 3000,8080
    python win-proc-tree.py --pid 12345          # 直接查指定 PID
    python win-proc-tree.py --list-sandbox       # 列出沙箱根进程
    python win-proc-tree.py --explain-heartbeat  # 打印心跳对照实验步骤
"""
import argparse
import ctypes
import ctypes.wintypes as w
import datetime
import subprocess
import sys

try:
    sys.stdout.reconfigure(errors='replace')
except Exception:
    pass

k32 = ctypes.WinDLL('kernel32', use_last_error=True)
ntdll = ctypes.WinDLL('ntdll')

TH32CS_SNAPPROCESS = 0x00000002
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
ProcessCommandLineInformation = 60

SHELL_ROOTS = ('explorer.exe', 'services.exe', 'wininit.exe', 'winlogon.exe')
SANDBOX_EXE = ('sandbox-cli.exe',)


class PROCESSENTRY32W(ctypes.Structure):
    _fields_ = [
        ('dwSize', w.DWORD), ('cntUsage', w.DWORD), ('th32ProcessID', w.DWORD),
        ('th32DefaultHeapID', ctypes.POINTER(ctypes.c_ulong)), ('th32ModuleID', w.DWORD),
        ('cntThreads', w.DWORD), ('th32ParentProcessID', w.DWORD),
        ('pcPriClassBase', ctypes.c_long), ('dwFlags', w.DWORD),
        ('szExeFile', w.WCHAR * 260),
    ]


def snapshot():
    """{pid: (ppid, exe_name)}"""
    snap = k32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
    if snap == -1:
        return {}
    e = PROCESSENTRY32W()
    e.dwSize = ctypes.sizeof(PROCESSENTRY32W)
    out = {}
    ok = k32.Process32FirstW(snap, ctypes.byref(e))
    while ok:
        out[int(e.th32ProcessID)] = (int(e.th32ParentProcessID), e.szExeFile)
        ok = k32.Process32NextW(snap, ctypes.byref(e))
    k32.CloseHandle(snap)
    return out


def proc_info(pid):
    """返回 (cmdline, create_time);取不到则为 None。"""
    h = k32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
    if not h:
        return None, None
    try:
        cmd = None
        need = w.ULONG(0)
        ntdll.NtQueryInformationProcess(h, ProcessCommandLineInformation, None, 0,
                                        ctypes.byref(need))
        if need.value:
            buf = ctypes.create_string_buffer(need.value)
            if ntdll.NtQueryInformationProcess(h, ProcessCommandLineInformation, buf,
                                               need.value, ctypes.byref(need)) == 0:
                length = ctypes.cast(buf, ctypes.POINTER(ctypes.c_ushort))[0]
                # 64 位下 UNICODE_STRING 的 Buffer 指针在偏移 8
                ptr = ctypes.cast(ctypes.addressof(buf) + 8,
                                  ctypes.POINTER(ctypes.c_void_p))[0]
                if ptr and length:
                    cmd = ctypes.wstring_at(ptr, length // 2)

        created = None
        tc, te, tk, tu = (w.FILETIME(), w.FILETIME(), w.FILETIME(), w.FILETIME())
        if k32.GetProcessTimes(h, ctypes.byref(tc), ctypes.byref(te),
                               ctypes.byref(tk), ctypes.byref(tu)):
            v = (tc.dwHighDateTime << 32) | tc.dwLowDateTime
            if v:
                created = datetime.datetime.fromtimestamp(v / 10_000_000 - 11644473600)
        return cmd, created
    finally:
        k32.CloseHandle(h)


def clean(s):
    return ' '.join(s.split()) if s else ''


def sandbox_roots(snap):
    """沙箱根:sandbox-cli.exe,以及命令行含 --serve 的 WorkBuddy.exe"""
    roots = {}
    for pid, (_pp, name) in snap.items():
        n = (name or '').lower()
        if n in SANDBOX_EXE:
            roots[pid] = n
        elif n == 'workbuddy.exe':
            cmd, _ = proc_info(pid)
            if cmd and '--serve' in cmd:
                roots[pid] = n
    return roots


def descendants(snap, root):
    children = {}
    for pid, (ppid, _n) in snap.items():
        children.setdefault(ppid, []).append(pid)
    seen, stack = set(), [root]
    while stack:
        cur = stack.pop()
        for c in children.get(cur, []):
            if c not in seen:
                seen.add(c)
                stack.append(c)
    return seen


def sandbox_tree(snap):
    """所有沙箱根及其后代 PID 的并集"""
    tree = set()
    for root in sandbox_roots(snap):
        tree |= descendants(snap, root) | {root}
    return tree


def listening_pids(port):
    out = subprocess.run(['netstat', '-ano'], capture_output=True, text=True,
                         encoding='utf-8', errors='replace').stdout
    pids = set()
    for line in out.splitlines():
        if ':%d ' % port in line and 'LISTENING' in line.upper():
            try:
                pids.add(int(line.split()[-1]))
            except ValueError:
                pass
    return pids


def trace_up(snap, pid, maxdepth=12):
    chain, cur, seen = [], pid, set()
    for _ in range(maxdepth):
        if not cur or cur in seen:
            break
        seen.add(cur)
        ppid, name = snap.get(cur, (None, None))
        if name is None:
            cmd, _ = proc_info(cur)
            chain.append((cur, '(已退出/取不到)', clean(cmd)))
            break
        cmd, _ = proc_info(cur)
        chain.append((cur, name, clean(cmd)))
        if name.lower() in SHELL_ROOTS:
            break
        cur = ppid
    return chain


def report(snap, tree, pid, now):
    cmd, ct = proc_info(pid)
    name = snap.get(pid, (None, '(快照里没有)'))[1]
    age = ''
    if ct:
        age = '启动于 %s(%.0f 分钟前)' % (ct.strftime('%m-%d %H:%M:%S'),
                                          (now - ct).total_seconds() / 60)
    print('  ▶ PID %-7d %-24s %s' % (pid, name, age))
    print('     cmd : %s' % (clean(cmd)[:180] or '(取不到)'))

    chain = trace_up(snap, pid)
    print('     父链:')
    for i, (p, n, c) in enumerate(chain):
        if i == 0:
            print('       [%d] %s   ← 本进程' % (p, n))
        else:
            print('       %s[%d] %s' % ('  ' * i, p, n))
            if c:
                print('       %s    %s' % ('  ' * i, c[:160]))

    top = (chain[-1][1].lower() if chain else '')
    chain_broken = bool(chain) and chain[-1][1].startswith('(已退出')

    in_tree = pid in tree
    if in_tree:
        print('     判定: ⚠ 属于沙箱进程树 ------ 停后台任务 / 会话结束就会被回收')
    else:
        print('     判定: ✅ 不在沙箱进程树内 ------ 可放心清理后台任务')
        if top in SHELL_ROOTS:
            print('           佐证: 父链顶端是 %s' % chain[-1][1])
        elif chain_broken:
            print('           注: 父链已断(启动它的临时宿主已退出),这不代表有问题;')
            print('               若需硬证据,跑 --explain-heartbeat 的心跳对照实验。')
    print()


def main():
    ap = argparse.ArgumentParser(description='Windows 进程树 / 沙箱归属诊断')
    ap.add_argument('--port', default='1933,3080',
                    help='要查的端口,逗号分隔(默认 1933,3080)')
    ap.add_argument('--pid', type=int, action='append', default=None,
                    help='直接查指定 PID(可重复)')
    ap.add_argument('--list-sandbox', action='store_true', help='列出沙箱根进程')
    ap.add_argument('--explain-heartbeat', action='store_true',
                    help='打印心跳对照实验步骤')
    args = ap.parse_args()

    if args.explain_heartbeat:
        print('心跳对照实验(验证某进程能否脱离沙箱)')
        print('=' * 76)
        print('1. 写两个内容相同的 .cmd,都往自己的日志里循环追加时间戳:')
        print('     @echo off')
        print('     :loop')
        print('     echo %date% %time% alive >> "%~dp0_heartbeat.log"')
        print('     ping -n 3 127.0.0.1 >nul')
        print('     goto loop')
        print('2. A 组用 explorer 启动,B 组在沙箱内用 cmd /c start 启动:')
        print('     subprocess.Popen([r"C:\\Windows\\explorer.exe", HB])')
        print('     subprocess.Popen(["cmd","/c","start","","/min", CT])')
        print('3. 本次命令内先读一次基线行数;**下一次工具调用**再读一次。')
        print('4. 结论:日志停止增长的那组被回收了。')
        print()
        print('本机实测结果(2026-09-15,跨两次工具调用读数):')
        print('  A explorer 启动  : 5 行 -> 29 行,仍在写    ✅ 存活')
        print('  B 沙箱内启动     : 5 行 -> 5 行,时间戳停住  ❌ 被回收')
        print('=' * 76)
        return

    snap = snapshot()
    now = datetime.datetime.now()

    if args.list_sandbox:
        roots = sandbox_roots(snap)
        tree = sandbox_tree(snap)
        print('=' * 84)
        print('沙箱根进程(这些进程的后代会被回收)')
        print('=' * 84)
        if not roots:
            print('  (没找到 sandbox-cli.exe / WorkBuddy.exe --serve)')
        for pid, name in sorted(roots.items()):
            cmd, ct = proc_info(pid)
            n = len(descendants(snap, pid))
            print('  PID %-7d %-22s 后代 %d 个' % (pid, name, n))
            print('     %s' % clean(cmd)[:160])
        print()
        print('  沙箱树总规模: %d 个进程' % len(tree))
        print()

    tree = sandbox_tree(snap)

    if args.pid:
        print('=' * 84)
        print('指定 PID')
        print('=' * 84)
        for pid in args.pid:
            report(snap, tree, pid, now)

    ports = [int(p.strip()) for p in str(args.port).split(',') if p.strip().isdigit()]
    for port in ports:
        print('=' * 84)
        print('端口 %d' % port)
        print('=' * 84)
        pids = listening_pids(port)
        if not pids:
            print('  (无人监听)')
            print()
            continue
        for pid in sorted(pids):
            report(snap, tree, pid, now)


if __name__ == '__main__':
    main()

关于这 4 个脚本的一点说明:它们不是"辅助材料",而是本文每一条结论的

可执行形态。文档会过期、会写错(我第一版手册就把 bootstrap 的用法写错了,

实测才发现空数组不建库);脚本每次跑都是最新的事实

如果你只从这篇文章里带走一样东西,建议是它们,而不是上面的任何一段分析。

相关推荐
鲜于言悠90543 分钟前
AgentLoop
人工智能
猫哥随身wifi1 小时前
AI 手机越智能,随身网络越关键|AI 终端带来的网络新需求
网络·人工智能·智能手机
Mr数据杨1 小时前
arkav1920多标签文本分类实战解析与建模思路
人工智能·数据分析·kaggle竞赛
明月_清风1 小时前
MHS:AI Agent 开始连接物理世界
人工智能·后端·网络协议
乱世刀疤1 小时前
Claude Code实用开发案例:远程控制软件IP中继版
人工智能·claude code
远航计算机1 小时前
GEO 选题从哪来?用一份 Query 词库把一年内容排出来
大数据·人工智能·算法·aigc
宣宣猪的小花园.1 小时前
【机器学习】神经网络与表征:为什么深度学习能自动提取特征
人工智能·嵌入式硬件·算法·机器学习
对角1 小时前
摸鱼神器:一边写代码,一边刷剧,从此没有一点摸鱼时间会被浪费!
ai编程·deepseek·vibecoding
小淮AI1 小时前
企业协同办公工具选型观察:文件管理、流程协同与文档协作
人工智能