OPENAI-COMPATIBLE GATEWAY · GO · SINGLE BINARY

SHANHAI GATEWAY 山海关

把多个上游模型供应商拧成一个 OpenAI 兼容端点。 SWRR 加权轮转、配额指数冷却、任务级会话亲和、影子留存观测—— 替你把失败截住,把账单砍下来。

Go ≥1.27 Docker 镜像 38.9 MB 纯 Go SQLite · 零 CGO License MIT 配置热加载 · 不重启
LIVE · SWRR 路由模拟 请求过闸分发 · 红圈 = 冷却中 · 点一下任一上游节点,手动拉爆它
调用方 clients → 来自下游的请求 山海关 · SWRR 把失败截住,把账单砍下来 上游 A · w3 上游 B · w2 上游 C · w2 免费池 D · w1 免费池 E · w1 兜底 F · w0.5
普通请求 任务亲和(X-Job-Id,钉回同一条道) 429 → 指数冷却,换道重试 命中后端
已路由0 被截住的 4290 冷却中0
01 / 为什么

它为什么长这样

先交代来历。这不是论文落地,是被免费池和账单逼出来的。

外国人开了个不限量的免费模型,中国人就去薅:注册一堆号,自己薅够了转手卖给别人薅, 薅到厂商受不了,免费就没了。

我干的是同一个路子,区别有两条:我免费教你怎么薅;而且我不白嫖——每个月拿一百多块养着场子, 商业通道、代金券、免费池混着走,该付的付掉,剩下的让免费池扛。 10 月这波实测,月 200 亿 token 的现金账单压到了 ¥170。

代价是毛病一堆。下面这些坑我全踩过,一个坑换回来一条规则,全记在运维语义表里。 山海关就是这么长出来的:把各路免费池、代金券和按量套餐拧成一个稳定的上游池。

你接了三家模型上游做轮询,然后开始遇到这些事:

production-incidents.log
001503某家上游今天抽风,整个产品跟着 503。retry 救不了这个。
002cache前缀缓存命中率 51~56%,恰好等于池内抽取概率——轮询就是天花板。
0034d+上游悄悄下架模型,后端 0 成功率空转了四天半才被发现。
0041720×一次断连风暴打出 1720×503,正常后端挨个进冷却,全网无后端可用。
005keykey 撒得到处都是,仓库永远不敢见人。
$→一个坑换回来一条规则
02 / 特性

它做什么

核心就一句话:让不靠谱的上游一起干靠谱的活。剩下的都是为这句话服务的。

FEAT-01

SWRR 加权轮转 + 兜底链隔离

权重、并发、超时逐后端可调。兜底后端与主力池隔离,互不污染。

FEAT-02

错误分类学驱动的冷却

403/429 指数退避 5m→10m→…→4h 封顶;满载排队 60s 而不是甩 503;客户端断连不吃冷却。

FEAT-03

任务级会话亲和

优先 X-Job-Id、退回 X-Session-Key,同一任务钉回同一后端。实测前缀缓存命中率 27% → 51%,峰值 69%。

FEAT-04

quirk 怪癖层

某家专属的报错体/用量键写进独立包,内核零按名分支。没怪癖的上游零配置。

FEAT-05

多用户下游 key 计费

sk-user-* 额度核发 + 可插拔登录(注册表模式)+ 公版用户中心。

FEAT-06

影子留存观测

SQLite 单库存 attempts/captures,落库前自动脱敏,/api/stats 细粒度看板。

FEAT-07

额度窗探针 + 自动摘除

按后端名查每家的 5h/7d/30d 额度窗,sidecar 每 60s 判一次:窗打满就摘,回落后再开回来。加订阅不改任何映射表。

FEAT-08

sidecar 慢周期自愈

独立 Python 进程,纯标准库零依赖。按窗口增量失败率做限流/降级,与网关快路径分工。

FEAT-09

Docker 一把梭

多阶段构建、非 root 运行、自带 healthcheck,镜像内零密钥。

03 / 实测

实测规模与账单

2026-10,生产环境实测。请连着数字一起读,文末有口径说明。

