本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版
模块:T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座
来源:xueai.miyang.cn(小山学堂 · 洛小山)
不改源码也能换掉深层能力:配置由四层 patch 对着空数组叠出来,晚应用的赢,命中同一条就整条替换。
核心判断:可配置性的上限不是开关有多少,而是用户能否在不读源码的前提下,说清现在生效的是哪一行、它从哪一层来。DSH 把账算在了可预测性这一边 —— 代价是写 patch 时要重述想保留的字段。
评估任何配置系统,都可以用这三个问题:
三个都答不上来,说明可配置性只是表面上存在。
本集这套系统对三问都有明确答案:不用读源码(一条 insert 即可换实现);升级不覆盖用户层(层是分开的);离线转储能看到真正生效的那一份。
| 名词 | 归属方 | 实体 | 能做什么 |
|---|---|---|---|
| Bundle | 发行版 | npm 包里的 patch 列表 | 给默认(base / web-app / headless) |
| Profile | 这套组装 | $DSH_HOME/profiles/<name>/ 目录 | 排 bundle 顺序 + 自带 patch |
| Patch | 改动最小单位 | 按 id 命中条目 | 改配置 / 禁用 / insert 新条目 |
树外插件也从 patch 进来:dsh plugin add 装进 profile 后,它就是普通的一层 patch。
| # | 层 | 跟着谁走 | 说明 |
|---|---|---|---|
| 1 | Bundle | 发行版 | 内置 base / web-app / headless |
| 2 | Profile | 这套组装 | manifest 排 bundle 顺序 |
| 3 | Home | 这台机器 | 对每个 profile 都生效,压过 Profile 层 |
| 4 | --patch | 这一次命令 | 一次性实验,可重复传多份 |
起点真的是空数组:根配置文件内容就是 [],模板注释直接写着「别改这个文件,去改 patch 文件」。所有实际内容都由 patch insert 进来。
出处:apps/cli/src/profile-boot.ts L60–64;Home 压过 Profile 的理由见 packages/boot/app-boot/README.zh.md L43;--patch 可重复传见 apps/cli/src/args.ts L132;热重载见同包 watchUserPatches。
反面做法:一个大配置文件,发行版默认、组装定制、个人偏好、临时实验全写在里面。三个月后升级发行版,新默认和旧改动搅在一起 —— 说不清哪一行是谁写的、哪一行能动,升级变成手工比对。
更日常的翻车:临时试一个实验模型,顺手改了配置忘了改回来,第二天整套环境跑的都是实验配置。
根源:改动没有归属 —— 谁写的、跟着什么走、什么时候该消失,一个文件说不清这三件事。
层序在源码里就是一个数组字面量:启动器把组合好的 profile 摊平成一个 patch 数组,数组顺序就是应用顺序。这段函数短到能当金句看 —— 四层的先后被写死在一个数组里,没有任何条件分支。出处:apps/cli/src/profile-boot.ts L121–129。
为什么这处漂亮:优先级是配置系统里最容易长歪的地方。一旦允许按条件调整顺序(如某环境下 Home 层提前),同一份配置在不同机器上结果不同,而用户完全看不出原因。写死一个数组 = 放弃灵活性,换来确定性。
分层覆盖是配置系统的通则:CSS 的层叠、systemd 的 drop-in 目录、编辑器里用户设置压过默认设置,全是同一个结构。只要一份产品同时被发行方、团队、个人、单次命令四种角色修改,层就必须分开。
| 直觉:deep-merge | 实际:整条替换 | |
|---|---|---|
| 写法 | 只写想改的字段 | 必须重述要保留的字段 |
| 删除 | 表达不了删除 | 天然支持 |
| 排查 | 结果和任何文件字面都对不上 | 字面即生效 |
| 成本 | 省事 | 啰嗦 |
deep-merge 的两个代价:
patch 按 id 命中目标条目后,把 patch 里除 id 之外的每个顶层键直接赋值过去。config 是一个顶层键,所以旧 config 对象被整个换掉,里面的字段一个都不保留。
官方文档明说的已知限制,原话:「profile 覆盖必须重述需要保留的组合包字段」。
出处:替换语义在 vendor/include/src/index.ts L110–124;官方说明在 packages/boot/app-boot/README.zh.md L60。
成本性质不一样:
一个是持续的无形成本,一个是一次性的有形成本。
官方文档把这条明说是已知限制,态度也值得学:不藏着、不包装成特性,直接写一句「必须重述」。这比给一个看起来聪明、实际难排查的语义要诚实。
Home 层只写了 model 一个字段,下面两层设好的 provider 和 temperature 就被刷没了,而且没有任何警告,只有结果不对。
用户会以为自己只改了一个字段,实际上删掉了两个。排查时日志里什么都没有 —— 从系统角度看这是一次完全合法的替换。
| 情况 | 行为 | 危险度 |
|---|---|---|
| patch 指向不存在的 id | 只警告不报错,一行 stderr 后跳过 | 高(静默没生效) |
| patch 文件为空/只有注释 | 直接抛异常(解析结果不是列表) | 低(至少会告诉你) |
| 想让某层什么都不做 | 写 [],不要留空文件 | — |
第一条与替换语义凑在一起最危险:你以为覆盖了,其实打空了。
这个算法全库只有一份:挂载用它,dsh --dump-config 的离线合成也用它 —— dump 出来的结果和真正启动的内容不可能漂移。
算法的输入永远不被改动、结果永远是深拷贝,这样配置热重载时撤掉一条 patch 才能真的还原,早先的值不会被烤进缓存。出处:vendor/include/src/index.ts L43–52。
配套:一份 3152 行的生成文档 docs/config-catalog.zh.md,把每个可加载包的 config 类型原样列出来 —— 想知道某条 patch 能写哪些键,查这份目录即可。
| 分层对象 | 合并语义 | 层序 | |
|---|---|---|---|
| DeepSeek Harness | 插件条目级 | 整条替换 | Bundle → Profile → Home → --patch |
| Claude Code | 设置字段级 | 字段级合并 | user / project / local / flag / policy 五层 |
| Grok Build | TOML 字段级 | deep merge | system_managed / managed / user |
insert 就能把第三方 compaction 实现接进主循环,这在前两家的配置系统里没有对应物。Claude Code 有管理员策略层和只在 managed 来源生效的锁定字段,DSH 目前没有等价机制。企业场景下团队想锁死某个能力时,缺的就是这个。(基于已公开材料)
四层覆盖解决的是「谁能最后说话」,没有解决「谁能禁止别人说话」。
一个只支持覆盖的系统天然做不到强制合规:最上层的 --patch 永远能盖掉下面所有层。要做锁定,必须引入一个独立于层序的维度(如受管来源白名单 + 锁定字段表),而不是再加一层。
写 patch 前逐条过:
[] 还是留了空文件?--dump-config 验证字面结果?--dump-config 看字面结果,不要靠启动验证。docs/config-catalog.zh.md。Profile 层写:
id: conversation-model
config: { provider: deepseek, model: v3.2, temperature: 0.2 }
Home 层写:
id: conversation-model
config: { temperature: 0 }
dsh --dump-config 的思路说明怎么在不启动的情况下验证答案。答案要点:最终 config 只剩temperature: 0,provider与model消失且无警告。对调后失败方向从「丢字段」变成「改不动」—— Home 那条被 Profile 覆盖,用户想改的温度反而没生效。
quizFiles)一律不进入口播稿,仅作为集页下方的文字自测卡渲染。DSH 的配置是四层 patch 对着空数组刷出来的,顺序是 Bundle、Profile、Home、--patch,晚应用的赢。patch 命中同一 id 时整条替换 config,不做字段合并,想保留的字段必须重述。同一个算法既管挂载也管 dump,所以 --dump-config 看到什么,改配置就能得到什么。
*来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)*