ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

我写了一个本地 API 中转站:把一堆“用一次就限流“的 Key 拧成一股绳

我写了一个本地 API 中转站:把一堆“用一次就限流“的 Key 拧成一股绳 我写了一个本地 API 中转站把一堆用一次就限流的 Key 拧成一股绳项目地址https://github.com/shixin2004/my_apiB 站讲解视频https://www.bilibili.com/video/BV1tkYY6bE6x《本地中转站帮你解决帐号限流问题》· 4 分 45 秒一、起因不是钱的问题是用不了的问题事情的起点很朴素——我手里攒了一把 API Key。它们来自不同渠道有的是一次性试用额度有的是某家中转站的按量付费有的是活动送的。单独拿出来每一个都能用但凑在一起干活就出问题有的服务商是用一次冷却 N 秒。第一个请求通了第二个立刻 429得等 3 秒有的限并发同一个账号同时只允许一个请求在跑有的会抽风5xx 一闪而过客户端直接报错退出你连重试的机会都没有配额还是分散的。每个 Key 的额度单独清零工具却只能认一个 Key于是永远在这个用完了那个还满着之间反复横跳。我用的是一个只接受单一 OpenAI 兼容地址的客户端。每次想换个 Key就得打开设置、改地址、改 Key然后因为 Key 用完了再改回来。这个手工操作本身就是最该被自动化掉的东西。所以我写了这个项目一个跑在本机的反向代理把一堆单账号有并发限制 / 用完进冷却的 OpenAI 兼容接口聚合成一个地址对外服务。客户端(WorkBuddy) → 本机代理:8787 → 按调度挑一个 key → 上游服务商 ↑ 并发控制 / 冷却避让 / 429 自动换 key / 失败熔断客户端那边看到的永远是http://127.0.0.1:8787/v1一个地址、一个 Key。背后到底有几家服务商、几个账号在轮班它完全不需要知道。二、核心Key 池化调度到底在调度什么这个项目的心脏是server/src/pool/KeyPool.ts600 多行只干一件事在正确的时间挑出一个正确的 Key。听起来简单但要真的跑稳得回答五个问题。2.1 谁可以被选—— 三道闸门一个 Key 想被派活必须同时通过闸门判据并发未满该 Key 当前在途请求数 maxConcurrencyPerKey默认 1不在冷却now cooldownUntil冷却来源可能是成功后主动避让、“429 退避或错误避让”未熔断连续失败次数没到failureThreshold或者熔断已到期自动复活三道闸门都是硬条件。这意味着一件事冷却中的 Key 会被自动跳过于是多个 Key 的冷却窗口天然错开。2.2 挑哪一个—— round-robin但有个坑在所有空闲 Key 里按桶内固定顺序轮转取用round-robin并且并发占用少的优先。这里我踩过一个很典型的坑值得单独讲我一开始想让健康的 Key 优先干活于是把「连续失败次数」加进了排序依据。结果非常反直觉——坏 Key 被永久压到队尾第一个 Key 承担了几乎全部流量其他好 Key 全程围观。因为只要第一个 Key 一直成功它的失败数就是 0永远排第一永远被选中。轮询的意义是均摊不是择优。我把这段教训直接写进了KeyPool.ts的注释里防止以后自己手贱再加回去。2.3 撞了 429 怎么办—— 先信上游再指数退避命中限流时优先采纳响应头里的Retry-After——服务商自己最清楚要等多久比任何猜测都准。如果上游没给退回指数退避1x → 2x → 4x基数取桶配置的rateLimitCooldownMs但无论怎么退都不超过maxCooldownMs默认 10 分钟这个天花板避免一个 Key 被退避到天荒地老。2.4 重试要不要换 Key—— 要看错误类型这是设计里最需要克制的地方。不是所有错误都值得重试。我把错误分了几类处理方式完全不同限流、5xx、网络抖动→ 换下一个 Key 重试客户端无感401 / 403→ 不重试直接禁用这个 Key没权限就是没权限重试一万次也一样主机不可达→ 也不重试。关于最后一条我实测过对一台不可达的主机重试 4 次总共耗掉 42 秒客户端早就超时了。重试的代价是延迟盲目重试只会把慢变成彻底用不了。重试次数由桶配置的maxRetries控制默认 3。2.5 所有 Key 都在冷却呢—— 排队而不是报错如果一道闸门把 Key 全挡住了代理不会立刻返回失败而是挂起等待直到有 Key 解冻或被唤醒为止。等待上限是acquireTimeoutMs默认 30 秒超时才返回 503。这个设计的价值在实测数据里体现得很清楚——见下一节。三、实测5 个 Key 扛住 12 个并发项目内置了一个 mock 上游专门模拟最难受的场景每个 Key 并发上限 1、用后强制冷却 3 秒、冷却期内返回 429带retry-after。启动方式npmrun mock# 另开一个终端监听 :8788MY_API_DATA_DIR./server/data-testnpmstart然后打 12 个并发请求进去。结果12 个请求全部返回 200总耗时 9 秒Key 分布均匀。拆开看就是3 轮 × 3 秒5 个 Key 第一轮吃掉 5 个请求冷却 3 秒第二轮又吃掉 5 个第三轮吃掉剩下 2 个。没有任何一个请求失败也没有任何一个 Key 被压垮。如果换成单 Key 直连同样的 12 个请求要么串行跑 36 秒要么在第 2 个请求就撞 429 失败。这就是 Key 池化最直观的收益把分散的额度叠成了吞吐。四、双协议让 OpenAI 和 Anthropic 都能连客户端的世界并不统一。有的工具只认 OpenAI 的/v1/chat/completions有的比如 Claude Code 这类说的是 Anthropic 的那套/v1/messages。我不想为两套协议维护两份逻辑所以代理同时提供两套入口内部统一接口路径OpenAI 兼容POST /v1/chat/completionsAnthropic 兼容POST /v1/messages模型列表GET /v1/modelsToken 估算POST /v1/messages/count_tokens健康检查GET /healthzserver/src/protocol/anthropic.ts负责双向转换请求侧把 Anthropic 格式摊平成 OpenAI 格式发给上游响应侧再翻译回来。真正麻烦的是流式。两套协议的 SSE 事件模型完全不一样——Anthropic 有message_start、content_block_start、content_block_delta、content_block_stop、message_delta、message_stop一串有状态的事件OpenAI 那边只是一串choices[0].delta.content。所以这里有一个AnthropicStreamTranslator类维护转换状态机逐条映射事件。这样客户端该收到的分片照收不误打字机效果完全正常。五、模型桶为什么不是全局配置 桶覆盖代理用「模型桶」来组织一切。一个桶 一个上游模型 一组可以混用的 Key。桶卡片上有四个入口API Key桶内 Key 的增删改、批量导入、单 Key 测试每条 Key 自带它所属中转站的地址运行配置这个桶专属的调度参数与上游请求超时请求日志只显示本桶的请求支持状态筛选、关键字搜索、翻页编辑桶名、上游模型名必填、客户端别名这里有两个设计决策我想单独说。5.1 上游地址跟着 Key 走不是跟着桶走一开始我写的是一个桶一个上游地址。很快就发现不对——同一个模型我可能同时用着 A 家和 B 家的 Key谁便宜/谁没限流就用谁。所以地址从桶级下沉到了 Key 级api_keys.base_url同一个桶里可以混着多家中转站的 Key 请求按本次实际用到的那个 Key 去对应的那家。地址没填的 Key 无法转发面板和请求日志都会明确提示去补填不会静默失败。5.2 没有「全局配置」这一层我最初的设计是两层一个全局调度参数桶可以覆盖它。后来删掉了。理由很简单“继承 覆盖意味着你永远要问这个参数到底生效的是哪一层”。排查问题时这句话要花时间。现在的分层是干净的三个层次各管一段没有交叠层级管什么存在哪Key 级上游地址该 Key 属于哪家中转站api_keys.base_url桶级调度参数完整一份 上游请求超时buckets.policy/buckets.request_timeout_ms进程级port / host / proxyApiKey / adminToken / 日志保留数 / 上游路径app_config桶级参数就是完整的一份不存在继承全局再覆盖——桶和桶之间完全隔离一个桶把并发调到 8不会对另一个桶产生任何影响没自定义过的桶直接用出厂默认值。同时我也删掉了GlobalConfigModal——配置入口越少用户越不容易找不到自己想改的那个。5.3 模型映射客户端说什么名字不重要客户端请求的模型名比如claude-3-5-sonnet通常和上游真实模型名对不上。桶里可以配映射规则claude-* - 上游真实模型名 gpt-4o - 上游真实模型名支持*前缀通配。未命中时回落到「默认模型」默认模型也为空则原样透传。这样客户端侧可以随便填模型名转发的事情交给代理。六、面板能看见,才敢调参调度系统最怕的是黑盒——出了 429 你只能猜。所以面板做了几件让状态可见的事。顶部「接入地址」卡片明文展示代理接口 Key 和两套协议的完整地址复制即用。请求日志记录每一次转发命中了哪个 Key脱敏显示只留首尾几位、状态码、耗时、失败原因。一键诊断是最实用的一个。它会遍历桶内的 Key 逐个连通性测试——先GET /models遇到 404/405 再退回发一个最小化的 chat 请求兜底有些中转站不实现 models 接口。诊断过程限流到最多 4 并发不会因为测试把上游打爆。我还专门处理了错误信息的可读性server/src/util/log.ts。两个细节stackOf()会展开cause链。Node 的 fetch 出错经常只给你一句苍白的fetch failed真正的ECONNREFUSED藏在cause里不展开等于没报错。networkError()按错误码给出中文排查提示而不是把ETIMEDOUT直接甩给用户。管理面板的实时刷新走 SSEGET /api/events配置改完立即生效全程无需重启。七、工程上的一些选择7.1node:sqlite够用而且真的省事配置和日志存在 SQLite 里默认server/data/proxy.db用的是Node 22 内置的node:sqliteDatabaseSync不装任何第三方数据库驱动。配套做了几件事WAL 模式读写不互相阻塞300ms 合并写入高频日志写不会把磁盘打爆ensureColumn()做轻量迁移新增列自动补上不用引 migration 框架构造函数里写老数据迁移比如policy_override → policy、桶级base_url下沉到桶内 Key。代价是启动必须带--experimental-sqlite基础镜像要node:22-alpinenode:sqlite要求 Node 22.5。但换来的是零依赖、单文件、跨平台对一个本地工具来说完全划算。7.2 运行时直接跑 TypeScript后端不是编译成 JS 再跑而是用tsx直接跑 TSnode--experimental-sqlite--watch--importtsx server/src/index.ts好处是改完即生效、没有编译等待、堆栈直接指向.ts行号。代价是运行阶段得保留完整的node_modulesDocker 镜像偏大——README 里我特意写明这属正常现象免得有人以为是构建出错。7.3 一个差点上线的 Bug客户端断开怎么检测代理需要知道客户端还在不在等。客户端要是已经断开了我们再费劲去重试就是纯浪费。直觉写法是监听req.raw.on(close)。这个是错的——在 HTTP 语义下请求体读完后req流就会正常关闭于是每个正常请求都会被误判成客户端断开。正确做法是监听reply.raw.on(close)并配合writableEnded判断。这段注释我留在proxy.ts里了。7.4 脱敏面板和日志里的 Key 一律只显示前 6 位 后 4 位maskKey()。日志默认不记请求/响应体logPayloads: false要排查问题才手动打开。八、上手只要三步gitclone https://github.com/shixin2004/my_api.gitcdmy_apinpminstallnpmrun dev# 后端 :8787 前端 :8788生产模式只开一个端口后端同时提供面板npmrun buildnpmstart# 访问 http://127.0.0.1:8787首次启动会自动生成数据库。然后在面板里建一个模型桶填好上游模型名必填进桶里粘贴 Key 和它们各自的中转站地址把客户端的地址指向http://127.0.0.1:8787/v1Key 填sk-local-proxy。全程不需要重启。Docker 部署仓库自带Dockerfile和docker-compose.yml前后端一起打包只暴露一个端口dockercompose up-d--build数据持久化在宿主机./data容器重建不丢 Key。有个容器部署的坑要注意容器内必须监听0.0.0.0否则宿主机端口映射连不上。编排文件用MY_API_HOST注入同时把发布端口绑回宿主机127.0.0.1——所以虽然容器里监听的是0.0.0.0实际上依旧只有本机能访问。要开给局域网删掉127.0.0.1:前缀并且务必先设好proxyApiKey。三个环境变量可以覆盖库里的配置MY_API_DATA_DIR、MY_API_HOST、MY_API_PORT。九、安全默认不出内网这个工具手里攥着你的真实 Key安全态度必须明确默认只监听127.0.0.1不出内网server/data/存放真实 Key已在.gitignore里不要提交面板与日志中的 Key 一律脱敏建议设置proxyApiKey防止本机其他程序蹭用你的池子唯一的0.0.0.0例外是容器部署处理方式见上一节。十、目录结构Dockerfile 多阶段构建前端打包 运行时镜像 docker-compose.yml 一键起容器数据落在宿主机 ./data .env.example 环境变量清单 server/src ├── index.ts 入口、鉴权、静态资源 ├── config.ts 配置读写含环境变量覆盖监听地址 ├── pool/KeyPool.ts 调度核心并发/冷却/熔断/退避 ├── upstream/openai.ts 上游调用 ├── protocol/anthropic.ts Anthropic ⇄ OpenAI 转换含流式事件 ├── routes/proxy.ts /v1/chat/completions、/v1/messages ├── routes/admin.ts 管理 API SSE 实时事件 └── mock-upstream.ts 模拟上游用于自测 src/ Vue 管理面板技术栈Fastify 5Vue 3TypeScript数据库Node 内置node:sqlite构建Vite 7。依赖表很短是有意为之。十一、写在最后这个项目解决的问题很小小到一句话就能说完“我有一堆 Key想让它们一起干活。”但真做进去要处理的是并发、冷却、退避、熔断、协议转换、状态可见性、以及一堆看起来对但其实错的实现细节。写完回头看最值钱的其实是那些踩坑后留在注释里的判断——比如轮询不该拿失败次数排序、比如客户端断开不能用req.raw监听、比如不可达主机重试 4 次要 42 秒。如果你也在被限流和并发卡住欢迎试试项目地址https://github.com/shixin2004/my_apiB 站讲解视频https://www.bilibili.com/video/BV1tkYY6bE6x —— 《本地中转站帮你解决帐号限流问题》4 分 45 秒从限流怎么来的讲到为什么要把 Key 池化编排有问题欢迎在 GitHub 提 Issue或者视频底下留言。
返回列表