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

本文不是“我为什么做这个项目”的情绪稿,而是把 Grimoire Router 的系统边界讲清楚,尤其是它为什么不是“单纯转发器”。
一条线就能解释核心问题:请求和职责到底属于谁?
当你把能力分成“控制面”和“数据面”后,会发现很多后续问题都自动清晰了:
- 控制面(
web+ Admin 页面 + API 管理接口)负责「谁能调什么」、「价格怎么配」、「什么可见」、「排障入口在哪」。 - 数据面(
Gateway+ResponsesRelayService+ImagesRelayService)负责「如何选路」、「如何容错」、「如何记账」、「如何落运行时状态」。 - 持久层(
Infrastructure)负责「能重启即恢复」的数据一致性。
这就是 Grimoire Router 的根本设计:/v1/* 只接收兼容接口,/api/* 只做平台管理,不混淆角色。

问题:为什么一个真正上线的 AI Relay 不止是“转发”?
把它写成简单中转,你会遇到这三件事:
-
业务会反复打穿同一条假设
比如把上游状态、配额、健康判断、路由决策都放在了控制台外面,一旦其中一个没做,故障会在运行时连锁放大。 -
治理能力一定是第一公民
你不是只要让单次请求成功,而是要保证“失败可诊断、重试可恢复、账务可追溯”。 -
系统的边界必须在代码里固定
路由、鉴权、速率、模型映射、日志、清理都是边界约定,不能在“后续加功能”时临时拼上。
这套边界在工程上如何落地(核心目录)
我用一条链路把角色映射写出来:
User/API Client
↓ /v1/* (OpenAI 兼容请求)
Api.Api (RelayApiKey鉴权 + 控制器入口)
↓
Gateway.Responses/Images Relay Service (ResolveContext -> 候选构建 -> 选路 -> 转发)
↓
Transport (HTTP to upstream) + RelayTrace (attempt/trace)
↓
Accounting (计费入账) + Persistence(运行态快照/健康状态/路由断言)
项目里对应的是 GrimoireRouter.Api、GrimoireRouter.Gateway、GrimoireRouter.Accounting、GrimoireRouter.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 的入口虽然只有一个方法名,但中间经过了几个硬性关卡:
- 解析 JSON 与
model字段; ResolveContextAsync构建用户上下文(模型、用户、API Key、配额);- 构建候选上游(用户授权 + 共享/分配规则);
SelectUserScopedTargetWithSparkFallbackAsync做模型级 / 会话级路由;- 发送上游并处理重试;
- 成功后入账(
RecordLedgerAsync)+健康/指标回写; - 失败后
MarkFailureAsync分类记录并触发下一候选; - 最终写 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 自愈 / 计费语义 / 运维保留”才有稳定承接点。

浙公网安备 33010602011771号