Agent 平台

接口能统一,能力差异不能

如何设计一个Provider Registry来统一多家模型供应商接入,同时暴露关键能力差异。

在多模型时代,一个AI产品不太可能只绑定一家供应商。客户会问"能不能接DeepSeek",你说"可以,稍等",然后发现每家的鉴权、请求格式、流式协议、错误码都不一样。这就是Provider Registry要解决的问题。

关键在于什么该统一,什么该暴露。统一过头会碰到差异的墙,暴露太多会让上游调用方的逻辑爆炸。我在这个项目里碰到的取舍决策,可能值得记录下来。

三层统一,一层保留差异

最初的想法是"搞一个Provider Registry,把所有差异封装起来,上游拿到一个统一的接口,根本不用关心用的是哪家"。这个想法在配置层确实能做到,但在能力层做不到。

我最终把Registry的职责分成四块:

第一块:统一鉴权与配置存储。所有Provider的API key、baseUrl、模型列表都写到一份配置文件(openclaw.json)里,用键值对结构 models.providers.<provider_id> 组织。用户在desktop-app里粘minimax的key,点保存,底层走CLI命令 openclaw config set --batch-file 写入这份文件。无论是minimax还是deepseek,写的流程完全一致——就是一个json patch操作。

第二块:统一请求/响应形状。这里的统一指的是"同一个Provider的多个模型,请求格式保持一致"。比如minimax用openai-compatible协议,所以不管你用MiniMax-Text-01还是MiniMax-M2.7,都走同一套 openai-completions adapter。当然,不同Provider可能走不同协议(minimax是openai-compatible,google用google-generative-ai,bedrock用bedrock-converse-stream),但这个差异在Provider层已经明确了,不会到上游去。

第三块:统一错误分类。vendor CLI 会把所有可能的执行错误映射到几个明确的category:schema validation failed、size-drop protection triggered、tamper detection、gateway unreachable、auth failed、subprocess timeout。desktop-app调openclaw_cli_run时,返回值里有一个error_kind字段,直接是枚举值,不用自己去解析stderr。

第四块:不统一能力差异。 这是关键。以推理(thinking)为例,claude有 thinking mode,但openai的o1/o3才有对应概念。我最初想的是"在ProviderModel的schema里加一个通用的reasoning字段",让上游统一判断"这个模型支持推理吗"。结果发现:

  • deepseek的thinking_content有返回值要求:如果开启thinking,服务端一定要把thinking_content写回来,desktop-app才能正常继续(不回传时上游直接拒绝请求)
  • 但openai的thinking_process在某些情况下服务端可能不返回
  • anthropic的thinking则是完全不同的结构

最后的结果是:我在ProviderModel里只留了一个reasoning?: boolean标记(这个模型支持这类能力吗),真正的细节——怎么请求、返回值里怎么提取、出错怎么处理——统统暴露给上游。上游(比如agent runtime)要根据具体的Provider来适配这些差异,不指望Registry替它们处理。

这看起来像是"没把问题解决好",但实际上它是正确的边界划分:Registry的职责是"让你有办法表达和存储配置",不是"替你处理所有差异"

为什么是CLI Subprocess而不是其他方案

这个决策过程本身也说明了架构边界的问题。我考虑过三个方案:

方案一:磁盘直写。desktop-app直接写openclaw.json文件。看起来最简单。真机烟测发现行不通——vendor设计了tamper protection机制,直写会被clobber到.clobbered.{timestamp}文件里,文件恢复到上一次known-good的快照。这是vendor的安全设计,我不应该绕过它,也不可能绕过(因为我没有权限改vendor的防护逻辑)。

方案二:Gateway WebSocket RPC直调。vendor gateway进程暴露了一个WebSocket接口,理论上可以直接调 gateway.secrets.reload RPC来热更新配置。这需要我逆向gateway的RPC schema、管理auth token、处理网络错误和重试——这些工作量加起来大概要10多天。而且每次vendor升级,RPC schema要是变了,我的代码就得跟着改。

