版本基准:dsh 0.2.1-rc.2,Node v22.22.0,Windows 10。
本文拆的是真实安装目录里的东西:内核装了 294 个 dsh-* 包,其中 25 个是工具插件、54 个是客户端 UI 插件。
数字不是估的,是我在这台机器上 Get-ChildItem 数出来的。


0. 结论先行:三层,一条流

DSH 的架构可以用一句话说完:

一个极薄的启动器,把一摞插件装配成一棵树;树的每个节点都是一个服务;所有节点说的话,最终落进同一条只追加的事件流。

展开是三层加一条流:

层职责代码在哪
启动器(launcher)解析命令、准备 profile、合成补丁层、交给 loaderdsh/lib/bin.js、profile-boot.js
插件树(kernel)cordis 的 Context/Service,插件按行装配,能力即服务cordis + 294 个 dsh-* 包
表面(surface)Web / headless / SDK / ACP / TUI,各是一个 bundledsh-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.agentsdsh-agent-loop创建/恢复 agent,跑 turn
ctx.toolsdsh-tools注册工具、校验参数、执行
ctx.sessionsdsh-session建会话、fork、查历史
ctx.llmdsh-llm流式调模型、发现模型能力
ctx.sandboxdsh-sandbox按策略跑子进程

这带来两个很实用的推论:

  1. 卸载一个包 = 抽掉一个能力。不装 dsh-tool-todo,模型就看不到 todo 工具,系统提示里也不会出现它的 schema。
  2. 同一个能力可以有多个实现。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 })
  },
}))

四个设计点,每个都能在生产里救你:

  1. 参数先校验再执行:非法输入变成「普通错误结果」,不会中断整个 turn。
  2. 输出有 canonical value 和 rendered content 两层:程序化调用者拿到原始值,界面只拿到渲染后的内容。要真正禁止访问,得在执行层拦,改渲染没用。
  3. 注册即可见:工具的 schema 会被自动喂进系统提示组装,不需要另外登记。
  4. 两种呈现模式: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不限制—

两个设计细节很硬核:

  1. 不能强制执行就拒绝执行,返回 SANDBOX_UNAVAILABLE,而不是「悄悄放开跑」。
  2. 被拒之后可以申请一次更宽的档位,由人来批。这就是审批弹窗的由来——它不是权限系统的装饰,是沙箱的出口。

但它明确声明是同世界隔离(same-world confinement):进程仍共享宿主内核与文件系统。要真正隔离环境,得用容器 / microVM / 远程执行器。

实测补充:在 Windows 上,受限沙箱会拒绝 Get-NetTCPConnection、Invoke-CimMethod 这类 WMI 查询(报 拒绝访问 / 0x80041003)。所以「用 WMI 拉起脱离 Job 的进程」这种操作,必须显式提权执行一次。别把沙箱报错误判成代码 bug。


9. 表面层:同一内核,四种入口

入口bundle形态典型用途
dsh webdsh-web-appHTTP + SPA,token URL图形界面、会话列表
dsh headless "任务"dsh-headless一次性,打印最终答案CI、脚本
dsh --profile sdkdsh-sdk-appJSON-RPC stdio被别的程序驱动
dsh --profile acpdsh-acp-appACP 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. 三个坑(踩过)

  1. 改 cordis.yml 等于没改——它每次启动被重写,改 cordis.patch.yml。
  2. bundle 顺序即优先级——把前端 bundle 放到 base 前面,会得到一个没有工具的空壳界面,而且不报错。
  3. 会话文件是多帧 zstd——单帧解压只得到一行,别急着判断文件损坏。

最后

如果只用一句话记住这套架构:DSH 把「能力」做成服务,把「装配」做成数据,把「历史」做成事件流。 三件事各管一段,互不越界,所以它能同时长出 Web、终端、SDK、飞书四种入口,而内核只有一份。

下一篇我打算拆 dsh-tools 的策略层:allow / deny / ask 到底在哪一层判定、并行安全怎么声明、PTC 模式把工具折叠成 SDK 之后提示词省了多少。想先看哪块,评论区说一声。

Logo

更多推荐