首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >合并 OpenClaw 配置:$include 指令 VS 自定义脚本

合并 OpenClaw 配置:$include 指令 VS 自定义脚本

作者头像
不可言诉的深渊
发布2026-07-20 21:55:39
发布2026-07-20 21:55:39
550
举报
上回说到,OpenClaw 并不能在安装完毕之后就立刻投入使用,在使用之前必须进行必要的配置。首先并且最重要的就是不让 OpenClaw 的网关服务暴露到公网,最后再强调一遍,如果是本地安装的 OpenClaw,请自行检查其网关服务是否暴露在公网,如果暴露在公网,除非你知道这样做的后果,否则请立刻马上修改配置(具体如何修改可以参考上一回)!其次,我通过借助 OpenClaw 官方文档提供的 Docker 沙箱配置教程来说明如何配置 Docker 沙箱容器以及如何检验 Docker 沙箱是否配置成功。接着,我针对 OpenClaw 的沙箱配置进行了逐字段的详细解释。最后,我把 OpenClaw 的沙箱配置中关于文件和目录的相关配置字段对应的文件和目录到底是位于沙箱内还是沙箱外整理成了表格供大家直接参考。然而,即使是 OpenClaw 官方文档,都不会给出配置文件 openclaw.json 的完整内容,OpenClaw 官方文档属于是在讲哪一部分就列出哪一部分的对应配置片段,比如当前页面讲的是沙箱配置,就只列出沙箱配置的对应片段。所以,你会得到多个配置片段,而且这多个配置片段可能并不全部来自 OpenClaw 官方文档,比如说,有可能模型配置会来自某个第三方中转站,频道配置可能来自于对应频道官方。因此,需要对这些配置片段进行合并,这个时候必定会有人问:“合并不就是把这些配置片段直接复制粘贴到 openclaw.json 吗?这有什么难的?”我的回答是:“对,但是这次合并不同于普通文本文件的合并,而是 JSON 文件的合并,如果把 JSON 文件的合并按照普通文本文件的合并方式来强行合并,会导致出现关于 JSON 的语法错误。”显然,我们需要通过正确的合并方式来合并这些配置片段,具体来说,目前有两种方式可以正确合并这些配置片段,其中第一种合并方式就是 OpenClaw 官方文档里面提到的 $include 指令,第二种合并方式就是通过自己或者让 AI 编写合并代码。因此,我们首先来看一下为什么像普通文本文件那样合并是不行的。其次,我们来看一下 OpenClaw 官方文档提到的 $include 指令如何使用以及它的优势和限制,这里会重点说明 OpenClaw 官方文档没有提及的操作机制。最后,我们来看一下如何通过自己或者让 AI 编写合并代码来解决 $include 指令的限制并且实现比 $include 指令更好的合并逻辑。

01、普通文本文件的合并方式

我们首先来看一下普通文本文件的合并方式,我现在有两个配置片段,分别是原始配置和模型配置,其中原始配置的配置片段如下所示:
代码语言:javascript
复制
{
  "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
      }
    }
  }
}

模型配置的配置片段如下所示:

