把 llm 凭据写进$DSH_HOME/cordis.patch.yml之后,pnpm dsh --profile web --dump-config依然打印旧的 baseUrl——这是我第一次给 DSH 换模型供应商时踩的坑。当时我以为补丁文件放对位置就行,结果发现自己把 Key 的来源也搞错了:随手填的中转地址让模型侧直接 401。后来统一改成从 TaoToken 官网 申请 Key,Base URL 固定写成https://taotoken.net/api,配置文件才真正跑通。这篇笔记沿着「观察 → 修改 → 验证」这条线,把 DSH 练习 4 里最容易翻车的几个点拆开讲:家目录级补丁为什么压过 profile 级、dump-config怎么用来做优先级取证、以及disabled: true和整行替换这两个语义坑。
1. 从一次「补丁没生效」的现场说起
那次现象很典型:
$ pnpm dsh --profile web --dump-config | grep -A3 'name: llm' - name: llm config: baseUrl: https://old-endpoint.invalid/v1 apiKey: ''而$DSH_HOME/cordis.patch.yml里明明写着新地址。排查了半小时才定位到两件事:
一是补丁文件里只有一行注释,YAML 解析出来是空文档而不是空列表,加载器直接判定结构非法,整个补丁层被跳过;二是我把apiKey写成了明文字符串,但 schema 期望的是!!js表达式或者经过inject的服务引用,两者对不上时该行被静默丢弃。
修好之后我给自己定了一条规矩:DSH 里任何一次配置改动,都要走完整的三步取证——先 dump 最终值,再定位来源层,最后改对应层复验。不满足这三步的改动,一律当成没改过。
这条规矩在练习 4 里尤其重要,因为练习 4 的目标不是"写一条补丁",而是"证明这条补丁的优先级正确"。证明不了,就等于没写。
2. 先读基线:base.yml 与 full.yml 的分层对照
在动手写凭据之前,最好先把当前配置树的两层基线打出来。这一步对应练习 1,但练习 4 也需要它做对照。
# 只看 bundle 层,不含任何用户补丁 pnpm dsh --profile web --dump-config --default-only > /tmp/base.yml # 完整组合:bundle + profile 补丁 + 家目录补丁 + 命令行 --patch pnpm dsh --profile web --dump-config > /tmp/full.yml # 看多出来的行来自哪一层 diff -u /tmp/base.yml /tmp/full.ymlbase.yml是"出厂态",full.yml是"实际生效态"。两者之间的差值,就是所有用户补丁叠加后的净效果。当你在练习 4 里加了一条家目录级补丁,再跑一次diff,如果差值里出现了你写的那一行,说明补丁被读进去了;如果没出现,说明它被更上层的补丁覆盖、被解析丢弃,或者压根没落到正确的层。
在full.yml里,我会重点确认七个功能分组对应的行是否存在、name指向哪个包、关键config字段长什么样。这七行分别是llm、session、agent-loop、tools、system-prompt、sandbox-policy、agent-presets。它们覆盖了基础设施、编排、凭据、持久化、安全、模型面工具和委派这几类职责。练习 4 只动llm一行,但你必须知道这一行在整棵树的哪个位置——它属于宿主组合平面,而不是 agent preset 平面。把凭据写到 preset 里去,是另一个常见翻车点。
还要记住一个约束:base.yml和full.yml都是可加载的 YAML,不是给人看的日志。你可以把full.yml存成文件,再用--patch叠加它,验证自己对层级的理解是否正确。这个动作看起来多余,实际上是把"我以为我懂了"变成"我能证明我懂了"。
3. 把 TaoToken 凭据写进家目录级 patch
练习 4 的核心动作是:在$DSH_HOME/cordis.patch.yml里加一条所有 profile 都生效的补丁,并验证它压过 profile 级补丁。$DSH_HOME默认是~/.dsh。
先拿到 Key。在 TaoToken 控制台 创建一条 API Key,然后回到本地写补丁。Base URL 是固定的:
https://taotoken.net/api接下来是补丁内容。注意patch是整行替换语义,不是深合并——这一点在练习 2 里已经强调过,练习 4 同样适用。所以你不能只写一个baseUrl就完事,必须把llm行原有的其余字段一并重述。
# $DSH_HOME/cordis.patch.yml # 家目录级补丁:对所有 profile 生效,优先级高于 profile 级补丁 - name: llm config: provider: openai-compatible baseUrl: https://taotoken.net/api apiKey: YOUR_API_KEY model: gpt-4.1 timeoutMs: 60000几个要点:
第一,文件不能为空。只有注释、没有列表项的 YAML 会被解析成空文档,加载器按"解析为空不是列表"直接判失败,这一层补丁整体不生效。哪怕你只想占位,也要确保文件里至少有一条合法的列表项。
第二,apiKey这里是明文占位符YOUR_API_KEY。如果你所在的环境更倾向用环境变量注入,可以保留原来的!!js写法,但要注意!!js是在挂载时求值,而不是每次读取时求值。挂载那一刻环境变量里没有值,后面再export也没用。
第三,如果llm行在 bundle 里带了inject字段,你在补丁里也要一并重述。整行替换会把inject一起换掉,漏写就等于把依赖声明删了,插件会一直停在 waiting 状态——这正好是练习 3 里强调过的那个卡点:ctx.serviceName只有在inject激活之后才能引用。
如果你不确定llm行原本有哪些字段,最稳的做法是从full.yml里把整行抠出来改:
# 用 python 从 dump 结果里截取 llm 行,便于原样重述 python3 - <<'PY' import re, pathlib text = pathlib.Path('/tmp/full.yml').read_text() blocks = re.split(r'\n(?=- name:)', text) for b in blocks: if b.startswith('- name: llm'): print(b) PY拿到原行之后,只改baseUrl和apiKey两个值,其余字段原样保留。这是最小改动原则,也是避免"顺手漏字段"的唯一办法。
4. 用 dump-config 与 diff 验证「家目录级 > profile 级」
补丁写完之后,不要凭感觉判断优先级。用两步取证。
第一步,在 profile 级补丁里故意写一个不同的值,制造冲突:
# $DSH_HOME/profiles/web/cordis.patch.yml - name: llm config: provider: openai-compatible baseUrl: https://profile-level.invalid/v1 apiKey: YOUR_API_KEY model: gpt-4.1 timeoutMs: 60000第二步,重新 dump 并检查最终值:
pnpm dsh --profile web --dump-config > /tmp/full-after.yml grep -A5 'name: llm' /tmp/full-after.yml如果优先级正确,输出里的baseUrl应该是https://taotoken.net/api,而不是https://profile-level.invalid/v1。家目录级覆盖了 profile 级,符合练习 4 的验收要求。
再补一个命令行层的验证:
pnpm dsh --profile web \ --patch '{"- name": "llm"}' \ --dump-config 2>/dev/null | head -n 5命令行--patch位于链条最末端,优先级最高。你可以用它做一次性试验,但不要把它当成持久化方案——重启就没了。完整的一行配置推导链条是这样的:
bundle 默认 → profile 补丁 → 家目录补丁 → --patch(命令行)越靠右越晚应用、越晚赢。这也是为什么"家目录级补丁优先于 profile 级"不是一条拍脑袋的规定,而是层级顺序的自然结果:家目录补丁是"全局默认",profile 补丁是"局部覆盖",全局默认反而要在局部之后应用,才能保证跨 profile 行为一致。
如果你还想验证 HMR,可以直接改家目录补丁文件、不重启,然后观察下一次--dump-config或 Web UI 是否反映新值。用户补丁层有热更新,改文件即生效,这也是调试期最省事的路径。
5. patch 是整行替换:persona 覆盖时必须重述字段
练习 2 用--patch覆盖system-prompt的 persona,练习 4 把这套覆盖持久化。两处都撞在同一个语义上:patch 按行替换,不做深合并。
假设原本system-prompt行是这样:
- name: system-prompt config: persona: default-assistant mode: balanced maxTokens: 4096 language: zh-CN你只想把 persona 换成自定义值,写:
- name: system-prompt config: persona: dsh-maintainer结果mode、maxTokens、language三个字段全部丢失,因为整行被替换成了只有 persona 的新行。正确写法是把其余字段一并重述:
- name: system-prompt config: persona: dsh-maintainer mode: balanced maxTokens: 4096 language: zh-CN这也是 base 注释里那句"mode-specific values live in mode bundles"的原因——模式相关的值有它们自己的归属层,不应该靠 patch 隐式继承。patch 层只负责显式覆盖,不负责帮你保留没写出来的东西。
同理,删字段也不要用"不写"来实现。你想让某个字段回到默认值,正确做法是显式写出默认值,而不是省略该字段。省略等于替换掉,默认值不会自己长回来。
6. disabled 而非删除:删行会让上层补丁静默失效
练习 4 的卡点里有一条很容易被忽略:禁用某行要用disabled: true,不要删行。
原因在于补丁是按行匹配、按层叠加的。如果你在某一层把行删了,上层针对该行的补丁就失去了匹配目标,匹配不到就静默跳过,你不会收到任何报错。等到排查问题时,你会看到"补丁写了但没生效",却查不出原因。
正确做法:
- name: some-optional-plugin disabled: true行还在,位置还占着,上层补丁依然能匹配到它,只是加载器跳过它的激活。这就是 web-app 层"禁用而非删除"的理由,也是配置树可维护性的基本要求。
反过来说,当你发现某条家目录级补丁"看起来没生效",第一件事就是确认目标行在 profile 层或 bundle 层是否被删掉了。如果被删了,先把行恢复出来,再谈补丁优先级。
7. 加载顺序不等于激活顺序:inject 决定谁是 waiting
配置树里行的先后顺序没有语义,写在上面的行不会先加载,写在下面的行也不会后加载。真正决定一个插件能不能起来的是它声明了什么依赖:
- name: llm-client inject: [llm, http-client] config: baseUrl: https://taotoken.net/api只要llm或http-client这两个服务键还没出现,这行就进入 waiting 状态,一直等下去。等依赖齐了才激活。这也是为什么"把新行插在文件哪个位置"不重要、"它 inject 了什么"才重要。
调试激活问题时,看的应该是服务键,而不是行序。命令大致是:
pnpm dsh --profile web --dump-config | grep -n 'inject:'把每一行的inject列出来,再对照最终输出里哪些服务键实际存在,缺失的那一环就是 waiting 的根因。
这一点和练习 3 的!!js卡点是同一个道理:!!js在挂载时求值,不是每次读取;ctx.serviceName只有在inject激活之后才可引用。挂载时刻依赖没就绪,表达式里引用服务名就会拿到 undefined,而不是等到运行时再补上。
8. 同一套凭据思路落到 Claude Code / Codex / CC Switch
DSH 里配好的这套凭据,思路可以平移到其他 AI 编码工具上。关键只有两个:Base URL 和 Key 的来源。Base URL 统一用https://taotoken.net/api,Key 从 TaoToken 官网 取。
Claude Code 走settings.json,用的是ANTHROPIC_*系列变量:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "YOUR_API_KEY", "ANTHROPIC_MODEL": "claude-sonnet-4-5" } }Codex 走config.toml,用的是 TOML 结构,不要和ANTHROPIC_*混用:
model = "gpt-5-codex" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "responses"CC Switch 三件套可以理解为:Claude Code 的settings.json、Codex 的config.toml,加上一份集中管理 API Key 的环境变量文件。三者的 Base URL 保持一致,Key 指向同一个来源,切换供应商时只改这一处,不会出现"Claude Code 通了、Codex 还在 401"的分裂状态。
这里最容易犯的错是把ANTHROPIC_*套到 Codex 上。Codex 不读这些变量,配了也不生效,只会让你误以为配置写对了。记住一条:Claude Code 看ANTHROPIC_*,Codex 看config.toml,两者不通用。
9. 三步排查法与自查清单
每一行配置出问题时,按这个顺序走:
--dump-config看最终值。确认现象是否真实存在。- 定位来源层。用
diff对比base.yml和full.yml,看这一行的值是从哪一层来的。 - 修改对应层验证。改完再 dump,确认最终值变了。
落到练习 4 的具体自查清单:
$DSH_HOME/cordis.patch.yml是否存在,且不是空文件或纯注释。llm行是否把原字段全部重述,包括inject。- profile 级补丁里是否也写了
llm行,用来验证优先级。 baseUrl的最终值是不是https://taotoken.net/api。apiKey占位符是否已被替换成真实 Key,且不在版本库里。- 禁用行是否用
disabled: true,而不是删除。 - 依赖没就绪的行,是否因为
inject缺失而停在 waiting。
另外,练习 1 到练习 4 的产物——/tmp/full.yml加上一份你自己写的家目录级补丁——建议保留下来,它们是后续所有练习的对照基线。没有基线,任何一次"我改了但好像没变"都会变成无意义的猜测。
10. 文末:把练习产物固化下来
如果你跟着走完这一遍,手上应该有三样东西:一份能解释层级关系的/tmp/full.yml、一份写在$DSH_HOME/cordis.patch.yml里对所有 profile 生效的llm凭据补丁,以及一条被dump-config验证过的优先级推导链。
想继续把其他工具也接上,可以按这个顺序走:
- 先在 模型对话 里确认 Key 能正常调通;
- 再按需看 Coding Plan,决定是走按量还是走套餐;
- 然后去 创建 API Key,把
YOUR_API_KEY替换掉; - 最后对照 Claude Code 文档 把
settings.json补全,把同一套 Base URL 复用到 Codex 的config.toml上。
配置树读得越透,改动就越小;改动越小,验证就越快。练习 4 真正训练的不是"写一条补丁",而是"每改一行都知道它在哪一层、被谁覆盖、最终值是什么"——这个习惯一旦建立,后面所有 profile 和工具的接入都会顺很多。