| 理念 | 课程里的话 | 实验将让你看到 |
|---|---|---|
| 一切皆插件 | "模型、工具、界面全是插件,没有特权核心" | 一整棵插件树——界面和模型躺在同一张清单里 |
| 日志是唯一事实源 | "模型看到的一切都记在一本只追加的账上" | 你说一句话,磁盘上的文件立刻变大 |
| 能力可整体替换 | "拔掉一个插件,系统照常活" | 你亲手拔掉一个工具,dsh 没崩,模型少了个本事 |
目标:证明"你以为的一个产品,其实是一张插件清单"。
在仓库根目录的终端里(源码方式)跑:
pnpm dsh web --dump-config
如果你用的是免安装方式(npx),则跑:
npx @deepseek-ai/dsh web --dump-config
终端会刷出一大坨带注释的 YAML。别试图读懂全部——只找这四个词出现的位置(眼睛扫,或复制到编辑器里 Ctrl+F):
| 找什么 | 它是什么 |
|---|---|
llm 相关行 | 模型调用——是插件 |
tool- 开头的行 | 模型手里的工具(读文件、跑命令……)——是插件 |
session 相关行 | 会话存储——是插件 |
web 相关行 | 你现在用的这个网页界面——也是插件 |
注意每一行上方的 # == 包名 注释——dump 输出会标明每行来自哪个包。你现在用的整个产品,就是这些包一层层叠出来的。
目标:证明"你和模型的每一句对话,都被记进磁盘上的一本账"。
dsh 把所有数据存在主目录(默认 ~\.dsh)。在 PowerShell 里跑:
Get-ChildItem ~\.dsh -Recurse -File -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending |
Select-Object -First 5 FullName, Length, LastWriteTime
记下最上面那个文件的名字和大小。
打开 dsh 的 Web 界面,随便问一句,比如 你好,介绍一下你自己。等它答完。
对比:是不是出现了新的文件,或者某个文件的大小和修改时间变了?
SELECT 都看得到数据,不是视图存了数据,是基表在、查询在跑。也正因存的是原料,同一本日志能拼出屏幕气泡、发给模型的消息、会话分叉、全文检索等多种视图,且永远一致——这正是"唯一事实源"的价值;若直接存"对话记录"成品,每种用途各存一份,就会出现界面与模型所见不一致这类 bug。目标:证明"没有哪个零件是焊死的"——你对一个正在运行的产品做减法,它照常活着。
回到 dump-config 的输出,挑一个 tool- 开头的、可有可无的行(比如网页搜索类、待办类工具)。记下它的 id。
fs(文件)、edit(编辑)、bash/pwsh(终端)的是模型干活的命根子,拔了实验场面会很难看。挑一个你平时用不上的。在仓库根目录建 unplug.yml(把 id 换成你选的):
- id: 你选的那个tool的id
disabled: true
pnpm dsh web --patch ./unplug.yml --dump-config
在输出里找到那一行:它现在带着"被 overlay 修改过"的注释。这就是 patch——在产品之上叠一层你的意愿,产品本身一个字节没动。
pnpm dsh web --patch ./unplug.yml
dsh 正常启动,界面正常,对话正常。然后问模型一个必须用那个工具的问题,比如拔的是搜索就问:
帮我搜一下今天的新闻
看它怎么办:它会明确告诉你它没有这个工具(或者用别的笨办法绕)。
去掉 --patch 正常启动,工具回来了。你刚才没有"重装"任何东西——那层补丁只是盖在上面,掀掉就还原。
disabled: true 写法)。不是所有车都敢让你在行驶中换零件。这一台,零件还带快拆扣。
带着刚才的画面,每课 10 分钟:
| 重读 | 现在你会看到什么 |
|---|---|
| L04 一切皆插件 | 文字描述的就是实验一那张清单 |
| L05 Cordis 五要素 | ctx = 每个插件手里的"延长线",一头连着全机共享的服务注册表(插座板);你拔插头发生在配置检查环节,inject 管的是幸存插件之间的加载先后——详见下方"展开阅读" |
| L06 能力缝三角色 | 你拔掉的 tool-* 行是 Consumer;它背后"定义/实现"的那两层你还留着——所以只失去一个本事,系统不崩 |
| L07 日志唯一事实源 | 实验二里变大的那个文件 |
如果某一课读到这里还是卡,告诉我卡在哪一句——我直接对那一句给你做拆解,比通读快得多。
三个词经常被搅在一起,用 Spring 的概念一次分清(你有后端背景,这个对照一秒就通):
| 词 | 准确身份 | Spring 世界对应 |
|---|---|---|
| 插座板(服务注册表) | 全机共享的一张表,每个服务认领一个名字 | ApplicationContext 本身 |
| 插座(服务名) | tools / llm / sessions 这些 key | Bean 名字 |
| ctx | 每个插件手里的一根延长线:插别人的插座(消费)、自己开新插座(供电)、挂效果(注册即撤销) | 你这个 bean 拿到的容器视图 |
| inject | "我必需这些插座有电,没电别启动我" | @Autowired |
所以 ctx.tools ≈ getBean("tools")——按名字取,不 import 具体实现,这就是实现可整体替换的根子。
开机
├─ ① 组层:bundles + patches 叠成一张行清单 ← 实验一看到的
├─ ② 逐行查 disabled → 摘掉,模块根本不加载 ← 实验三拔插头发生在这里
├─ ③ 加载幸存的行,读出各自的 inject
├─ ④ 排期:依赖没就绪的插件等着 ← inject 生活在这里
└─ ⑤ 轮到谁就调谁的 apply(ctx)
② 是"去不去"(配置说了算),④ 是"先后"(依赖推导)——两码事。一条真实的依赖链:
dsh-bash-local(Provider) apply 跑完 → ctx.shell 插座有电
↓ 框架发现 tool-bash 的 inject ['shell'] 已满足
dsh-tool-bash(Consumer) apply 才被调用 → 里面放心用 ctx.shell
加载顺序没人手工排,框架读 inject 自动推导(同 Spring 解析 bean 依赖图)。拔插头也会牵动"谁等谁":拔 Consumer(工具行)无人问津;拔 Provider(如 bash-local),所有 inject 它的插件会一起起不来——实验三让你挑"可有可无的工具",就是在避开这条连锁。
别背定义。回到 dump-config 的输出——四个名词各指图上的一个东西:
# == dsh-base ← 一层,来自叫 dsh-base 的 npm 包(= 一个 bundle)
- id: llm-deepseek ← 一行,一个插件
- id: tool-fs ← 一行,又一个插件
...(共几十行,全在这一层里)
# == dsh-web-app ← 又一层(= 另一个 bundle)
- id: web-ui ← 一行,一个插件
| 名词 | 指什么 | 磁盘上是什么 |
|---|---|---|
| 插件 | 清单里的一行 | 一个 .ts 文件(name/inject/apply) |
| bundle | 清单里的一整层(一个 # == 分节) | node_modules 里一个包:若干 .ts + 一份 yml,package.json 带 dsh.bundle 标记 |
| patch | "往清单加行 / 按 id 改行"的指令文件 | 一个 .yml 文件 |
| profile | 整张清单的组装方案(哪几层、什么顺序) | ~/.dsh/profiles/<名>/ 一个目录 |
一句话:行叫插件,层叫 bundle。你 node_modules 里躺着的 dsh-base 文件夹,打开看是几十个插件 + 一份 yml——叫它"插件"不准(里面是几十个),叫它"普通依赖"也不准(普通依赖 dsh 不理,它却要被叠层),所以造了 bundle 这个词。困惑的根源通常是以为"装一个包 = 得到一个功能",而 dsh-base 一个包给了你几十个功能——装的单位和功能的单位不是一比一。
① 写 my-plugin.ts → 此刻叫"插件"
② 写 cordis.patch.yml(往清单插一行) → 这份 yml 叫"patch"
③ npm publish 打包(ts+yml+标记) → 这个包叫"bundle"
④ 同事 dsh plugin add 你的包 → 他的 profile 多一层