DeepSeek Harness 配置与 Patch:像打补丁一样改 Agent 行为
上一篇结尾说「改行为而不碰源码」。这一篇就讲这个——Harness 的配置与 Patch 体系。它是整个框架「可组装、可替换」承诺的落地机制:你想改默认模型、换存储后端、加一个工具、关掉某个 provider,都不需要 fork 源码,改一个 YAML 文件就够了。
但要真正用好,得先分清它其实有两套配置体系,很多人一开始就栽在这里。
一、两套配置体系,别混为一谈
Harness 里叫「配置」的东西有两套,职责完全不同:
- 组合配置(
cordis.patch.yml):决定运行时组装成什么样——加载哪些插件、每个 seam 选哪个 provider。它管的是「结构」。 - 用户设置(Settings seam):决定用户可编辑的那部分行为——某个 namespace 下的具体值。它管的是「数值」,而且是面向「用户在界面或配置页里能改」的那一子集。
官方对二者的边界说得很清楚:组合配置留在cordis.yml,一个 namespace 只携带用户可编辑的子集。换句话说——「换哪个存储后端」是组合配置的事,「这个后端的连接串」才是 Settings 的事。
二、cordis.patch.yml:两种操作,一个铁律
cordis.patch.yml里能做的操作就两种:
- insert:往树里加新行(新插件、新服务);
- id 寻址覆盖:用稳定的
id找到某一行,替换它的配置。
每一行都有两个字段:稳定的id(跨层寻址用)和name(要加载的 npm 包)。id 是「谁被覆盖」的锚点,name 是「加载什么」。
# 概念示意:patch 的两种操作rows:-id:system-prompt# insert 或 override 都靠 id 定位name:'@deepseek-ai/dsh-system-prompt'config:persona:"You are a coding agent…"三、last-write-wins:整行替换,不深合并
这是 Patch 体系里最重要的一条铁律,记不住会踩大坑:
一个后层 patch 用 id 命中某一行时,替换的是那一行的整个
config,而不是深合并。
也就是说,如果你想覆盖某个行,必须把那一行「自己拥有的每个 key」都重述一遍。少写一个 key,它不会「保留之前的默认值」,而是直接没了。官方反复强调:patch 替换目标行的整个 config,没有 deep-merge。
这条规则的代价是「覆盖要写全」,但好处也明显:每一行最多属于一个 bundle 层 + 用户覆盖层。于是「这个值到底是谁定的」永远可查、可审计——不会出现「六个插件各自往同一个对象里 merge 一点点,最后谁也不知道完整值从哪来」的乱象。
四、bundle layer ordering:谁后叠谁赢
上一篇已经埋过伏笔,这里展开。层叠顺序决定了「谁覆盖谁」:
dsh.profile.bundles里每个 bundle 的 patch,按声明顺序;- profile 自己的
cordis.patch.yml; - home 级的
$DSH_HOME/cordis.patch.yml; --patch命令行覆盖层。
顺序的意义在于:你的 bundle 的 patch 应用在它之前所有 bundle 之后,所以它能覆盖前面任何一个 bundle 配过的行。而后面的 profile / home /--patch又能覆盖你的 bundle。这是一条清晰的「谁后谁赢」链。
一个典型的应用:base 层把一个「按 mode 不同而不同」的行留白,由各 mode bundle 去填各自完整 config——这样「不同形态不同默认值」的差异就落在了各自的 mode bundle 里,而不是污染 base 层。
五、用户 Settings:namespace + schema + 三层解析
Settings 这边更「应用层」。每个插件可以用ctx.settings.register(ns, schema, options)注册一个namespace,得到一个SettingsScope:
constscope=ctx.settings.register('my-plugin',schema,{base:{timeout:3000},// 组合层默认值applies:'live',// live 即时生效 / restart 重启生效validate:(v)=>{/* schema 表达不了的交差校验 */},})awaitscope.update({timeout:5000})// 合并稀疏 patch 到用户层awaitscope.replace({timeout:5000})// 整段替换(reset 路径)scope.watch((next,prev)=>{/* 观察变更 */})解析值的顺序永远是:schema 默认值 → 组合base→ 用户层。
三个写接口语义不同,别用错:
update(patch):把稀疏 patch合并进用户层(只改你给的 key);replace(section):整段替换用户层,缺的 key 回落到base和 schema 默认——这是「删除/重置」路径,replace({})等于全重置;mutate(ops):路径寻址编辑({op:'set'|'unset', path}),给「只拿到脱敏视图、无法重建整段」的调用方用,避免误删没见过的 secret 字段。
每次提交还会发settings/updated (ns, next, prev, source)事件,source区分是进程内update还是外部provider编辑。validate是 schema 表达不了的交差校验(比如「这个 provider profile 我服务不了」),在写的时候就拒绝,而不是存一个会静默禁用主人的值。
六、避坑清单
- 整行替换不是深合并。覆盖一行要把它拥有的 key 写全,别指望少写的 key 能「继承默认」。
- 两套配置别搞混。「换哪个后端」写 patch,「后端连哪」写 Settings,放错位置要么不生效要么语义错乱。
replace会重置没写的 key。只想改一个字段用update或mutate,别图省事replace只传一个 key,否则其余字段全回落默认。- secret 字段靠
mutate保护。任何 wire 界面必须redactSecrets: true;拿到脱敏视图的调用方只能用mutate按路径改,用replace会静默删掉它从没见过的 secret。 - 层叠顺序决定覆盖力。要临时改用
--patch,要持久改写 home 级 patch,别去改内置 bundle 的源文件。
小结
Patch 体系是 Harness「去锁死、可替换、可组合」这一价值观的机械实现。它用「id 寻址 + 整行替换 + last-write-wins」换来一个极其可贵的性质:最终配置永远可被精确归因。你想知道「这个模型是从哪来的」,顺着层叠链一查就知道,而不是对着一个被 merge 了 N 次的运行时对象干瞪眼。
下一篇我们往「运行起来之后」看——配置改好了、Agent 跑起来了,怎么知道它到底干得怎么样?这就是可观测性与 OpenTelemetry 遥测。
参考:deepseek-ai/deepseek-harness 官方仓库docs/subsystems/settings.md、apps/cli/README.md。