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 按顺序做四件事:

  1. 提取 API Key(从 Authorization: Bearer xxx header)
  2. 调用户服务鉴权(用户是否存在、是否启用、余额是否充足)
  3. 用户级限流(滑动窗口,每用户独立 RPM 配额)
  4. 路由转发(按路径前缀分发到对应 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(最后启动,因为它依赖用户服务做鉴权)


这套基础设施的设计原则

回顾一下,几个贯穿始终的原则:

  1. 单一职责。Gateway 只管鉴权限流路由,不管业务。上游代理只管凭据限流计费,不管业务。Skill 后端只管业务,不管鉴权和凭据。
  2. Fail-closed。任何安全相关的组件挂了,宁可拒绝服务也不放行。
  3. 集中管理。凭据一份、限流一处、计费一套。不散落、不重复。
  4. 异步不阻塞。计费上报、日志记录——这些不影响用户请求的关键路径。
  5. 简单优先。单机部署、host 网络、文件日志。不上 K8s、不上消息队列、不上分布式追踪。够用就行,等规模到了再升级。

成本

整套基础设施跑在一台云服务器上(2C4G 足够)。除了云服务器本身的费用,没有额外的基础设施成本——不需要 Redis、不需要消息队列、不需要负载均衡器、不需要 CDN。

这是有意为之。在用户量小的阶段,简单就是最大的优势。每多一个组件就多一个故障点、多一份运维成本。等真正需要的时候再加,不提前过度设计。

Read more

三台机器部署 ClickHouse 高可用集群实战记录

本文是一份可发布版部署记录。真实 IP、域名、账号、密码、下载链接、业务目录名、机器唯一标识等敏感信息已经替换为占位符。命令中的 <...> 需要按自己的环境替换。 目标与拓扑 这次目标是用三台数据节点部署一套 ClickHouse 高可用集群,拓扑采用: 1 shard x 3 replicas 含义是:集群只有一个逻辑分片,三台机器都保存同一份数据的完整副本。任意一台数据节点宕机时,只要 ClickHouse Keeper 仍然有多数派,剩余节点仍可继续提供读写服务。 规划节点如下: 主机名示例地址角色ch-01<ch-01-ip>ClickHouse Server + ClickHouse Keeperch-02<ch-02-ip>ClickHouse Server + ClickHouse Keeperch-03<ch-03-ip&

By ladydd

折腾记(二):接入火山引擎实时语音 API,家庭语音助手体验直接拉满

接上篇 上一篇用全开源组件(Whisper + Hermes + Edge-TTS)搭了个语音助手,能跑,但体验就是"能用"二字: * 中文识别只有 70 分,方言基本歇菜 * 英文唤醒词"Alexa"喊着别扭 * 说完到回复要等 4-8 秒 * 它说话的时候你插不了嘴 这些问题靠堆开源组件很难根治。于是我去试了火山引擎(字节跳动)的语音服务,结果直接换了条路。 这篇分两段:先讲怎么用火山引擎的 ASR/TTS 替换掉开源组件(小改),再讲怎么上端到端实时语音模型(大改)。 第一段:先把 ASR 和 TTS 换成火山引擎 为什么换 我用豆包输入法的时候发现它语音识别准得离谱。一查,豆包用的就是字节自家的火山引擎 Seed-ASR。开通后有免费额度(

By ladydd

折腾记(一):用全开源组件给家里搭一个语音助手,对接自己的 Hermes Agent

起因 事情是从一块 ESP32-S3 开发板开始的。 我手上有一块 Seeed Studio XIAO ESP32-S3 Sense,带摄像头和麦克风。最初的想法很美好:用这块板子做一个无线语音终端,对着它说话,连到我服务器上跑的 Hermes Agent(一个自托管的 AI agent),让它回答我。 但折腾到一半我突然意识到一件事:我的麦克风、音响、服务器全在家里,为什么要绕一圈用 ESP32?直接把麦克风和音响插到服务器上不就行了? ESP32 那条路(做无线拾音终端)当然也有价值,但那是"为了学嵌入式而学",不是解决问题的最短路径。于是这个项目就从"嵌入式项目"变成了"在服务器上拼一个语音助手"。这篇就记录后者。 教训零:先想清楚你要解决的是什么问题。很多时候最优解比你最初设想的简单得多。 目标

By ladydd

Kiro 的三种代理设置方法:本地、服务端、Remote

作为kiro的骨灰级用户,这篇是我自己折腾 Kiro / Kiro Remote / Ubuntu Server 代理问题后的复盘。 核心不是“怎么配一个代理”,而是先判断:到底是谁在访问外网? 谁访问外网,代理就要配给谁。 0. 先说结论 Kiro 相关代理大概分三类: 场景真正访问外网的进程在哪里代理应该配在哪里本地 KiroWindows / Mac 本机本机 Clash / Proxifier / 系统代理服务端 Kiro / CLIUbuntu Server 上的 shell、CLI、node、kiro 进程Ubuntu 的环境变量,比如 HTTP_PROXY / HTTPS_PROXYKiro Remote远程 Ubuntu 上的 ~/.kiro-server 和 extensionHost远程 Ubuntu 的 Kiro Server

By ladydd
陕公网安备61011302002223号 | 陕ICP备2025083092号