
01、普通文本文件的合并方式
{
"skills": {
"install": {
"nodeManager": "npm"
},
"entries": {
"coding-agent": {
"enabled": true
},
"apple-notes": {
"enabled": false
},
"apple-reminders": {
"enabled": false
},
"bear-notes": {
"enabled": false
},
"discord": {
"enabled": false
},
"goplaces": {
"enabled": false
},
"imsg": {
"enabled": false
},
"model-usage": {
"enabled": false
},
"openai-whisper-api": {
"enabled": false
},
"peekaboo": {
"enabled": false
},
"sag": {
"enabled": false
},
"sherpa-onnx-tts": {
"enabled": false
},
"slack": {
"enabled": false
},
"things-mac": {
"enabled": false
},
"trello": {
"enabled": false
},
"voice-call": {
"enabled": false
}
}
},
"wizard": {
"lastRunAt": "2026-05-28T16:54:08.170Z",
"lastRunVersion": "2026.5.22",
"lastRunCommand": "doctor",
"lastRunMode": "local"
},
"meta": {
"lastTouchedVersion": "2026.5.22",
"lastTouchedAt": "2026-05-28T16:54:08.182Z"
},
"agents": {
"defaults": {
"workspace": "/home/openclaw/.openclaw/workspace"
}
},
"gateway": {
"mode": "local",
"port": 18789,
"bind": "loopback",
"auth": {
"mode": "token",
"token": "..."
},
"tailscale": {
"mode": "off",
"resetOnExit": false
}
},
"tools": {
"web": {
"search": {
"enabled": false
},
"fetch": {
"enabled": false
}
}
}
}模型配置的配置片段如下所示:
{
"agents": {
"defaults": {
"model": {
"primary": "xxx/gpt-5.5-Pro"
},
"models": {
"xxx/gpt-5.3-codex-spark": {"alias": "GPT 5.3 Codex Spark [xxx]"},
"xxx/gpt-5.5": {"alias": "GPT 5.5 [xxx]"},
"xxx/gpt-5.5-Pro": {"alias": "GPT 5.5 Pro [xxx]"},
"xxx/claude-haiku-4-5": {"alias": "Claude Haiku 4.5 [xxx]"},
"xxx/claude-sonnet-4-5": {"alias": "Claude Sonnet 4.5 [xxx]"},
"xxx/glm-5-turbo": {"alias": "GLM 5 Turbo [xxx]"},
"xxx/glm-5.1": {"alias": "GLM 5.1 [xxx]"},
"xxx/MiniMax-M2.7": {"alias": "MiniMax M2.7 [xxx]"},
"xxx/qwen3.6-plus": {"alias": "Qwen3.6 Plus [xxx]"}
}
}
},
"models": {
"mode": "merge",
"providers": {
"xxx": {
"baseUrl": "https://api.xxx.com/v1",
"apiKey": "...",
"api": "openai-completions",
"models": [
{"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},
{"id": "gpt-5.5", "name": "GPT 5.5"},
{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},
{"id": "claude-haiku-4-5", "name": "Claude Haiku 4.5"},
{"id": "claude-sonnet-4-5", "name": "Claude Sonnet 4.5"},
{"id": "glm-5-turbo", "name": "GLM 5 Turbo"},
{"id": "glm-5.1", "name": "GLM 5.1"},
{"id": "MiniMax-M2.7", "name": "MiniMax M2.7"},
{"id": "qwen3.6-plus", "name": "Qwen3.6 Plus"}
]
}
}
}
}如果参考普通文本文件合并方式进行合并,这同时也就绝大多数小白在首次配置 OpenClaw 期间的做法,合并后的配置如下所示:
{
"skills": {
"install": {
"nodeManager": "npm"
},
"entries": {
"coding-agent": {
"enabled": true
},
"apple-notes": {
"enabled": false
},
"apple-reminders": {
"enabled": false
},
"bear-notes": {
"enabled": false
},
"discord": {
"enabled": false
},
"goplaces": {
"enabled": false
},
"imsg": {
"enabled": false
},
"model-usage": {
"enabled": false
},
"openai-whisper-api": {
"enabled": false
},
"peekaboo": {
"enabled": false
},
"sag": {
"enabled": false
},
"sherpa-onnx-tts": {
"enabled": false
},
"slack": {
"enabled": false
},
"things-mac": {
"enabled": false
},
"trello": {
"enabled": false
},
"voice-call": {
"enabled": false
}
}
},
"wizard": {
"lastRunAt": "2026-05-28T16:54:08.170Z",
"lastRunVersion": "2026.5.22",
"lastRunCommand": "doctor",
"lastRunMode": "local"
},
"meta": {
"lastTouchedVersion": "2026.5.22",
"lastTouchedAt": "2026-05-28T16:54:08.182Z"
},
"agents": {
"defaults": {
"workspace": "/home/openclaw/.openclaw/workspace"
}
},
"gateway": {
"mode": "local",
"port": 18789,
"bind": "loopback",
"auth": {
"mode": "token",
"token": "..."
},
"tailscale": {
"mode": "off",
"resetOnExit": false
}
},
"tools": {
"web": {
"search": {
"enabled": false
},
"fetch": {
"enabled": false
}
}
}
}
{
"agents": {
"defaults": {
"model": {
"primary": "xxx/gpt-5.5-Pro"
},
"models": {
"xxx/gpt-5.3-codex-spark": {"alias": "GPT 5.3 Codex Spark [xxx]"},
"xxx/gpt-5.5": {"alias": "GPT 5.5 [xxx]"},
"xxx/gpt-5.5-Pro": {"alias": "GPT 5.5 Pro [xxx]"},
"xxx/claude-haiku-4-5": {"alias": "Claude Haiku 4.5 [xxx]"},
"xxx/claude-sonnet-4-5": {"alias": "Claude Sonnet 4.5 [xxx]"},
"xxx/glm-5-turbo": {"alias": "GLM 5 Turbo [xxx]"},
"xxx/glm-5.1": {"alias": "GLM 5.1 [xxx]"},
"xxx/MiniMax-M2.7": {"alias": "MiniMax M2.7 [xxx]"},
"xxx/qwen3.6-plus": {"alias": "Qwen3.6 Plus [xxx]"}
}
}
},
"models": {
"mode": "merge",
"providers": {
"xxx": {
"baseUrl": "https://api.xxx.com/v1",
"apiKey": "...",
"api": "openai-completions",
"models": [
{"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},
{"id": "gpt-5.5", "name": "GPT 5.5"},
{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},
{"id": "claude-haiku-4-5", "name": "Claude Haiku 4.5"},
{"id": "claude-sonnet-4-5", "name": "Claude Sonnet 4.5"},
{"id": "glm-5-turbo", "name": "GLM 5 Turbo"},
{"id": "glm-5.1", "name": "GLM 5.1"},
{"id": "MiniMax-M2.7", "name": "MiniMax M2.7"},
{"id": "qwen3.6-plus", "name": "Qwen3.6 Plus"}
]
}
}
}
}这种做法肯定是错的,错到甚至不需要通过执行 openclaw doctor 命令来发现,借助稍微智能一点的 IDE(比如 PyCharm)就能发现问题,如图所示:

接下来我们看一下如何正确地合并多个 OpenClaw 的配置片段,共有两种合并方式,即 OpenClaw 官方文档推荐的 $include 指令的合并方式外加上自己或者让 AI 编写合并代码的合并方式,我们首先来看一下 $include 指令的合并方式。
02、OpenClaw 的官方文档推荐的 $include 指令的合并方式
$include 单文件操作
OpenClaw 的官方文档推荐的 $include 指令的合并方式的操作步骤非常简单,比如现在把 agents 字段下的所有内容放在 openclaw.json(OpenClaw 的配置文件)的同级目录下的 agents.json5 文件中,然后对 OpenClaw 的配置文件 openclaw.json 做以下修改,如下所示:
{
...
"agents": {"$include": "./agents.json5"}
}其中省略号对应和 agents 字段平级的字段,比如 gateway 等字段。上述写法表示用 agents.json5 的内容替换掉 {"$include": "./agents.json5"} 这个东西,举个例子,如果 agents.json5 的内容如下所示:
{
"defaults": ...
}其中,省略号表示 defaults 字段下的具体内容。那么,OpenClaw 的配置文件就等价于如下所示的写法:
{
...
"agents": {
"defaults": ...
}
}$include 多文件操作和嵌套 $include 操作
这个时候必定会有人问这么一个问题:“上述案例中一定要把 agents 字段下的所有内容都一整个的全部放在 agents.json5 这一个文件中吗?”我只能这么回答:“不一定,你完全可以对 agents.json5 作进一步拆分,拆分之后的合并方式有两种。第一种合并方式是在 openclaw.json 中 $include 多个文件,第二种合并方式就是使用嵌套 $include 操作。”
$include 多文件操作
我们首先来看第一种合并方式,即在 OpenClaw 配置文件 openclaw.json 中 $include 多个文件,为了方便理解其合并机制,我们现在假设 agents 字段下的所有内容被拆分成如下的两个文件且均位于 openclaw.json 的目录下,即 defaults.json5 和 list.json5,其中 defaults.json5 文件的大致内容如下所示:
{
"defaults": ...
}其中,省略号依旧表示 defaults 字段下的具体内容。list.json5 文件的大致内容如下所示:
{
"list": ...
}其中,省略号表示 list 字段下的具体内容。接下来就是说明如何在 OpenClaw 配置文件中同时 $include 这两个文件,方法很简单,把 $include 的取值从文件名字符串改为文件名字符串数组即可,对应到 OpenClaw 的配置文件 openclaw.json 的大致内容如下所示:
{
...
"agents": {
"$include": [
"./defaults.json5",
"./list.json5"
]
}
}嵌套 $include 操作
第一种合并方式就是这么简单,接下来让我们看一下第二种合并方式,即使用嵌套 $include 操作,嵌套 $include 的操作逻辑是这样的,首先在 OpenClaw 配置文件 openclaw.json 中 $include 文件 A,然后文件 A 又 $include 文件 B。还是上面的例子,我们假设 defaults.json5 对应文件 A,list.json5 文件对应文件 B,首先,我们在 defaults.json5 文件(文件 A)中 $include 文件 list.json5(文件 B),defaults.json5 文件(文件 A)修改后的大致内容如下所示:
{
"defaults": ...
"$include": "./list.json5"
}最后,我们只要在 openclaw.json 中 $include 文件 defaults.json5 即可,修改后的 openclaw.json 大致内容如下所示:
{
...
"agents": {"$include": "./defaults.json5"}
}第二种合并方式同样也是这么简单,显然,这两种合并方式完全等价,它们都是让 OpenClaw 配置文件等价于如下所示的写法:
{
...
"agents": {
"defaults": ...
"list": ...
}
}$include 指令对应文件位置
在上述案例中,我总是把需要 $include 的文件的位置放在和 OpenClaw 配置文件 openclaw.json 的同一级目录下,OpenClaw 其实并没有这个限制,完全可以放在 openclaw.json 当前目录的子目录下,比如在上面 $include 多文件操作案例中的两个配置文件,即 defaults.json5 文件和 list.json5 文件,我把这两个文件放到 openclaw.json 当前目录下的子目录 agents 中,这个时候只需要修改 OpenClaw 配置文件 openclaw.json 即可,对应到 OpenClaw 的配置文件 openclaw.json 的大致内容如下所示:
{
...
"agents": {
"$include": [
"./agents/defaults.json5",
"./agents/list.json5"
]
}
}那目前为止,还有两个问题没有说清楚,第一个问题就是如果目前采用的是上文中所示的嵌套 $include 操作,只知道 openclaw.json 中只需要 $include 字符串 ./agents/defaults.json5,defaults.json5 中不清楚是要 $include 字符串 ./list.json5 还是 ./agents/list.json5;答案是前者,即 ./list.json5 字符串,因为在 defaults.json5 中 $include 文件,路径的解析是相对于对应文件 defaults.json5 而言的而不是相对于 OpenClaw 配置文件而言的。第二个问题就是 $include 字段值对应的文件位置是不是可以任意放,这个问题的答案显然是不行的,因为它们只能放在 OpenClaw 配置文件 openclaw.json 的目录中或者其子目录中,不能放到 openclaw.json 对应目录的外面,比如在我这里 openclaw.json 位于 ~/.openclaw 目录中,$include 字段值对应的文件也只能放在这里面(当然可以在里面新建子目录并把 $include 字段值对应的文件放进去),不能放在 ~ 目录中。
冲突处理
$include 多文件冲突
上述案例中,我一直假设 defaults.json5 和 list.json5 两个需要 $include 的文件中没有同级同名字段,即 defaults.json5 中只有一个顶级字段 defaults 而 list.json5 中只有一个顶级字段 list,现在我们来考虑如果两个文件中有同级同名字段且都在一个地方被 $include,会发生什么事,我们假设有两个文件,其都在 openclaw.json 的目录下,记作 agents1.json5 和 agents2.json5,其中 agents1.json5 的内容如下所示:
{
"defaults": {
"model": {
"primary": "xxx/gpt-5.5-Pro"
},
"sandbox": {
"mode": "all"
}
}
}agents2.json5 的内容如下所示:
{
"defaults": {
"model": {
"primary": "xxx/glm-5.1"
},
"sandbox": {
"backend": "docker"
}
}
}显然,我们可以发现 primary 字段存在冲突。接着我们在 OpenClaw 的配置文件 openclaw.json 中的同一个地方 $include 这两个文件,如下所示:
{
...
"agents": {
"$include": ["./agents1.json5", "./agents2.json5"]
}
}这样合并之后就会让 OpenClaw 配置文件等价于如下所示的写法:
{
...
"agents": {
"defaults": {
"model": {
"primary": "xxx/glm-5.1"
},
"sandbox": {
"mode": "all",
"backend": "docker"
}
}
}
}我们可以发现合并之后 primary 字段对应的是 agents2.json5 的对应的字段值,这是因为 $include 对应值如果是一个文件名数组,当存在字段冲突时会用后面的文件内容来覆盖前面的文件内容。
$include 单文件冲突
通过上述案例,绝大多数人会想当然的认为冲突只会发生在 $include 对应值是一个文件名数组的情况下,如果只 $include 单个文件,就彻底没有冲突了。如果这么想,你就错了,依旧会有冲突。我们现假设需要被 openclaw.json 所 $include 的文件依旧位于 openclaw.json 的同级目录下,这个文件的名称就叫 agents.json5,其内容如下所示:
{
"defaults": {
"model": {
"primary": "xxx/gpt-5.5-Pro"
},
"sandbox": {
"mode": "all"
}
}
}OpenClaw 配置文件 openclaw.json 对应内容如下所示:
{
...
"agents": {
"$include": "./agents.json5",
"defaults": {
"model": {
"primary": "xxx/glm-5.1"
},
"sandbox": {
"backend": "docker"
}
}
}
}这就是一个比较简单的只 $include 单文件依旧会有冲突的案例,说得通俗易懂点就是 OpenClaw 配置文件本身和 $include 对应的单文件存在冲突,这样合并之后就会让 OpenClaw 配置文件等价于如下所示的写法:
{
...
"agents": {
"defaults": {
"model": {
"primary": "xxx/glm-5.1"
},
"sandbox": {
"mode": "all",
"backend": "docker"
}
}
}
}我们可以发现合并之后存在冲突的 primary 字段对应的值就是 OpenClaw 配置文件 openclaw.json 中的 primary 字段的对应值,即当存在上述冲突的时候就用 OpenClaw 配置文件 openclaw.json 中的值覆盖掉 $include 对应的单个文件的值,如果 $include 的是多个文件,结论一样,最后无论如何都是用的 OpenClaw 配置文件 openclaw.json 中的值。
总结一下,OpenClaw 冲突处理逻辑如下所示,假设在 OpenClaw 配置文件的一个地方同时 $include 多个文件,当遇到冲突时会用后面的文件覆盖前面的文件,最后再用 OpenClaw 配置文件本身的冲突字段做一遍覆盖。
根位置 $include 操作
在上述所有案例中,我都是在 agents 字段下进行 $include 操作,那么能不能在 agents 同级字段下,也就是所谓的根位置,因为 agents 没有上级字段,来进行 $include 操作,假设我把 OpenClaw 的所有配置都放到和原始配置文件openclaw.json 同级目录下的 config.json5 中,config.json5 的部分内容如下所示:
{
...
"agents": ...
}考虑到所有配置都移到了 config.json5 中,我们只需要在 openclaw.json 中写一个键值对即可,无需再写其他内容,如下所示:
{
"$include": "./config.json5"
}当然,即使是根位置 $include 也能同时 $include 多个文件,当然也允许存在冲突,处理冲突的机制可以参考上文。
当 $include 文件是 JSON 数组而非 JSON 对象时
$include 单个 JSON 数组文件
上述所有案例,我均假设 $include 的文件对应的是一个 JSON 对象,即文件格式是形如 {...} 的 JSON 对象,我们来看一下如果对应文件格式是形如 [...] 的 JSON 数组,会发生什么事。我们首先假设有一个这样的配置文件,其位置位于 OpenClaw 配置文件 openclaw.json 的同级目录下,文件名称就简单记作 models.json5,其中 models.json5 的文件内容如下所示:
[
{"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},
{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"}
]接下来就是在 OpenClaw 配置文件中的对应位置执行 $include 操作,如下所示:
{
...
"models": {
"mode": "merge",
"providers": {
"xxx": {
...
"models": {"$include": "./models.json5"}
}
}
}
}在这里我依旧通过使用省略号的方式来省略无关的配置内容,接下来我们就来看看到底能不能成功 $include 一个 JSON 数组文件。修改之后我们直接运行命令 openclaw config validate 来检查一下是不是可以成功,命令执行结果如图所示。

显然我们可以发现上述操作是合法的,但是仅仅是合法是不足以说明没问题,我们还需要确保对应配置的内容是符合预期的,继续运行命令 openclaw config get models.providers.xxx.models,命令执行结果如图所示。

$include 多个 JSON 数组文件
既然能 $include 单个 JSON 数组文件,接下来我们试试能不能在同一个地方 $include 多个 JSON 数组文件,假设有俩配置文件 models1.json5 和 models2.json5,它们的目录和上述案例中的 models.json5 完全一样,其中 models1.json5 的内容如下所示:
[
{"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},
{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"}
]models2.json5 的内容如下所示:
[
{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},
{"id": "gpt-5.5", "name": "GPT 5.5"}
]OpenClaw 配置文件 openclaw.json 的内容如下所示:
{
...
"models": {
"mode": "merge",
"providers": {
"xxx": {
...
"models": {
"$include": ["./models1.json5", "models2.json5"]
}
}
}
}
}在这里我依旧通过使用省略号的方式来省略无关的配置内容,接下来我们就来看看到底能不能成功 $include 多个 JSON 数组文件。修改之后我们直接运行命令 openclaw config validate 来检查一下是不是可以成功,命令执行结果如图所示。

显然我们可以发现上述操作是合法的,但是仅仅是合法是不足以说明没问题,我们还需要查看一下对应配置内容是什么,继续运行命令 openclaw config get models.providers.xxx.models,这个命令因为输出太长截图显示不全,我把结果复制到一个文本文件中了,命令执行结果如图所示。

我们可以发现,如果在同一个地方 $include 多个 JSON 数组文件,最后得到的 JSON 数组就是把这些文件的 JSON 数组按顺序做个拼接操作。但是,值得注意的是该操作会保留重复项,换句话说就是该操作不保证每一项只出现一次。
字段名冲突字段值数组
接下来我们回到 $include 多个 JSON 对象的场景下,看一下如果出现当字段名出现冲突且字段值是 JSON 数组,其还能不能像上述场景下按顺序做拼接操作。案例还是同名同目录的俩文件,即 models1.json5 和 models2.json5,但是内容有变化,models1.json5 的内容如下所示:
{
"models": [
{"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},
{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"}
]
}models2.json5 的内容如下所示:
{
"models": [
{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},
{"id": "gpt-5.5", "name": "GPT 5.5"}
]
}OpenClaw 配置文件 openclaw.json 的内容如下所示:
{
...
"models": {
"mode": "merge",
"providers": {
"xxx": {
"baseUrl": "https://api.xxx.com/v1",
"apiKey": "...",
"api": "openai-completions",
"$include": ["./models1.json5", "./models2.json5"]
}
}
}
}在这里我依旧通过使用省略号的方式来省略无关的配置内容外加上敏感的配置内容,经检验,最终的配置和 $include 多个 JSON 数组文件完全等价,这里就不再截图说明,即如果同一个地方 $include 多个文件,当出现字段名冲突且字段值是 JSON 数组,其处理方式是按顺序依次拼接每个文件对应字段的数组,并不是官方文档中所谓的后者覆盖前者。最后,我想说的是如果 OpenClaw 配置文件 openclaw.json 本身也和需要被 $include 的文件存在字段名冲突且字段值是 JSON 数组,这个 JSON 数组会在所有 $include 的文件拼完之后拼在最后面。考虑到 openclaw config get models.providers.xxx.models 命令的输出非常非常长,这里就不提供案例了,可自行检验。
当 $include 操作位于 JSON 数组中时
JSON 数组中 $include 单文件
上述所有案例都是在 JSON 对象中使用 $include 指令,那么如果是在 JSON 数组中使用 $include 指令,还能不能成功。我们现在就假设只有一个配置文件 model.json5,其位置和 OpenClaw 的配置文件 openclaw.json 位于同目录下,model.json5 的内容如下所示:
{
"id": "gpt-5.3-codex-spark",
"name": "GPT 5.3 Codex Spark"
}OpenClaw 的配置文件 openclaw.json 的内容如下所示:
{
...
"models": {
"mode": "merge",
"providers": {
"xxx": {
...
"models": [
{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},
{"$include": "./model.json5"}
]
}
}
}
}在这里我依旧通过使用省略号的方式来省略无关的配置内容,接下来我们直接运行命令 openclaw config validate 来检查一下是不是可以成功,命令执行结果如图所示。

显然我们可以发现上述操作是合法的,但是仅仅是合法是不足以说明没问题,我们还需要确保对应配置的内容是符合预期的,继续运行命令 openclaw config get models.providers.xxx.models,命令执行结果如图所示。

我们可以发现,即使在 JSON 数组中 $include 单个 JSON 对象文件,依旧会进行正确的替换且不覆盖数组中的任何一个元素。当然,如果当 $include 的单个 JSON 对象文件中的 JSON 对象和 JSON 数组中的某个 JSON 对象出现重复时,依旧不会去除重复的元素。
JSON 数组中 $include 多文件
既然能在 JSON 数组中 $include 单个 JSON 对象文件,接下来我们试试能不能在 JSON 数组中 $include 多个 JSON 对象文件。假设有两个 JSON 对象文件 model1.json5 和 model2.json5 均位于 OpenClaw 的配置文件同级目录下,model1.json5 的内容如下所示:
{
"id": "gpt-5.3-codex-spark",
"name": "GPT 5.3 Codex Spark"
}model2.json5 的内容如下所示:
{
"id": "gpt-5.5",
"name": "GPT 5.5"
}在测试之前我首先需要声明的是在 JSON 数组中 $include 多个 JSON 对象文件的方式有两种:第一,一次性 $include 多个 JSON 对象文件;第二,分多次 $include 多个 JSON 对象文件,其中每次只 $include 一个 JSON 对象文件。
我们首先来看第一种操作方式,即一次性 $include 多个 JSON 对象文件。此时 OpenClaw 配置文件 openclaw.json 的内容如下所示:
{
...
"models": {
"mode": "merge",
"providers": {
"xxx": {
...
"models": [
{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},
{"$include": ["./model1.json5", "./model2.json5"]}
]
}
}
}
}在这里我依旧通过使用省略号的方式来省略无关的配置内容,接下来我们直接运行命令 openclaw config validate 来检查一下是不是可以成功,命令执行结果如图所示。

显然我们可以发现上述操作是合法的,但是仅仅是合法是不足以说明没问题,我们还需要确保对应配置的内容是符合预期的,继续运行命令 openclaw config get models.providers.xxx.models,命令执行结果如图所示。

结果似乎不符合预期,因为 model1.json5 对应内容消失了。出现这种情况其实并不难理解,因为在前面我就说过 OpenClaw 的 $include 操作当遇到冲突的时候会进行后者覆盖前者的处理方式,只不过这里的场景是位于 JSON 数组中,在这里因为 $include 的是一个文件数组,文件 model2.json5 是排在文件 model1.json5 的后面的且两个文件对应内容有冲突外加上冲突字段对应值是字符串而非 JSON 数组,所以 model1.json5 中的内容被 model2.json5 覆盖掉了。
接下来我们看一下第二种操作方式,即分多次 $include 多个 JSON 对象文件,其中每次只 $include 一个 JSON 对象文件。此时 OpenClaw 配置文件 openclaw.json 的内容如下所示:
{
...
"models": {
"mode": "merge",
"providers": {
"xxx":{
...
"models": [
{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},
{"$include": "./model1.json5"},
{"$include": "./model2.json5"}
]
}
}
}
}在这里我依旧通过使用省略号的方式来省略无关的配置内容,接下来我们直接运行命令 openclaw config validate 来检查一下是不是可以成功,命令执行结果如图所示。

显然我们可以发现上述操作是合法的,但是仅仅是合法是不足以说明没问题,我们还需要查看一下对应配置内容是什么,继续运行命令 openclaw config get models.providers.xxx.models,这个命令因为输出太长截图显示不全,我把结果复制到一个文本文件中了,命令执行结果如图所示。

我们可以发现第一种方法带来的覆盖问题在这里被成功的解决了,两个文件中的内容都在正确的位置上被完整的合并进来了。
JSON 数组中 $include 单个 JSON 数组文件
既然 JSON 数组中可以 $include 普通 JSON 对象文件,那么我们来试试能不能 $include 对应内容是 JSON 数组的文件。假设有个文件位于 OpenClaw 配置文件 openclaw.json 的同级目录下,其名称叫做 models.json5,其内容如下所示:
[
{"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},
{"id": "gpt-5.5", "name": "GPT 5.5"}
]OpenClaw 配置文件 openclaw.json 的内容如下所示:
{
...
"models": {
"mode": "merge",
"providers": {
"xxx": {
...
"models": [
{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},
{"$include": "./models.json5"}
]
}
}
}
}在这里我依旧通过使用省略号的方式来省略无关的配置内容,接下来我们直接运行命令 openclaw config validate 来检查一下是不是可以成功,命令执行结果如图所示。

显然,在 JSON 数组中 $include 对应内容是 JSON 数组的文件在这里是有问题的,至于什么问题我们暂时还不知道,不要慌,接下来我们运行 openclaw doctor 命令就能找到具体是什么问题了,因为命令可能需要交互式执行,所以这里只给出最关键的输出,如下所示:
models.providers.xxx.models.1: Invalid input: expected object, received array哦,我明白了,上述操作会使得 OpenClaw 的配置等价于如下所示的内容:
{
...
"models": {
"mode": "merge",
"providers": {
"xxx": {
...
"models": [
{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},
[
{"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},
{"id": "gpt-5.5", "name": "GPT 5.5"}
]
]
}
}
}
}出现上述问题很明显是因为在 JSON 数组中 $include 对应内容是 JSON 数组的文件,JSON 数组不会展开,而是形成嵌套 JSON 数组的结构。
JSON 数组中 $include 多个 JSON 数组文件
既然 JSON 数组中 $include 单个 JSON 数组文件会形成嵌套 JSON 数组的结构,$include 多个 JSON 数组文件同样也会如此,唯一的区别就是合并后的数组中嵌套的小 JSON 数组可能不止一个,取决于是一次性 $include 多个这样的文件还是分多次 $include,如果是一次性 $include,嵌套的小 JSON 数组只有一个;如果是分多次 $include,嵌套的小 JSON 数组就有多个。
$include 标量 JSON 文件
$include 单个 JSON 标量文件
除了 JSON 数组和 JSON 对象外,一个 JSON 文件也可以代表一个 JSON 标量,JSON 标量指的是除 JSON 数组和 JSON 对象外的其他任意 JSON 类型。如果 $include 一个 JSON 标量文件,我们来看看会发生什么事。首先准备一个 JSON 标量文件,其名称叫做 model_primary.json5,所在目录位置和 OpenClaw 配置文件 openclaw.json 一样,其内容如下所示:
"xxx/gpt-5.5-Pro"OpenClaw 配置文件 openclaw.json 关键内容如下所示:
{
...
"agents": {
...
"defaults": {
...
"model": {
"primary": {"$include": "./model_primary.json5"}
}
}
}
}在这里我依旧通过使用省略号的方式来省略无关的配置内容,接下来我们直接运行命令 openclaw config validate 来检查一下是不是可以成功,命令执行结果如图所示。

显然我们可以发现上述操作是合法的,但是仅仅是合法是不足以说明没问题,我们还需要查看一下对应配置内容是什么,继续运行命令 openclaw config get agents.defaults.model.primary,命令执行结果如图所示:

显然我们可以发现 $include 一个 JSON 标量文件就是用对应 JSON 标量来替换 $include 指令。
$include 多个 JSON 标量文件
既然可以 $include 单个 JSON 标量文件,那么接下来我们试试能不能在同一个地方 $include 多个 JSON 标量文件。首先准备两个 JSON 标量文件,其名称叫做 model_primary1.json5 和 model_primary2.json5,所在目录位置和 OpenClaw 配置文件 openclaw.json 一样,model_primary1.json5 内容如下所示:
"xxx/gpt-5.5-Pro"model_primary2.json5 内容如下所示:
"xxx/gpt-5.3-codex-spark"OpenClaw 配置文件 openclaw.json 关键内容如下所示:
{
...
"agents": {
...
"defaults": {
...
"model": {
"primary": {
"$include": [
"./mode1_primary1.json5",
"./model_primary2.json5"
]
}
}
}
}
}在这里我依旧通过使用省略号的方式来省略无关的配置内容,接下来我们直接运行命令 openclaw config validate 来检查一下是不是可以成功,命令执行结果如图所示。

显然我们可以发现上述操作是合法的,但是仅仅是合法是不足以说明没问题,我们还需要查看一下对应配置内容是什么,继续运行命令 openclaw config get agents.defaults.model.primary,命令执行结果如图所示:

我们可以发现,依旧是后者覆盖前者的处理方式,最终带来的结果就是只保留最后一个 JSON 标量文件的标量值。
$include 操作总结
总结一下 OpenClaw 的 $include 操作,首先是基本操作逻辑和基本注意事项,如下所示:
其次是冲突处理机制,这个主要分 3 种情况讨论:
我们可以发现,OpenClaw 官方文档推荐的 $include 指令的合并方式并不是特别好的 OpenClaw 配置合并方式,因为 $include 在上述问题的基础上还有一个致命的缺陷,即如果存在多个长度相等且其元素是 JSON 对象的 JSON 数组需要逐元素合并而不是拼接,单纯靠 $include 指令基本上是无法实现的。因此,我们接下来尝试编写合并代码来实现多个配置片段的合并操作。
03、自己或者让 AI 编写合并代码的合并方式
在编写合并代码之前,我们先列举一下要求及功能:
导出旧配置
针对上述第一点,很多人已经习惯 OpenClaw 官方文档推荐的 $include 合并方式,并且目前的 OpenClaw 配置文件有很多个外加上组织方式非常复杂,比如很多个 $include 指令甚至还有快要接近 10 层上限的嵌套 $include 操作,手动去除所有 $include 指令是非常不切实际的。但是,这个根本不需要你来手动去除。我们可以通过编写一个脚本文件来去除所有 $include 指令,但前提是你的 OpenClaw 配置本身是合法的,可通过 openclaw config validate 命令进行检验。既然有命令来检查配置是否合法,现在的问题是是否有命令能获取当前所有配置内容。很可惜,答案是没有,只能通过 openclaw config get ... 命令获取对应字段下的所有配置。虽然不可以一步获取当前的所有配置内容,但是我们可以分两步操作,先获取 OpenClaw 配置下的所有顶级字段,然后依次使用命令 openclaw config get <对应顶级字段> 来获取所有顶级字段下的所有配置再合并起来。现在还差一个问题,如何获取 OpenClaw 配置下的所有顶级字段。这个很简单,我们可以先获取 OpenClaw 的配置说明,可以通过运行命令 openclaw config schema 来获取配置说明,其中必然会列出所有的顶级字段。
获取 OpenClaw 配置下的所有顶级字段
要想获取 OpenClaw 配置下的所有顶级字段,我们首先必须知道获取配置说明的命令 openclaw config schema 的输出是什么,绝大多数人会尝试通过执行这个命令来获取对应输出。但是,这个命令的输出非常长,长到远远超出了终端的显示范围,它只会保留显示范围内的最新的一部分输出,要想获取完整的输出内容只能通过把输出内容重定向到一个文件中,执行以下命令即可:
openclaw config schema > a.txt接下来我们查看 a.txt 的内容,如下图所示。

我们可以发现虽然文件开头存在一些不知道是什么的鬼画符,但是该命令的输出是一个 JSON 内容,并且 OpenClaw 配置下的所有顶层字段位于 properties 字段对应 JSON 对象的所有顶级字段,即 $schema、meta、env 等字段就是 OpenClaw 配置的顶级字段。
消除鬼画符
然而,为了让内容可以被 JSON 解析器解析,开头的鬼画符必须想个办法除掉,我们首先可以看一下开头的鬼画符在终端能不能正常显示,执行命令 openclaw config schema | head -c 100 来输出这个命令输出内容的前 100 个字符,如下图所示。

如果去一个一个字符数很可能会发现不到 100 个字符,这是因为有些字符,比如换行符,你看不见。重点关注开头的竖线和小方框,a.txt 文件开头出现的不知道是什么的鬼画符在终端能够被正常显示而且现实的内容非常熟悉(参考上文 openclaw config validate 命令执行结果截图)。这说明即使我们尝试把结果保存到 a.txt 中,但是 OpenClaw 依旧以为这是一个终端,所以 a.txt 开头才会出现这些不知道是什么的鬼画符。要想消除这些不知道是什么的鬼画符首先必须知道 OpenClaw 是如何判断当前是不是终端,判断终端的方法无非就两种,通过判断 stdout 或者 stderr 是否指向终端;至于 stdin 无需关注,因为这个命令没有交互式输入操作。上述把命令输出写入 a.txt 文件的操作已经重定向了 stdout,但是没有重定向 stderr,所以我们只要再额外重定向 stderr 就可以了,修改后的命令如下所示:
openclaw config schema 2>/dev/null >a.json考虑到这个命令的输出对应一个 JSON 内容,所以我把文件扩展名改成 .json 了,这次打开 a.json 查看一下鬼画符是不是没了,如图所示。

我们可以发现鬼画符消失了,这个时候可以直接通过 JSON 解析器解析 a.json 文件并拿出 OpenClaw 配置下的所有顶级字段。但是我们不这样做,因为这样做割裂了操作的连续性,这样做等价于两步手动操作,第一步就是是获取配置说明的 JSON 文件,就是这里的 a.json;第二步是编写脚本执行 OpenClaw 配置导出操作。
一键导出旧配置的代码
我们直接合并成一步操作,一步操作对应代码如下:
def export_old_config():
schema = subprocess.run(('openclaw', 'config', 'schema'), capture_output=True, check=True,
encoding='UTF-8-SIG', text=True)
schema = json.loads(schema.stdout)
top_fields = {_ for _ in schema['properties'] if not _.startswith("$")}
config = {}
for top_field in top_fields:
cur_config = subprocess.run(('openclaw', 'config', 'get', top_field), capture_output=True,
encoding='UTF-8-SIG', text=True)
if not cur_config.returncode:
config[top_field] = json.loads(cur_config.stdout)
return config为了在 Python 脚本程序中执行相关命令,我在这里使用的是 subprocess 模块下的 run 函数,这个函数非常简单,第一个参数是命令字符串经过空格分割之后的字符串序列,类似于 Dockerfile 中的 CMD 指令。
capture_output 参数设置为 True 表示捕获命令的输出,既捕获 stdout 的内容又捕获 stderr 的内容外加上不会让 stdout 的内容和 stderr 的内容混到一起,设置为 False 则表示不捕获命令输出,要想获取 stdout 的内容只需要直接访问 run 函数返回值的 stdout 属性,要想获取 stderr 的内容只需要直接访问 run 函数返回值的 stderr 属性。
check 参数设置为 True 表示一旦命令执行失败就立刻终止,不再继续执行后续操作浪费时间,如果该值设置为 False 则是即使命令失败还会执行后续操作。需要注意的是我只在获取 OpenClaw 配置说明的时候设置了 check=True,在之后获取所有顶级字段的配置的时候没有设置 check=True 是因为在这里希望把所有的顶级字段的配置都获取到,而不是只要其中一个顶级字段的配置获取不到(命令执行失败)就直接因为终止后续操作从而导致放弃后续的顶级字段的配置获取。当然,我为了确认命令到底是成功还是失败我在这里是通过返回码或者说是退出码是否为 0 来判断的,如果退出码为 0,则表示命令成功。
text 参数设置为 True 则表示把 stdout 外加上 stderr 的内容从字节串转为字符串,设置为 False 表示保留字节串。
此外,我在获取 OpenClaw 配置中的所有顶级字段的时候把美元符号开头的字段给排除了,这是因为这些字段属于特殊字段而非功能字段,所以无需考虑。
不管之前的 OpenClaw 配置是如何花式使用 $include 操作的,运行上述代码必定可以一键导出 OpenClaw 的旧配置到变量 config 中去,而且这个返回值,即变量 config 中不会出现一个 $include 指令,写入把变量对应内容写入文件我们之后再做,这里就先放一下。
通用的合并算法的代码
我们首先来实现一下通用的合并算法,为了简单起见,现在假设合并的位置都是根位置,待合并的两个配置片段也全是 JSON 对象,JSON 对象被 Python 解析后对应的是字典,所以该算法的输入是两个字典,然而又因为字典传给参数传的是引用,所以该算法可以选择其中一个输入的字典来原地修改,该算法对应代码如下所示:
def merge(origin_config, extra_config):
for _ in extra_config:
if _ in origin_config:
if isinstance(origin_config[_], dict) and isinstance(extra_config[_], dict):
merge(origin_config[_], extra_config[_])
elif isinstance(origin_config[_], list) and isinstance(extra_config[_], list):
origin_config[_].extend(extra_config[_])
else:
origin_config[_] = extra_config[_]
else:
origin_config[_] = extra_config[_]
其中,origin_config 参数对应的是原始的配置,extra_config 参数对应的是额外的配置,这个代码实现的是把额外的配置合并到原始的配置中去,原地修改原始的配置对应的字典。这个代码的逻辑非常简单,遍历额外的配置对应的字段,看是否和原始的配置存在冲突,如果存在冲突考虑三种情况,具体是哪三种情况可以参考上文,这里不再详细说明,如果没有冲突直接合并即可。虽然到目前为止已经实现了根位置的合并操作,但是接下来我们不去实现非根位置的合并操作。接下来我们完善一下通用的合并算法,在完善通用的合并算法之前,我们先简单分析一下上述通用的合并算法的问题,如下所示:
虽然存在上述 4 个问题,但我们先只解决前 3 个问题,完善后的通用的合并算法代码如下所示:
def merge_(self, origin_config, extra_config, check_type):
for _ in extra_config:
self.loc.append(_)
if _ in origin_config:
if isinstance(origin_config[_], dict) and isinstance(extra_config[_], dict):
self.merge_(origin_config[_], extra_config[_], check_type)
elif isinstance(origin_config[_], list) and isinstance(extra_config[_], list):
origin_config[_].extend(extra_config[_])
elif isinstance(origin_config[_], list):
origin_config[_].append(extra_config[_])
elif isinstance(extra_config[_], list):
origin_config[_] = [origin_config[_]]+extra_config[_]
elif type(origin_config[_]) is not type(extra_config[_]) and check_type:
raise TypeError(f'{".".join(self.loc)} 字段类型不一致!期望类型:'
f'{self.type_table[type(origin_config[_])]},实际类型:'
f'{self.type_table[type(extra_config[_])]}')
else:
origin_config[_] = extra_config[_]
else:
origin_config[_] = extra_config[_]
del self.loc[-1]
在这里我把函数改成了某个类的方法,并使用 self.loc 属性记录位置,外加上使用属性 self.type_table 把 Python 类型映射成 JSON 类型。此外,我给输入多加了一个参数 check_type,该参数是用来指定是否进行类型检查。为什么要提供是否执行类型检查的选项,后面会提到,现在暂时先按下不表。我们已经实现并完善了通用的合并算法。
虽然我们实现了通用的合并算法,但是通用的合并算法要求输入的两个字典和一个布尔值,两个字典对应两个配置片段,配置片段一般都是放在文件中,所以我们还需要给通用的合并算法做一个包装方法,在实现包装方法之前我们需要想一下包装方法的输入是什么,输入其实很简单,就是合并位置外加上对应位置的配置片段所对应的文件。但是,如果有很多个(比如 100 个)配置片段,同时也有同样多的合并位置与之对应,现在我们首先需要解决的问题是通过什么方法记住这 100 条记录,这里我采用 CSV 文件,CSV 文件的格式是 N 行 2 列,每行对应一条这样的记录,两列中的第一列是合并位置,第二列是对应的配置片段的文件名。文件名比较简单,这里不做说明,重点说一下合并位置怎么写。合并位置的写法我参考上文访问 OpenClaw 配置中某个字段对应的值,用点号从顶级字段依次遍历下去,举个例子,假设合并位置是 agents.defaults,则表示把对应配置片段合并到 agents 字段下的 defaults 子字段下。显然,我们可以发现,用这种方式提供合并位置比直接去配置文件中找到对应位置要简单太多了!此外需要注意的是如果合并位置为空字符串,则表示对应的合并位置是根位置。下面直接给出包装方法的完整代码,如下所示:
def merge(self, filename):
if not os.path.exists(filename):
raise FileNotFoundError(f'元配置文件 {filename} 不存在!')
if os.path.isdir(filename):
raise IsADirectoryError(f'{filename} 是目录而非文件!')
# TODO: 转二元组列表,二元组的第一个元素是合并位置,第二个元素是待合并文件
# 之所以 idx 初值设为 -1,是因为这方便后续 raise csv.Error 可以统一计算行号,从而不需要单独考虑是不是首行
configs, idx = [], -1
try:
for idx, row in enumerate(csv.reader(open(filename, encoding='UTF-8-SIG'), strict=True)):
if not len(row):
continue
elif len(row) > 2:
raise ValueError(
f'元配置文件 {filename} 的第 {idx+1} 行的列数大于 2!有以下解法:\n'
f'1. 检查元配置文件 {filename} 的第 {idx+1} 行的内容!'
f'若需合并的配置文件路径对应字符串或者合并位置对应字符串有逗号,需用双引号包裹,'
f'否则可能会导致逗号被当成分隔符把当前行分割成多列(超过 2 列),从而导致这个问题!\n'
f'2. 检查元配置文件 {filename} 中是否有多个连续的且被双引号包裹的逗号,若有,保留一个即可!')
elif len(row) == 1 or not row[1]:
raise ValueError('第 %d 行,%s 没有待合并配置文件!' % (
idx+1, f'当前位置 {row[0]}' if row[0] else '根位置'))
if '\n' in row[0] or '\r' in row[0]:
raise ValueError(f'元配置文件 {filename} 的第 {idx+1} 行的合并位置对应字符串出现跨行!')
if '\n' in row[1] or '\r' in row[1]:
raise ValueError(f'元配置文件 {filename} 的第 {idx+1} 行的待合并配置文件名对应字符串出现跨行!')
configs.append((row[0], row[1]))
except UnicodeDecodeError:
raise ValueError(f'元配置文件 {filename} 的编码不是 UTF-8!')
except csv.Error:
# 此处 idx+1 对应的是上一行,所以要用 idx+2
raise csv.Error(f'元配置文件 {filename} 第 {idx+2} 行存在语法错误!检查以下内容:\n'
f'1. 若某字符串有双引号,则需在该字符串两边各放一个双引号,'
f'且其中的双引号必须在前面或者后面再来一个双引号实现转义(即两个连续的双引号)!\n'
f'2. 若某字符串被双引号包裹,左边的双引号的左边或者右边的双引号的右边要么什么都没有,要么只有逗号,'
f'不可出现其他东西!')
# TODO: 依次把其他配置和旧配置做合并
for config in configs:
if not os.path.exists(config[1]):
raise FileNotFoundError(f'待合并配置文件 {config[1]} 不存在!')
if os.path.isdir(config[1]):
raise IsADirectoryError(f'{config[1]} 是目录而非文件!')
try:
c = json5.load(open(config[1], encoding='UTF-8-SIG'), parse_constant=parse_constant)
# 因为 UnicodeDecodeError 是 ValueError 的子类,所以必须先 except UnicodeDecodeError
except UnicodeDecodeError:
raise ValueError(f"待合并配置文件 {config[1]} 的编码不是 UTF-8!")
except ValueError:
raise ValueError(f'1. 待合并配置文件 {config[1]} 有语法错误!\n'
f'2. 待合并配置文件存在特殊内容,比如 NaN、Infinity!')
if not config[0]:
if not isinstance(c, dict):
raise TypeError(f'待合并配置文件 {config[1]} 的合并位置是根位置,其内容必须是一个 JSON 对象!')
cur_config = c
else:
# TODO: 转嵌套字典
loc = config[0].split('.')
loc.reverse()
for _ in loc:
c = {_: c}
cur_config = c
self.merge_(self.old_config, cur_config, True)
其中,self.old_config 表示的导出的旧配置,即 export_old_config 函数的返回值,至于代码中的其他部分就非常简单了,注释外加上异常信息已经能够提供充分的信息了,这里不再进一步做详细的解释。
逐元素合并多个等长且其元素为 JSON 对象的 JSON 数组
到目前为止,我们已经在 $include 指令的基础上额外做了两个扩展的功能,如下所示:
接下来我们继续在此基础上实现一下如何针对多个等长且其元素为 JSON 对象的 JSON 数组进行逐元素的合并,具体是什么意思我先举个简单的例子说明一下,假设有两个 JSON 数组,第一个 JSON 数组的对应内容如下所示:
[
{"id": "id1"},
{"id": "id2"}
]第二个 JSON 数组的对应内容如下所示:
[
{"name": "name1"},
{"name": "name2"}
]合并之后的对应内容如下所示:
[
{"id": "id1", "name": "name1"},
{"id": "id2", "name": "name2"}
]显然,这个合并逻辑非常简单。但是,这个功能用 $include 指令根本就是无法实现。在给出完整代码之前,首先需要说一下注意事项:
显然,一个合并位置对应多个 JSON 文件,所以如果有多个合并位置,依旧可以通过 CSV 文件进行记录,CSV 文件的格式是 N 行且列数可变,之所以要让列数可变是因为有的位置文件少有的位置文件多。下面给出逐元素合并的完整的代码,如下所示:
def element_wise_merge(self, filename):
if not os.path.exists(filename):
raise FileNotFoundError(f'元配置文件 {filename} 不存在!')
if os.path.isdir(filename):
raise IsADirectoryError(f'{filename} 是目录而非文件!')
# TODO: 转二元组列表,二元组的第一个元素是合并位置,第二个元素是待合并文件列表
# 之所以 idx 初值设为 -1,是因为这方便后续 raise csv.Error 可以统一计算行号,从而不需要单独考虑是不是首行
configs, idx = [], -1
try:
for idx, row in enumerate(csv.reader(open(filename, encoding='UTF-8-SIG'), strict=True)):
row = [_ for _ in row if _]
for _ in row:
if '\n' in _ or '\r' in _:
raise ValueError(
f'元配置文件 {filename} 的第 {idx+1} 行的合并位置对应字符串或者待合并文件名对应字符串出现跨行!')
if not row:
continue
if len(row) < 2:
raise ValueError(f'第 {idx+1} 行的合并位置 {row[0]} 没有待合并配置文件!')
configs.append((row[0], row[1:]))
except UnicodeDecodeError:
raise ValueError(f'元配置文件 {filename} 编码不是 UTF-8!')
except csv.Error:
# 此处 idx+1 对应的是上一行,所以要用 idx+2
raise csv.Error(f'元配置文件 {filename} 第 {idx+2} 行存在语法错误!检查以下内容:\n'
f'1. 若某字符串有双引号,则需在该字符串两边各放一个双引号,'
f'且其中的双引号必须在前面或者后面再来一个双引号实现转义(即两个连续的双引号)!\n'
f'2. 若某字符串被双引号包裹,其左边或者右边要么什么都没有,要么只有逗号,不可出现其他东西!')
# TODO: 合并配置
for config in configs:
# TODO: 读取首个配置
first_config = check_and_get_array4element_wise_merge(config[1][0])
expected_length = len(first_config)
# TODO: 读取其他配置
for filename in config[1][1:]:
cur_config = check_and_get_array4element_wise_merge(filename)
cur_length = len(cur_config)
if cur_length != expected_length:
raise ValueError(f'当前合并位置:{config[0]}\n'
f'当前文件:{filename}\n'
f'当前 JSON 数组的长度和期望长度不一致,无法进行逐元素合并!\n'
f'当前长度:{cur_length},期望长度:{expected_length}')
for _ in range(expected_length):
# 因为逐元素合并的过程中出现报错信息会有歧义,所以这里直接不做类型检查!具体会有什么歧义,举例如下:
# 假设合并位置为 agents.list,如果用 agents.list.0.<...> 表示出错位置,其对应两种解释:
# 1. agents.list 数组下的第 1 个元素;2. agents.list 对象下键为 0 的值
# 如果用 agents.list[0] 表示出错位置,一样会有两种解释:
# 1. agents.list 数组下的第 1 个元素;2. agents 对象下键为 list[0] 的值
self.merge_(first_config[_], cur_config[_], False)
config[1][:] = first_config
# TODO: 转嵌套字典
for i in range(len(configs)):
loc = configs[i][0].split('.')
loc.reverse()
d = {loc[0]: configs[i][1]}
for _ in loc[1:]:
d = {_: d}
configs[i] = d
return configs其中,唯一值得注意的是之前之所以要提供是否进行类型检查,是因为数组内合并如果做类型检查报错位置会有歧义,所以在这里我选择妥协一下,直接关闭类型检查(上方的注释已经提供了必要的说明)。这就是为什么我要提供是否进行类型检查的选项,如果都做,可能会导致报错位置歧义;如果都不做,就退化成了 $include 指令。因此,我选择折中一下,在避免报错位置歧义的情况下做类型检查,至于代码中的其他部分就非常简单了,注释外加上异常信息已经能够提供充分的信息了,这里不再进一步做详细的解释。
此外,我在上述代码中使用了一自定义的函数,该函数的完整实现如下所示:
def check_and_get_array4element_wise_merge(filename):
if not os.path.exists(filename):
raise FileNotFoundError(f'待合并配置文件 {filename} 不存在!')
if os.path.isdir(filename):
raise IsADirectoryError(f'{filename} 是目录而非文件!')
try:
config = json5.load(open(filename, encoding='UTF-8-SIG'), parse_constant=parse_constant)
# 因为 UnicodeDecodeError 是 ValueError 的子类,所以必须先 except UnicodeDecodeError
except UnicodeDecodeError:
raise ValueError(f"待合并配置文件 {filename} 的编码不是 UTF-8!")
except ValueError:
raise ValueError(f"1. 待合并配置文件 {filename} 有语法错误!\n"
f"2. 待合并配置文件存在特殊内容,比如 NaN、Infinity!")
if not isinstance(config, list) or not all(isinstance(_, dict) for _ in config):
raise TypeError(f'待合并配置文件 {filename} 对应内容必须是其元素为 JSON 对象的 JSON 数组!')
return config
需要注意的是,在上述所有 json5 的 load 函数中指定了 parse_constant 参数,parse_constant 参数是一个函数指针,用来指定解析特殊常数的做法,特殊常数只有两个,即 NaN 和 Infinity。当然,OpenClaw 配置中肯定不能出现这两个特殊常数,所以 parse_constant 函数的定义就很简单了,直接引发异常就行,如下所示:
def parse_constant(_):
# 防止 JSON5 解析在 JSON 中不合法的内容,比如 Infinity、NaN
raise ValueError
JSON 数组元素去重
到目前为止,我们还差最后一个功能没有被实现,这个功能就是用来进行去除掉 JSON 数组中的重复的元素。考虑到 JSON 数组会被 Python 解析成列表,针对列表去重很多人都会想到使用集合类型 set;然而,虽然集合中没有重复的元素,但是集合中的元素必须是可哈希的数据类型,如果列表中的元素是字典,就不能使用集合来去重,所以只能使用相等运算符,但是相等运算符有一个问题,就是数据类型不一样但值一样也会被认为是重复的元素,所以我们不仅要确保值一样,还要确保数据类型一样。此外,无论数组在什么位置,哪怕嵌套字典有很多层,依旧要进行去重。下面给出去重操作的完整代码,如下所示:
def deduplicate(config):
if isinstance(config, dict):
for _ in config:
deduplicate(config[_])
elif isinstance(config, list):
for _ in config:
deduplicate(_)
li = []
for ec in config:
if not any(strict_eq(ec, el) for el in li):
li.append(ec)
config[:] = li
其中,strict_eq 的函数定义如下所示:
def strict_eq(a, b):
if type(a) is not type(b):
return False
if isinstance(a, dict):
if a.keys() != b.keys():
return False
return all(strict_eq(a[_], b[_]) for _ in a)
elif isinstance(a, list):
if len(a) != len(b):
return False
return all(strict_eq(ea, eb) for ea, eb in zip(a, b))
return a == b
完整代码
到目前为止,我们已经实现了所有功能,包括合并位置的友好表示、数据类型的检查、支持逐元素合并 JSON 数组外加上给 JSON 数组去重。下面直接给出完整代码,如下所示:
import argparse
import csv
import json
import json5
import os
import subprocess
class Merger:
def __init__(self, old_config):
self.loc, self.old_config = [], old_config
self.type_table = {type(None): '空值类型', bool: '布尔类型', dict: 'JSON 对象', float: '浮点类型', int: '整型',
list: 'JSON 数组', str: '字符串类型'}
def element_wise_merge(self, filename):
if not os.path.exists(filename):
raise FileNotFoundError(f'元配置文件 {filename} 不存在!')
if os.path.isdir(filename):
raise IsADirectoryError(f'{filename} 是目录而非文件!')
# TODO: 转二元组列表,二元组的第一个元素是合并位置,第二个元素是待合并文件列表
# 之所以 idx 初值设为 -1,是因为这方便后续 raise csv.Error 可以统一计算行号,从而不需要单独考虑是不是首行
configs, idx = [], -1
try:
for idx, row in enumerate(csv.reader(open(filename, encoding='UTF-8-SIG'), strict=True)):
row = [_ for _ in row if _]
for _ in row:
if '\n' in _ or '\r' in _:
raise ValueError(
f'元配置文件 {filename} 的第 {idx+1} 行的合并位置对应字符串或者待合并文件名对应字符串出现跨行!')
if not row:
continue
if len(row) < 2:
raise ValueError(f'第 {idx+1} 行的合并位置 {row[0]} 没有待合并配置文件!')
configs.append((row[0], row[1:]))
except UnicodeDecodeError:
raise ValueError(f'元配置文件 {filename} 编码不是 UTF-8!')
except csv.Error:
# 此处 idx+1 对应的是上一行,所以要用 idx+2
raise csv.Error(f'元配置文件 {filename} 第 {idx+2} 行存在语法错误!检查以下内容:\n'
f'1. 若某字符串有双引号,则需在该字符串两边各放一个双引号,'
f'且其中的双引号必须在前面或者后面再来一个双引号实现转义(即两个连续的双引号)!\n'
f'2. 若某字符串被双引号包裹,其左边或者右边要么什么都没有,要么只有逗号,不可出现其他东西!')
# TODO: 合并配置
for config in configs:
# TODO: 读取首个配置
first_config = check_and_get_array4element_wise_merge(config[1][0])
expected_length = len(first_config)
# TODO: 读取其他配置
for filename in config[1][1:]:
cur_config = check_and_get_array4element_wise_merge(filename)
cur_length = len(cur_config)
if cur_length != expected_length:
raise ValueError(f'当前合并位置:{config[0]}\n'
f'当前文件:{filename}\n'
f'当前 JSON 数组的长度和期望长度不一致,无法进行逐元素合并!\n'
f'当前长度:{cur_length},期望长度:{expected_length}')
for _ in range(expected_length):
# 因为逐元素合并的过程中出现报错信息会有歧义,所以这里直接不做类型检查!具体会有什么歧义,举例如下:
# 假设合并位置为 agents.list,如果用 agents.list.0.<...> 表示出错位置,其对应两种解释:
# 1. agents.list 数组下的第 1 个元素;2. agents.list 对象下键为 0 的值
# 如果用 agents.list[0] 表示出错位置,一样会有两种解释:
# 1. agents.list 数组下的第 1 个元素;2. agents 对象下键为 list[0] 的值
self.merge_(first_config[_], cur_config[_], False)
config[1][:] = first_config
# TODO: 转嵌套字典
for i in range(len(configs)):
loc = configs[i][0].split('.')
loc.reverse()
d = {loc[0]: configs[i][1]}
for _ in loc[1:]:
d = {_: d}
configs[i] = d
return configs
def merge(self, filename):
if not os.path.exists(filename):
raise FileNotFoundError(f'元配置文件 {filename} 不存在!')
if os.path.isdir(filename):
raise IsADirectoryError(f'{filename} 是目录而非文件!')
# TODO: 转二元组列表,二元组的第一个元素是合并位置,第二个元素是待合并文件
# 之所以 idx 初值设为 -1,是因为这方便后续 raise csv.Error 可以统一计算行号,从而不需要单独考虑是不是首行
configs, idx = [], -1
try:
for idx, row in enumerate(csv.reader(open(filename, encoding='UTF-8-SIG'), strict=True)):
if not len(row):
continue
elif len(row) > 2:
raise ValueError(
f'元配置文件 {filename} 的第 {idx+1} 行的列数大于 2!有以下解法:\n'
f'1. 检查元配置文件 {filename} 的第 {idx+1} 行的内容!'
f'若需合并的配置文件路径对应字符串或者合并位置对应字符串有逗号,需用双引号包裹,'
f'否则可能会导致逗号被当成分隔符把当前行分割成多列(超过 2 列),从而导致这个问题!\n'
f'2. 检查元配置文件 {filename} 中是否有多个连续的且被双引号包裹的逗号,若有,保留一个即可!')
elif len(row) == 1 or not row[1]:
raise ValueError('第 %d 行,%s 没有待合并配置文件!' % (
idx+1, f'当前位置 {row[0]}' if row[0] else '根位置'))
if '\n' in row[0] or '\r' in row[0]:
raise ValueError(f'元配置文件 {filename} 的第 {idx+1} 行的合并位置对应字符串出现跨行!')
if '\n' in row[1] or '\r' in row[1]:
raise ValueError(f'元配置文件 {filename} 的第 {idx+1} 行的待合并配置文件名对应字符串出现跨行!')
configs.append((row[0], row[1]))
except UnicodeDecodeError:
raise ValueError(f'元配置文件 {filename} 的编码不是 UTF-8!')
except csv.Error:
# 此处 idx+1 对应的是上一行,所以要用 idx+2
raise csv.Error(f'元配置文件 {filename} 第 {idx+2} 行存在语法错误!检查以下内容:\n'
f'1. 若某字符串有双引号,则需在该字符串两边各放一个双引号,'
f'且其中的双引号必须在前面或者后面再来一个双引号实现转义(即两个连续的双引号)!\n'
f'2. 若某字符串被双引号包裹,左边的双引号的左边或者右边的双引号的右边要么什么都没有,要么只有逗号,'
f'不可出现其他东西!')
# TODO: 依次把其他配置和旧配置做合并
for config in configs:
if not os.path.exists(config[1]):
raise FileNotFoundError(f'待合并配置文件 {config[1]} 不存在!')
if os.path.isdir(config[1]):
raise IsADirectoryError(f'{config[1]} 是目录而非文件!')
try:
c = json5.load(open(config[1], encoding='UTF-8-SIG'), parse_constant=parse_constant)
# 因为 UnicodeDecodeError 是 ValueError 的子类,所以必须先 except UnicodeDecodeError
except UnicodeDecodeError:
raise ValueError(f"待合并配置文件 {config[1]} 的编码不是 UTF-8!")
except ValueError:
raise ValueError(f'1. 待合并配置文件 {config[1]} 有语法错误!\n'
f'2. 待合并配置文件存在特殊内容,比如 NaN、Infinity!')
if not config[0]:
if not isinstance(c, dict):
raise TypeError(f'待合并配置文件 {config[1]} 的合并位置是根位置,其内容必须是一个 JSON 对象!')
cur_config = c
else:
# TODO: 转嵌套字典
loc = config[0].split('.')
loc.reverse()
for _ in loc:
c = {_: c}
cur_config = c
self.merge_(self.old_config, cur_config, True)
def merge_(self, origin_config, extra_config, check_type):
for _ in extra_config:
self.loc.append(_)
if _ in origin_config:
if isinstance(origin_config[_], dict) and isinstance(extra_config[_], dict):
self.merge_(origin_config[_], extra_config[_], check_type)
elif isinstance(origin_config[_], list) and isinstance(extra_config[_], list):
origin_config[_].extend(extra_config[_])
elif isinstance(origin_config[_], list):
origin_config[_].append(extra_config[_])
elif isinstance(extra_config[_], list):
origin_config[_] = [origin_config[_]]+extra_config[_]
elif type(origin_config[_]) is not type(extra_config[_]) and check_type:
raise TypeError(f'{".".join(self.loc)} 字段类型不一致!期望类型:'
f'{self.type_table[type(origin_config[_])]},实际类型:'
f'{self.type_table[type(extra_config[_])]}')
else:
origin_config[_] = extra_config[_]
else:
origin_config[_] = extra_config[_]
del self.loc[-1]
def check_and_get_array4element_wise_merge(filename):
if not os.path.exists(filename):
raise FileNotFoundError(f'待合并配置文件 {filename} 不存在!')
if os.path.isdir(filename):
raise IsADirectoryError(f'{filename} 是目录而非文件!')
try:
config = json5.load(open(filename, encoding='UTF-8-SIG'), parse_constant=parse_constant)
# 因为 UnicodeDecodeError 是 ValueError 的子类,所以必须先 except UnicodeDecodeError
except UnicodeDecodeError:
raise ValueError(f"待合并配置文件 {filename} 的编码不是 UTF-8!")
except ValueError:
raise ValueError(f"1. 待合并配置文件 {filename} 有语法错误!\n"
f"2. 待合并配置文件存在特殊内容,比如 NaN、Infinity!")
if not isinstance(config, list) or not all(isinstance(_, dict) for _ in config):
raise TypeError(f'待合并配置文件 {filename} 对应内容必须是其元素为 JSON 对象的 JSON 数组!')
return config
def deduplicate(config):
if isinstance(config, dict):
for _ in config:
deduplicate(config[_])
elif isinstance(config, list):
for _ in config:
deduplicate(_)
li = []
for ec in config:
if not any(strict_eq(ec, el) for el in li):
li.append(ec)
config[:] = li
def export_old_config():
schema = subprocess.run(('openclaw', 'config', 'schema'), capture_output=True, check=True,
encoding='UTF-8-SIG', text=True)
schema = json.loads(schema.stdout)
top_fields = {_ for _ in schema['properties'] if not _.startswith("$")}
config = {}
for top_field in top_fields:
cur_config = subprocess.run(('openclaw', 'config', 'get', top_field), capture_output=True,
encoding='UTF-8-SIG', text=True)
if not cur_config.returncode:
config[top_field] = json.loads(cur_config.stdout)
return config
def main():
parser = argparse.ArgumentParser(
description='合并 OpenClaw 多个配置片段的脚本程序,合并顺序如下所示:\n'
'1. 旧配置\n'
'2. 逐元素合并的 JSON 数组及其位置(内部按照元配置文件的顺序来)\n'
'3. 额外的配置(内部按照元配置文件的顺序来)\n\n'
'注意事项:\n'
'1. 合并期间会对 JSON 数组去除重复元素(以首次出现的顺序为准)\n'
'2. 冲突处理方式参考 $include 指令的对应处理方式\n'
'3. 报错信息若出现 " <文件名> " 的内容,则表示 <文件名> 对应文件有问题,比如不存在!\n'
'4. 所有文件,包括元配置文件、待合并配置文件以及导出最终配置的文件,编码必须全是 UTF-8!\n\n'
'<合并位置>注意事项,当合并位置出现类似索引的信息,比如 agents.list.0,'
'其会转化为 {"agents": {"list": {"0": ...}}} 而不是 {"agents": {"list": [...]}}\n'
'这样做的目的是为了不让合并位置出现在 JSON 数组内部,换句话说就是直接从根本上防止合并后的配置出现直接嵌套 JSON 数组,'
'当然,JSON 数组的元素是 JSON 对象,JSON 对象内又有 JSON 数组,这样的间接嵌套是允许的,由此可得:\n'
'1. 在进行逐元素合并的过程中,会禁止类型一致性检查,因为报错位置根本无法被完美定义,举个例子,假设报错位置是'
' agents.list 下的第一个元素,比如:\n'
'\t(1)用 agents.list.0 表示报错位置会有两种解读方式,第一种是 {"agents": {"list": {"0": ...}}},'
'第二种是 {"agents": {"list": [...]}}\n'
'\t(2)用 agents.list[0] 表示报错位置也会有两种解读方式,第一种是 {"agents": {"list[0]": ...}},'
'第二种是 {"agents": {"list": [...]}}\n'
'2. 无法检查数组中的元素类型是否一致,原因同上,即报错位置歧义',
formatter_class=argparse.RawTextHelpFormatter)
parser.add_argument(
'--export-old-config', action='store_true',
help='是否导出旧配置?命令行不提供该参数表示不导出旧配置;否则表示导出旧配置')
parser.add_argument(
'--element-wise-merge', default='', type=str,
help='需要进行逐元素合并的元配置文件的带路径的文件名,空字符串表示没有需要进行逐元素合并的元配置文件,默认值为空字符串。\n'
'元配置文件是一个至少两列且无表头的 CSV 文件,每一行格式如下所示:\n'
'<合并位置>,<文件名 1>,<文件名 2>,...,<文件名 N>\n'
'其中<合并位置>不能为空,<文件名 1>一直到<文件名 N>对应文件的内容必须全是等长且其元素为 JSON 对象的 JSON 数组,\n'
'<合并位置>示例 1:"agents.list",对应解释:agents 字段下的 list 子字段\n'
'<合并位置>示例 2:"agents..list",对应解释:agents 字段下的 空字符串 子字段,空字符串 子字段下的 list 子字段\n'
'<合并位置>示例 3:".agents",对应解释:空字符串 字段下的 agents 子字段\n'
'<合并位置>示例 4:"agents.",对应解释:agents 字段下的 空字符串 子字段\n\n'
'注意事项:\n'
'1. 为了提升容错能力,这里直接令当前行首个非缺失值作为<合并位置>,即若<合并位置>为空,则可能会把某个文件名当成合并位置,'
'会导致不符合预期的结果!\n'
'2. <合并位置>、<文件名 1>一直到<文件名 N>对应字符串必须是单行字符串!')
parser.add_argument(
'--merge', default='', type=str,
help='需要进行合并的元配置文件的带路径的文件名,空字符串表示没有需要进行合并的元配置文件,默认值为空字符串。\n'
'元配置文件是一个只有两列且无表头的 CSV 文件,每一行格式如下所示:\n'
'<合并位置>,<文件名>\n'
'其中,<合并位置>可为空,为空表示合并位置是根位置;<文件名>不能为空。\n'
'如果合并位置不是根位置,那么<文件名>对应文件内容可以是一个任意的 JSON 内容;'
'如果合并位置是根位置,那么<文件名>对应文件内容必须是一个 JSON 对象\n'
'<合并位置>的写法参考--element-wise-merge 的帮助信息\n\n'
'注意事项:\n'
'1. <合并位置>和<文件名>对应字符串必须是单行字符串!\n'
'2. 若要在一个<合并位置>放多个待合并文件,必须使用以下写法:\n'
'```\n'
'<合并位置 1>,<文件名 11>\n'
'<合并位置 1>,<文件名 12>\n'
'...\n'
'<合并位置 1>,<文件名 1N>\n'
'```')
parser.add_argument(
'--dump', type=str, required=True,
help='最终的配置文件名,必填参数。注意事项:\n'
'1. 别带路径,直接提供文件名即可,该文件对应目录就是当前工作目录!\n'
'2. 检查是否带路径这里是通过检查 / 或者 \\ 是否位于 --dump 参数值中,因此,即使 \\ 不是路径分隔符,此处依旧禁用!\n'
'3. 该参数对应字符串不可含有以下字符::、*、?、"、<、> 和 |\n'
'4. 该参数不可以是以下字符串:空字符串 和 空白字符串\n'
'5. 该参数对应字符串不能以点号结尾,点号右边加上空白字符也不行!\n'
'6. 若该脚本所在目录无修改权限,可考虑把该脚本文件复制一份到有修改权限的目录,比如用户家目录!\n'
'7. 最终的配置文件不可提前存在!\n'
'8. 若是 Windows 系统,则该参数对应字符串不能是保留设备名!\n'
'9. 本代码未检查该参数对应字符串是不是保留设备名,若使用保留设备名作为该参数对应字符串可能会出现各种奇怪的问题!')
args = parser.parse_args()
if not args.dump:
raise ValueError('最终的配置文件名是空字符串!')
if os.path.exists(args.dump):
if os.path.isdir(args.dump):
raise IsADirectoryError(f'{args.dump} 已存在且 {args.dump} 是目录!其绝对路径:{os.path.abspath(args.dump)}')
else:
raise FileExistsError(f'{args.dump} 已存在且 {args.dump} 是文件!其绝对路径:{os.path.abspath(args.dump)}')
if '/' in args.dump or '\\' in args.dump:
raise ValueError('--dump 参数只需提供文件名,别带路径!')
for _ in ':*?"<>|':
if _ in args.dump:
raise ValueError(f'文件名 {args.dump} 中有非法字符(非法字符::、*、?、"、<、> 和 |)!')
if args.dump.isspace():
raise ValueError(f'文件名 {args.dump} 是空白字符串!')
if args.dump.rstrip().endswith('.'):
raise ValueError(f'文件名 {args.dump} 不能以点号结尾,点号右边加上空白字符也不行!')
config = {}
if args.export_old_config:
config.update(export_old_config())
merger = Merger(config)
if args.element_wise_merge:
element_wise_configs = merger.element_wise_merge(args.element_wise_merge)
for element_wise_config in element_wise_configs:
merger.merge_(config, element_wise_config, True)
if args.merge:
merger.merge(args.merge)
if config:
deduplicate(config)
json.dump(config, open(args.dump, 'w', encoding='UTF-8'), ensure_ascii=False, indent=2)
print(f'最终配置不为空!已经导出到 {args.dump} 文件中!其绝对路径:{os.path.abspath(args.dump)}')
else:
print('最终配置是空的!')
def parse_constant(_):
# 防止 JSON5 解析在 JSON 中不合法的内容,比如 Infinity、NaN
raise ValueError
def strict_eq(a, b):
if type(a) is not type(b):
return False
if isinstance(a, dict):
if a.keys() != b.keys():
return False
return all(strict_eq(a[_], b[_]) for _ in a)
elif isinstance(a, list):
if len(a) != len(b):
return False
return all(strict_eq(ea, eb) for ea, eb in zip(a, b))
return a == b
main()
04、总结
我们可以发现 $include 指令和自己或者让 AI 编写合并代码各有优劣,不能简单地说哪种方法更好。就比如说 $include 指令不会把相同的配置存储在两个文件中,直接在主配置文件中指一下就行,而自定义编写合并脚本是把分散的配置片段以复制的方式合并到一个配置文件中去。然而,这很可能就是 $include 指令唯一的优势了,其余方面自己或者让 AI 编写合并代码基本上可以说是完胜这个 $include 指令。
本文分享自 Python机器学习算法说书人 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!