系统只转发上游 Provider。不训练、不托管权重、不在本地做推理。入站走网关凭证,出站注入 Provider SK。容量上限在两处:上游 SK 的 RPM / TPM / 并发槽,以及数据面的在途连接数。
两类负载不能共用一种连接模型。文本(含 AI Coding)是长 SSE、可随时取消、工具调用 delta、对 TTFT 敏感,走数据面直连。图片是长耗时、大 payload、上游并发窄,走异步核心。
协议字段见 主流模型协议。网关只处理转发、切帧和必须透传的头。
一、定位与目标
vLLM、continuous batching、前缀缓存都在上游。本系统做鉴权、限流、选路、协议处理和转发。路径按交互模型分流;限流与熔断分层;按在途连接水平扩展;多区域 Active-Active;streaming 优先保证首包。
| 文本 / AI Coding | 生图 | |
|---|---|---|
| 交互 | 长 SSE,stream: true |
提交 + 查询,或同步门面 |
| 持有连接 | 秒到数分钟 | 异步提交后即释放;同步可到几十秒 |
| 限流 | RPM + TPM | 张数、档位、每 Key 并发槽 |
| 取消 | 必须传到上游,否则继续计费 | 多数上游提交后无法取消 |
| 风险 | 连接数、缓冲、孤儿流 | 长等待占满连接、打爆 SK 并发 |
目标四条:数据面无状态可扩;文本 TTFT 不被中间层缓冲吃掉;图片入口速率与上游并发解耦;单区域故障时新流量进其他区域,已建立的流不热迁移。
领域对象
热路径只认下面这些对象。没有 Organization / Team 层级。
| 对象 | 不变量 |
|---|---|
Identity |
调用方。一个 Identity 可有多把 VirtualKey |
VirtualKey |
入站唯一凭证。绑定 Identity、可用模型、限额策略。禁用后超过缓存 TTL 必须拒绝 |
Channel |
一条出站通道:Provider + SK 引用 + 冷却状态。SK 明文只在出站那一刻出现 |
QuotaBucket |
维度是 (region, virtual_key, kind),kind ∈ RPM / TPM / 图片张数 / in_flight。预扣与退回同一把 Lua |
SkSlot |
维度是 (region, channel) 的上游并发槽。与 QuotaBucket 分开。槽满只排队或 429,不再开出站连接 |
Task |
图片任务。id 全局唯一。状态只许 queued → running → {succeeded,failed,canceled},禁止终态回退。重投按 id 幂等 |
UsageRecord |
一次结算:已产生 usage 或确认的张数。异步写入。流未结束没有最终记录 |
flowchart LR
Id["Identity"] --> VK["VirtualKey"]
VK --> QB["QuotaBucket"]
VK --> Ch["Channel"]
Ch --> Slot["SkSlot"]
VK --> Task["Task"]
Task --> UR["UsageRecord"]
VK --> UR
系统边界
flowchart LR
Client["客户端 SDK / IDE"] --> GW["本系统"]
Ops["运维"] --> GW
GW --> Prov["上游 OpenAI / Anthropic / DashScope"]
GW -.-> Bill["用量库"]
GW -.-> Obs["指标"]
客户端只持 VirtualKey。管理员改 Channel 与限额。上游只看见 Provider SK。计费与指标在旁路,不在热路径上同步确认。
目标值
假设:文本流平均占用 30s,图片生成 20s,单区域 2 个 AZ。
| 项 | 目标 | 怎么验 |
|---|---|---|
| 数据面额外 TTFT | p99 < 50ms(不含上游) | 网关写入时刻 − 上游首字节 |
| 空闲不断连 | thinking / 工具间隙 60s 内保持 | 注释心跳;LB idle ≥ 300s |
| 取消到上游 | 客户端 close 后 100ms 内 RST_STREAM | 取消率与上游费用应同向下降 |
| 单实例连接 | 2k–8k;HPA 在上限 60–70% | in_flight_connections |
| 单区域摘除 | 新连接 RTO 1–3 min | 健康检查失败后 DNS / 任播摘区 |
| 图片提交 | 返回 task_id p99 < 100ms |
不含 Worker 执行 |
| Redis 不可用 | 摘实例,不放行 | 新请求失败;已建 SSE 尽量维持到写不动 Redis 退预扣为止 |
二、分层
热路径实线,控制面虚线。
| 数据面 | 控制面 | |
|---|---|---|
| 组成 | 无状态 Gateway;图片还有本区 Queue + Worker | Redis、关系库、异步计费、指标、健康检查 |
| 职责 | 鉴权缓存、预扣、选路、SSE 转发、任务提交 | 限额计数、Key 健康、任务状态、配置、对账 |
| 扩缩 | in_flight_connections |
不必跟连接走 |
| 失败 | 丢该实例上的连接 | 不应拆已建立的流;Redis 挂则摘实例,不要改成不限流放行 |
| 约束 | 热路径禁止同步写库 | 持久化和跨区协调只走这里 |
边缘只做 TLS、WAF、就近接入。必须关响应缓冲,空闲超时覆盖文本长流(常见 5–15 分钟)。
异步核心只服务生图及同类长任务。文本 SSE 不进队列,否则 TTFT 和取消都会坏。鉴权、限流、选路两路复用,执行层分开。
flowchart LR
A["Auth 缓存"] --> L["RateLimiter Lua"]
L --> K["KeySelector 连接池"]
K --> P["Adapter 默认真传"]
P --> S["SSE Proxy"]
P --> I["ImageSubmitter"]
Adapter 出故障不得挡热路径默认透传。
三、文本路径
热路径:鉴权缓存 → RPM/TPM 预扣 → 选健康 Key → 透传或轻量映射 → 连接池取上游 HTTP/2 → 第一个 SSE 事件立刻写出。
容量按连接算
QPS 不适合单独当容量指标。请求占用连接 10–120 秒时,QPS 200 对应的在途连接是 2k–24k。HPA 挂 in_flight_connections(或 process_open_fds),阈值按实例上限的 60–70% 触发,不要看 CPU 或 RPS。长连接把 fd 打满时 CPU 往往还很低。
单实例经验范围(Go / Rust,默认 4–8Gi 内存):客户端连接 2k–8k 可稳跑;超过 1 万要先核 ulimit -n、每连接缓冲和上游复用连接数。粗算:每条 SSE 连接按 32–128KiB 用户态缓冲计,8k 连接就是 256Mi–1Gi,还没算 goroutine 栈和 HTTP/2 流。Python 代理功能可以先用,万级长连接靠多实例摊。
连接池按 Provider、必要时按 SK 切分。HTTP/2 复用省握手,不同 Authorization 不能共用一条连接。429 / 5xx 让该 Key 冷却,不要对着同一条 SK 重试到被封。
缓冲、背压、心跳
中间层默认缓冲会吃掉 TTFT。Gateway 每写完一帧就 flush;Ingress 显式关缓冲。
慢客户端必须背压:下游写不动就停读上游,缓冲设上限。无限堆积会先打满内存,再拖垮实例上的其他流。
thinking 和工具往返可以长时间没有 data:。用 SSE 注释行保活,否则空闲超时会拆掉客户端连接,上游仍在生成。
取消
客户端断开(TCP close / AbortController)后的顺序必须固定:
- 立刻取消上游:HTTP/2 发
RST_STREAM,HTTP/1.1 关掉出站连接。 - 若已有部分
usage(或能从已转发的帧估算),按已产生量结算。 - 同一把 Redis Lua 里退回预扣余额。
- 记一条取消,带自己的 request id 和上游
x-request-id/request-id。
取消不到上游就是孤儿生成:用户已经停了,SK 还在吐 token。取消率要单独看:突然升高可能是打断变多,也可能是空闲超时或心跳没配好。后一种会同时推高取消率和上游费用。
协议:数据面要守的部分
优先双原生:/v1/chat/completions、/v1/responses、/v1/messages 透传。翻译作为可选分支。统一方言方便调用方,工具流的事件转换是主要故障点。
出站替换认证头。OpenAI 侧按渠道带 OpenAI-Organization / OpenAI-Project,空值不传。Anthropic 侧 anthropic-version: 2023-06-01 必带,重复拼接会 400;anthropic-beta 决定 cache / thinking 等能力。x-api-key 与 Authorization: Bearer 不是同一种凭证。
流式按帧转发,不要把半截 arguments / partial_json 当完整 JSON 解析。Chat 结束帧是 data: [DONE];Responses 看 typed 事件;Messages 的 stop_reason 在 message_delta,content_block_start.input 是占位。按空行切 SSE 事件,避免切断 UTF-8。
停止信号与回传键不要在翻译里写错:tool_calls / function_call / tool_use,回传分别是 tool_call_id、call_id、tool_use_id。Messages 没有 role: tool。previous_response_id 的状态在 OpenAI,网关保持无状态即可。
四、图片路径
图片持有连接常 10–60s 以上,上游并发又窄。「一个 HTTP 挂到生成结束」不能当主容量路径。入口洪峰会先打满网关连接,上游生成能力还没用上。
异步是核心路径:
- Gateway 鉴权、配额检查后生成
task_id,写入本区 Redis(queued),投递到本区队列,立刻返回task_id,连接释放。 - Image Worker 按
(provider, key)并发槽拉任务。 - 占到槽才出站调用上游,状态改为
running。 - 终态
succeeded/failed/canceled写回 Redis,用量异步落库。 - 状态带 TTL(24–72h)。过期后若还要对账,从持久化补查,不把大 payload 长期留在 Redis。
终态不可回退。queued 取消不占 SK;running 多数上游已计费。
查询读 Redis。结果优先 URL,不要在队列和 Gateway 之间复制 base64。Worker 槽是硬限制:队列堆积只是等待,不再开出站连接。任务重投用 task_id 去重,同一任务不能向下游提交两次。
同步门面只是兼容层:对外仍是 /v1/images/generations,内部走同一套提交,再阻塞等终态。超时应短于上游最坏生成时间,超时后返回错误或 202 + task_id。门面不承担主容量。
| 同步门面 | 异步核心 | |
|---|---|---|
| 连接占用 | 全程 | 提交后释放 |
| 入口容量 | 被等待时间限制 | 接近普通写入 QPS |
| 上游保护 | 与连接数缠在一起 | 槽位可配置 |
| 未开跑取消 | 难 | 可出队 |
| 适用 | 小图、低并发、兼容旧客户端 | 批量、高清、高峰 |
扩缩看异步核心的队列深度和 Worker 槽使用率,不看同步接口 QPS。用量记在 running 或 succeeded,禁止只入队就扣。图片超时与文本空闲超时分开配。
非流式文本可走短同步或独立轻量队列,不要和生图 Worker 混成一种消费者——延迟谱和并发谱不同。
五、多区域
区域本地有 Redis 计数、任务状态、正在进行的 SSE。主备切换时备区是冷的,进行中的流也无法搬迁。Active-Active:每个区域独立走完整热路径,跨区只同步配置和用量账本。
每个区域自带 Gateway + Redis + Queue,热路径不出区。跨区只同步配置和用量账本。新流量可切到活区;已建立的 SSE 和进行中的图片任务不迁移,调用方重试。不要设计 SSE 热迁移。边缘负责选区,不拼业务。
区域摘除时:边缘停新连接;该区已建 SSE 随实例或出站失败结束,客户端重放请求;queued 任务可随队列一起丢,调用方用同一幂等键重提;running 按上游是否完成结算,不在另一区续跑。
上游 API 本身是全球入口。多区域优化的是接入和本系统控制面,不是把 Provider 变成多活。两区若共享同一组 SK,并发槽必须显式划分,否则等效双倍打上游。
在 AWS 上,选区可以用 Route 53 延迟/地理解析,或 Global Accelerator 任播到区域 ALB。健康检查失败则从解析或任播摘区。RTO 取决于检查间隔,通常分钟级。
六、限流边界
热路径用本区 Redis,是为了延迟和故障域。限额计数因此默认按区域隔离。
两区 Active-Active、且无粘性时,「每分钟 600 RPM」的有效全局上限接近 1200。这不是实现 bug,是隔离换来的放大。策略里必须写明,并主动管,不要假定存在一个全球单一计数器。
| 方案 | 行为 | 全局上限 | 热路径 | 适用 |
|---|---|---|---|---|
| 纯区域 | 每区独立桶 | 差,约按区域数放大 | 最低 | 初期、限额松 |
| 粘性 | 同一凭证尽量进同一区(Key 哈希或 preferred_region) |
较好,近似单区 | 低;切区时短暂偏离 | 默认平衡点 |
| 本地硬 + 全局软 | 本地 Lua 快速拒绝;异步汇总后再收紧各区限额 | 长期可守,窗口内可短暂超 | 仍本地 | 有全局预算时 |
| 热路径全球同步 | 每次请求写跨区存储 | 最强 | 多一跳,限流自己会成故障点 | 一般不采用 |
生产默认组合:区域本地硬限流(Redis Lua 预扣);对高配额 Key 开粘性;全局用量异步汇总,接近日/月预算时由配置中心下调各区本地限额。用户侧 RPM/TPM 与上游 (provider, key) 并发槽严格分开。用户限额防入口被占满,Key 槽位防打穿上游。即使调用方配额还有剩,SK 槽满了任务停在队列,不再开出站连接。
预扣与退回:
- 文本:进入时用 prompt 长度估算 +
max_tokens预扣。流正常结束或取消时,用上游usage多退少补。预扣和退回在同一把 Redis Lua 里完成,避免并发超卖。 - 图片:按「张数 × 档位」预扣。进入
running(已打到上游)或最终succeeded才确认扣减。仍在queued就被取消或失败的,全额退回。
放大要落到数字上。目标全局 RPM = 600、当前 2 个区域:纯区域时单区设 300–350(留余量);开粘性后单区可到 500–550。看各区实际命中分布,长期 80% 打在一个区,说明粘性或解析需要调。
七、状态
| 数据 | 位置 | 一致 |
|---|---|---|
| 限流计数、Key 冷却 | 区域 Redis | 区域立即可见 |
| 图片任务 | 区域 Redis + 队列 | 区域立即可见 |
| 渠道与凭证元数据 | 跨区库,短 TTL 缓存 | 变更后尽快各区可见 |
| 用量明细 | 库 / 分析存储 | 流结束后最终一致 |
| 请求日志 | 队列再落盘 | 最终一致 |
SSE 不在网关存会话。previous_response_id 在上游。图片 queued → running → 终态,重投按 task_id 幂等。用量延迟数秒可接受;凭证已禁用仍放行超过缓存 TTL 不可接受。
八、隔离与安全
入站 Virtual Key 映射到身份、可用模型和限额。出站 SK 只出现在网关到上游的请求里,放密钥管理服务,运行时用工作负载身份读取,不进镜像。日志脱敏 Authorization 与 Key。
内容检查:请求侧可拦 prompt。响应侧全量审核与零缓冲冲突,只能旁路采样或结束后扫。图片比文本更适合在出站前拦。
数据面出站仅 Provider 与本区 Redis / 队列 / 库。控制面与数据面分网段。
多租户与 Noisy Neighbor
当前是 Key 级 基础多租户,没有 Organization / Team / Project 层级。隔离靠限额和槽位,不靠独立队列或独立集群。
系统化防护只做这四层:
| 层 | 做法 | 挡住的邻居 |
|---|---|---|
| 入口 | 每 Key 独立 RPM / TPM / 图片配额 | 某一 Key 把入口 QPS 打满 |
| 文本连接 | 每 Key in_flight 上限,超了 429 |
某一 Key 占满 Gateway 连接 |
| 图片执行 | 每 Key 同时 running 上限,其余排队或 429 |
某一 Key 占满 Worker 槽 |
| 上游 SK | (provider, key) 并发槽与用户限额分开 |
某一调用方打穿共享 SK |
同步门面必须有短超时,否则一个慢租户的同步生图会把 Gateway 连接占死。图片队列是本区共享队列,公平性靠「每 Key running 上限」,不上每租户一条队列。
不做:层级配额与 RBAC、调用方自助管 Key、调用方用量看板、预付费 / 出账、租户级独立队列、按租户的审计隔离与合规驻留。可观测只做平台侧。完整多租户要另补租户模型、自助、计费闭环和调用方可见性。
九、观测
| 指标 | 用途 |
|---|---|
| TTFT | 缓冲和上游快慢 |
| 在途连接 | 扩缩与容量 |
| 取消率 | 取消是否传到上游 |
| 上游 429/5xx 按 Key | 冷却 |
| 队列深度与等待 | 是否拒同步门面、是否加 Worker |
| 分区域连接数 | 是否倾斜 |
健康检查不能只探 Gateway 进程活着。至少四层,失败动作事先写死:
- 进程
/healthz:失败则从 LB 摘该 Pod。 - 本区 Redis:读写探活。失败则该实例不能接新请求(限流和任务状态都不可用),从 LB 摘实例,不要降级成「不限流放行」。
- 队列消费延迟:积压超过阈值,摘同步门面或对新提交返回 429 / 202,Worker 继续消化。不要再加同步长连接。
- 上游低频探测:独立预算、按 Key。单 Key 连续失败 → 冷却该 Key;同一 Provider 多 Key 同时失败 → 摘该 Provider 渠道;整区出站失败 → 让边缘摘区。
业务请求不当探针。上游探测失败若只打日志、实例仍标健康,等于让用户流量去探活。
| 失败 | 预期 | 怎么验 |
|---|---|---|
| 客户端取消 | 上游 100ms 内停;Lua 退预扣 | 取消后上游不再涨 token |
| 上游 429 / 5xx | 该 Channel 冷却;换健康 SK 或 429 | 同一 SK 不得被打满重试 |
| Redis 短暂不可用 | 摘实例,不放行 | 新请求失败;不出现无限流 |
| 区域摘除 | 新连接 1–3 min 进活区;在途结束 | 无跨区续流 |
| Worker 槽满 | 任务停在 queued | 出站连接数不超过槽位 |
| 慢客户端 | 背压,缓冲有上限 | 实例内存不随慢客户端线性涨 |
演练按上表做。热路径日志带自己的 request id 和上游 x-request-id / request-id,不记录整段 SSE。
十、落地顺序
- 单区域:LB → Gateway,区域 Redis,用量库。文本双原生透传。
- 关缓冲、拉长空闲超时;HPA 挂
in_flight_connections。 - 密钥走身份注入。
- 图片:队列 + Worker + 任务查询;再加同步门面。
- 第二区域复制数据面;跨区只同步配置和用量;DNS 或任播选区。
- 需要全局上限时再上软对账,不要先做热路径跨区同步限流。
Gateway 与 Image Worker 分 Deployment,超时组分开。前者扩连接,后者扩槽位和内存。
十一、取舍
| 决策 | 默认 | 改另一侧的条件 |
|---|---|---|
| 运行时 | 数据面 Go / Rust | 先要对齐大量 Provider、用现成代理多实例 |
| 协议 | 双原生透传 | 调用方只支持一种 SDK,翻译放分支并测工具流 |
| 文本 | 零缓冲、背压、立即取消 | 必须同步审输出 |
| 图片 | 异步核心 + 可选门面 | 负载很小且全是同步客户端 |
| 限流 | 区域硬限 + 可选全局软对账 | 限额很松则纯区域 |
| 多区域 | Active-Active | 单区域已满足可用性 |
| 非流文本 | 短同步或独立轻队列 | 不要并进生图 Worker |
文本不进队列:入队会抬 TTFT,取消无法传到正在生成的上游。区域本地限流:热路径不能多一跳跨区;放大用粘性 + 软对账管。SSE 不热迁移:流绑在本区连接和上游请求上,迁过去等于新请求。
十二、扩展
视频按图片模型:任务、Worker 槽、更长 TTL,不要走同步门面。实时语音按文本模型:长连接、更短 TTFT,单独数据面,不要进图片队列。
路由在「健康 + 权重」之后,才用得上用量和延迟记录做选路。降级是 Key 冷却和明确的模型别名,不要在网关里静默换成另一个模型。
上游 prompt cache 是协议字段(如 cache_control),网关负责传上去。