智能体的模型列表为什么要从三处来源合成:一次让 GPT-6.1 Sol 选不上的发布延迟
原文:OpenClaw Blog - 《Use new models in OpenClaw without an update》(https://openclaw.ai/blog/use-new-models-in-openclaw-without-an-update)
一、先说问题:模型发布了,你的 Agent 用不上
做 Agent 开发的人对这一幕不会陌生。厂商今天发了一个新模型,你打开自己的客户端准备试,下拉框里没有它。你去查文档、翻更新日志,发现客户端本身没坏,只是它还不知道这个模型存在。
OpenClaw 团队 11 月 6 日的这篇博客,就从这个具体的尴尬场景写起。他们讲了 GPT-6.1 Sol 发布时的真实情况:用 ChatGPT 登录方式接入 OpenClaw 的用户选不到 Sol。原因很机械------OpenClaw 内置的 Codex 是 0.158.0,而 ChatGPT 只把 Sol 提供给 0.159.2 及以上版本。想改这个版本号,只能由他们发一次新版本。
这里的成本被他们说得很直白:发布当天正是大家最想试模型的时候,而让人等的原因是他们自己。
二、旧的模型列表为什么必然滞后
要理解这次改造,先看清旧结构的问题。
过去每个 provider 插件把模型清单写在自己的 manifest 文件里,而这个文件是随 OpenClaw 包一起发出去的。插件绑定发布版本,所以清单也绑定发布版本。
这个结构带来的后果是:只要厂商新增一个模型,你就得等 OpenClaw 裁一次新版本、你更新一次客户端。在这条链路上,厂商的节奏、上游项目的节奏、你自己的升级节奏三者必须对齐,任何一环慢了,模型就用不上。
这次改造的工作量是大约五周、约 80 个 pull request,把「OpenClaw 怎么知道有哪些模型」这件事重做了一遍。
三、新结构:三处来源各管一段
现在的模型列表由三个地方补水(原文用 hydrate 这个词),每处负责一类信息:
| 来源 | 回答的问题 | 更新频率 | 覆盖范围 |
|---|---|---|---|
| 托管目录 catalog.openclaw.ai | 公开世界里有这个模型吗 | 每 4 小时重建 | 约 1000 个模型 |
| 你的 provider 账号 | 你的账号能不能用它 | 登录或刷新时 | 账号实际可用的 chat 模型 |
| 本机安装的 Codex | 本次请求该由哪个二进制跑 | 启动时探测 | 取决于安装版本 |
三块拼起来才是完整的答案。只看目录会漏掉账号私有模型,只看账号会漏掉能力元数据,只看 Codex 版本会卡在「清单能拉到、turn 跑不起来」的错位上。
四、来源一:每四小时重建一次的托管目录
目录放在 catalog.openclaw.ai,有一个任务每 4 小时重建一次。数据来源是 models.dev,OpenClaw 把自己的 manifest 叠在上面,补上价格,然后发布。
有两个细节值得记下来。
第一是热切换。运行中的 Gateway 在后台检查是否有新目录,发现新的就直接切。
- 切换前:Gateway 下载完新目录,仍继续用旧目录,直到你重启。
- 切换后:新的 turn 用新目录,已经在进行的 turn 用启动时的那份跑完。
这个「turn 内模型列表与价格始终匹配」的设计很实用。如果中途换目录,同一个 turn 前半段按一个价、后半段按另一个价,成本核算就没法看了。
第二是退役模型的清理。发布器会移除厂商已经下架或不再提供的模型。他们举的例子是 NVIDIA 在 API 上停掉两个模型,而目录里还挂着。
一次把目录打挂的事故
9 月 21 日,models.dev 把 kimi-for-coding 这一条拆成了两个新条目。OpenClaw 的发布器把「缺少某个 provider」当成致命错误,于是每次运行都失败。那段时间 GPT-6 Sol 和 Luna 进不了已有安装。
现在的处理是:缺少 provider 只记一条警告,只有那一个 provider 拿不到新数据,其他不受影响。
这一条几乎是所有「聚合外部数据源」的系统的必修课。一个来源改名、一个字段缺失,不应该让整批更新停摆。
五、来源二:账号能列出来的,就能选
目录知道公开世界有什么,你的账号知道你能用什么。这是两个不同的集合。
登录某个 provider 之后,OpenClaw 向它请求你账号的模型列表,把上面的每个 chat 模型都显示出来,包括比你安装版本更新的模型,也包括只有你这个账号能访问的模型。
原文给了一张对照表,把「改之前」和「改之后」放在一起:
- OpenAI API key:改之前报
Unknown model: openai/gpt-5.2,虽然账号里列着它;改之后能显示也能跑。 - Anthropic API key:改之前新 Claude 模型要等 OpenClaw 发版;改之后 Anthropic 的模型列表一返回就出现。
- Claude Code:改之前是团队自己维护的固定清单;改之后用 Claude Code 为你账号报告的菜单。
- GitHub Copilot:改之前 kimi-k3 第一轮就以
unsupported_api_for_model失败;改之后读每个模型接受哪个 endpoint,用对应的那个。 - Grok 订阅:改之前 grok-4.7-build-fast 只有 off 和 ultra 两档思考;改之后按它的 listing 给出 low、medium、high、xhigh。
- Ollama:改之前你 pull 完模型也不出现;改之后下次刷新就出现。
- OpenCode Go:改之前账号可服务的四个模型缺失;改之后全部出现。
这张表里最有信息量的是 Copilot 和 Grok 两行。它们暴露的是一个更隐蔽的问题:思考档位和请求格式。
OpenClaw 以前靠模型名字去猜思考档位和请求格式。这个做法对老模型勉强能用,对任何没见过的新模型就会猜错。而 provider 的 listing 里本来就写明了这些能力,直接读就可以。
顺带一提,Ollama 那条也很典型:本地拉下来的模型不在静态清单里,只要清单是编译进包里的,它就永远不会出现。
六、来源三:Codex 版本错位,一个更值钱的反例
第三处来源是原文标题里那个 Sol 问题的正解,但它的价值其实在于中间那次失败的尝试。
第一步:上报最新的 Codex 版本
最直接的修法是:向 npm 问一下最新的 Codex 版本号,拉模型列表时把那个数字报上去。Sol 果然出现在选择器里了。
第二步:发现问题被搬到了更糟的位置
实际结果是:列表请求报的是 0.161.0,而 turn 仍然跑在内置的 0.160.0 上。那个二进制会在每个请求里上报自己的版本号。
于是,需要更新版本的模型会出现在选择器里,然后第一条消息就 HTTP 400。他们把这次改动回滚了,并用一句话总结了这个错误:失败点从选择器搬到了第一条消息,后者更糟。
这条教训对做客户端或多组件系统的人都成立------真正需要关心的不是「这个模型是否显示出来」,而是「声明能力的那个组件」和「执行请求的那个组件」是不是同一个。如果两者不同,那么把前者的版本号伪造高,只会把错误推迟到更难排查的地方。
第三步:现在上线的做法
现在的逻辑是这样的:先在 PATH 里找一个 Codex;如果它是稳定版、比内置版本新、并且是同一个大版本,就和它做一次真正的手握手;握手通过,就用同一个二进制去拉模型列表、跑 turn;任何一步失败,退回内置 Codex。
原文贴了一台装了 Codex 0.162.1 的 Mac 上的 Gateway 日志:
[agent/embedded] Codex app-server: using installed ~/.local/bin/codex 0.162.1 (newer than bundled 0.161.0)
注意这条路径的设计取舍:它只使用你已经安装的东西,从不替你安装任何东西。这既避免了静默下载未知二进制,也让「更新版本」这件事留在用户手上。
七、列表变短的问题,比列表变长更麻烦
找到模型是一个问题,让模型一直留在屏幕上、别无故消失,是另一个问题。原文列了三个会让列表缩水的场景:
| 场景 | 改之前 | 改之后 |
|---|---|---|
| OpenRouter 超时一次 | 466 个变 3 个,要手动刷新才回来 | 自动重试:先 30 秒,之后间隔拉长到最多 30 分钟 |
| Gateway 切换到新目录 | 599 个变 54 个,生产环境持续最多 4 分钟 | 全程保持 599,旧列表保留到新列表完整 |
| 修改默认模型 | 68---142 秒,重载所有大插件时卡住 | 0.6---1.4 秒,复用已加载的插件 |
第二行那个「599 变 54」值得琢磨。这不是数据错了,而是切换过程中出现了中间态:新目录还没下完,旧目录已经不用了。他们的处理方式是不暴露中间态------等新列表完整了再换。这是典型的双缓冲思路,只是用在了一条模型列表上。
选择器也改成了立即打开:先显示 OpenClaw 已知的模型,剩下的在后台补齐。以前要等发现流程跑完,最长 5 秒。
八、一千个模型之后:推荐清单
所有模型都显示出来之后,新问题来了:列表太长。光 OpenRouter 一家就有 466 个。
他们的办法是让目录给每个 provider 带一份简短的推荐清单,选择器把它放在最前面,但搜索仍然覆盖全部模型。推荐清单是人工维护、每天根据自身经验和各种 benchmark 更新。
原文举了自己一个 Gateway 上的例子:OpenAI 分组里,当前模型在最前,然后是两个推荐模型,剩下的 33 个收在一行后面。
这里的分层其实跟前面三处来源是同一个思路:默认值要短,全集要全,两者不能混成一层。
九、一个最小验证脚本
原文建议用 openclaw models list --refresh 直接看你的安装当前能触达哪些模型。如果你想在自己项目里把这件事做成可观测的,可以写一个很小的比对脚本。
下面这段是我自己写的,不是官方代码,用来验证「目录里的模型」和「我账号实际能调的模型」差多少------差异部分就是本文第三节那三处来源没对齐的地方。
python
# diff_sources.py ------ 对比「托管目录」与「账号可用」两份模型清单
# 说明:示例脚本,非官方实现;接口与字段以官方文档为准
import json
import pathlib
import sys
def load_catalog(path: str) -> set[str]:
"""读取一份模型目录快照,返回模型 slug 集合。
参数:
path: 目录 JSON 的本地路径(形如 catalog 快照)
返回:
所有模型的 slug 集合;字段缺失的条目按 slug 缺失跳过
"""
data = json.loads(pathlib.Path(path).read_text(encoding="utf-8"))
models = data.get("models") or data.get("data") or []
slugs = set()
for m in models:
slug = m.get("slug") or m.get("id")
if slug:
slugs.add(slug)
return slugs
def diff(account: set[str], catalog: set[str]) -> dict:
"""比较账号清单与目录清单的差集。
返回:
only_account ------ 账号可用但目录没有(通常是私有或新模型)
only_catalog ------ 目录有但账号调不了(通常是权限或 provider 未开通)
"""
return {
"only_account": sorted(account - catalog),
"only_catalog": sorted(catalog - account),
}
if __name__ == "__main__":
# 用法: python diff_sources.py catalog.json account.json
if len(sys.argv) != 3:
raise SystemExit("usage: diff_sources.py <catalog.json> <account.json>")
report = diff(load_catalog(sys.argv[2]), load_catalog(sys.argv[1]))
for key, items in report.items():
print(f"{key}: {len(items)}")
for slug in items[:20]:
print(f" {slug}")
调用链很短:load_catalog 把两份 JSON 都归一化成 slug 集合,diff 做双向差集,主流程按类别打印前 20 条。
字段名用了 slug 和 id 两种兜底,是因为不同来源的命名习惯不一样;真实接入时,这两个键名务必以官方文档为准。打印只取前 20 条,是为了在差异几百条时也能读得下去。
十、四条可以带走的结论
原文结尾总结了四条,我认为对做 Agent 客户端或 gateway 的人都能直接用上:
- 列出模型的版本必须同时是运行模型的版本。只换前者会把故障从一个明显的位置推到一个更隐蔽的位置。
- 能力要从 listing 读,不要从名字猜。以名字为规则的判断,在遇到第一个没见过的模型时就会失效。
- 保留上一份可用列表。一次失败的刷新或一次目录切换,都不该让选择器里的模型比之前更少。
- 一个来源坏了不该阻塞其他来源。models.dev 上一个 provider 改名,曾经让所有 provider 的目录更新停摆。
这两件仍然需要发版的事也很实在:没装新版 Codex 时,内置版本依然决定你能看到哪些 ChatGPT 模型;比目录更新的模型还没有价格,成本显示为未知,会先用保守的默认限额运行。
把「模型列表」当成一个多源合成、可热切换、失败可降级的子系统来设计,这件事本身就是这次改造最值得借鉴的地方------它看起来只是下拉框里的几行字,实际牵扯发布节奏、缓存一致性、能力元数据和降级策略四个方向。