AI Skill 平台的基础设施设计:Gateway、用户管理、上游代理
背景
上一篇讲了 Skill 本身的设计——用户侧 Skill 包、CLI 层、服务端、展示层。但 Skill 后端不是孤立运行的,它需要一套基础设施来解决:谁能调、调多少、花多少钱、上游怎么保护。
这篇讲的是 Skill 后端之外的那些"不性感但不能没有"的系统。
整体拓扑
用户(CLI)
│
│ HTTP + API Key
▼
┌─────────────────────────────────────────────────────┐
│ Gateway(Go) │
│ 鉴权 → 余额检查 → 用户限流 → 路由转发 │
│ │
│ ┌──────────────────────────────────┐ │
│ │ 用户服务(Python/FastAPI) │ │
│ │ 用户 CRUD / API Key / 余额 / 流水 │ │
│ └──────────────────────────────────┘ │
└────────────────────────┬────────────────────────────┘
│
┌────────────────┼────────────────┐
▼ ▼ ▼
Skill 后端 A Skill 后端 B Skill 后端 C ...
│ │ │
└────────────────┼────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ 上游代理(Go) │
│ 凭据注入 → 全局限流 → 计费上报 │
│ │
│ /sellersprite/* → SellerSprite API │
│ /xiyou/* → 西柚 API(签名鉴权) │
│ /feiyu/* → 飞鱼 API │
│ /clickhouse/* → 本地 ClickHouse │
│ /rufus/* → 本地搜索服务 │
└─────────────────────────────────────────────────────┘
Gateway:唯一对外入口
Gateway 是整个系统唯一暴露给公网的端口。所有 Skill 后端、上游代理、用户服务都只监听 localhost。
做什么
每个请求进来,Gateway 按顺序做四件事:
- 提取 API Key(从
Authorization: Bearer xxxheader) - 调用户服务鉴权(用户是否存在、是否启用、余额是否充足)
- 用户级限流(滑动窗口,每用户独立 RPM 配额)
- 路由转发(按路径前缀分发到对应 Skill 后端)
# 路由配置
routes:
- prefix: /listing
target: http://localhost:8899
- prefix: /niche
target: http://localhost:8900
- prefix: /competitor
target: http://localhost:8901
- prefix: /ads
target: http://localhost:8902
设计决策
Fail-closed,永不降级。用户服务挂了 → 503。不走本地缓存、不走 fallback key、不放行。理由很简单:如果鉴权层可以被绕过,后面所有的余额检查和限流都是摆设。
用 Go 写。原因:
- 反向代理是 Go 的强项(标准库
httputil.ReverseProxy) - 高并发低延迟,不需要 GC 调优
- 编译成单文件,部署简单
限流用滑动窗口。不是固定窗口(会有边界突刺),不是令牌桶(实现复杂且这个场景不需要突发容忍)。每个用户一个独立的窗口,RPM 值从用户服务拿(不同用户可以配不同的限额)。
用户服务:积分制计费
为什么不用按次计费或包月
- 按次计费:不同 API 命令成本差异巨大(查个健康状态 vs 拉一年数据),统一按次不合理
- 包月:用户量小的时候包月定价很难定,而且不能精确控制成本
所以用积分制:充值积分 → 每次调用按倍率扣积分 → 余额不足拒绝服务。
数据模型
users → id, name, api_key, balance, rate_limit, status
skill_pricing → skill_name, path_pattern, multiplier
transactions → user_id, type(recharge/consume), points_change, balance_after
request_logs → user_id, skill, path, status, latency_ms, points_cost, request_id
倍率配置:每个上游 API 命令可以配独立的倍率。比如:
- SellerSprite 的
keyword mine→ 倍率 2(贵,要调多次) - 本地 ClickHouse 查询 → 倍率 0(免费,不消耗上游资源)
- 西柚找词 → 倍率 37(按它的计费规则换算)
管理员在后台调倍率,不需要改代码、不需要重启服务。
内部 API
用户服务暴露 /internal/* 接口给 Gateway 和上游代理调用:
GET /internal/check?api_key=xxx→ Gateway 鉴权时调,返回用户状态 + 余额 + RPM 配额POST /internal/proxy-report→ 上游代理计费上报,扣积分 + 记流水
这些接口用 X-Internal-Token 保护,外部直接调会被拒绝。
管理后台
一个简单的 Web 页面(Jinja2 + TailwindCSS CDN),功能:
- 创建用户(自动生成 API Key)
- 充值积分
- 查看消费流水
- 调整倍率配置
不需要复杂的前端框架,够用就行。
上游代理:凭据集中 + 全局限流 + 计费
为什么需要这一层
如果让每个 Skill 后端自己调上游 API:
- 凭据散落在 4 个后端的配置里,改一个 key 要改 4 个地方
- 限流各管各的,4 个后端同时打,总 RPM 可能超限
- 计费逻辑每个后端写一遍,容易不一致
- 加新的上游数据源要改所有后端
加一个代理层,这些问题全部集中解决。
做什么
Skill 后端请求:POST /sellersprite/v1/traffic/extend
│
▼
上游代理:
1. 匀速限流(等待令牌,保证全局 RPM 不超限)
2. 注入凭据(secret-key header / 签名计算)
3. 转发到真实上游
4. 透传响应
5. 异步计费上报(fire-and-forget + 重试)
Skill 后端发出的请求是"裸"的——不带凭据、不管限流。代理负责穿衣服。
限流设计
每个上游数据源一个独立的限流器,匀速排队:
type RateLimiter struct {
rpm int // 配额(如 30/min)
interval time.Duration // = 60s / rpm
lastTick time.Time
mu sync.Mutex
}
func (rl *RateLimiter) Wait() {
rl.mu.Lock()
defer rl.mu.Unlock()
next := rl.lastTick.Add(rl.interval)
if now := time.Now(); now.Before(next) {
time.Sleep(next.Sub(now))
}
rl.lastTick = time.Now()
}
不是"超限就拒绝",是"超限就排队等"。因为 Skill 后端的请求是异步任务里的,等几秒没关系,但被拒绝了就要重试,反而更麻烦。
计费上报
每次成功转发上游请求后,异步上报给用户服务扣费:
转发完成 → 生成 UUID(request_id)→ 异步 POST /internal/proxy-report
↓ 失败?
重试 3 次(1s / 3s / 9s 指数退避)
↓ 还失败?
记日志,放弃(极端情况,用户服务长时间不可用)
幂等去重:每次上报带 UUID,用户服务端用 unique 约束防重复扣费。重试不会多扣,丢失不会少记(除非代理进程本身挂了)。
为什么是 fire-and-forget 而不是同步扣费?
- 同步扣费会增加请求延迟(多一次 HTTP 往返)
- 扣费失败不应该影响用户的请求结果(钱的事后面补,数据先给用户)
- Gateway 已经在入口检查了余额,到这一步余额大概率够
凭据管理
所有上游 API 的凭据只存在上游代理的环境变量里:
SELLERSPRITE_SECRET_KEY=xxx
XIYOU_CLIENT_ID=xxx
XIYOU_CLIENT_SECRET=xxx
FEIYU_TOKEN=xxx
Skill 后端的 .env 里没有任何上游凭据。它们只知道"调 http://127.0.0.1:8880/sellersprite/...",不知道真实的上游地址和密钥。
服务间通信安全
内部服务之间不是裸奔的。虽然都在同一台机器上监听 localhost,但还是加了一层保护:
Internal Token:Gateway 和上游代理调用户服务的 /internal/* 接口时,必须带 X-Internal-Token header。不带或不匹配 → 401。
为什么?因为用户服务的管理后台端口是对外开放的(管理员要用),如果 /internal/* 没有保护,任何能访问管理后台的人都能伪造计费请求。
端口隔离:防火墙只开放 Gateway 端口和管理后台端口。Skill 后端(8899-8902)、上游代理(8880)、数据库——全部不对外。绕过 Gateway 直接打后端?打不到。
部署:全部 Docker + host 网络
每个服务一个 Docker 容器,全部用 network_mode: host:
laochen-gateway → :8088(对外)
laochen-user → :8881(管理后台对外)
upstream-proxy → :8880(仅 localhost)
listing-backend → :8899(仅 localhost)
niche-backend → :8900(仅 localhost)
competitor-backend → :8901(仅 localhost)
ads-planner-backend → :8902(仅 localhost)
为什么用 host 网络而不是 Docker bridge?
- 服务间通信直接走 localhost,零网络开销
- 不需要管 Docker 网络配置和 DNS 解析
- 单机部署,没有跨机通信的需求
- 简单。出问题时
curl localhost:8900/health就能排查,不用进容器
启动顺序:数据库 → 用户服务 → 上游代理 → Skill 后端 → Gateway(最后启动,因为它依赖用户服务做鉴权)
这套基础设施的设计原则
回顾一下,几个贯穿始终的原则:
- 单一职责。Gateway 只管鉴权限流路由,不管业务。上游代理只管凭据限流计费,不管业务。Skill 后端只管业务,不管鉴权和凭据。
- Fail-closed。任何安全相关的组件挂了,宁可拒绝服务也不放行。
- 集中管理。凭据一份、限流一处、计费一套。不散落、不重复。
- 异步不阻塞。计费上报、日志记录——这些不影响用户请求的关键路径。
- 简单优先。单机部署、host 网络、文件日志。不上 K8s、不上消息队列、不上分布式追踪。够用就行,等规模到了再升级。
成本
整套基础设施跑在一台云服务器上(2C4G 足够)。除了云服务器本身的费用,没有额外的基础设施成本——不需要 Redis、不需要消息队列、不需要负载均衡器、不需要 CDN。
这是有意为之。在用户量小的阶段,简单就是最大的优势。每多一个组件就多一个故障点、多一份运维成本。等真正需要的时候再加,不提前过度设计。
陕公网安备61011302002223号