我为什么说 AI 网关计费最危险的是“字段语义”而不是乘法
我为什么说 AI 网关计费最危险的是“字段语义”而不是乘法
如果你只算总量,你会发现很多项目都能把 AI 网关的账单“算起来”。
真正把系统吃掉的是这三件事:
- 价格单元与模型单元没对齐;
- 用量账本与月额度判断用了不同语义;
- 上游 quota 字段的“used%/remaining%”被误读。
Grimoire Router 这篇就是把这三件事在代码里怎么“关死”写清楚。

一、先说问题:同一系统里有四种配额状态,常见踩坑都发生在它们混为一谈
在项目里,以下四层一定要分开:
- 价格卡。
PriceCard定义每个模型的分项单价。 - 账本。
UsageLedger记录单次请求实际的 token 与金额。 - 月配额。
BillingCycle做用户月度额度的“可用/剩余”。 - 运行时配额。
UpstreamAccount.Runtime*记录上游实时限额窗口。
如果你把 1 和 4 混在一起,常见错误会是:
- 用量计算正常但管理端看起来像没钱用了;
- 页面显示剩余百分比和路由决策方向相反;
- 5h/7d 窗口语义写反导致限流判断延迟。
二、价格卡与内置模型目录:把“无可用价格配置”变成启动期可恢复状态
项目里不是把模型价格只靠手工建库。
GrimoireRouter.Infrastructure/Persistence/GrimoireRouterDbInitializer.cs 的 SeedBuiltinModelCatalogAsync 会在启动时补齐缺失条目:
private static async Task SeedBuiltinModelCatalogAsync(
GrimoireRouterDbContext dbContext,
CancellationToken cancellationToken)
{
var seeds = BuiltinModelCatalogSeeds();
var priceCards = await dbContext.PriceCards.ToDictionaryAsync(entry => entry.ModelAlias, StringComparer.OrdinalIgnoreCase, cancellationToken);
var aliases = await dbContext.ModelAliases.ToDictionaryAsync(entry => entry.Alias, StringComparer.OrdinalIgnoreCase, cancellationToken);
// ... 缺省即创建,不覆盖现有条目
}
这里不是“覆写式修复”,是缺失即补。
gpt-5.5走真实 USD 单价,直接可用;gpt-5.3-codex-spark先用 0 价格作为占位;- 已有历史配置一律不改,减少升级噪音。
这一步对运营很关键,因为它把管理员日常错误“忘配模型价格”降到最小。
三、请求入账链路:先确保有 usage,再谈金额拆分
ResponsesRelayService.RelayAsync 在拿到上游返回后,最终会进入 RecordLedgerAsync。
private async Task RecordLedgerAsync(
Guid relayApiKeyId,
ApplicationUser user,
BillingCycle cycle,
ModelAlias model,
ModelAliasTarget selectedTarget,
string rawJsonPayload,
int? providerInputTokens,
int? providerCachedInputTokens,
int? providerOutputTokens,
string outputPayload,
string requestStatus,
string? traceId,
CancellationToken cancellationToken)
{
var hasProviderUsage = providerInputTokens is > 0 || providerOutputTokens is > 0;
var inputTokens = hasProviderUsage && providerInputTokens.HasValue ? providerInputTokens.Value : EstimateTokens(rawJsonPayload);
var outputTokens = hasProviderUsage && providerOutputTokens.HasValue ? providerOutputTokens.Value : EstimateTokens(outputPayload);
// ...
}
这段逻辑解决了一个现实问题:
- 某些上游返回
providerInputTokens = 0,不是“没消耗”,而是 usage 缺失; - 项目就会退到
EstimateTokens(...),继续保持账单连续性。
计费本体在 UsagePricingService:
var charge = usagePricingService.CalculateCharge(model.PriceCard!, inputTokens, cachedInputTokens, outputTokens);
var accountRateMultiplier = GetBillingRateMultiplier(selectedTarget.UpstreamAccount);
var totalAmountCny = ApplyRateMultiplier(charge.TotalAmountCny, accountRateMultiplier);
单次加账直接写入 UsageLedger:
AmountCnyInputAmountCny / CacheReadAmountCny / OutputAmountCnyUsageSource区分 provider reported 还是估算RequestStatus
并把 cycle.SpentCny += totalAmountCny 回写到当月账期,形成可追溯的财务状态。
这条链路里有两个非常硬的细节:
ShouldKeepLedgerResponseSnapshot只在失败场景存ResponseBodySnapshot,成功场景不存正文,降低隐私和日志体积。GetBillingRateMultiplier只针对账户乘系数,不改变模型基础价格卡。
四、运行时 quota 语义:把 used% 和 remaining% 辨清,并在 5h/7d 上做窗口归一化
UpstreamRuntimeSnapshotParser.Parse 是这篇里最关键的文件之一。
var primaryUsedPercent = TryParseDouble(headers, "x-codex-primary-used-percent");
var secondaryUsedPercent = TryParseDouble(headers, "x-codex-secondary-used-percent");
GrimoireRouter 的关键约束是:
- 两个
x-codex-*-used-percent都按上报原义当used%存储。 - 运行时不做反转。
DetermineCodexRateLimitResetAt里先NormalizeCodexWindows,再按used=100作为 exhausted 判定。
private static bool IsExhausted(CodexWindowLimit window)
{
return window.UsedPercent.HasValue && window.UsedPercent.Value >= 100d;
}
ResponseRelayService 会把快照落到运行时状态:
upstreamAccount.RuntimePrimaryQuotaUsedPercent = runtimeSnapshot.PrimaryUsedPercent;
upstreamAccount.RuntimePrimaryQuotaResetAtUtc = runtimeSnapshot.PrimaryResetAtUtc;
upstreamAccount.RuntimePrimaryQuotaWindowMinutes = runtimeSnapshot.PrimaryWindowMinutes;
// secondary 同步
这意味着:
- 前端展示层只负责按
remaining = 100 - used计算, - 路由层依据
RateLimitResetAtUtc做实际限流。
五、月额度与运行时限流的边界:QuotaDecisionService 只看月度账期,ResponsesRelayService 只决定 runtime
ResponsesRelayService.ResolveContextAsync 在路由前先做 QuotaDecisionService:
var cycle = await billingCycleManager.EnsureCurrentCycleAsync(...);
var quotaDecision = quotaDecisionService.Evaluate(cycle);
if (!quotaDecision.IsAllowed)
return RelayRequestContext.WithError(402, SerializeQuotaExhaustedError(...));
如果月额度拒绝,直接在入口卡掉。
UpstreamRuntimeSnapshot 不参与月度额度判断,只作为“上游当前状态”更新,避免用错指标导致“运行时耗尽误判月额度”这类问题。
六、实操模板:按这套顺序做,不会被语义打脸
- 先确认
ModelAlias绑定PriceCard,Alias与Card缺一不可。 - 在
ResolveContextAsync里保留月额度拦截,不让月度额度和上游窗口混用。 - 在
RecordLedgerAsync实现 provider usage 回退到估算。 - 计费加成统一走
UsagePricingService+ApplyRateMultiplier。 - 将上游 quota 解析集中在
UpstreamRuntimeSnapshotParser。 - 运行时回写集中在
ResponsesRelayService.Apply*RuntimeSnapshot。 - 前端列表和详情按
remaining = 100-used统一转换。
七、怎么验收(命令 + 用例 + 观察点)
命令
dotnet test tests/GrimoireRouter.Api.Tests/GrimoireRouter.Api.Tests.csproj --filter FullyQualifiedName~UsagePricingServiceTests
dotnet test tests/GrimoireRouter.Api.Tests/GrimoireRouter.Api.Tests.csproj --filter FullyQualifiedName~UpstreamRuntimeSnapshotParserTests
dotnet test tests/GrimoireRouter.Api.Tests/GrimoireRouter.Api.Tests.csproj --filter FullyQualifiedName~UsageLedgerReadModelsTests
dotnet test tests/GrimoireRouter.Api.Tests/GrimoireRouter.Api.Tests.csproj --filter FullyQualifiedName~RelayStreamingResponsesTests
用例核对
UpstreamRuntimeSnapshotParserTests里有Parse_ShouldNotInvertFiveHourWindowUsedPercent,证明 5h 不再反转。UsagePricingServiceTests证明 input/cache/output 计费拆分逻辑。UsageLedgerReadModelsTests验证读模型不带快照大字段。RelayStreamingResponsesTests可以用于做一次成功与一次失败账务落库对照。
八、结论
这篇想传达的不是“计费算法很复杂”,而是“先把语义边界固定”。
只要你把 4 层问题分清,且在代码里把每层职责落到对应方法里,计费系统就不会在真实生产里出现“数学上看似正确,但工程上长期错误”的情况。

浙公网安备 33010602011771号