0亿
月 Token 吞吐(单机生产)
¥0
月度现金账单(目录价约 $3,800)
0MB
Docker 镜像体积,零 CGO
0 次
主业务上游 429(免费池塌了也不受影响)
0%
免费池 / 缓存读
硬抗了 ~63% 的吞吐,商业通道只承担 ~37% 的真实请求。免费池频繁报错,主业务的上游 429 依然为 0。
会话亲和:把前缀缓存接回来
接入亲和前27%
接入亲和后51%
亲和峰值69%
轮询把第二轮甩给别家,前缀缓存就废了。钉回同一条道,命中率翻倍。

生产日耗 10 亿 Token 的场景下,靠路由平摊、缓存亲和(命中率 70%)和「多号代金券 + Free 赠送池」的滚动轮转:

通道数量月 Token 承载月现金
火山订阅4 个 80 亿 2 个现金付 ¥99.8,另 2 个由渠道券覆盖(还剩 10 元)
opencode Go2 个 12 亿(付费) 收购半价 $5/个(≈¥70)
Free 赠送池 · credits 赚取 · 缓存读— ~63% ¥0

→ ¥170 买 200 亿 Token ≈ ¥0.85/亿。真正按全新 token 全额计价的只有 23%。 光靠代码(缓存亲和 + 免费档混编 + 按请求大小分流)能把按目录价付费的量压到 1/4~1/3。

