我写了个本地 LLM 网关 Z·GATE
前言
事情本身很小:我机器上跑着几个用得上大模型的东西——写代码的 CLI、桌面客户端、几个自建的小工具。它们各自要填一份 Base URL、一个 Key、一个模型名。上游一换、账号一加、模型一改名,就得挨个去改,改到第三个就开始怀疑人生。
更麻烦的是两件事:
一是账号不止一个。不同来源的额度、重置时间、限速都不一样,有的还会在你不知道的时候进入「休息」状态。手写配置根本没法在几个账号之间轮转,更不可能在某个账号 502 的时候自动换一个。
二是方言不止一种。有的客户端说 OpenAI 那套 /v1/chat/completions,有的张口就是 Anthropic 的 /v1/messages,还有的说 Responses API。同一个上游,面对三种问法,得有三套适配。
于是我写了个网关,取名 Z·GATE。它只做一件事:把自己伪装成你需要的那个 API,同时把背后到底是谁在回答这件事,藏起来。
一句话概括它的位置:
1 | |
本文只记结论和坑,不铺过程。代码不在公开仓库,但打包方式和所有配置项都在下面写清了,照着能复刻一个八九不离十的。不想看过程、只想拿走的,直接跳到第九节,那里有打包好的免安装版下载(夸克网盘)。
一、它要解决的三个痛点
| 痛点 | 没有网关时 | 有网关时 |
|---|---|---|
| 客户端配置散落 | N 个客户端 × 3 个字段,改一次上游全改一遍 | 客户端只记 127.0.0.1:8787 + 一把 Key |
| 多账号 | 手动填死一个,额度用完自己发现 | 账号池按策略轮转,限额/429 自动冷却切换 |
| 协议不兼容 | 换客户端就得换上游,或者找不到适配 | 入站三种方言,上游两种方言,任意组合 |
| 出口 IP | 一个 IP 打到底,容易触发风控 | 代理池多出口轮转,失败自动顺延 |
| 可观测 | 不知道谁答的、答了多久、错在哪 | 每次请求一条 JSONL 流水,带账号、模型、耗时、缓存命中 |
定位上有三个刻意的选择:
- 只监听
127.0.0.1。它是个本机工具,不是给别人用的服务。要对外开就自己去改host,后果自担。 - 零 npm 依赖。
package.json里没有一个dependencies,bin/zgate.js直接跑。这意味着它不会因为某个依赖升了个大版本而突然挂掉,也意味着打包分发时不需要npm install。 - 配置是一个 JSON 文件,不是数据库。
%USERPROFILE%\.zgate\store.json,想看就看,想备份就拷,想改就改。
二、架构:四层
| 层 | 职责 | 代码位置 |
|---|---|---|
| 入站 | 解析三种方言,提取统一的「规范请求」 | src/server.js |
| 路由 | 按 Key 白名单 → 模型映射 → 选账号(策略 + 粘滞 + 冷却) | src/router.js |
| 上游 | 按供应商类型拼请求、走哪个出口、失败顺延 | src/providers.js |
| 出网 | 代理池选路、健康标记、冷却 | src/proxy.js |
中间有一层被刻意抽出来:规范请求(canonical)。不管是 messages 进来的还是 chat/completions 进来的,都先归一成一个中间结构,再由上游类型决定怎么序列化出去。这样加一种新方言只需要写「进」和「出」两个转换函数,不用改路由和账号池。
入站端点一共四个:
1 | |
三、三种方言:真正麻烦的地方
客户端问什么,上游答什么,这个矩阵里有 3×2 种组合,每一种都要能跑。
| 入站 | 上游 openai | 上游 anthropic |
|---|---|---|
| chat/completions | 直通 | 转换 + 流式重打包 |
| messages | 转换 + 流式重打包 | 直通 |
| responses | 转成 chat 再发 | 转成 messages 再发 |
流式是这里最容易被低估的一块。三种方言的 SSE 事件形状完全不同:OpenAI 是 data: {...} 每条一个 delta;Anthropic 是 event: content_block_delta 加 data:;Responses 又是另一套事件名。网关要边收边转,边转边往下游写,还要在结束时补上 [DONE] 或者 message_stop,否则客户端会一直转圈等。
两个具体的坑:
- Responses 方言必须带
instructions。不带的话上游直接 400,报「first message is not system prompt」。这不是网关的锅,是那个接口的约定,但第一次遇到会很懵。 max_tokens给小了会拿到空内容。有些模型先吐 reasoning 再吐 content,max_tokens设成 64,等它想完就没额度了,content是空字符串而 HTTP 还是 200。排查时特别容易误判成「网关丢了响应」,其实是预算不够。建议 ≥ 200。
四、账号池:五种策略 + 会话粘滞 + 冷却
账号池是整个东西里最有意思的部分。同一个供应商下挂多个账号,选谁答这一条请求,有五种策略:
| 策略 | 行为 | 适合 |
|---|---|---|
priority |
严格按 priority 数字,小的先用,挂了才顺延 |
主号 + 备号,省钱 |
rotate |
轮转,均摊到每个账号 | 多个等额度的账号 |
usage |
按已用额度算,挑用得少的 | 多账号共享月度额度 |
smart |
综合额度余量、重置时间、最近失败率打分 | 账号多、情况杂 |
pace |
按时间进度分配,避免月初就把额度烧完 | 想用满整月 |
默认是 priority。这个默认值是刻意的——它可预测。你放一个主号 priority=1,它就一直用主号,直到主号进冷却,不会出现「昨天还好今天怎么跑到另一个号上了」这种困惑。
会话粘滞(affinity) 是另一个关键设计,默认开,TTL 30 分钟。逻辑是:同一会话优先回到上次答它的那个账号。
为什么这件事重要?因为上游的 prompt cache 是按账号(更准确说是按组织)隔离的。同一段上下文,上个账号刚缓存过,换一个账号就得重新全价计费。实测效果很明显:
| 场景 | 耗时 | 说明 |
|---|---|---|
| 命中粘滞 | 2.9s | 同一账号,缓存命中 99%(1.7K 来自缓存,15 新增) |
| 撞上换号 | 4.4s | 首个账号 502 进休息,换号后缓存全丢,重新计费 |
差的那 1.5 秒和那笔重新计费,就是粘滞存在的理由。
会话标识从哪来?优先读请求头 x-session-id;没有这个头时,退化成对「首条 system + 首条 user」内容做指纹。也就是说,哪怕客户端完全不管会话,只要它上下文稳定,粘滞也能生效。
冷却:账号返回 429 或者明确限额时,进入冷却(默认 60 秒),期间不参与选择;连续失败到阈值也会冷却。冷却结束后自动回到池子里,不需要人工干预。
五、模型名映射:一个不得不做的脏活
客户端会发各种模型名。Claude Code 发 claude-sonnet-4-5-20250929,后台小任务还偷偷发 claude-3-5-haiku-*;别的客户端可能发 gpt-4o;你想用的其实是上游的 GLM-5.3。
所以在选账号之前,有一层 modelMap,支持前缀通配:
1 | |
顺序有意义:更具体的前缀先匹配,所以 claude-3-5-haiku* 会先于 claude-* 命中。这一点很实用——Claude Code 的后台小任务如果把请求打到旗舰模型上,成本会莫名其妙地高,单独给 haiku 系指一个小模型就行。
这里踩了个价值两小时的坑,值得单独说。
上游那个本地中转服务(ClaudeCN 客户端拉起的 magpie,监听 3425)对两个方言用的不是同一套模型命名空间:
| 模型名 | /v1/chat/completions |
/v1/messages |
|---|---|---|
auto/GLM-5.3 |
✅ | ❌ 404 |
zcode/GLM-5.3 |
✅ | ✅ |
auto/GLM-5.3-Flash |
✅ | ✅ |
group/smart-split |
✅ | ✅ |
现象是:网页/curl 测 auto/GLM-5.3 一切正常,但一接 Claude Code 就全 404,报「knows no model “group/auto-glm-5-3”」——它把 auto/GLM-5.3 解析成了「组 auto + 模型 auto-glm-5-3」。
结论很简单:映射目标统一用 zcode/*,两个方言都认。教训是:当一个上游暴露多种方言时,不要假设它的模型命名空间是统一的,一定要按方言分别验证一遍矩阵。
顺带一个观察:那种中转服务的 /v1/models 是动态的。某个模型组的账号全在休息时,这个模型就会从列表里消失,看起来像「配置丢了」,其实只是暂时没号可用。别被列表骗了,直接发一条请求才算数。
六、代理池:多出口与失败顺延
单 IP 打到底,在订阅型上游上很容易出事。所以做了个代理池,一行一条,支持 http / https / socks5,带用户名密码的 URL 也认:
1 | |
四种策略:
| 策略 | 行为 |
|---|---|
rotate(默认) |
每个请求换一个出口,均摊流量 |
sticky |
同一会话固定同一出口,配合上游风控和缓存区域 |
random |
随机挑一个健康的 |
failover |
永远用最靠前且健康的,失败才顺延 |
优先级从高到低,一共五档:
1 | |
前两档是账号级的:某台机器需要单独走出口,就在账号上配一条,它就不参与全局池的轮转。这个设计后来证明很有用——有的上游对 IP 和账号的绑定关系敏感,混着用反而容易触发验证。
失败处理是这块的重点:某个出口连接失败或超时,不是直接报错,而是标记、进冷却(默认 120 秒),然后顺延到池内下一个出口重试。 全部冷却了也不会硬失败,会挑最快恢复的那个硬试一次。
实测过极端情况:池里塞三条假地址,发一条真实请求,日志里能看到它按顺序试完三个出口后才返回 502——是串行顺延,不是并发乱撞。
七、双底座:一个供应商,两个地址
有些上游同时提供 OpenAI 和 Anthropic 两个入口,但只有走原生入口才能保住特性。最典型的是 prompt cache:走 OpenAI 入口转换过去,cache_read_input_tokens 就变成了 cached_tokens: 0,缓存白做。
一开始我的做法是「再建一个供应商,类型选 anthropic」。后来发现网关本身支持更好的写法:同一供应商配一个额外的 anthropicBaseURL,prefer 保持 auto:
1 | |
auto 的含义是:入站是 Anthropic 方言就走原生端点,入站是别的方言就走 openai 端点。
怎么确认它真的走了原生端点?看响应里有没有 cache_read_input_tokens。这个字段只有 Anthropic 原生接口才有:
1 | |
有它,就是真直通;只剩 cached_tokens: 0,就是被转换过了。
为什么不干脆新建第二个供应商? 因为默认路由策略是 priority,严格按数字排。新账号 priority 设得小,会直接把全部流量抢走;设得大又永远排不上。更要命的是两套配置会分散冷却状态和粘滞记录,排查问题时看到两套数据对不上。双底座只改路由,不碰账号池,没有这些副作用。
注意一个容易写错的地方:kind: "anthropic" 的供应商,请求 URL 是 baseURL + /v1/messages 拼出来的。所以 baseURL 要填 http://host:port,不能带 /v1,否则会变成 /v1/v1/messages。
八、可观测性与安全默认
管理台在 http://127.0.0.1:8787/,十个页签:路由、会话、供应商、账号池、代理池、网关、用量、脱敏、任务、设置。所有配置都能在界面上改,不用手编 JSON。
几个我认为值得保留的设计:
- 请求流水是 JSONL,一天一个文件,每条带入站方言、映射后的模型、选中的账号、耗时、token 用量、缓存命中、错误原因。排查「为什么这次慢/贵/失败」的时候,看这个比看日志快。
- 脱敏默认开着。账号名、模型名、供应商名在界面上打码,截图发出去不用手工涂。
- 网关 Key 可以限定范围:一把 Key 只放某几个供应商、某几个模型。给不同的客户端发不同的 Key,出了事能定位是谁。
- 未配 Key 时
/v1/*一律拒绝,返回「网关 Key 未配置或不正确」。这是个安全默认:宁可你配好再用,也不要默认裸奔。
最后一条实践上的建议:别把网关 Key 填到上游去。就遇到过这种配置——某个客户端的供应商面板里填的是 127.0.0.1:8787,但 Key 一栏填了上游的 Key,或者反过来,把网关 Key 发给了公网上游,然后对着 401 找半天原因。两把 Key 分清楚:网关 Key 只在本机流转,上游 Key 只存在网关的账号里。
九、打包:从 91 KB 到 31 MB
零依赖的好处在这一步体现得淋漓尽致——不需要 npm install,不需要构建,源码拷过去就能跑。
打了两个包:
| 包 | 体积 | 适用 |
|---|---|---|
| 源码版 | 91 KB / 24 文件 | 目标机已经有 Node 20+ |
| 免安装版 | 31.7 MB / 27 文件 | 目标机什么都没有,自带 node.exe |
start-zgate.cmd 即可运行,目标机不需要装 Node。首次运行若被 SmartScreen 拦,点「更多信息」→「仍要运行」。
包内
runtime\node.exe 为 Node.js v22.22.2 官方二进制(MIT 许可,未经修改),许可全文见 runtime\LICENSE-node.txt。
免安装版里 runtime/node.exe 是 84 MB 的官方二进制,压完剩 31.7 MB。它单独一个文件就够用,不需要带 DLL。
启动器 start-zgate.cmd 的 node 查找顺序是:
1 | |
免安装版总是命中第一个,所以行为确定,不受目标机已装版本影响;源码版没有 runtime 目录,自动往后回落。启动器跟随脚本位置(cd /d "%~dp0"),路径带空格也能用,参数直接透传:
1 | |
迁移就是拷 %USERPROFILE%\.zgate\ 这个目录,里面是 store.json、请求流水和计数。
两个打包时踩的细节,记一下给以后省事:
- PowerShell 5.1 的
Compress-Archive用本地代码页写条目名,中文文件名进 zip 会变乱码。所以说明文件叫QUICKSTART.txt(内容还是中文)。 - 它还用反斜杠当路径分隔符,Linux/macOS 的
unzip会先抛一句 warning,但能正常解开。
打包前记得清两样东西:源码里硬编码的 Key(换成环境变量或占位符),以及 ~/.zgate 那个目录(里面有明文凭证,绝对不能进包)。
结语
这东西写完之后,我改客户端配置的次数从「每次动上游」变成「零」。真正的收益不是省了那几次复制粘贴,而是上游可以随时换——今天用这个账号,明天加那个号,后天换个中转服务,客户端那边一个字都不用动。
代价也很清楚,得说明白:
- 它本身不做任何「绕过」的事。上游能给你什么,它才能转什么。上游被封、额度用尽、条款不许第三方接入,网关救不了。
- 账号来源的责任在使用者。把订阅型的额度转成 API 给别的程序用,多数服务的用户协议里是有限制的,被判定异常时的后果也落在真实账号上。这部分我没有任何办法替谁兜底,写出来只是提醒。
- 它也不是一个能对外提供服务的网关。只监听本机、没有多租户、没有速率限制、配置是明文 JSON。想拿它做公网服务,方向就错了。
从工程角度看,这次最值钱的收获其实不是代码量(一共几千行,零依赖,一个下午能写个骨架),而是那几个坑:不同方言的命名空间不统一、缓存是按账号隔离的、优先级策略会悄悄抢走流量。这些东西文档里不会写,只有自己撞一次才知道。
哪天你也被「十个客户端、三套 Key、五种方言」折磨到,可以考虑搞一个自己的。不用写这么多,一个能映射模型名、能轮转账号、能透传流式的骨架,就已经解决八成问题了。