代码语言:javascript
复制
{
  "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 期间的做法,合并后的配置如下所示:

代码语言:javascript
复制
{
  "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)就能发现问题,如图所示:

我们可以看到有大段内容下方划了红线,这大段下方划红线的内容对应着右边的滑轨上面的一根红柱子,鼠标放在右边滑轨的红柱子上会弹出为什么这段内容要划红线的说明,即“JSON 标准只允许使用一个顶层值”。

接下来我们看一下如何正确地合并多个 OpenClaw 的配置片段,共有两种合并方式,即 OpenClaw 官方文档推荐的 $include 指令的合并方式外加上自己或者让 AI 编写合并代码的合并方式,我们首先来看一下 $include 指令的合并方式。

02、OpenClaw 的官方文档推荐的 $include 指令的合并方式

$include 单文件操作

OpenClaw 的官方文档推荐的 $include 指令的合并方式的操作步骤非常简单,比如现在把 agents 字段下的所有内容放在 openclaw.json(OpenClaw 的配置文件)的同级目录下的 agents.json5 文件中,然后对 OpenClaw 的配置文件 openclaw.json 做以下修改,如下所示:

代码语言:javascript
复制
{
  ...
  "agents": {"$include": "./agents.json5"}
}

其中省略号对应和 agents 字段平级的字段,比如 gateway 等字段。上述写法表示用 agents.json5 的内容替换掉 {"$include": "./agents.json5"} 这个东西,举个例子,如果 agents.json5 的内容如下所示:

代码语言:javascript
复制
{
  "defaults": ...
}

其中,省略号表示 defaults 字段下的具体内容。那么,OpenClaw 的配置文件就等价于如下所示的写法:

代码语言:javascript
复制
{
  ...
  "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 文件的大致内容如下所示:

代码语言:javascript
复制
{
  "defaults": ...
}

其中,省略号依旧表示 defaults 字段下的具体内容。list.json5 文件的大致内容如下所示:

代码语言:javascript
复制
{
  "list": ...
}

其中,省略号表示 list 字段下的具体内容。接下来就是说明如何在 OpenClaw 配置文件中同时 $include 这两个文件,方法很简单,把 $include 的取值从文件名字符串改为文件名字符串数组即可,对应到 OpenClaw 的配置文件 openclaw.json 的大致内容如下所示:

代码语言:javascript
复制
{
  ...
  "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)修改后的大致内容如下所示:

代码语言:javascript
复制
{
  "defaults": ...
  "$include": "./list.json5"
}

最后,我们只要在 openclaw.json 中 $include 文件 defaults.json5 即可,修改后的 openclaw.json 大致内容如下所示:

代码语言:javascript
复制
{
  ...
  "agents": {"$include": "./defaults.json5"}
}

第二种合并方式同样也是这么简单,显然,这两种合并方式完全等价,它们都是让 OpenClaw 配置文件等价于如下所示的写法:

代码语言:javascript
复制
{
  ...
  "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 的大致内容如下所示:

代码语言:javascript
复制
{
  ...
  "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 的内容如下所示:

代码语言:javascript
复制
{
  "defaults": {
    "model": {
      "primary": "xxx/gpt-5.5-Pro"
    },
    "sandbox": {
      "mode": "all"
    }
  }
}

agents2.json5 的内容如下所示:

代码语言:javascript
复制
{
  "defaults": {
    "model": {
      "primary": "xxx/glm-5.1"
    },
    "sandbox": {
      "backend": "docker"
    }
  }
}

显然,我们可以发现 primary 字段存在冲突。接着我们在 OpenClaw 的配置文件 openclaw.json 中的同一个地方 $include 这两个文件,如下所示:

代码语言:javascript
复制
{
  ...
  "agents": {
    "$include": ["./agents1.json5", "./agents2.json5"]
  }
}

这样合并之后就会让 OpenClaw 配置文件等价于如下所示的写法:

代码语言:javascript
复制
{
  ...
  "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,其内容如下所示:

代码语言:javascript
复制
{
  "defaults": {
    "model": {
      "primary": "xxx/gpt-5.5-Pro"
    },
    "sandbox": {
      "mode": "all"
    }
  }
}

OpenClaw 配置文件 openclaw.json 对应内容如下所示:

代码语言:javascript
复制
{
  ...
  "agents": {
    "$include": "./agents.json5",
    "defaults": {
      "model": {
        "primary": "xxx/glm-5.1"
      },
      "sandbox": {
        "backend": "docker"
      }
    }
  }
}

这就是一个比较简单的只 $include 单文件依旧会有冲突的案例,说得通俗易懂点就是 OpenClaw 配置文件本身和 $include 对应的单文件存在冲突,这样合并之后就会让 OpenClaw 配置文件等价于如下所示的写法:

代码语言:javascript
复制
{
  ...
  "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 的部分内容如下所示:

代码语言:javascript
复制
{
  ...
  "agents": ...
}

考虑到所有配置都移到了 config.json5 中,我们只需要在 openclaw.json 中写一个键值对即可,无需再写其他内容,如下所示:

代码语言:javascript
复制
{
  "$include": "./config.json5"
}

当然,即使是根位置 $include 也能同时 $include 多个文件,当然也允许存在冲突,处理冲突的机制可以参考上文。

当 $include 文件是 JSON 数组而非 JSON 对象时

$include 单个 JSON 数组文件

上述所有案例,我均假设 $include 的文件对应的是一个 JSON 对象,即文件格式是形如 {...} 的 JSON 对象,我们来看一下如果对应文件格式是形如 [...] 的 JSON 数组,会发生什么事。我们首先假设有一个这样的配置文件,其位置位于 OpenClaw 配置文件 openclaw.json 的同级目录下,文件名称就简单记作 models.json5,其中 models.json5 的文件内容如下所示:

代码语言:javascript
复制
[
  {"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},
  {"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"}
]

接下来就是在 OpenClaw 配置文件中的对应位置执行 $include 操作,如下所示:

代码语言:javascript
复制
{
  ...
  "models": {
    "mode": "merge",
    "providers": {
      "xxx": {
        ...
        "models": {"$include": "./models.json5"}
      }
    }
  }
}

在这里我依旧通过使用省略号的方式来省略无关的配置内容,接下来我们就来看看到底能不能成功 $include 一个 JSON 数组文件。修改之后我们直接运行命令 openclaw config validate 来检查一下是不是可以成功,命令执行结果如图所示。

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

我们可以发现,上图配置内容(JSON 数组)中的每一项的 id 字段以及 name 字段和文件 models.json5 完全一样,连顺序都一样,至于多出来的其它字段就是所谓的默认值,没有必要去理会。因此,对应配置内容完全符合预期。

$include 多个 JSON 数组文件

既然能 $include 单个 JSON 数组文件,接下来我们试试能不能在同一个地方 $include 多个 JSON 数组文件,假设有俩配置文件 models1.json5 和 models2.json5,它们的目录和上述案例中的 models.json5 完全一样,其中 models1.json5 的内容如下所示:

代码语言:javascript
复制
[
  {"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},
  {"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"}
]

models2.json5 的内容如下所示:

代码语言:javascript
复制
[
  {"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},
  {"id": "gpt-5.5", "name": "GPT 5.5"}
]

OpenClaw 配置文件 openclaw.json 的内容如下所示:

代码语言:javascript
复制
{
  ...
  "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 的内容如下所示:

代码语言:javascript
复制
{
  "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 的内容如下所示:

代码语言:javascript
复制
{
  "models": [
    {"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},
    {"id": "gpt-5.5", "name": "GPT 5.5"}
  ]
}

OpenClaw 配置文件 openclaw.json 的内容如下所示:

代码语言:javascript
复制
{
  ...
  "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 的内容如下所示:

代码语言:javascript
复制
{
  "id": "gpt-5.3-codex-spark",
  "name": "GPT 5.3 Codex Spark"
}

OpenClaw 的配置文件 openclaw.json 的内容如下所示:

代码语言:javascript
复制
{
  ...
  "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 的内容如下所示:

代码语言:javascript
复制
{
  "id": "gpt-5.3-codex-spark",
  "name": "GPT 5.3 Codex Spark"
}

model2.json5 的内容如下所示:

代码语言:javascript
复制
{
  "id": "gpt-5.5",
  "name": "GPT 5.5"
}

在测试之前我首先需要声明的是在 JSON 数组中 $include 多个 JSON 对象文件的方式有两种:第一,一次性 $include 多个 JSON 对象文件;第二,分多次 $include 多个 JSON 对象文件,其中每次只 $include 一个 JSON 对象文件。

我们首先来看第一种操作方式,即一次性 $include 多个 JSON 对象文件。此时 OpenClaw 配置文件 openclaw.json 的内容如下所示:

代码语言:javascript
复制
{
  ...
  "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 的内容如下所示:

代码语言:javascript
复制
{
  ...
  "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,其内容如下所示:

代码语言:javascript
复制
[
  {"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},
  {"id": "gpt-5.5", "name": "GPT 5.5"}
]

OpenClaw 配置文件 openclaw.json 的内容如下所示:

代码语言:javascript
复制
{
  ...
  "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 命令就能找到具体是什么问题了,因为命令可能需要交互式执行,所以这里只给出最关键的输出,如下所示:

代码语言:javascript
复制
models.providers.xxx.models.1: Invalid input: expected object, received array

哦,我明白了,上述操作会使得 OpenClaw 的配置等价于如下所示的内容:

代码语言:javascript
复制
{
  ...
  "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 一样,其内容如下所示:

代码语言:javascript
复制
"xxx/gpt-5.5-Pro"

OpenClaw 配置文件 openclaw.json 关键内容如下所示:

代码语言:javascript
复制
{
  ...
  "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 内容如下所示:

代码语言:javascript
复制
"xxx/gpt-5.5-Pro"

model_primary2.json5 内容如下所示:

代码语言:javascript
复制
"xxx/gpt-5.3-codex-spark"

OpenClaw 配置文件 openclaw.json 关键内容如下所示:

代码语言:javascript
复制
{
  ...
  "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 操作,首先是基本操作逻辑和基本注意事项,如下所示:

  1. $include 单个 JSON 对象文件且没有冲突,执行递归合并
  2. 同一个位置同时 $include 多个 JSON 对象文件且没有冲突,按顺序执行递归合并
  3. 允许使用嵌套 $include,但是如果参考 OpenClaw 的官方文档的话,就会发现最多只能 10 层嵌套
  4. include 指令对应文件位置的相对路径是相对于调用 include 指令的那个文件的路径而言的,不是相对于 OpenClaw 配置文件 openclaw.json 的路径而言的
  5. $include 指令对应文件必须在 OpenClaw 配置文件 openclaw.json 对应目录或者其子目录内,除非设置环境变量 OPENCLAW_INCLUDE_ROOTS 来指定对应目录,如果需要设置很多个这样的目录,参考环境变量 PATH 的格式即可
  6. $include 指令对应文件经过系统解析后得到的绝对路径对应字符串长度必须小于 4096,系统解析包含两个步骤:第一,相对路径转绝对路径;第二,跟随符号链接找真实目标路径
  7. 可以在根位置使用 include 指令,但是如果参考 OpenClaw 官方文档的话,就会发现根位置使用 include 指令会导致 OpenClaw 配置文件关闭自动写入,即除了修改文件本身外,无法通过其他方式修改 OpenClaw 的配置
  8. 除根位置 include 外,include 多个 JSON 文件,有冲突,这两种情况也会让 OpenClaw 配置文件关闭自动写入,即除了修改文件本身外,无法通过其他方式修改 OpenClaw 的配置
  9. 可以 $include 单个或者多个 JSON 数组文件
  10. $include 单个 JSON 数组文件,执行替换操作
  11. 同一个位置同时 $include 多个 JSON 数组文件,先按顺序把多个文件对应的 JSON 数组拼接成一个大的 JSON 数组,再执行替换操作
  12. $include 单个或者多个 JSON 数组文件,不会进行去除重复的操作
  13. 可以 $include 单个或者多个 JSON 标量文件
  14. $include 单个 JSON 标量文件,执行替换操作
  15. 同一个位置同时 $include 多个 JSON 标量文件,用最后一个 JSON 标量文件的标量值执行替换操作
  16. 如果尝试 include 单个或者多个 JSON 数组或者 JSON 标量文件,不能给 include 字段设置同级字段;注意:不管是直接 include 单个或者多个这样的 JSON 文件,还是通过嵌套 include 方式来间接 include 单个或者多个这样的 JSON 文件,还是通过符号链接的方式来变相 include 单个或者多个这样的 JSON 文件,都认定为是
  17. 如果在同一个位置 $include 多个 JSON 文件,这些 JSON 文件要么必须全是 JSON 对象文件,要么必须全是 JSON 数组文件,要么必须全是 JSON 标量文件,不能出现二者或者三者的混合
  18. JSON 数组中也能使用 $include 指令
  19. JSON 数组中 $include 单个 JSON 对象文件,在对应位置执行替换操作且不覆盖 JSON 数组中的其他元素
  20. JSON 数组中一次性 $include 多个 JSON 对象文件,参考第 2 点和之后提到的冲突处理机制得到一个 JSON 对象,在对应位置执行替换操作且不覆盖 JSON 数组中的其他元素
  21. JSON 数组中分多次 include 多个 JSON 对象文件,每次只 include 一个 JSON 对象文件,依次在对应位置执行替换操作且不覆盖 JSON 数组中的其他元素
  22. JSON 数组中 $include 单个或者多个 JSON 数组文件,会产生嵌套 JSON 数组的结构

其次是冲突处理机制,这个主要分 3 种情况讨论:

  1. 如果该冲突字段对应值类型既不是 JSON 对象也不是 JSON 数组,采用后者覆盖前者的操作;最后别忘了如果和 OpenClaw 配置文件 openclaw.json 本身的对应字段存在冲突,最终的覆盖结果就是 OpenClaw 配置文件的对应字段的值
  2. 如果该冲突字段对应值类型是 JSON 数组,采用顺序拼接操作;最后别忘了如果和 OpenClaw 配置文件 openclaw.json 本身的对应字段存在冲突,需要把 OpenClaw 配置文件 openclaw.json 本身的对应字段的值拼接到最后面去
  3. 如果该冲突字段对应值类型是 JSON 对象,依次采用递归合并操作,直到无法递归为止;如果无法递归且存在冲突,处理方式参考上述两点

我们可以发现,OpenClaw 官方文档推荐的 $include 指令的合并方式并不是特别好的 OpenClaw 配置合并方式,因为 $include 在上述问题的基础上还有一个致命的缺陷,即如果存在多个长度相等且其元素是 JSON 对象的 JSON 数组需要逐元素合并而不是拼接,单纯靠 $include 指令基本上是无法实现的。因此,我们接下来尝试编写合并代码来实现多个配置片段的合并操作。

03、自己或者让 AI 编写合并代码的合并方式

在编写合并代码之前,我们先列举一下要求及功能:

  1. 不管是 OpenClaw 配置文件 openclaw.json 还是需要合并的 JSON 配置文件,其中都不能出现 $include 指令
  2. 通过一个元配置文件来说明当前需要合并的 JSON 文件合并到 OpenClaw 配置文件的哪一处地方
  3. 去除 JSON 数组中的重复元素
  4. 多个 JSON 数组的合并可以选择拼接合并也可以选择逐元素合并,通过另一个元配置文件指定

导出旧配置

针对上述第一点,很多人已经习惯 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 的输出是什么,绝大多数人会尝试通过执行这个命令来获取对应输出。但是,这个命令的输出非常长,长到远远超出了终端的显示范围,它只会保留显示范围内的最新的一部分输出,要想获取完整的输出内容只能通过把输出内容重定向到一个文件中,执行以下命令即可:

代码语言:javascript
复制
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 就可以了,修改后的命令如下所示:

代码语言:javascript
复制
openclaw config schema 2>/dev/null >a.json

考虑到这个命令的输出对应一个 JSON 内容,所以我把文件扩展名改成 .json 了,这次打开 a.json 查看一下鬼画符是不是没了,如图所示。

我们可以发现鬼画符消失了,这个时候可以直接通过 JSON 解析器解析 a.json 文件并拿出 OpenClaw 配置下的所有顶级字段。但是我们不这样做,因为这样做割裂了操作的连续性,这样做等价于两步手动操作,第一步就是是获取配置说明的 JSON 文件,就是这里的 a.json;第二步是编写脚本执行 OpenClaw 配置导出操作。

一键导出旧配置的代码

我们直接合并成一步操作,一步操作对应代码如下:

代码语言:javascript
复制
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 解析后对应的是字典,所以该算法的输入是两个字典,然而又因为字典传给参数传的是引用,所以该算法可以选择其中一个输入的字典来原地修改,该算法对应代码如下所示:

代码语言:javascript
复制
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 参数对应的是额外的配置,这个代码实现的是把额外的配置合并到原始的配置中去,原地修改原始的配置对应的字典。这个代码的逻辑非常简单,遍历额外的配置对应的字段,看是否和原始的配置存在冲突,如果存在冲突考虑三种情况,具体是哪三种情况可以参考上文,这里不再详细说明,如果没有冲突直接合并即可。虽然到目前为止已经实现了根位置的合并操作,但是接下来我们不去实现非根位置的合并操作。接下来我们完善一下通用的合并算法,在完善通用的合并算法之前,我们先简单分析一下上述通用的合并算法的问题,如下所示:

  1. 考虑到用户在输入单个元素的列表的时候可能会忘记中括号,所以可以允许列表和非列表之间进行合并
  2. 对于有冲突且对应冲突值是标量的情况,没有检查标量的数据类型是否一致
  3. 如果有冲突且冲突值是标量,外加上标量数据类型不一致,需要提供人性化的报错,即给出对应位置、期望的数据类型和实际的数据类型
  4. JSON 数组没有去除重复的元素

虽然存在上述 4 个问题,但我们先只解决前 3 个问题,完善后的通用的合并算法代码如下所示:

代码语言:javascript
复制
    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 子字段下。显然,我们可以发现,用这种方式提供合并位置比直接去配置文件中找到对应位置要简单太多了!此外需要注意的是如果合并位置为空字符串,则表示对应的合并位置是根位置。下面直接给出包装方法的完整代码,如下所示:

代码语言:javascript
复制
    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 指令的基础上额外做了两个扩展的功能,如下所示:

  1. 当合并位置有冲突时,可以执行类型检查,防止配置错误
  2. 不同于 $include 指令需要去 JSON 配置文件中找到对应位置,一旦配置文件复杂起来对应位置会非常难找,在此处我选择参考上文访问 OpenClaw 配置中某个字段对应的值,用点号从顶级字段依次遍历下去

接下来我们继续在此基础上实现一下如何针对多个等长且其元素为 JSON 对象的 JSON 数组进行逐元素的合并,具体是什么意思我先举个简单的例子说明一下,假设有两个 JSON 数组,第一个 JSON 数组的对应内容如下所示:

代码语言:javascript
复制
[
  {"id": "id1"},
  {"id": "id2"}
]

第二个 JSON 数组的对应内容如下所示:

代码语言:javascript
复制
[
  {"name": "name1"},
  {"name": "name2"}
]

合并之后的对应内容如下所示:

代码语言:javascript
复制
[
  {"id": "id1", "name": "name1"},
  {"id": "id2", "name": "name2"}
]

显然,这个合并逻辑非常简单。但是,这个功能用 $include 指令根本就是无法实现。在给出完整代码之前,首先需要说一下注意事项:

  1. 不要求必须是两个这样的 JSON 数组做逐元素合并,允许超过两个,只要等长且元素是 JSON 对象就能通过
  2. 需要对每次的合并结果提供一个合并位置

显然,一个合并位置对应多个 JSON 文件,所以如果有多个合并位置,依旧可以通过 CSV 文件进行记录,CSV 文件的格式是 N 行且列数可变,之所以要让列数可变是因为有的位置文件少有的位置文件多。下面给出逐元素合并的完整的代码,如下所示:

代码语言:javascript
复制
    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 指令。因此,我选择折中一下,在避免报错位置歧义的情况下做类型检查,至于代码中的其他部分就非常简单了,注释外加上异常信息已经能够提供充分的信息了,这里不再进一步做详细的解释。

此外,我在上述代码中使用了一自定义的函数,该函数的完整实现如下所示:

代码语言:javascript
复制
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 函数的定义就很简单了,直接引发异常就行,如下所示:

代码语言:javascript
复制
def parse_constant(_):
    # 防止 JSON5 解析在 JSON 中不合法的内容,比如 Infinity、NaN
    raise ValueError

JSON 数组元素去重

到目前为止,我们还差最后一个功能没有被实现,这个功能就是用来进行去除掉 JSON 数组中的重复的元素。考虑到 JSON 数组会被 Python 解析成列表,针对列表去重很多人都会想到使用集合类型 set;然而,虽然集合中没有重复的元素,但是集合中的元素必须是可哈希的数据类型,如果列表中的元素是字典,就不能使用集合来去重,所以只能使用相等运算符,但是相等运算符有一个问题,就是数据类型不一样但值一样也会被认为是重复的元素,所以我们不仅要确保值一样,还要确保数据类型一样。此外,无论数组在什么位置,哪怕嵌套字典有很多层,依旧要进行去重。下面给出去重操作的完整代码,如下所示:

代码语言:javascript
复制
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 的函数定义如下所示:

代码语言:javascript
复制
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 数组去重。下面直接给出完整代码,如下所示:

代码语言:javascript
复制
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 指令。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-07-19,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Python机器学习算法说书人 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档