声明,请连着数字一起读:
  • 活动期价格有保质期。券与半价随时会退,回归常态约 ¥340/月,降本仍 98%+。
  • 「免费/低档承接 63%」随上游给量波动,实测区间 37%~57%,不承诺具体额度。
  • 免费/赠送额度是上游给的,不是本项目给的。本项目永久开源的是缓存亲和、换道重试、按请求大小分流、每个订阅独立计量额度并自动摘除。
  • 数字为 2026-10 单机生产实测(日耗 4.4~13 亿 Token)。
  • 04 / 快速开始

    三分钟上产线

    只想有个能用的网关:Docker 三条命令,把 base_url 换掉就能跑。权重、冷却、亲和全部开箱默认,不用读架构章节。

    bash — docker compose
    # 克隆 + 准备真实配置(进挂载卷,不进 git)
    git clone https://github.com/ffseika0304/shanhai-gateway.git
    cd shanhai-gateway
    mkdir -p runtime/config runtime/data
    cp config/backends.example.json runtime/config/backends.json
    cp config/keys.example.json    runtime/config/keys.json
    
    # 起容器(宿主侧默认 :18016)
    docker compose -f deploy/docker-compose.yml up -d --build
    
    # 冒烟
    curl -s http://127.0.0.1:18016/health
    {"status":"ok","version":"0.5.1"}
    bash — Go ≥ 1.27
    make build    # 等价于 go build -o shg ./cmd/shg
    
    cp config/backends.example.json config/backends.json
    # 凭据全假也能先跑
    
    export LISTEN=":18006"
    export ROUTER_VALIDATE=0   # 跳过启动外网自检
    
    ./shg &
    curl -s http://127.0.0.1:18006/v1/chat/completions \
      -H 'Content-Type: application/json' \
      -d '{"model":"demo-model","messages":[{"role":"user","content":"hi"}]}'
    # 200 即通
    config/backends.json — 最省版
    {
      "accounts": [
        { "name": "vendor-a",
          "base_url": "https://api.example-llm.com/v1",
          "api_keys": ["sk-xxxxx"] }
      ],
      "backends": [
        {
          "name": "vendor-a-main",
          "account": "vendor-a",
          "model": "demo-model",
          "base_url": "https://api.example-llm.com/v1",
          "weight": 1,
          "concurrency": 8,
          "enabled": true,
          "is_primary": true
        }
      ]
    }

    LAN 口 POST /v1/chat/completions 不强制 key(免账通道);下游 key 计费只在公网口/用户中心启用。 配置按 mtime 热加载,改权重、加后端不用重启。字段全集见 config/backends.example.json。

    05 / 运维语义

    事故沉淀的规则表

    要把它钉进产线或者魔改路由,先读这里的「为什么」。每个魔法数字都有事故出处。

    01

    客户端断连不冷却

    +
    10-03 一次断连风暴打出 1720×503:客户端断开被记进后端失败 → 正常后端挨个进冷却 → 全网无后端可用。断连只能归为 network 类观测,不吃冷却、不踩熔断。
    02

    模型不存在是后端的锅

    +
    上游悄悄下架 model 时网关表现为 4xx/黑洞,某兜底后端曾 0 成功率空转 4 天半才被发现。这类 4xx 不能归给客户端,要落在对应后端头上走冷却/摘除,身份走 model_expire / model_fallback 打补丁。
    03

    配额类走指数退避,满载走排队

    +
    403/429 指数冷却 5m→10m→…→4h 封顶,不反复白撞;满载不降级,排队到 60s(QUEUE_WAIT)再给答案,而不是直接扔 503。
    04

    黑名单只写不删,须人工清理

    +
    backends.blocked.json 是启动自检判定的「永久失效」清单,没有自动解除路径——自动复白会用失败率当证据,而黑名单的判断依据是探测事实。摘条目必须人工复核后从文件里删。
    05

    strike 只在成功时清零,勿乱改

    +
    10-01 的 403 自锁风暴:配额没恢复就清零 strike → 后端带着空鼓回池子 → 立刻再 403 → 冷却重启 → 循环打。指数退避的整条链依赖「连续击中数只靠成功归零」,admin 热改不提供 strike 入口是设计,不是缺失。
    06

    会话亲和 = 轮转外一层「优先选择」

    +
    10-10 实测前缀缓存命中率 0%:SWRR 把同一对话轮流甩给不同 provider,前缀缓存在 provider 侧,换家就凉。亲和键优先 X-Job-Id(任务级,正是前缀缓存的复用单元),退回 X-Session-Key。只有最终成功且内容已交付才写表,防失败会话钉死在坏后端;逐出/保持判定与冷却/熔断同源,不发明第二层健康层。
    07

    按家怪癖进 quirk 层,内核零名字分支

    +
    揉进通用规则会伤别家,加 if name==xxx 会把名字判定撒进内核,两条都是错路。「只在某家出现的问题」写进 internal/core/quirk/xxx.go,内核只在出口装饰 / 错误分类 / 用量解析三个固定缝上查表。怪癖是数据(配置字段),不是代码分支。
    08

    观测契约对齐 SLR 同形

    +
    attempts 表含 job_id 列(对已存在库幂等 ALTER 迁移);/api/stats 提供同形 affinity{hits,misses,puts,pinned_jobs} 对象。按 job 归因的脚本迁移后原样可用。
    06 / 排查

    故障排查

    都是实际被问过的。

    网关起不来,报「没有后端配置」
    env 指错目录。GATEWAY_CONFIG_DIR 必须包含 backends.json;docker 场景检查挂载卷 runtime/config/ 是否真的有文件(docker logs shg 看启动段解析了几个后端)。
    某个后端一直不被选中
    GET /api/stats 看三个字段:enabled(配置里被关)、cooldown_remain_s(配额类冷却中,最长 4h)、circuit_open。注意 circuit_open:false 不代表没冷却,两者是两套计数。
    全部后端都在冷却,请求全挂
    这正是规则 #1 要防的形态。检查 fail_breakdown:大量 network 类失败多半是客户端断连风暴误伤(老版本语义),确认你在跑当前版本;如果是 401 集中爆发,是 key 失效,换 key 前冷却不会自己好。
    看板有数据,想看原始请求
    查 SQLite captures 表(先读仓库里的 captures 消费契约)。落库前已经过 scrub 脱敏(密钥/内网路径/手机号/Bearer 头 → [mask]),这是特性不是缺陷。
    docker 容器反复重启
    docker logs shg 看退出原因;大概率是 runtime/config/ 缺文件或 JSON 不合法。healthcheck 连续失败会让编排层标记 unhealthy,但 restart: unless-stopped 只看进程退出。