我为什么做了一个 OpenAI 兼容 AI Relay 网关:Grimoire Router 的系统边界与主链路

我为什么做了一个 OpenAI 兼容 AI Relay 网关:Grimoire Router 的系统边界与主链路

我做这个项目的第一天是把它当成一个“代理壳”想的:
拿到 /v1/responses,转发到上游;拿到 api key,就能用了。
但系统上线后很快发现:
只要你要长期稳定服务多个用户、多个上游、多个模型,这套系统就会在“边界”上马上出血。

image

本文不是“我为什么做这个项目”的情绪稿,而是把 Grimoire Router 的系统边界讲清楚,尤其是它为什么不是“单纯转发器”。

一条线就能解释核心问题:请求和职责到底属于谁?

当你把能力分成“控制面”和“数据面”后,会发现很多后续问题都自动清晰了:

  • 控制面(web + Admin 页面 + API 管理接口)负责「谁能调什么」、「价格怎么配」、「什么可见」、「排障入口在哪」。
  • 数据面(Gateway + ResponsesRelayService + ImagesRelayService)负责「如何选路」、「如何容错」、「如何记账」、「如何落运行时状态」。
  • 持久层(Infrastructure)负责「能重启即恢复」的数据一致性。

这就是 Grimoire Router 的根本设计:/v1/* 只接收兼容接口,/api/* 只做平台管理,不混淆角色。

image

问题:为什么一个真正上线的 AI Relay 不止是“转发”?

把它写成简单中转,你会遇到这三件事:

  1. 业务会反复打穿同一条假设
    比如把上游状态、配额、健康判断、路由决策都放在了控制台外面,一旦其中一个没做,故障会在运行时连锁放大。

  2. 治理能力一定是第一公民
    你不是只要让单次请求成功,而是要保证“失败可诊断、重试可恢复、账务可追溯”。

  3. 系统的边界必须在代码里固定
    路由、鉴权、速率、模型映射、日志、清理都是边界约定,不能在“后续加功能”时临时拼上。

这套边界在工程上如何落地(核心目录)

我用一条链路把角色映射写出来:

User/API Client
   ↓  /v1/* (OpenAI 兼容请求)
Api.Api (RelayApiKey鉴权 + 控制器入口)
   ↓
Gateway.Responses/Images Relay Service (ResolveContext -> 候选构建 -> 选路 -> 转发)
   ↓
Transport (HTTP to upstream) + RelayTrace (attempt/trace)
   ↓
Accounting (计费入账) + Persistence(运行态快照/健康状态/路由断言)

项目里对应的是 GrimoireRouter.ApiGrimoireRouter.GatewayGrimoireRouter.AccountingGrimoireRouter.Infrastructure 四个项目组。

先把骨架拉对:Program.cs

边界最先体现在启动注册里。这里的服务注册不是配置文件,而是职责声明:

// src/GrimoireRouter.Api/Program.cs(摘录)
builder.Services.AddScoped<UsagePricingService>();
builder.Services.AddScoped<ModelCatalogService>();
builder.Services.AddScoped<HealthWeightedRouteSelector>();
builder.Services.AddScoped<RelayTraceService>();
builder.Services.AddScoped<ResponsesRelayService>();
builder.Services.AddScoped<ImagesRelayService>();

builder.Services.AddHostedService<OAuthTokenRefreshService>();
builder.Services.AddHostedService<ImageGenerationJobWorker>();
builder.Services.AddHostedService<RelayTraceRetentionWorker>();
builder.Services.AddHostedService<UsageLedgerRetentionWorker>();
builder.Services.AddHostedService<DataRetentionWorker>();

这段注册顺序把主链路和后台维护绑在了同一套职责模型里:
“请求转发”与“系统可运营性”被同一个应用生命周期管理,不再是两个仓促拼装的系统。

请求主链路到底是什么样(文本)

ResponsesRelayService.RelayAsync 的入口虽然只有一个方法名,但中间经过了几个硬性关卡:

  1. 解析 JSON 与 model 字段;
  2. ResolveContextAsync 构建用户上下文(模型、用户、API Key、配额);
  3. 构建候选上游(用户授权 + 共享/分配规则);
  4. SelectUserScopedTargetWithSparkFallbackAsync 做模型级 / 会话级路由;
  5. 发送上游并处理重试;
  6. 成功后入账(RecordLedgerAsync)+健康/指标回写;
  7. 失败后 MarkFailureAsync 分类记录并触发下一候选;
  8. 最终写 trace(便于排障)。
// src/GrimoireRouter.Gateway/Responses/ResponsesRelayService.cs(关键路径)
var context = await ResolveContextAsync(userId, relayApiKeyId, modelAlias, cancellationToken);
var candidates = BuildUserScopedCandidates(context);
var selected = await SelectUserScopedTargetWithSparkFallbackAsync(...);
var upstreamResponse = await transport.SendBufferedAsync(...);
await MarkSuccessAsync(selected, upstreamResponse.RuntimeSnapshot, cancellationToken);
await RecordLedgerAsync(...);

这个顺序不是随意写的,它决定了“什么时候拒绝、什么时候重试、什么时候入账”的可观察性边界。

图片链路为什么也能复用这套思路

ImagesRelayService.RelayAsync 也是先拿 ResolveContextAsync,在 ResolveContextAsync 里同样做:

  • model 是否可用(alias
  • API Key 是否有效
  • 模型授权(HasModelAccess
  • 月度额度判断

做了同样的入口后,文本和图片在“是否允许继续服务”上保持一致,不会出现“文本可用、图片不可用”的策略歧义。

路径层面的设计对照(/v1 与 /api)

如果用“路径”定义系统边界,建议这样理解:

  • /v1/responses/v1/images/*:兼容 OpenAI 的服务入口;
  • /api/auth/*:登录、会话;
  • /api/me/*:用户侧自助侧边功能;
  • /api/admin/*:管理员配置与观测。

这种划分能让你在审计时第一时间判断:谁可以调用、调用了什么、产生了什么副作用

一句话总结

AI Relay 做到“稳定”之前,第一件事不是加更多功能,而是先把边界定义清楚:
/v1 负责可预测转发,/api 负责可控治理。
有了这个边界,后续所有“OAuth 自愈 / 计费语义 / 运维保留”才有稳定承接点。

posted @ 2026-06-21 23:58  初久的私房菜  阅读(6)  评论(0)    收藏  举报
作者:初久的私房菜
好好学习,天天向上
返回顶部小火箭
好友榜:
如果愿意,把你的博客地址放这里
张弛:https://blog.zhangchi.fun/