DSH 架构详细解析:294 个插件、一条事件流,从入门到实战
目录
版本基准:
dsh 0.2.1-rc.2,Node v22.22.0,Windows 10。
本文拆的是真实安装目录里的东西:内核装了 294 个dsh-*包,其中 25 个是工具插件、54 个是客户端 UI 插件。
数字不是估的,是我在这台机器上Get-ChildItem数出来的。
0. 结论先行:三层,一条流
DSH 的架构可以用一句话说完:
一个极薄的启动器,把一摞插件装配成一棵树;树的每个节点都是一个服务;所有节点说的话,最终落进同一条只追加的事件流。
展开是三层加一条流:
| 层 | 职责 | 代码在哪 |
|---|---|---|
| 启动器(launcher) | 解析命令、准备 profile、合成补丁层、交给 loader | dsh/lib/bin.js、profile-boot.js |
| 插件树(kernel) | cordis 的 Context/Service,插件按行装配,能力即服务 | cordis + 294 个 dsh-* 包 |
| 表面(surface) | Web / headless / SDK / ACP / TUI,各是一个 bundle | dsh-web-app、dsh-headless… |
| 事件流(session log) | 每个模型可见的事实都追加成事件,可重放、可分叉 | dsh-session + dsh-session-persistence-jsonl |
大部分人把 DSH 当「又一个 CLI agent」用,这没错;但它的设计重心其实在装配与可复原这两件事上,理解了这两点,后面所有行为都能推出来。
1. 全景图
dsh web
│
├─ bin.js 命令语法、--profile/--patch、版本闸门
├─ profile-boot 读 package.json 的 dsh.profile.bundles
│ → 合成补丁层 → 写 cordis.yml(每次启动重写)
│
└─ cordis loader 按行实例化插件,形成一棵 Context 树
│
├─ dsh-base 模型连接 / 工具全集 / 会话 / 安全默认值
│ ├─ dsh-llm provider 适配、流式、deep-frozen 请求
│ ├─ dsh-agent-loop turn / step / 工具调度
│ ├─ dsh-tools 工具注册表、校验、allow/deny/ask
│ ├─ dsh-session 只追加事件流 + 投影 + 压缩
│ └─ dsh-sandbox 子进程文件访问策略
│
├─ dsh-web-app HTTP 宿主 + /api + SPA
│ └─ dsh-host-webserver node:http,只做路由表
│
└─ 你的扩展 dsh-opencode-go、dsh-feishu-bridge…
一个关键事实:Web 不是内核的一部分。dsh-host-webserver 的 README 写得很直白——「它不认识任何 harness 概念,也不服务任何文件」;/api 桥、插件 bundle、HMR 事件流、SPA 静态资源,全是别的插件注册进去的路由。所以同一个内核换个 bundle 就变成 TUI 或 SDK,不需要改一行内核代码。
2. 启动链路:四步,每步都能被覆盖
第一步:命令语法
dsh [--profile] <name> [options] [app-args...]
dsh plugin --profile <name> <pnpm args...>
启动器只解析自己的 flag(--profile、--patch、--from-default-profile…),第一个不认识的 token 起,全部交给 app。所以 dsh web --port 8080 里 --port 属于 Web app,不属于启动器。
第二步:准备 profile
prepareProfile() 会确保 $DSH_HOME/profiles/<name>/ 里有 package.json 和 cordis.patch.yml,并重写 cordis.yml。这个文件的内容永远是:
[]
注释里写着「Edit cordis.patch.yml, not this file」。这就是我上一篇说「改它等于白改」的原因——它是每次启动重新合成出来的产物,不是配置源。
第三步:合成补丁层
顺序是硬编码的,值得背下来:
bundle 层(按 dsh.profile.bundles 数组顺序)
→ profile 自己的 cordis.patch.yml
→ 命令行 --patch 覆盖(可重复)
我这份 web profile 的 bundles 是 5 层:
["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app",
"dsh-opencode-go", "@opencode2dsh/dsh-plugin", "dsh-feishu-bridge"]
后面的层可以覆盖前面同 id 的条目——这就是「你的改动永远在最上面」的实现方式。
第四步:加载
loader 拿到合成后的行列表,逐行实例化插件,形成 Context 树。任何一行加载失败,启动就失败(installFailLoud),不会带着半棵树跑。
3. profile:一个目录,两种文件,三层覆盖
一个 profile 目录只有两样东西重要:
~/.dsh/profiles/web/
├── package.json # 依赖 + dsh.profile.bundles(有序栈)
├── cordis.patch.yml # 你的补丁层(唯一该改的文件)
└── cordis.yml # 启动时重写的空根,别看它
补丁条目长这样,本质是「按 id 定位一个插件,改它的 config」:
- id: agent-default-model
name: "@deepseek-ai/dsh-agent-default-model"
config:
provider: dsh-opencode-go
model: deepseek-v4.1-flash
reasoningEffort: high
实战:换模型不改代码。 三种粒度,从粗到细:
| 粒度 | 手段 | 生效时机 |
|---|---|---|
| 全局默认 | 改 cordis.patch.yml 的 agent-default-model | 重启 |
| 单次启动 | dsh web --patch .\extra.yml | 本次进程 |
| 会话内 | GUI 里切模型(写进会话投影) | 立刻 |
顺序也解释了为什么「我改了 patch 却不生效」:很可能你改的是 cordis.yml,或者被后加载的 bundle 覆盖了。
4. cordis:服务即能力,插件即行
cordis 是这个架构的地基。它提供四样东西:显式依赖注入、作用域服务、生命周期清理、配置驱动加载。
内核里所有「能力」都是 cordis 服务,挂在 Context 上:
| 服务 | 提供者 | 谁在用 |
|---|---|---|
ctx.agents | dsh-agent-loop | 创建/恢复 agent,跑 turn |
ctx.tools | dsh-tools | 注册工具、校验参数、执行 |
ctx.sessions | dsh-session | 建会话、fork、查历史 |
ctx.llm | dsh-llm | 流式调模型、发现模型能力 |
ctx.sandbox | dsh-sandbox | 按策略跑子进程 |
这带来两个很实用的推论:
- 卸载一个包 = 抽掉一个能力。不装
dsh-tool-todo,模型就看不到 todo 工具,系统提示里也不会出现它的 schema。 - 同一个能力可以有多个实现。
dsh-pwsh-local和dsh-pwsh-sandbox是两个实现,选哪个由装配决定,而不是if判断。
5. 一轮对话的生命周期
dsh-agent-loop 的职责写得很清楚:创建或恢复 agent,然后把每一轮(turn)驱动成「模型请求 → 流式响应 → 工具执行 → 写入会话历史」。
turn 开始
└─ step 1: 组装请求(系统提示 + 允许的工具 schema + 历史投影)
└─ ctx.llm.stream() ── 一个 stream = 一次 provider 尝试
└─ 文本增量 → 直接推给界面
└─ tool_calls → 并行安全的一组并发执行,独占的按序执行
└─ step 2: 把工具结果写回会话,再请求
└─ … 直到没有工具调用,turn 结束
三个容易被忽略的细节:
| 细节 | 行为 | 为什么重要 |
|---|---|---|
| 并行上限 | maxParallelToolCalls 限制并发数 | 避免一次几十个 pwsh 打爆机器 |
| 独占调用 | 独占类工具保持顺序 | 有副作用的操作不能乱序 |
| 取消 | 取消会保留已流出的文本 | 中断不会让用户已看到的内容「回滚消失」 |
6. 工具层:注册、校验、策略、两种呈现
工具不是「函数」,是带契约的能力声明。defineTool 的骨架:
ctx.tools.register(defineTool({
name: 'read_file',
description: 'Read a file from disk.',
parameters: {
path: { type: 'string', required: true },
offset: { type: 'number' },
limit: { type: 'number' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
},
}))
四个设计点,每个都能在生产里救你:
- 参数先校验再执行:非法输入变成「普通错误结果」,不会中断整个 turn。
- 输出有 canonical value 和 rendered content 两层:程序化调用者拿到原始值,界面只拿到渲染后的内容。要真正禁止访问,得在执行层拦,改渲染没用。
- 注册即可见:工具的 schema 会被自动喂进系统提示组装,不需要另外登记。
- 两种呈现模式:
native(每个工具一个 schema)或ptc(只暴露一个run_code加一份生成的 SDK)。
第 4 点值得展开——它决定了「模型看到多少个工具」。工具越多,提示越长、选错率越高;ptc 模式把工具集折叠成一个代码执行入口,模型写代码调 SDK。这是用代码生成换上下文预算的典型取舍。
7. 会话:只追加的事件流,才是唯一事实
dsh-session 的定位非常克制:它不调模型,只记录。所有模型可见的事实都追加成事件,模型历史是从这条流推导出来的。
session.v4.jsonl.zstd
{type:"assistant/message", ...}
{type:"tool/call", ...}
{type:"tool/result", ...}
{type:"job/registered", ...}
{type:"turn/end", ...}
三个特性由此而来:
| 特性 | 机制 | 实际价值 |
|---|---|---|
| 可重放 | 事件是唯一事实,投影可重算 | 出问题能离线复盘每一步 |
| 可分叉 | fork() 从稳定前缀建子会话 | 试两条路不用重跑一遍 |
| 可压缩 | compaction 隐藏被取代的条目,不删除 | 上下文能瘦身,历史不丢 |
实测一个反直觉的点:这个文件是多帧 zstd。一个 6 轮、1313 条事件的会话,解压后 3.2 MB,由 732 个 zstd 帧拼接而成。用 zstdDecompressSync 一次只能解出第一帧——我第一次读只拿到 1 行,差点以为文件写坏了。正确做法是按 magic 28 B5 2F FD 切帧逐段解。
持久化是另一个插件的事:dsh-session 只在内存里维护事件源,dsh-session-persistence-jsonl 订阅 session/event 事件流落盘。这就是为什么「能不能持久化」是装配问题,不是内核问题。
8. 安全:三档沙箱 + 一次提权
dsh-sandbox 管的是子进程及其所有后代的文件访问:
| 档位 | 语义 | 失败姿态 |
|---|---|---|
read-only | 不写 | 执行但拒绝写 |
workspace-write | 只写工作区 | 同上 |
danger-full-access | 不限制 | — |
两个设计细节很硬核:
- 不能强制执行就拒绝执行,返回
SANDBOX_UNAVAILABLE,而不是「悄悄放开跑」。 - 被拒之后可以申请一次更宽的档位,由人来批。这就是审批弹窗的由来——它不是权限系统的装饰,是沙箱的出口。
但它明确声明是同世界隔离(same-world confinement):进程仍共享宿主内核与文件系统。要真正隔离环境,得用容器 / microVM / 远程执行器。
实测补充:在 Windows 上,受限沙箱会拒绝
Get-NetTCPConnection、Invoke-CimMethod这类 WMI 查询(报拒绝访问/ 0x80041003)。所以「用 WMI 拉起脱离 Job 的进程」这种操作,必须显式提权执行一次。别把沙箱报错误判成代码 bug。
9. 表面层:同一内核,四种入口
| 入口 | bundle | 形态 | 典型用途 |
|---|---|---|---|
dsh web | dsh-web-app | HTTP + SPA,token URL | 图形界面、会话列表 |
dsh headless "任务" | dsh-headless | 一次性,打印最终答案 | CI、脚本 |
dsh --profile sdk | dsh-sdk-app | JSON-RPC stdio | 被别的程序驱动 |
dsh --profile acp | dsh-acp-app | ACP stdio | 编辑器/自动化客户端 |
Web 这条链再拆一层:
dsh-web-app
└─ dsh-host-webserver node:http,路由表:精确 → 最长前缀 → fallback
├─ /api/* dsh-api-* 控制器(session/job/workspace/settings…)
├─ 插件 bundle 客户端 UI 插件被按需打包下发
├─ HMR 事件流 开发时热替换
└─ SPA dist 其余请求交给前端
启动时它打印一个带 token 的 URL;--no-open 时不自动开浏览器,端口可改,可以绑一个具体本机 IP,也能用自带证书起 HTTPS,通配地址会被拒绝——这条限制很对:把 token 暴露到 0.0.0.0 上是真事故。
10. 实战一:写一个最小工具插件
一个工具插件 = 一个 cordis 插件 + 一次 ctx.tools.register。骨架如下(按官方契约):
// src/index.ts
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { Context } from '@deepseek-ai/cordis'
export const name = 'dsh-tool-hello'
export const inject = ['tools'] // 声明依赖,未就绪不会执行
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'hello',
description: 'Say hello to someone.',
parameters: { who: { type: 'string', required: true } },
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
async execute(args) { return `hello, ${args.who}` },
}))
}
挂进 profile 只要两步:
dsh plugin --profile web add file:C:\path\to\dsh-tool-hello
# ~/.dsh/profiles/web/cordis.patch.yml
- name: dsh-tool-hello
重启后模型就能看到 hello 这个工具。注意 inject:它让插件等 ctx.tools 就绪再跑,省掉一堆启动期 race。
11. 实战二:给 CI 搭一个专用 profile
不要拿 web 去做 CI——它会起 HTTP 服务、装一堆 UI 插件。正确做法是复制一份模板再改:
dsh --profile ci --from-default-profile headless
dsh plugin --profile ci add @deepseek-ai/dsh-tool-pwsh
# ~/.dsh/profiles/ci/cordis.patch.yml
- id: agent-default-model
name: "@deepseek-ai/dsh-agent-default-model"
config: { provider: dsh-opencode-go, model: deepseek-v4.1-flash, reasoningEffort: low }
然后:
dsh --profile ci "跑 pnpm test,失败就只输出第一个失败用例的栈"
判断标准:这个 profile 里不该有它不需要的插件。每多一个 bundle,就多一份启动时间、多一段提示词、多一个可能的失败点。
12. 观测:怎么看它到底干了什么
| 想知道 | 看哪里 |
|---|---|
| 这次装配了哪些插件 | ~/.dsh/profiles/<name>/cordis.yml(合成结果) |
| 补丁有没有生效 | --dump-config / --dump-default-config 对比 |
| 模型看到了什么 | session.v4.jsonl.zstd 里的请求组装与工具 schema |
| token 花在哪 | 会话投影里的 tokenUsage(含 cacheRead / uncached 分项) |
| 为什么卡住 | ~/.dsh/logs/、宿主 stdout |
一个我常用的排查顺序:先看 cordis.yml 确认装配 → 再看会话事件确认模型视角 → 最后才看日志找异常。顺序反了就会在日志里翻半天,而问题其实是「那个插件根本没装上」。
13. 设计取舍:三个「为什么」
为什么是事件溯源,而不是「保存对话数组」?
因为压缩、分叉、重放、审计都要同一个事实源。会话可以 fork 出两条路,压缩只隐藏不删除,都是这个选择的直接后果。
为什么模型请求要 deep-frozen?
dsh-llm 的 README 写了:loop 组装的请求不可被扩展或适配器改写。这让「模型实际看到的东西」和「会话里记录的东西」严格一致——否则任何插件都能悄悄往提示词里塞私货,可复现性就没了。
为什么沙箱是「同世界」而不是容器?
因为目标是日常开发(读写你自己的工作区),不是跑不可信代码。容器化的代价是文件系统映射、端口、GPU 全要重新接线。它选择了诚实:明确告诉你「这不是隔离」,并给了 danger-full-access 这个诚实命名。
14. 三个坑(踩过)
- 改
cordis.yml等于没改——它每次启动被重写,改cordis.patch.yml。 - bundle 顺序即优先级——把前端 bundle 放到 base 前面,会得到一个没有工具的空壳界面,而且不报错。
- 会话文件是多帧 zstd——单帧解压只得到一行,别急着判断文件损坏。
最后
如果只用一句话记住这套架构:DSH 把「能力」做成服务,把「装配」做成数据,把「历史」做成事件流。 三件事各管一段,互不越界,所以它能同时长出 Web、终端、SDK、飞书四种入口,而内核只有一份。
下一篇我打算拆 dsh-tools 的策略层:allow / deny / ask 到底在哪一层判定、并行安全怎么声明、PTC 模式把工具折叠成 SDK 之后提示词省了多少。想先看哪块,评论区说一声。
更多推荐

所有评论(0)