把外部模型塞进 AI 客户端的下拉框:一个 JSON 文件就够了
> 不改一行源码,改一个配置文件,下拉框里就多出 7 个模型。 ## 这次要准备什么 | 项目 | 内容 | |---|---| | 环境 | Windows 11 + WorkBuddy 桌面客户端 | | 要改的文件 | `~/.workbuddy/models.json`(就一个配置文件) | | 前置条件 | 本机已跑聚合网关,端口 7865 | | 最终产出 | 模型下拉框新增 7 项,请求按前缀自动挑额度 | ## 起因:额度散在四个地方 我手上的额度是碎的:三个 WorkBuddy 账号的积分、智谱开放平台的 tokens、两个第三方聚合站的额度。 要用它们,得在四个地方各配一遍 API 地址。更麻烦的是**额度耗尽的切换**——某一边没钱了,得手动改配置。 所以我想要两件事: 1. 这些模型直接出现在我日常用的客户端模型下拉框里,点一下就切 2. 一边没钱了能自动换另一边,不用我管 第 1 件事,答案是客户端自己留的一个配置文件。 ## models.json:客户端的自定义模型清单 它在 `~/.workbuddy/models.json`,内容是一个数组,每个条目 9 个字段: ```json { "id": "cn:auto", "name": "积分池-Auto(双池自动)", "vendor": "Custom", "url": "http://127.0.0.1:7863/v1", "apiKey": "", "supportsToolCall": true, "supportsImages": true, "supportsReasoning": true, "useCustomProtocol": false } ``` 四个关键点: **`id` 是路由凭据,不只是名字。** 我用 `cn:` 前缀标记"这笔钱走 WorkBuddy 池",用 `glm-` 前缀标记"走智谱"。后面网关就是靠这个前缀决定花谁的钱——**所以命名本身参与了调度**。 **`name` 和 `id` 可以不一样。** `id` 是给机器看的(`cn:auto`),`name` 是给人看的(`积分池-Auto(双池自动)`)。下拉框显示的是 `name`,所以我用中文写清楚这笔钱从哪来。 **`vendor` 一律填 `Custom`。** 这是告诉客户端"这不是官方内置模型,别去官方清单里校验"。 **`url` 可以填本机地址。** 这是我整套方案的关键——它不必须是某个公网 API,指到 `127.0.0.1` 也认。 我现在的清单里有 7 个条目,四类来源: | 来源 | 地址 | 用途 | |---|---|---| | 本机账号池 | `127.0.0.1:7863/v1` | 三个 WorkBuddy 号自动选号 | | 智谱开放平台 | `open.bigmodel.cn` | 直接调 GLM 系列 | | 聚合站 A | `tokease.cn/v1` | 备用 | | 聚合站 B | `api.lansekafei.cn` | 备用 | ## 三层端点 `url` 指向本机时,后面其实堆了三层: ```text 7863 账号池上游 三个号自动选号、自动签到 7864 管理端 Web 面板看积分与流水 7865 聚合网关 按 model 前缀路由 + 失败自动切换 ``` 7865 是大脑。它收到请求只看 `model` 字段的前缀: ```python if model.startswith("cn:"): primary, pm = WB_POOL, model # 花 WorkBuddy 积分 backup, bm = ZHIPU, FALLBACK_MODEL[model] elif model.startswith("glm-"): primary, pm = ZHIPU, model # 花智谱 tokens backup, bm = WB_POOL, FALLBACK_MODEL[model] ``` 主渠道返回非 200 → **自动用备用渠道的映射模型重试一次**。因为 HTTP 状态码在响应头里,流式请求也能在吐出第一个字之前完成切换,客户端无感。 ## 验证:看响应头就知道花的是谁的钱 网关给每个响应打了两个标记: ```text x-upstream: workbuddy-pool x-upstream-model: cn:auto ``` 实测一次请求: ```text $ curl -X POST http://127.0.0.1:7865/v1/chat/completions \ -d '{"model":"cn:auto","messages":[{"role":"user","content":"只回复两个字:收到"}]}' → x-upstream: workbuddy-pool → 响应体 model 字段: hy4-preview ``` 有意思的地方在这里:**我请求的是 `cn:auto`,实际干活的是 `hy4-preview`**。 `cn:auto` 不是真实模型,是个"代号"。7863 收到代号后按三因子加权挑出 Top5,再随机抽签选一个当前有额度的号,最终落到具体模型。一次请求经过了两级调度。 ## 坑:脚本改了,进程没重启 这一层本来应该是自动的。我写了个看门狗,60 秒巡检三个端口,谁挂了拉谁。 结果今天早上查状态,7863 和 7864 都活着,**7865 是 DOWN**。 看门狗明明在跑,心跳日志每 30 分钟一行,一直没断: ```text 2026/10/26 06:43:17 heartbeat: 7863=up 7864=up 2026/10/26 07:13:18 heartbeat: 7863=up 7864=up 2026/10/26 07:43:18 heartbeat: 7863=up 7864=up ``` **问题就藏在这行日志里——它只报了两个端口。** 我打开脚本文件,第 59 行写的是 7863、7864、7865 三个都有。但看时间戳: ```text WorkBuddy-Stack.ps1 修改时间 10-24 23:32 ← 加入 7865 巡检段 看门狗进程 启动时间 10-24 19:11 ← 加载的是加入之前那一版 ``` **脚本是 23:32 改的,进程是 19:11 启的。改完之后,这个进程从来没重启过,它脑子里只有两个端口。** 7865 在那之后就一直是裸奔状态,某次挂掉之后再没人管。而因为我所有对外文档都写"聚合网关在 7865",最后只能把 models.json 里的地址从 7865 改回 7863 应急——**等于把刚做好的自动切换功能整个绕过去了**,还留下了三个备份文件: ```text models.json.bak-20261024 models.json.bak-20261024b models.json.bak-20261026-port7865fix ``` 备份文件名里的 `port7865fix` 现在看着有点讽刺:那次"修复"其实是把端口从 7865 改回 7863,方向是反的。 ## 教训 **给常驻进程写自愈逻辑时,"改脚本"和"生效"之间隔着一个重启。** 验证它是否真的加载了新逻辑,不要看文件——看它的输出。心跳日志只有两个端口,就已经说明它跑的是旧版。 顺便一条相关经验:在这个环境里,**工具调用启动的进程活不过当次调用**(调用内请求 200,调用一结束进程就没了)。所以长期服务只能靠脱离子进程树的方式启动,事后还要用互斥体确认它真的在岗,而不是靠命令行匹配进程名——后者匹配不到那些以编码命令启动的孤儿进程。 ## 产出物 改完拿到的是三样东西: 1. **模型下拉框多出 7 项**,每项名字直接写明这笔钱从哪来(如"积分池-Auto(双池自动)") 2. **一个响应头 `x-upstream`**——任意一次请求都能看出花的是谁的额度 3. **自动切换**——主渠道返回非 200 时,网关自动用备用渠道的映射模型重试一次 ## 小结 改一个 JSON 文件,把四个平台的额度收进一个下拉框。真正的价值不是"多几个模型可选",而是**把调度逻辑藏在一个 `127.0.0.1` 地址后面**——客户端以为自己在调一个普通 API,实际上后面是三选一的账号池、前缀路由、失败切换。 前提是那个地址得真的活着。 --- *本文所有时间戳、端口状态、响应头与日志均取自实机。密钥与口令已脱敏。*