多租户计费要把「实际云成本」和「客户套餐消耗」拆成两个独立字段,否则续约谈判时一个值变另一个也跟着变,导致计费逻辑被污染。
成本与可计费用量是不同的数字
将两者混为一谈是大多数按租户计费痛苦的根源。它们回答的是不同的问题,各自独立变动,应该作为独立的列。
在同一行保留两个字段。cost_usd 是你实际支付的费用;billable_units 是你将用于收费的数量,由一个带版本号的函数计算。当你和提供商重新谈判合同时,cost_usd 下降而 billable_units 不变——这正是你想要的行为,而如果你只有一个列,这就无法实现。
共享成本问题
并非每个美元都归属于某一个租户。在几乎每个多租户 LLM 产品中都会出现三种情况,每种都需要明确声明策略,而不是依赖 group-by 碰巧得到的结果:
缓存的共享前缀。如果一个长的 system prompt 被缓存并在租户之间重用,发出请求填充缓存的租户支付了全价,之后的所有人享受缓存价格。按字面意思计费意味着一个客户在补贴其他人。要么向所有人收取混合费率并吸收差异,要么按未缓存的标价计费并将缓存视为你的利润——两种都是合理的选择,默默地因为偶然做了第一种是不可接受的。
共享语料 embedding。对所有租户都可以搜索的文档集进行 embedding 是平台成本。将其标记为 tenant_id = null,feature = 'platform',完全将其排除在按租户的统计之外。
重试和失败。一个失败后重试的请求花费了你双倍成本但只交付了一个答案。cost_usd 记录两次尝试;billable_units 只记录一次。这就是为什么每条记录应该记一次尝试而不是一个逻辑调用的具体原因。
汇总表,以及为什么它不是一个视图
对账单周期直接查询原始请求日志在日志规模较小时可行,之后就会变得缓慢且不一致——同一查询相隔几分钟的两次运行会返回不同的数字,因为记录还在不断涌入。计费需要一个在账期关闭后稳定的数字。
create table llm_usage_daily (
tenant_id text not null,
day date not null,
feature text not null,
model text not null,
requests bigint not null,
input_tokens bigint not null,
output_tokens bigint not null,
cached_tokens bigint not null,
cost_usd numeric(14,6) not null,
billable_units numeric(14,4) not null,
computed_at timestamptz not null default now(),
source_max_id uuid, -- high-water mark of rows folded in
revision integer not null default 1,
primary key (tenant_id, day, feature, model)
);
-- Recompute one day, idempotently. Safe to run any number of times.
insert into llm_usage_daily as u
(tenant_id, day, feature, model, requests, input_tokens, output_tokens,
cached_tokens, cost_usd, billable_units)
select tenant_id,
started_at::date,
feature,
served_model,
count(*) filter (where attempt = 1), -- logical calls
sum(input_tokens),
sum(output_tokens),
sum(cached_input_tokens),
sum(cost_usd), -- every attempt costs
sum(billable_units) filter (where attempt = 1)
from llm_request
where environment = 'prod'
and tenant_id is not null
and started_at >= $1::date
and started_at < $1::date + 1
group by 1, 2, 3, 4
on conflict (tenant_id, day, feature, model) do update
set requests = excluded.requests,
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
cached_tokens = excluded.cached_tokens,
cost_usd = excluded.cost_usd,
billable_units = excluded.billable_units,
computed_at = now(),
revision = u.revision + 1;
三个属性使其可以安全地从 cron 任务以至少一次交付语义运行。它是有 key 的,所以重复运行会覆盖而不是翻倍。它限定在一天范围内,所以回填就是一个循环。它携带 revision,所以"这个数字在我们展示给客户之后发生了变化"是可以检测到的而不是神秘的。
晚到的记录、修正和幂等性
记录会在所属日期之后才到达。一个在 00:00:03 完成的流式响应的时间戳是其开始时间;一个异步 worker 延迟刷新缓冲区;提供商 webhook 重报用量。两条规则处理几乎所有这些情况。
对尾部窗口重新计算,而不只是昨天。每晚重新运行最后三天几乎不花什么代价,能吸收除了真正的重报之外的一切。只有在开票时才冻结一个周期。
向前修正,绝不就地修改。一旦一个周期已开票,发现的错误变成下一期的一条调整记录,带有原因码。编辑已关闭的周期会破坏发票唯一需要具备的东西:从存储数据可重现。
如果你向计量或计费系统发出用量事件,使用 request_id 作为事件的幂等 key,而不是按批次的 id。重试投递是正常的,收到相同 request_id 两次的计量系统应该只记录一次,而双方都不需要为此费心。
在午夜和月末仔细观察边界。涉及两个时钟——请求的开始时间和完成时间——对于一个长流式响应,它们可能落在不同的日期。选择一个并在所有地方使用它:开始时间是更好的选择,因为它在记录写入之前就已知的,不会改变,并且与用户描述他们何时发出请求的方式一致。无论选择哪个,在汇总表、发票和仪表板中使用相同的那个,否则三个略有不同的总数会并存,没有人能说哪个是正确的。
财务实际会问的查询
不是"我们花了多少钱"。问题是哪些客户的成本高于他们支付的费用,这是一个你的用量汇总表和订阅表的 join。
select s.tenant_id,
s.plan,
s.mrr_usd,
round(sum(u.cost_usd), 2) as model_cost,
round(s.mrr_usd - sum(u.cost_usd), 2) as gross_margin,
round(100 * (1 - sum(u.cost_usd) / nullif(s.mrr_usd, 0)), 1) as margin_pct,
round(sum(u.cost_usd) / nullif(sum(u.requests), 0), 6) as cost_per_request
from llm_usage_daily u
join subscription s using (tenant_id)
where u.day >= date_trunc('month', now())::date
group by 1, 2, 3
order by gross_margin asc
limit 25;
升序排列是整个技巧所在:第一页是亏损客户的名单,这是这个报表唯一有人会真正去看的版本。每周跑一次,并将其与分布而不是均值配对——在大多数按用量付费的产品中,少量租户占据了大部分支出,所以平均利润率可能看起来很健康,而少数账户却深度亏损。
在它旁边还有一个值得拥有的配套查询:对已停止使用产品的定费计划租户进行同样的计算。那些账户看起来像纯利润,但通常是流失预警而非成功案例,这提醒我们单独看的成本报告倾向于奖励错误的东西。在同一个视图中将利润率与参与度数字配对,对话会保持诚实。
关于分母的一个注意事项。Cost per request 是一个诱人的单位,也是一个误导的单位,因为一个 request 不是价值单位。在可以定义的地方,优先使用 cost per completed task——每个已解决的工单、每份生成的文档、每个被采纳的建议。那个数字在你改进 prompt 时会变动,而 cost per request 往往不会。
这方面通常比较棘手的是定价表:你需要按提供商维护最新的每模型费率,包括缓存输入费率,否则上述每个数字都会漂移。Multigrid 维护这个目录并在每条请求上标注定价后的成本,这至少给你第二个数字来与你自己的计算进行核对。