方案三:CLI Subprocess。vendor本身提供了 openclaw config set --batch-file 命令。我只需要:

  1. 调这个命令(通过Tauri的subprocess接口)
  2. 解析返回的error code和stderr
  3. 根据特定的error pattern分类(schema validation / size-drop / tamper / timeout)
  4. 返回给上游一个枚举值

vendor CLI是vendor自己维护的接口,意味着:

  • vendor会负责tamper protection检测
  • vendor会负责schema校验
  • vendor会负责atomic写盘
  • 如果vendor升级,CLI接口保持稳定(vendor有兼容性承诺)
  • desktop-app需要的所有信息都通过exit code和stderr传出来,我不需要逆向内部RPC

选CLI Subprocess最关键的原因是职责清晰。desktop-app的职责就是"构造一个正确的batch JSON,传给vendor CLI,等它返回结果,根据error kind决定怎么展示给用户"。vendor CLI的职责是"完成真正的写盘和保护"。两者分工明确,即使vendor升级也不容易破。

维度磁盘直写WebSocket RPCCLI Subprocess
是否绕过vendor的tamper protection✓(无法)
需要逆向vendor内部结构
对vendor升级的脆弱性高(配置格式可能改)高(RPC schema可能改)低(CLI稳定)
工时N/A(已证明不可行)10+ 天6 天
错误诊断难度低(没有vendor反馈)中等(需逆向RPC)低(stderr明确)

这三个方案的比较说明的是:当你需要跟另一个系统(vendor)交互时,优先选择对方主动暴露的官方接口,其次才考虑逆向内部结构或绕过防护。官方接口代表了对方的兼容性承诺。

数据模型:咬字清楚很重要

实现Provider Registry时,我犯过一个细节错误,后来成了教训:

素材里的第一版Plan曾假设vendor的openclaw.json schema是snake_case的(比如 base_url / api_key)。这是因为user.yml(user.yml是用户手写的YAML,确实用snake_case)里确实是这样写的。我原以为"既然user.yml这样写,vendor读的时候应该也认snake_case"。

真机跑命令 openclaw config set models.providers.minimax.apiKey sk-xxx 时发现失败——报错说 baseUrl: expected string, received undefined。这才明白vendor的正式schema是camelCase的。user.yml里的snake_case是vendor CLI在"模式1:读user.yml时"自己做的转换,但当你用"模式2:config set命令"时,必须用camelCase。

这个细节影响了整个batch JSON的生成逻辑。我不得不在toOpenclawConfigBatch函数里明确做一遍 snake_case → camelCase的转换,确保生成的batch JSON符合vendor的正式schema。

这个教训是:当一个系统同时支持多种输入格式(user.yml用snake_case,REST API用camelCase),一定要明确弄清楚"最终落盘的schema是什么"。不能假设格式一致。

限制与未来的债项

这个Registry设计有明确的能力边界:

第一,Phase 1不做SecretRef。目前所有API key都是明文存在openclaw.json里。完整的方案应该是API key存在Windows Credential Manager里,openclaw.json里只记录一个指针(SecretRef)指向凭据存储。这个完整方案留给Phase 2(标记为debt-04-Z)。风险是什么?主要是"U盘被别人插上,openclaw.json会暴露API key",但这在便携设备上是已知的风险。

第二,Provider删除有限制。vendor的size-drop protection机制会拒绝配置大幅缩减的操作(比如从569 bytes缩到275 bytes)。这是vendor的安全设计,防止误删。结果是desktop-app里"删除某个provider"这个操作目前没有好办法。暂时的workaround是"留着条目,清空apiKey",后续可能需要vendor补一个--force-shrinkflag。

第三,能力差异持续外溢。即使Registry层正式了,上游(比如agent runtime)处理不同Provider的差异的代码也在增多。reasoning支持、token计数、流式响应格式……每一项都需要上游适配。这不是Registry的问题(Registry本来就不该包揽这些),而是多模型系统的固有复杂度。

如果我要总结一句可迁移的判断,那就是:把供应商的差异当作一等公民,而不是"需要封装的细节"。Registry的价值不在统一差异,而在让差异可以有序地表达和消费。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。