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

把 Codex CLI 的登录态"搬"到一台新服务器

场景:你在一台老机器上早就登录好了 Codex CLI,现在开了台新服务器、装好了 codex,但它没登录。你不想在新机上重新走一遍 OAuth 网页授权(有时候服务器上根本打不开浏览器),只想把老机器上那份"已经登录好的身份"复制过去。 这篇讲的就是这个搬运动作的完整方法论——为什么能搬、怎么搬、有哪些坑。命令里所有隐私都用占位符,照着换成你自己的即可。 一、先理解一件事:Codex 的登录就是一个文件 这是整个操作的地基。Codex CLI(ChatGPT OAuth 登录模式下)的登录状态,不在什么系统钥匙串里,也不在环境变量里,就是家目录下一个单独的 JSON 文件: ~/.codex/auth.json 它长这样(字段名是真的,值我打码了): { "auth_mode": "

By ladydd

哨兵机制:让 Agent 一触即醒

0. 一句话点破本质 **让"等"发生在便宜的子进程里,让贵的 agent 只在有事时醒。**心跳解决"最迟多久必有人查岗",探针解决"事情一发生几乎立刻有人到场"——两个机制回答的是两个不同的问题,谁也替代不了谁。 1. 机制全貌:会自杀的轮询进程 + 宿主的"尸体通知" 我的实现只有两块积木: 积木一:一个有明确死法的后台循环 # 放行任务的同时,后台挂上(run_in_background) for i in $(seq 1 20); do 信号=$(ssh data "tmux capture-pane -t dna

By ladydd

Agent 心跳机制·设计与实现

0. 一句话点破本质 **心跳不是闹钟,是"带着完整世界快照的自我唤醒"。**闹钟只解决"什么时候醒";心跳真正要解决的是你点出的那个问题——醒来的那个瞬间,清楚自己是谁、任务到哪了、这一跳该干什么。我所有跑得好的心跳,提示词都写得像给一个失忆的陌生人看的;所有出过事的心跳,都是因为假设"我还记得"。 1. 第一性原理:为什么"醒来知道干啥"这么难 一个长期任务里的 agent 面临三重失忆: 1. 上下文会被压缩——多轮之后早期细节只剩摘要,心跳打进来时,那条心跳提示词可能是上下文里唯一高保真的任务描述 2. 世界在你睡着时变了——下属可能干完了、卡死了、跑偏了,你脑子里的"进度"从睡着那刻就开始过期 3. 任务本身会变—

By ladydd

我没手动映射 3000,公网为什么还能访问?一次 UPnP 误开孔复盘

写在前面:标题里的“自己打开”只是当时的主观感受。路由器没有失控,也不存在神秘穿透。真正发生的是:排障自动化从局域网主动调用了 UPnP AddPortMapping,路由器按协议新增了公网映射。 1. 原本的设计边界 家里的 Open WebUI 跑在一台 Ubuntu 主机的 Docker 中: 内网主机 192.168.x.x:3000 路由器上手动配置的入口是: 公网 TCP 13000 → 内网主机:3000 外部用户不直接访问家宽端口,而是先到云端 Caddy: 用户浏览器 → https://ai.example.com (云端 Caddy) → http://home.example.com:13000 (DDNS → 家宽公网

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