返回归档
🔐LLM 与 Agent

企业级模型网关:纯 Relay 的系统设计

纯 Relay 网关:文本零缓冲 SSE、生图异步优先、多区域 Active-Active、限流的区域边界。

文章目录

系统只转发上游 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 退预扣为止

二、分层

热路径实线,控制面虚线。

Client → Edge → Gateway: text SSE flush to Provider; images enqueue to Image Worker

数据面 控制面
组成 无状态 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)后的顺序必须固定:

  1. 立刻取消上游:HTTP/2 发 RST_STREAM,HTTP/1.1 关掉出站连接。
  2. 若已有部分 usage(或能从已转发的帧估算),按已产生量结算。
  3. 同一把 Redis Lua 里退回预扣余额。
  4. 记一条取消,带自己的 request id 和上游 x-request-id / request-id

取消不到上游就是孤儿生成:用户已经停了,SK 还在吐 token。取消率要单独看:突然升高可能是打断变多,也可能是空闲超时或心跳没配好。后一种会同时推高取消率和上游费用。

POST stream=true, Lua reserve, outbound HTTP/2; client close then RST_STREAM and settle usage

协议:数据面要守的部分

优先双原生:/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-keyAuthorization: Bearer 不是同一种凭证。

流式按帧转发,不要把半截 arguments / partial_json 当完整 JSON 解析。Chat 结束帧是 data: [DONE];Responses 看 typed 事件;Messages 的 stop_reasonmessage_deltacontent_block_start.input 是占位。按空行切 SSE 事件,避免切断 UTF-8。

停止信号与回传键不要在翻译里写错:tool_calls / function_call / tool_use,回传分别是 tool_call_idcall_idtool_use_id。Messages 没有 role: toolprevious_response_id 的状态在 OpenAI,网关保持无状态即可。

四、图片路径

图片持有连接常 10–60s 以上,上游并发又窄。「一个 HTTP 挂到生成结束」不能当主容量路径。入口洪峰会先打满网关连接,上游生成能力还没用上。

异步是核心路径:

  1. Gateway 鉴权、配额检查后生成 task_id,写入本区 Redis(queued),投递到本区队列,立刻返回 task_id,连接释放。
  2. Image Worker 按 (provider, key) 并发槽拉任务。
  3. 占到槽才出站调用上游,状态改为 running
  4. 终态 succeeded / failed / canceled 写回 Redis,用量异步落库。
  5. 状态带 TTL(24–72h)。过期后若还要对账,从持久化补查,不把大 payload 长期留在 Redis。

Task: queued → running (got SkSlot) → succeeded; cancel/fail are terminal

终态不可回退。queued 取消不占 SK;running 多数上游已计费。

POST submit SET queued + enqueue → task_id; Worker slot SET running then generate; poll returns URL

查询读 Redis。结果优先 URL,不要在队列和 Gateway 之间复制 base64。Worker 槽是硬限制:队列堆积只是等待,不再开出站连接。任务重投用 task_id 去重,同一任务不能向下游提交两次。

同步门面只是兼容层:对外仍是 /v1/images/generations,内部走同一套提交,再阻塞等终态。超时应短于上游最坏生成时间,超时后返回错误或 202 + task_id。门面不承担主容量。

同步门面 异步核心
连接占用 全程 提交后释放
入口容量 被等待时间限制 接近普通写入 QPS
上游保护 与连接数缠在一起 槽位可配置
未开跑取消 可出队
适用 小图、低并发、兼容旧客户端 批量、高清、高峰

扩缩看异步核心的队列深度Worker 槽使用率,不看同步接口 QPS。用量记在 runningsucceeded,禁止只入队就扣。图片超时与文本空闲超时分开配。

非流式文本可走短同步或独立轻量队列,不要和生图 Worker 混成一种消费者——延迟谱和并发谱不同。

五、多区域

区域本地有 Redis 计数、任务状态、正在进行的 SSE。主备切换时备区是冷的,进行中的流也无法搬迁。Active-Active:每个区域独立走完整热路径,跨区只同步配置和用量账本。

Region A and Region B each have Gateway + Redis + Queue; Edge picks nearest or failovers

每个区域自带 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。

十、落地顺序

  1. 单区域:LB → Gateway,区域 Redis,用量库。文本双原生透传。
  2. 关缓冲、拉长空闲超时;HPA 挂 in_flight_connections
  3. 密钥走身份注入。
  4. 图片:队列 + Worker + 任务查询;再加同步门面。
  5. 第二区域复制数据面;跨区只同步配置和用量;DNS 或任播选区。
  6. 需要全局上限时再上软对账,不要先做热路径跨区同步限流。

Gateway 与 Image Worker 分 Deployment,超时组分开。前者扩连接,后者扩槽位和内存。

十一、取舍

决策 默认 改另一侧的条件
运行时 数据面 Go / Rust 先要对齐大量 Provider、用现成代理多实例
协议 双原生透传 调用方只支持一种 SDK,翻译放分支并测工具流
文本 零缓冲、背压、立即取消 必须同步审输出
图片 异步核心 + 可选门面 负载很小且全是同步客户端
限流 区域硬限 + 可选全局软对账 限额很松则纯区域
多区域 Active-Active 单区域已满足可用性
非流文本 短同步或独立轻队列 不要并进生图 Worker

文本不进队列:入队会抬 TTFT,取消无法传到正在生成的上游。区域本地限流:热路径不能多一跳跨区;放大用粘性 + 软对账管。SSE 不热迁移:流绑在本区连接和上游请求上,迁过去等于新请求。

十二、扩展

视频按图片模型:任务、Worker 槽、更长 TTL,不要走同步门面。实时语音按文本模型:长连接、更短 TTFT,单独数据面,不要进图片队列。

路由在「健康 + 权重」之后,才用得上用量和延迟记录做选路。降级是 Key 冷却和明确的模型别名,不要在网关里静默换成另一个模型。

上游 prompt cache 是协议字段(如 cache_control),网关负责传上去。