本文是一次完整真实 的排障复盘:在 Windows 上从零跑通 DeepSeek Harness(dsh), 再装上一个第三方 client UI 插件,然后把踩到的每一个坑连根因带修法全部拆开。 文中所有报错原文、版本号、字节数、耗时、进程树结论均来自真机实测,没有推测。 配套交付:一份排障手册 + 4 个只读排障脚本(见第六节)。
〇、先给结论:三句话
如果你正准备装 dsh 或它的第三方插件,这三条能省掉大部分试错:
-
插件报的错,多半不是你的配置错。 我遇到的两个故障------知识库页
fetch failed、 新建会话preset not found------根因都在插件的发布包:一个把硬依赖写成了"可选", 另一个干脆漏打了一个目录。你的配置从头到尾没写错。 -
502是插件的兜底错误码,不代表 dsh 挂了。 看源码就知道,整个路由 handler 的catch分支统一return json(res, 502, ...),所有异常都是 502 。 而且它的接口分三类 行为,第三类会静默返回空------这是最容易误判的一条。 -
最隐蔽的坑不在安装,在"谁启动了服务"。 让 AI 助手在会话里帮你把服务跑起来, 服务会挂在会话的进程沙箱上:助手说"已就绪、路由全 200"是真的,过一会儿连不上也是真的。 想清理助手留下的后台任务又怕服务挂?根因在这里,修法在第六节。
一、环境与版本(可对照)
先把坐标系定死,本文所有结论都基于这套环境:
| 组件 | 实测版本 | 说明 |
|---|---|---|
| 操作系统 | Windows 11 | 中文系统,代码页 936 |
| DeepSeek Harness | 0.1.5-rc.1(latest 标签) |
开发者预览版,官方明说会有破坏性变更 |
| 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.json的scripts(找postinstall这类自动执行钩子) → 读读写文件系统的那几个模块 → 扫lib/client.js的eval/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.conf:auth_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.json 的 files 字段是:
"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/vectordb、data/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 上 pythonurllib会读注册表里的系统代理 (Clash 这类工具写的), 导致探测127.0.0.1被劫持成502/WinError 10061, 看起来像服务挂了,其实请求根本没发出去 。 顺带一提:curl只读环境变量、Node 的fetch也不走系统代理------ 所以只有 python 探测需要这个处理。
两个脚本都在正常态与故障态各跑了一遍做验证------故障态是主动 kill 掉 OpenViking 实测的 (冷启动 18 秒后恢复)。
📎 文件怎么拿 :CSDN 博客不支持附件下载,所以我把这 4 个脚本的完整源码内联在文末 附录 B 里 (4 段共 852 行),直接复制存成同名文件即可运行 ,无需任何改动。 依赖说明:
chengce-doctor.py与win-proc-tree.py只用 Python 标准库(后者零依赖, 仅用ctypes);verify_presets.mjs需要 Node(它要importdsh 自己的 discovery 模块);make_chengce_presets.py只用标准库。
八、以下三种情况"不是故障",别浪费时间
8.1 选某些专家提示「执行层尚未接入」
城策的 12 位专家里只有 2 位接了真实执行层 (availability: 'available' 且 preset 非 null), 其余 10 位是 planned 且 preset: 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 没跑(第三类"静默失败型")
九、复盘:可以带走的四条经验
-
区分"配置错"和"包错"。 我这次两个故障的根因都在发布包(漏打
presets/、 硬依赖写成"可选")。先读源码定位责任方,再动手改环境------否则会在自己的配置里 空转很久。读源码的成本,比乱试低一个数量级。 -
同一个错误码背后可能有三类行为。
502统一兜底、/resources静默吞错------ 如果只按"错误码"分类,就会把"页面能打开但列表为空"误判成"还没上传文档"。 按接口的源码行为分类,而不是按 HTTP 状态码分类。 -
"能连上端口"不等于"服务独立"。 判断服务会不会被回收,必须看进程树归属 , 不能靠"我现在能访问"。而且
IsProcessInJob这类 API 在真机上可能给出假阳性, 最终要用心跳对照实验拿硬证据。 -
把排查结论固化成脚本,而不是文档。 文档会过期、会写错(我第一版手册就把 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 的用法写错了,
实测才发现空数组不建库);脚本每次跑都是最新的事实。
如果你只从这篇文章里带走一样东西,建议是它们,而不是上面的任何一段分析。