1. 一份只剩注释的补丁文件,把 HMR 变成了「什么都没发生」
上周在 web profile 上换 llm 凭据,我做的操作再普通不过:打开$DSH_HOME/profiles/web/cordis.patch.yml,把 baseUrl 指到https://taotoken.net/api,apiKey 换成从 TaoToken 官网 控制台取的新值,保存,然后等 HMR 把新配置推上去。结果 Web UI 里模型调用照样鉴权失败,再跑一次--dump-config,输出的还是旧的 baseUrl。
排查了二十分钟才反应过来:我在编辑前把文件里原有的几行内容删了,只留了一段注释。DSH 的用户补丁层要求解析结果是一个非空的列表,空文件或纯注释会被判定为加载失败,补丁根本没进组合,HMR 自然也就没有东西可热更。这跟「改了没生效」完全是两回事——前者是文件没被接受,后者才是热更链路的问题。
这篇把原文那套「观察 → 修改 → 验证」的四个练习,按一个 DSH 配置维护者的日常视角重走一遍。重点落在练习 4:怎么把 llm 凭据的覆盖持久化进 profile 补丁,改完不重启,直接看 Web UI 或者下一次 dump 出来的值有没有变。中间会把 dump-config 的分层、patch 的整行替换语义、!!js的求值时机、以及加载顺序与激活顺序这几件事钉死。所有涉及 Key 的步骤都走 TaoToken,Base URL 统一填https://taotoken.net/api,不绕任何弯路。
2. 先读出层次:base 与 full 的 diff 到底差在哪
DSH 里「组合即真相」这句话不是修辞。你看到的每一个运行时行为,背后都是一棵已经求值完成的配置树;能不能读懂这棵树,决定了你是在维护系统还是在猜系统。练习 1 的目标很朴素:对 web profile 的完整组合建立行级直觉。
先把两份快照落盘,一份只含 bundle 默认层,一份是叠加了用户补丁之后的最终值:
export DSH_HOME="${DSH_HOME:-$HOME/.dsh}" # 只看 bundle 层,不含任何用户补丁 pnpm dsh --profile web --dump-config --default-only > /tmp/base.yml # 完整组合:bundle + profile 补丁 + 家目录补丁 pnpm dsh --profile web --dump-config > /tmp/full.yml # 差异行就是「用户侧引入的部分」 diff -u /tmp/base.yml /tmp/full.yml | head -n 80diff 的输出信息量比你想象的大。多出来的行只有三类来源:profile 级补丁、家目录级补丁、以及命令行--patch临时叠加的内容。如果 diff 里出现了你没印象的条目,优先去$DSH_HOME/cordis.patch.yml找——家目录级的补丁会作用于所有 profile,是最容易被遗忘的一层。
接着在/tmp/full.yml里定位那七行关键条目:llm、session、agent-loop、tools、system-prompt、sandbox-policy、agent-presets。对每一行,你要能说出两件事:它由哪个包提供(name 字段),以及它携带的关键 config 有哪些。按我自己的归类习惯,这七行大致覆盖:基础设施、编排、凭据设置、持久化、安全策略、模型面工具、以及委派预设。功能分组的具体命名因人而异,但你必须形成一套自己的映射,否则每次读配置都是重新认识一遍。
这里有个容易被忽略的互斥关系:bash-sandbox与pwsh-sandbox不能同时作为生效项。两者提供的是同一类能力——命令执行的沙箱策略——注册到同一个服务键下。在同一份组合里,同一个服务键只允许一个提供者处于激活态,所以它们必须二选一,选择依据是你的实际运行平台与 shell 环境。理解了这条,你就理解了 DSH 组合里「同名服务只能有一个赢家」这条底层规则,后面system-prompt、llm的覆盖行为都是它的推论。
验收方式很直接:合上文件,凭记忆把 base 的七个分组说一遍;再把full.yml存成文件,用--patch叠加一次,看看输出是否和你的预期一致。这一步做完,你手上就有了后续所有练习的对照基线。
3.--patch只做整行替换:验证「后写的层赢」
练习 2 要验证两个语义:后写的层覆盖先写的层,以及覆盖的粒度是整行而不是深合并。
准备一个最小补丁文件,只改system-prompt这一行:
# /tmp/persona-override.yml - name: system-prompt config: persona: "你是一名严谨的 SRE,先给结论,再给证据链。"叠加到现有组合上,然后和基线对比:
pnpm dsh --profile web --dump-config \ --patch /tmp/persona-override.yml > /tmp/patched.yml # 只看被改动的行 diff -u /tmp/full.yml /tmp/patched.yml预期结果是:system-prompt行的 persona 变成了你写的那句,其余条目原封不动。如果你在补丁里只写了persona,却指望原来的其他字段(比如工具白名单、输出约束)继续保留,那你一定会失望——patch 是整行替换,不是字段级合并。想让某个字段保持原值,就必须在新行里把它重新写出来。base 层里那句「mode-specific values live in mode bundles」的注释,讲的就是这个道理:既然覆盖是整行语义,那跟模式相关的取值就应该集中放在模式 bundle 里,避免每次覆盖都要重述一大堆无关字段。
这条规则带来一个很实用的习惯:写补丁前,先把你打算覆盖的那一整行从full.yml里复制出来,改掉需要变的字段,再整体贴进补丁。这样既不会丢字段,也方便你在 diff 里一眼看出哪几行真的被替换了。
4.!!js的求值时机在挂载,不在每次读取
练习 3 的对象是运行时表达式。在--dump-config的输出里把所有!!js出现的位置找出来,逐个判断两件事:什么时候求值,在什么上下文里求值。
答案是固定的:求值发生在插件挂载那一刻,而不是每次有人读取这个字段的时候;求值环境是 Loader 上下文,你能拿到的是process、ctx、dshHomePath、baseUrl这几个入口。
「挂载时求值」这一条有几个直接推论,值得单独记住:
第一,写在!!js里的环境变量只会在挂载时读一次。你在进程已经跑起来之后再去改环境变量,不会影响已经求值完成的结果。想让新值生效,得让这一行重新挂载。
第二,ctx.serviceName只有在inject对应的服务激活之后才可引用。如果这一行声明了inject,那么它在服务出现之前处于 waiting 状态,此时去取ctx上的服务引用是没有意义的。这也是为什么同一个表达式在不同行里表现不一样:差异不在表达式本身,而在那一行的inject声明。
第三,dshHomePath与baseUrl这类由 Loader 注入的值,会跟着运行时环境走。用dshHomePath('sessions')拼路径,比手写~/.dsh/sessions更稳,因为$DSH_HOME被改到别处时你不用回头改配置。
把这三个变量拼进llm行的补丁里,就是你后面换凭据时最实用的写法:
- name: llm config: baseUrl: https://taotoken.net/api apiKey: !!js process.env.TAOTOKEN_API_KEY注意这里的取舍:把 Key 交给环境变量,补丁文件本身就不含明文凭据,可以安全地进 Git。代价是改环境变量必须重新挂载该行才能生效。如果你更在意「改完立刻热更」,那就得走后面那节讲的补丁文件热更路径,用明文或外部密钥文件承载凭据。
5. 练习 4 主菜:把 llm 凭据写进 cordis.patch.yml
前面三个练习都是在命令行上临时验证,练习 4 要做的是把它持久化。这是本篇的核心动作。
第一步,确认 profile 目录位置。默认情况下$DSH_HOME是~/.dsh,web profile 的补丁路径就是:
ls -l "${DSH_HOME:-$HOME/.dsh}/profiles/web/cordis.patch.yml"如果这个文件不存在,可以新建;但新建之后必须是合法的 YAML 列表,不能是空文件。
第二步,去 TaoToken 控制台取 Key。入口在 TaoToken 官网,登录后在控制台创建 API Key,拿到以sk-之类的形式开头的字符串。本文所有示例里统一用占位符YOUR_API_KEY,你替换成自己的即可。Base URL 固定为https://taotoken.net/api,这个地址在工具配置里不加任何查询参数。
第三步,把 llm 覆盖写进 profile 补丁:
# ~/.dsh/profiles/web/cordis.patch.yml - name: llm config: provider: openai-compatible # 字段名以你 dump 出的 llm 行为准 baseUrl: https://taotoken.net/api apiKey: !!js process.env.TAOTOKEN_API_KEY model: <在 TaoToken 控制台选定的模型 ID>这里要强调一句:上面config下的字段名只是结构示意,真正生效的键名请以你本机--dump-config输出的llm行为准。不同版本、不同 bundle 的字段可能有差异,照抄字段名而不对照实际输出,是最常见的一类无效补丁。
第四步,在 shell 里提供环境变量,然后再启动:
export TAOTOKEN_API_KEY="YOUR_API_KEY" pnpm dsh --profile web第五步,也是这一节的关键:不要重启。保持进程运行,直接编辑cordis.patch.yml,把baseUrl改成另一个合法值或者把model换掉,保存。用户补丁层是有 HMR 的,文件落盘之后新的值会被重新求值并应用到组合上。
如果你不想用环境变量,希望直接在文件里写凭据,那就把那一行改成:
- name: llm config: baseUrl: https://taotoken.net/api apiKey: YOUR_API_KEY明文的代价是这份文件不能进版本库,收益是改完保存能立刻看到热更效果,不用去动进程环境。两种写法选一种,别混着用,否则你会分不清到底哪一层的值在生效。
第六步,验证家目录级的优先级。在$DSH_HOME/cordis.patch.yml里再写一条补丁:
# ~/.dsh/cordis.patch.yml —— 对所有 profile 生效 - name: llm config: baseUrl: https://taotoken.net/api apiKey: YOUR_API_KEY model: <全局默认模型 ID>最终值的推导顺序是:bundle 默认 → profile 补丁 → 家目录补丁 → 命令行--patch。后面一层赢,覆盖粒度是整行。所以上面这条家目录级补丁会把 profile 里的 llm 行整个顶掉,包括你为 web profile 单独挑的 model。这正是需要警惕的地方:家目录级适合放「所有 profile 都该一致」的东西,比如统一的 baseUrl;一旦把 model 这类需要按 profile 区分的字段塞进去,就会静默覆盖掉下层更精细的设置。
6. 不重启看 HMR:Web UI 与 dump-config 两条验证路径
补丁写完了,怎么确认它真的生效?两条路径,一快一准。
快的那条是 Web UI。打开正在运行的 Web 界面,进到模型或会话相关的设置页,直接发一条最短的请求。如果 llm 行的 baseUrl 和 apiKey 都对,鉴权会通过;如果补丁没被接受,你会看到和改之前一模一样的报错。这条路的好处是反馈快,坏处是它只能证明「能用」,不能告诉你最终值到底是什么。
准的那条是 dump-config。新开一个终端跑:
pnpm dsh --profile web --dump-config > /tmp/after.yml diff -u /tmp/full.yml /tmp/after.ymldiff里应当出现你刚加的那行 llm 覆盖。注意这里读的是磁盘上最新的补丁文件,跟运行中的进程是不是热更成功没有必然关系——dump 反映的是「现在加载会得到什么」,不是「进程里现在是什么」。想验证热更本身,还是要回到 Web UI 那条路径,或者观察进程日志。
把两条路径合起来用,你就有了一个可靠的判断链:diff 确认文件被正确解析并参与组合,Web UI 确认运行中的实例确实拿到了新值。两者结果不一致时,问题大概率出在 HMR 这一环,而不是补丁语法。
值得注意的是,HMR 覆盖的是用户补丁层。bundle 层的内容变化不会走这条热更路径,因为 bundle 属于组合的骨架,改动它意味着重新挂载。理解了这一点,你就不会去纠结「为什么我改了默认 bundle 里的东西没热更」——它本来就不在设计范围内。
7. 层级优先级与三个高频卡点
这一节把练习 4 里最容易翻车的地方集中列一下。
卡点一:补丁文件为空或只有注释。前面已经踩过一次。补丁层的解析结果必须是非空列表,纯注释会被当成空解析结果,直接加载失败。规则是:要么至少写一条有效条目,要么这个文件干脆不存在。
卡点二:想禁用某一行时直接删行。删掉一条上层定义过的行,会让上层补丁「静默失效」——你以为自己只是去掉了它,实际上覆盖链断了,后续依赖这行的逻辑可能拿不到预期的值。正确的做法是保留行、显式声明禁用:
- name: agent-presets disabled: true这也是 web-app 那一层坚持「禁用而非删除」的原因:组合的层叠关系是显式的,删行会让层级之间的对应关系对不上号。
卡点三:把家目录级的覆盖当成「兜底默认」。它不是兜底,它是覆盖。优先级排在 profile 之后,意味着它会赢。想让某个值成为低优先级兜底,正确的位置是 bundle 层或者 profile 层,家目录级只应该放那些你希望强制统一的项。
把这三个卡点记住,再回头看--dump-config的输出,你会发现「为什么值是现在这样」这个问题,大部分时候都能在三步内定位:先看最终值,再用--default-only做 diff 定位来源层,最后去改那一层并重新验证。
8. 加载顺序 ≠ 激活顺序:行放在哪里不重要
新手常有一个误解:把新行插在cordis.patch.yml的开头还是结尾,会影响它的行为。答案是不会。组合里行的先后顺序没有语义,真正决定行为的是依赖关系。
规则只有一条:行声明了inject: [service...],它就会进入 waiting 状态,直到对应的服务被提供出来。服务的出现驱动激活,而不是行在文件里的位置驱动激活。
这条规则解释了两件事。第一,调整行的位置对激活结果没有任何影响,你不需要为了「让它先加载」而把某行挪到最上面。第二,调试激活问题时该看的是服务键,不是行号。某个插件没启动,先确认它inject的那些服务键有没有被别人提供;如果提供者本身还在 waiting,那问题要往上游追一层,而不是在原地反复改配置。
在补丁语境下,这条规则还有一个衍生结论:覆盖某一行时,你改变的是那一行的 config,不是它在组合里的激活时序。inject声明通常也应该在覆盖时一并重述——毕竟整行替换,不重述就等于把原来的依赖声明也抹掉了。这是个很容易被漏掉的坑:改完 config 发现插件不激活了,多半就是inject没跟着写回去。
9. 顺手把同一套凭据思路带到 Claude Code / Codex / CC Switch
DSH 的补丁层只是入口之一。同一个 TaoToken 账号,同样的 Base URL,在别的 AI 编程工具里也能直接复用。这里给出三套可以照抄的配置,注意别把 Anthropic 的环境变量套到 Codex 上——那是两套完全不同的读取逻辑。
Claude Code 走settings.json,位置在~/.claude/settings.json:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "YOUR_API_KEY" } }Claude Code 读的是ANTHROPIC_*系列变量,ANTHROPIC_BASE_URL指向 TaoToken 的接口地址,ANTHROPIC_AUTH_TOKEN填你的 Key。改完之后重开一个会话,鉴权失败会立刻暴露,不用等半天。完整字段说明和排障步骤可以参考 Claude Code 接入文档。
Codex 走config.toml,位置在~/.codex/config.toml:
model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY"然后在 shell 里导出TAOTOKEN_API_KEY。注意这里的键名是base_url和env_key,跟 Claude Code 的ANTHROPIC_*毫无关系。把 Claude Code 的环境变量原样搬到 Codex 的配置文件里,是新手最典型的无效配置。
如果你同时维护好几个工具、好几套供应商,用 CC Switch 这类切换器会省事很多。每个档案要填的核心就是三样:Base URL(https://taotoken.net/api)、API Key(YOUR_API_KEY)、以及默认模型。切档案本质上是切这三样,和 DSH 里换个补丁覆盖 llm 行是同一件事,只是作用在工具层面而不是组合层面。
10. 收尾:产物清单与三步排查法
回到开头那四个练习,你手上应该留下两份可复用的东西:
一是/tmp/full.yml,web profile 在某个时间点的完整组合快照,后续任何「值为什么变了」的问题都可以跟它做 diff;二是一份自己写的 profile 补丁,至少包含 llm 行指向https://taotoken.net/api与YOUR_API_KEY的覆盖,外加一条家目录级的统一设置。
排查链路固定成三步:--dump-config看最终值 → 用--default-only做 diff 定位来源层 → 改对应层并重新验证。这三步能覆盖绝大多数「配置改了不生效」的场景,剩下的那部分,基本都落在空补丁、删除行、以及忘记重述inject这三个坑里。
最后补一句平面意识:每次动手改一行配置之前,先想清楚它属于宿主组合还是 agent preset。这两者的覆盖方式、生效路径和 HMR 行为都不一样,把它们混在一起改,是长期维护里最贵的错误。
需要实际动手的时候,按这个顺序走最省时间:先在 模型对话 里确认目标模型可用,再看 Coding Plan 选合适的档位,接着去 创建 API Key 拿到YOUR_API_KEY,把它填进cordis.patch.yml的 llm 行,保存,然后按第 6 节的两条路径验证 HMR 是否真的把新值推了上去。