一个 AI 服务准备推出付费套餐:每月收取 20 美元平台费,包含十万 Token,超出部分每 Token 收取 0.00001 美元。客户可以升级套餐,运营可以额外赠送额度,企业客户还可以预付一笔余额。产品页面上,这些规则只占几行字。到了实现中,却会出现一连串需要分别回答的问题:赠送的额度月底是否清零;额度耗尽后是否继续放行;套餐升级从今天还是下个月生效;已经出账的费用能否跟着新价格重算。
OpenMeter 用客户、计量器、产品目录、订阅、权益、费用、账单和账务记录来承载这些规则。本文沿着这个 AI 服务展开,重点介绍各个概念为什么存在、源码怎样表达它们,以及相邻模型如何协作。
文中的 Invoice 统一称为“账单”,表示面向客户的费用结算单据,不直接等同于中国税务语境中的发票。代码中的 issuing 表示账单正式出具,是否另有税务开票流程取决于外部集成。
业务全景
核心概念
先把本文中反复出现的名词放在同一张表里。后文会逐一展开它们之间的关系。
| 概念 | 回答的问题 | AI 服务中的例子 |
|---|---|---|
| 客户(Customer) | 用量和商业关系归属于谁 | Acme 公司 |
| 计量器(Meter) | 从事件中统计什么数值 | 汇总模型调用的 Token 数 |
| 功能(Feature) | 向客户提供哪项能力 | 文本生成、审计日志 |
| 费率卡(RateCard) | 一项服务采用什么权益与收费规则 | Token 额度模板、Token 阶梯价格 |
| 套餐(Plan) | 多项服务怎样组合出售 | Pro 套餐及其试用、正式阶段 |
| 订阅(Subscription) | 某位客户在各个时间段采用哪些规则 | Acme 从十月开始订阅 Pro |
| 权益(Entitlement) | 客户在某时刻能否使用某项功能 | 文本生成功能可用,剩余额度 98,800 Token |
| 额度授予(Grant) | 某份功能额度的数量、有效期与消耗规则 | 本月十万 Token、额外赠送两万 Token |
| 费用(Charge) | 一项收费安排如何计算并完成结算 | 十月份 Token 费用及其计算记录 |
| 账单(Invoice) | 哪些费用集中向客户结算 | 平台费账单、月末用量账单 |
| 账务记录(Ledger) | 价值的来源、消耗和支付怎样留痕 | 预付余额减少、应收款产生、款项到账 |
表中的 Grant 指功能额度领域的授予。账务侧也会出现 credit、grant 等词,代表的可能是预付价值。阅读源码时,需要结合所属领域判断其单位和用途。
模型关系
这些对象之间既有定义与实例的关系,也有计算与结算的关系。下面的 ASCII 图展示业务上的主要联系,不表示每条连线都是数据库外键。
1 | Product catalog |
产品目录描述可出售的服务,订阅保存客户具体采用的服务安排。订阅中的相关条目会派生权益,计费同步则生成费用或账单行。费用生命周期还会协调额度抵扣、账单和账务处理。
图中保留了“订阅直接生成账单行”和“先生成 Charge”两条分支。当前实现同时存在兼容路径与基于 Charge 的路径,已有计费对象继续由其原有后端管理;不能把所有订阅都理解为必然经过同一条收费流水线。
客户与用量
客户身份
这里的工作区(Workspace)是示例 AI 服务中组织成员、项目和资源的业务空间。例如,Acme 可以为研发团队和客服团队各建一个工作区,分别管理成员、API 密钥和模型调用记录,再由公司统一付费。工作区是业务应用自己的概念,在 OpenMeter 中可以用事件的 subject 表示其用量归属。
一家公司可能拥有多个工作区,而模型网关上报事件时只携带本次调用所属的工作区标识。如果把每个工作区都直接当作一个付费客户,公司统一结算时就会遇到困难。
OpenMeter 将客户(Customer)与事件归属键(subject)分开。客户保存商业身份和联系信息,例如名称、账单地址、币种;事件的 subject 则表示这次用量在业务系统中的归属。
源码中的 CustomerUsageAttribution 将两者连接起来。它的 GetValues() 返回可以归入该客户的所有键:
1 | func (ua CustomerUsageAttribution) GetValues() []string { |
这里有三个不同的身份:
Customer.ID是 OpenMeter 内部的客户标识,用于业务对象之间的关联。Customer.Key是客户的外部业务键,同时参与用量归属。SubjectKeys是该客户额外关联的一组使用归属键。
例如,Acme 的客户键是 acme,另有两个归属键 workspace-a 和 workspace-b。按该客户统计用量时,这三个键都参与归属匹配。内部客户 ID 用于查找客户,并不会自动成为事件匹配键。
这允许模型网关继续按工作区上报,而订阅和账单围绕公司管理。客户也无需与应用中的某个登录用户一一对应:个人、团队、公司都可以成为客户,取决于业务把商业关系建在哪个层级。
命名空间
除了区分客户,OpenMeter 还需要区分不同组的数据。命名空间(namespace)就是这组数据的标识:客户、计量器、产品和权益等对象都记录自己所属的 namespace,查询时也会指定这个范围。
以本文的 AI 服务为例,服务提供方可以将自己的套餐和客户放在同一个 namespace 中。Acme 和另一家公司分别是其中的两个 Customer,Acme 的两个工作区则通过 subject 关联到 Acme:
1 | Namespace: ai-service |
这个例子里,namespace 表示 AI 服务这一组数据,Customer 表示付费公司,subject 表示使用记录的归属。为 Acme 创建客户、关联工作区,就可以统计它的用量并管理订阅,无需为每位客户再建一套命名空间。图中的工作区来自业务应用,subject 连线表示用量归属关系。
namespace 也不负责验证调用者身份。当前开源 API server 使用配置中的默认 namespace 处理请求;如果部署需要让不同调用方访问不同 namespace,还需要在接入层完成身份校验,并确定每次请求允许使用哪个 namespace。
使用事实
一次模型调用产生的 Token 数量是已经发生的事实。例如:
1 | { |
计量器负责解释这些事实。一个 SUM 计量器可以选择 llm.completion 事件,提取 data.tokens,并保留按模型分组的能力。查询时,客户归属决定统计哪些 subject,计量器决定怎样取值和聚合。
这条事件本身没有声明套餐价格、免费额度或付款状态。同样的 1,200 Token,可以用于用量展示、权益计算和费用计算,各自采用相应规则。
产品目录
功能定义
假设 AI 服务的 Pro 套餐包含文本生成和审计日志两项功能:
- 文本生成:需要统计客户消耗了多少 Token,才能计算剩余额度和费用。
- 审计日志:产品规定 Pro 客户可以使用,免费客户不能使用。这个规则只涉及功能是否开放,无需统计日志条数。
计量器能统计 Token 数,却不负责定义套餐提供哪些功能。OpenMeter 因此用功能(Feature)统一表示“文本生成”“审计日志”这样的产品能力:文本生成功能可以关联 Token 计量器,审计日志功能则可以不关联计量器。至于某位客户是否拥有这项功能,由后文的权益(Entitlement)表达。
下面节选 Feature 中的两个计量相关字段,省略了字段注释:
1 | MeterID *string `json:"meterID,omitempty"` |
例如,llm_tokens 计量器统计所有模型的 Token,premium_generation 功能可以在此基础上限定模型维度,只关注高阶模型。过滤字段必须来自计量器声明的分组维度,不能任意引用事件 JSON 中的字段。
计量器由此可以服务于多个业务定义;Feature 则为权益和收费提供产品侧的标识。模型中还有可选的单位成本配置(UnitCost),用于表达成本信息。成本与向客户出售服务的价格分别建模,两者并非必然相同。
费率卡
“文本生成”只确定了服务的名称,还没有说明每月授予多少额度、采用什么价格、何时收费。这些商业条款放在费率卡(RateCard)中。
RateCardMeta 的相关字段如下,节选中省略了展示、税务和其他元数据:
1 | Feature *FeatureReference `json:"-"` |
费率卡把功能引用、权益模板与价格放在一起,但它们各自仍有独立含义:
- 功能引用确定这份条款对应哪项服务。
- 权益模板规定订阅实例化时,客户应得到什么权益,例如每月十万 Token、额度耗尽后仍可访问。
- 价格规定计费用量怎样换算为费用,例如首十万 Token 免费,超出部分采用单价。
固定平台费也可以是一张费率卡,并不需要为了收费而人为创建一份 Token 权益。反过来,没有价格的服务条目也可以提供权益。
代码区分 flat_fee 和 usage_based 两类费率卡。前者适合固定收费,后者具有用量相关的收费安排。费率卡还有自己的计费周期,因此它承载的内容比“一个价格字段”更完整。
套餐与阶段
客户购买的通常是一组服务。套餐(Plan)将费率卡组合起来,并提供默认币种、计费周期和结算模式。套餐内部还有阶段(Phase),用于表达随时间变化的服务组合。
源码中的层级很直接:
1 | type Plan struct { |
1 | type Phase struct { |
例如,一个套餐可以先提供七天试用,再进入正式阶段。试用阶段和正式阶段分别拥有费率卡,因此价格和权益模板可以不同。套餐校验要求非末尾阶段具有时长,末尾阶段不设置固定时长;客户实际何时结束服务,由订阅安排控制。
套餐还具有 key 和 version。同一个 Pro 套餐可以发布新版本,而既有客户的订阅保留已经建立的条款。发布新版本不会自动把所有客户改到新价格,需要通过订阅的迁移或变更流程明确处理。
附加项(Addon)用于表达套餐之外的可选服务,例如额外的功能包。它会影响订阅的目标内容,并保留自己的应用关系与数量历史,便于后续调整或移除。带有附加项的订阅需要使用对应工作流,通用运行中编辑并不能代替这套处理。
订阅时间表
客户条款
套餐是可重复销售的定义,客户合同却各有起始日期、取消安排和定制内容。只保存一个 customer.plan_id,无法回答“这个客户在上周三实际采用哪套规则”。
订阅(Subscription)保存客户具体的商业时间表。它可以从套餐创建,也允许没有目录套餐引用的定制安排。客户、开始与结束时间、账单币种、计费周期和结算模式,都是订阅自己的事实。
完整目标内容由 SubscriptionSpec 表达:
1 | type SubscriptionSpec struct { |
其中的阶段按相对订阅起点的偏移排列。一个阶段的结束由下一个阶段或订阅结束决定;阶段内的服务条目则保存费率卡和相对时间安排。
订阅读取时还可以展开为 SubscriptionView,包含客户、阶段、条目、功能和权益等信息。Spec 用于描述目标内容,View 则把它与已保存的实体关联起来,供编辑和下游计费处理使用。
两类阶段
套餐和订阅都使用“阶段(Phase)”这个词,表达的是同一套服务安排在两个层面的模型:套餐阶段定义可供客户采用的规则,订阅阶段保存某位客户实际采用的安排。
| 对比 | 套餐阶段(Plan Phase) | 订阅阶段(Subscription Phase) |
|---|---|---|
| 所属对象 | 套餐(Plan) | 某位客户的订阅(Subscription) |
| 表达内容 | 例如先试用七天,再进入正式阶段 | 例如 Acme 从十月一日开始试用,十月八日起进入正式阶段 |
| 服务规则 | 通过费率卡(RateCard)定义该阶段的价格和权益模板 | 通过服务条目(Subscription Item)保存客户采用的费率卡、时间安排及条目版本 |
| 时间表示 | 非末尾阶段配置时长(Duration) | 阶段规格保存相对订阅起点的偏移(StartAfter),持久化阶段记录实际开始时间(ActiveFrom) |
例如,Pro 套餐定义“试用七天,随后正式收费”。Acme 在十月一日零点开通,另一位客户在十月十日零点开通。从同一个套餐创建订阅后,两位客户的阶段时间不同:
1 | Plan: Pro |
创建订阅时,工作流依据套餐阶段生成订阅阶段,并把费率卡转换为相应服务条目的规格。此后,订阅保存自己的客户条款和时间表。发布新套餐版本不会自动改写这些订阅阶段,客户的变更需要经过订阅工作流处理。
条目历史
一份订阅可以同时包含平台费和 Token 服务。它们在订阅中分别对应一个服务条目(Subscription Item),每个条目保存自己的费率卡,也就是这项服务采用的价格和权益规则。条目键(Item Key)用于在所属阶段内识别这项服务,例如用 platform_fee 表示平台费,用 tokens 表示 Token 服务。
同一项服务的条款也可能在阶段中途变化。为单独说明这种变化,假设某位客户的 Token 服务没有免费区间:十月一日开始时,每 Token 收费 0.00001 美元;到了十月十五日,双方约定从当天零点起改为 0.00002 美元,平台费保持原样。如果直接把 Token 条目的单价覆盖成新价格,再计算十月上半月的费用时,就无法从这份条目得知当时约定的旧价格。OpenMeter 因此在同一个条目键下保留多个版本,每个版本都有自己的适用时间。
SubscriptionPhaseSpec 用下面的字段保存它们:
1 | ItemsByKey map[string][]*SubscriptionItemSpec `json:"itemsByKey"` |
这层结构可以拆成两部分理解:
map的键表示哪项服务,例如tokens。- 键对应的切片保存这项服务的不同时间版本,每个元素是一份
SubscriptionItemSpec,其中包含该版本的费率卡和时间安排。
在上面的调价例子中,ItemsByKey["tokens"] 有两个元素,分别对应旧条款和新条款。下面用示意结构展示它们,省略实际字段:
1 | Phase: regular |
切片中有两个元素,表示 Token 服务先后采用两套条款,不是客户购买了两份 Token 服务。平台费没有变化,仍然只有原来的版本。
两个 Token 版本在时间上的关系如下:
1 | Subscription: Oct 01 ------------------------------------> |
[Oct 01, Oct 15) 表示包含十月一日零点、不包含十月十五日零点。因此,十月十四日发生的消费使用旧价格,十月十五日零点及之后的消费使用新价格。这个例子按消费发生时间选择条款,而不是按事件上报时间选择。
图中的 v[0]、v[1] 就是切片中的版本位置。它们只描述这项服务的条款历史,与前文的套餐版本不同:套餐版本表示可销售方案的修订,条目版本表示某位客户的某项服务在不同时间采用的规则。
这些版本位置还会参与后续计费同步的身份匹配,因此不能随意删去历史元素或调整顺序。另一方面,保存订阅变更时,某些数据库记录会被归档后重新创建,记录 ID 可能改变。要关联业务上的同一个条目版本,需要结合订阅、阶段键、条目键和版本位置识别,不能只依赖数据库记录 ID。
实际编辑由订阅工作流构造新的目标 Spec,再由订阅服务保存阶段、条目及相关权益。上面的结构用于解释保存后的业务含义,并不意味着调用方可以绕过工作流直接修改版本数组。
周期与生效时间
月付订阅需要同时表达“多久一个周期”和“周期从哪里对齐”。这分别由 BillingCadence 和 BillingAnchor 承担。P1M 表示日历月周期,与固定三十天并不等价。
例如,周期为一个月,锚点在每月一日,通常会形成十月一日至十一月一日这样的区间。若订阅或阶段提前结束,具体服务区间还要被相应边界截断。
修改请求另有 Timing,表示命令何时生效。源码定义了两个枚举值:
1 | const ( |
next_billing_cycle 根据当前时间所在的对齐计费周期,解析到该周期结束。不同操作允许的时间不同:运行中编辑不接受任意自定义时刻;对齐订阅的自定义取消时间必须落在计费边界。
订阅状态同样与时间有关。一个有未来结束时间、当前仍提供服务的订阅,可以返回 canceled,表示取消已经安排;到结束时间才变成 inactive。因此,看到 canceled 不能直接断言客户此刻已经失去权益。
取消会缩短订阅及其派生对象的有效期,继续订阅则移除可撤销的结束安排。删除还有自己的可见性与清理规则,不能与取消混用。
功能权益
权益类型
客户订阅了 Pro,还需要回答具体的访问问题:是否拥有审计日志、采用什么配置、文本生成还剩多少额度。权益(Entitlement)就是客户与某项功能在一段时间内的授权关系。
OpenMeter 提供三类权益:
| 类型 | 业务含义 | 例子 |
|---|---|---|
boolean |
有效期间允许使用功能 | 开启审计日志 |
static |
允许使用,并携带一份固定 JSON 配置 | 模型白名单、服务配置 |
metered |
根据计量结果与额度计算访问状态 | 每月十万 Token |
static 中的配置由业务应用解释。例如,JSON 可以声明某种服务档位,但 OpenMeter 不会因此自动替应用限制并发数。
权益可以直接创建,也可以由订阅中的权益模板生成。对于订阅管理的权益,订阅服务在保存客户时间表时同步安排其有效期;后续账单同步属于另一个过程。
同一客户、同一功能在同一时刻最多有一份有效权益。权益开始时间包含在有效期内,结束时间不包含。覆盖或删除通过结束、替换历史定义表达,而不是把过去的权益含义直接改掉。
访问判断
计量权益的值包含余额(Balance)、周期内用量(UsageInPeriod)、超额用量(Overage)和各份授予的余额。它还根据软限制配置返回 HasAccess:
1 | func (m *MeteredEntitlementValue) HasAccess() bool { |
这段方法在权益有效性检查之后执行。它表达了两种访问政策:
- 硬限制:余额大于零时允许访问,耗尽后不允许。
- 软限制:额度耗尽后仍允许访问,用量和超额部分继续计算。
开头的 AI 套餐允许超额付费,因此示例采用软限制。若采用硬限制,产品希望的是到量停用,就不能再假设客户会按正常流程持续产生超额消费。
不过,查询一次 HasAccess 并没有预留下一次模型调用所需的 Token。事件异步进入计量系统,多个并发请求也可能同时看到正余额。业务若要求严格控制单次调用和并发超额,还需要在调用入口设计相应的额度预留或流量控制;这段读取逻辑本身不提供这类保证。
额度与授予
授予规则
“本月可用十万 Token”只涉及一份额度。实际产品还会出现活动赠送、补偿、额外购买等多份额度,它们的有效期和消耗顺序可能不同。
额度授予(Grant)将每份额度单独记录。它包含数量、所属权益、生效时间、过期时间、优先级和周期补充配置。额度计算由 Credit 领域负责;这里的 credit 计量单位随功能而定,可以是 Token、请求次数或换算后的资源单位。
例如,Acme 同时拥有两份授予:
| 授予 | 数量 | 优先级 | 有效期 |
|---|---|---|---|
| 临时额度 | 100,000 Token | 2 | 本月 |
| 活动赠送 | 20,000 Token | 1 | 本周 |
消耗时先使用优先级数值较小的授予。若优先级相同,再比较过期时间,较早过期的先用;仍相同时按创建时间排序,最后用 ID 保持确定顺序。排序器中的第一项比较如下:
1 | if priorityOrder := cmp.Compare(i.Priority, j.Priority); priorityOrder != 0 { |
因此,示例中的活动赠送先被消耗,即使临时额度更早创建。排序测试还覆盖了无过期时间、相同过期时间和创建时间相同时的行为。
Grant 保存的是授予定义,余额是结合用量计算出来的结果。作废一份授予会结束它的有效期,但历史查询仍需知道它在过去曾经有效。
余额计算
最简单的情况下,一份十万 Token 授予减去 1,200 Token 用量,剩余 98,800。随着生效、到期、周期补充和重置加入,这个计算就需要沿时间推进。
Credit 引擎把授予状态变化、周期补充和使用周期重置等边界纳入计算。在各段内按确定顺序消耗有效授予,无法被覆盖的用量记为 Overage,并保留消耗历史。
这也解释了为什么不能总用“累计授予减去累计用量”求余额。假设一份赠送额度周五才生效,周一的消费不能因为周五到账就被当作当时已有额度;而一份周二过期的额度,也不能用于覆盖周三的新消费。
功能额度不足和货币欠款是不同的结果。 十万 Token 用尽后,Credit 可以得到两万 Token 的超额用量,但它不知道这两万 Token 是免费、收取 0.20 美元,还是按另一套阶梯价格计费。金额由价格与费用模型决定。
重置与补充
月度套餐通常有用量周期,但“新周期开始”并不只有一种处理方式。OpenMeter 区分授予自身的周期补充和权益使用周期的重置:
- 授予补充(Recurrence):在授予自己的周期点,将这份授予余额恢复到它定义的数量。
- 使用重置(Reset):结束当前使用周期,对仍有效的授予应用结转上下限,再开始新的周期。
结转余额的核心公式是:
1 | func (g Grant) RolloverBalance(endingBalance float64) float64 { |
例如,周期结束时还剩 30,000 Token,最低结转为零、最高结转为 20,000,则留下 20,000。若最低与最高都设置为某个固定数量,重置也能将余额恢复到该数量。
权益的 IssueAfterReset 就利用了这个规则。创建默认授予时,代码将 ResetMinRollover 和 ResetMaxRollover 都设置为默认数量,并且不设置授予过期时间。因而,“每月恢复十万 Token”可以通过同一份授予在使用周期重置时恢复余额实现,不必每个月再插入一份新授予。
PreserveOverageAtReset 决定是否保留超额用量。保留时,先计算结转余额,再让旧周期的超额消耗新周期可用额度。重置涉及余额和使用周期时间线,需要一起完成,不能只改其中一份状态。
计算快照
历史越长,从权益起点重新计算的成本越高。Credit 因此保存余额快照(Balance Snapshot),作为某个时刻的完整计算检查点。后续读取可以从最近的可用快照继续,而不必每次重放全部历史。
快照保存的不只有一个剩余额度数字,还包括各授予余额、用量周期状态和继续计算所需的信息。当前实现的快照是独立检查点,不要求保留完整的前序快照链。
快照也带来正确性约束:如果新增或作废授予影响了过去的计算,相关快照及其后的快照就需要失效。源码在这些输入变更路径上安排失效处理;保存快照时还使用可配置宽限窗口,让近期用量保留重新计算的空间。这个窗口不能被理解成对任意久远的补报都自动兜底。
此外,Credit 的时间分辨率是一分钟。非整分钟的余额读取会向上取整,以包含当前不完整分钟的用量;授予和重置等时间也按分钟处理。这里不能依赖同一分钟内几秒钟的先后顺序表达精细的访问变化。
数量与价格
计费单位
事件中的原始单位未必就是售卖单位。模型网关可以上报 Token,但产品以千 Token 报价;存储系统上报字节,而产品按 GB 出售。
UnitConfig 描述费率卡上的数量换算,支持乘法、除法和舍入。它的 Apply 方法分别返回精确换算数量与账单数量,下面节选实际换算部分:
1 | switch c.Operation { |
例如,除以 1,000 后,1,200 Token 变成 1.2 个千 Token 单位。若账单配置为整数向上取整,账单数量可以是 2;权益计算仍使用精确的 1.2。
因此,配置单位换算后,授予也必须使用换算后的单位。若额度是十万 Token,按千 Token 计算时就应表达为 100 个单位,不能继续填 100,000。单位换算会随费率卡保存到相应权益,避免计费与额度计算各自使用一套换算规则。
源码测试用字节转 GB 验证了这一区别:100 GB 授予消耗 99.3 GB 后,余额仍是 0.7 GB。计费舍入不会提前把权益余额扣到零。本文主示例保持原始 Token 单位,不启用额外换算。
价格类型
价格(Price)负责把计费数量变成金额。当前源码包含五类:
| 类型 | 含义 | 典型场景 |
|---|---|---|
flat |
固定金额 | 每月平台费 |
unit |
数量乘以单价 | 每 Token 固定单价 |
tiered |
根据数量区间计算 | 免费区间、用量阶梯 |
package |
按固定大小的资源包收费,不足一包按一包计算 | 每千次请求一包 |
dynamic |
将输入量按配置倍数换算为金额 | 已计算成本的加价收费 |
价格中的金额使用十进制数值类型。计量和权益代码中的某些数量仍使用 float64,不能由金额类型推断整个系统都采用同一种数值表示。
阶梯价格还分为两种模式:
- 分段阶梯(graduated):每段数量采用该段单价,最后求和。
- 整量阶梯(volume):总数量决定命中的档位,全部数量采用该档位规则。
假设前一千单位单价 1 美元,超过一千的档位单价 0.8 美元,总量为 1,500,且各档没有额外固定费:
1 | graduated: 1,000 x $1 + 500 x $0.8 = $1,400 |
两者差异会影响后续累计计费与价格修正。不能只看到“阶梯价”就按一种公式实现。
免费用量
开头的“包含十万 Token”需要同时落到访问规则与收费规则上。
权益模板可以创建十万 Token 的默认授予,供余额与访问判断使用。价格则另行配置为分段阶梯:前十万 Token 单价为零,之后每 Token 0.00001 美元。若总用量是十二万 Token:
1 | Entitlement: |
创建十万 Token 的 Grant 不会自动给价格增加免费区间。 同理,运营额外赠送两万 Token 的权益额度,也不会自动调整阶梯边界或货币账务余额。如果活动承诺的是“这两万 Token 也不收费”,还必须在收费安排中表达对应优惠。
这三个常见需求需要分别识别:
- 增加可用次数或资源额度,属于权益与授予。
- 减少用于定价的数量,属于用量优惠,例如
Discounts.Usage。 - 用预付价值抵扣费用,属于费用结算与账务。
它们可以在同一个产品中配合使用,但不能因为都叫“赠送额度”就只修改一处状态。
优惠与分摊
实际计算还可能有百分比优惠、最低消费和最高金额。当前通用定价实现将数量换算、数量舍入、用量优惠放在价格计算之前,再对价格结果应用相关金额规则。
金额上限限制的是这项服务的收费结果,不会自动阻止继续使用;最低消费也不表示客户已经消耗了相应资源。把这些金额规则当作访问额度,会让产品行为偏离配置含义。
服务在周期中途开始或结束时,还可能需要按比例分摊(Proration)。模型保留实际服务期间(ServicePeriod)和完整服务期间(FullServicePeriod),为分摊保留参照。是否启用以及订阅结束如何计费,由相应配置控制。
例如,一个完整三十天服务周期的固定费用为 20 美元,实际只提供十五天服务。在启用适用的价格分摊规则时,可以得到 10 美元。该例不表示每次取消都默认退款,也不表示系统会把功能授予自动减半;费用分摊、退款与权益额度各有自己的规则。
计费同步
目标与现状
客户时间表保存成功后,需要为相应服务生成收费对象。消息可能重复,后台也可能在处理中失败,因此“每收到一次订阅事件就新增一笔费用”会重复收费。
订阅计费同步(Subscription sync)采用目标与现状比较的方式。它读取已经提交的订阅视图,在给定时间范围内展开应有的计费项目,再与已有账单行和 Charge 对比,生成创建、更新或删除计划。
其主要步骤是:
- 读取该订阅已有的计费对象。
- 按阶段、条目版本和周期生成目标服务区间。
- 通过稳定业务标识匹配目标与现有对象。
- 在同一计费事务中应用差异,并更新下次同步时间。
重复执行同一份目标,应收敛到同一结果。订阅事件提供及时触发,周期协调任务修复遗漏或失败;某些入口还会在同步后收集可出账项目,使预先收费的固定费用能够及时生成账单。
同步有自己的生成范围。它通常扩展到传入时刻所在的对齐计费周期末尾,并不会在订阅创建当天一次性生成无限未来的全部费用。
稳定身份
周期计费项目的标识由订阅路径和周期组成,源码形式如下:
1 | {subscriptionID}/{phaseKey}/{itemKey}/v[{itemVersion}]/period[{periodIndex}] |
它把前面介绍的条目版本与某个收费周期关联起来。即使持久化子实体被重建,也可以继续识别这份服务安排;是否复用或替换现有 Charge,还要比较相关计费条款及已发生的结算历史。
当前同步既支持生成兼容账单行,也支持生成 Charge。已存在对象的持久化归属决定后续由哪套后端处理,新对象则根据可用能力、功能开关、价格和结算模式路由。
无论采用哪条路径,已经不可变的账单历史都不会因为订阅改了,就被悄悄覆盖。同步会报告相关差异,后续修正需要遵循收费和账单各自的规则。
费用模型
收费安排与执行记录
一项服务可能经历多次用量计算、额度抵扣和支付处理。如果只保留账单行,很难完整表达“原来约定收什么”和“目前已经执行到哪里”。
费用(Charge)保存一项收费安排,以及履行它时产生的状态和结果。当前有三类:固定费用(flat fee)、用量费用(usage based)和预付额度购买(credit purchase)。第三类属于账务价值的授予与结算,不是前面功能额度的 Grant。
以用量费用为例,模型分为:
- 收费意图(Intent):客户、价格、币种、服务期间、结算方式和来源关系。
- 执行状态(State / Status):下次处理时间、当前生命周期阶段及其他持久化状态。
- 执行记录(Realizations):已经完成的计算批次、抵扣、修正、支付及账务引用。
源码中用量费用的聚合类型如下:
1 | type Charge struct { |
Charge 的状态由对应类型的状态机控制,计算和持久化子服务执行具体工作。账单行引擎(Line Engine)将 Charge 的结果映射为账单行,再把账单出具、支付授权和结算等进展反馈给费用生命周期。
这样,账单负责单据与流程,费用负责一项收费安排的执行。两者需要协作,但并不承担彼此的全部职责。
结算方式
固定费用和用量费用支持两种结算模式:
1 | const ( |
credit_then_invoice 先消耗符合条件的正向账务余额,剩余部分进入账单结算;credit_only 通过额度账务实现消耗,当前余额投影可以体现负向敞口。因此,credit_only 也不能直接翻译成“余额不足时所有请求一定被拒绝”。访问控制仍需按权益和应用调用策略判断。
这里抵扣的是账务价值。例如,一项 Token 费用为 3 美元,客户有 2 美元符合使用条件的预付余额,采用 credit_then_invoice 时,可先覆盖 2 美元,余下 1 美元进入账单。它与“还剩两万 Token”属于不同单位的模型。
预付额度购买另有促销赠送(promotional)、外部结算(external)和账单结算(invoice)三种安排。对于需要付款的购买,额度发放、支付授权、支付结算分别保存;当前流程可以先发放额度,再进入待付款阶段,因此“余额已经增加”不足以证明资金已经到账。
模型还支持受管理的自定义币种。相关费用会保存自己的币种身份和适用的价值换算依据;标准账单使用法定货币。同一个显示名称不能替代完整币种身份,功能 Token 也不会只因名称相似就自动变成账务币种。
累计计算
一个用量费用可能分多次结算。如果每次都把累计总量完整收费,会产生重复;若只按新到事件计算,又可能漏掉晚到事件和阶梯价格变化。
用量费用保存多次执行批次(Realization Run)。每次记录事件时间上界、落库时间截点、累计计量结果、金额明细及相关引用。当前首选的 Delta 定价引擎使用这样的计算方式:
1 | 本次应记费用明细 |
例如,单价为每单位 10 美元,上次累计五单位、已记 50 美元,本次累计八单位、应记总额 80 美元,那么新增费用是 30 美元。
源码通过下面的调用计算明细差额:
1 | remainingDetailedLines, err := subtract.SubtractRatedRunDetails( |
相减前,代码先从已计费明细中排除账务抵扣。预付余额分配的变化属于结算变化,不应被误认为用量或价格变化。相减的对象也是价格明细,不是只减一个总数量:整量阶梯换档可能让全部数量采用新的单价,需要同时表达旧价格部分的抵消和新价格部分的计算。
记录两种时间边界也有实际作用:服务期间限定消费发生在哪段时间,StoredAtLT 限定本轮可见的落库事件集合。一条补报用量可以在后续累计计算中被纳入,不能只看它是哪个时间段发生的,就认为之前必然已经计费。
当前 Delta 引擎把差额放在本轮执行的服务期间。仓库另有保留原服务期间的修正引擎,但文档明确指出其账单修正支持尚不完整,当前首选引擎仍是 Delta。因而不能笼统声称晚到用量都会被自动记回原账单期间,也不能据此承诺任意已完成费用都会自动重新开启结算。
账单生命周期
待出账项目
固定月费可以在周期开始时收费,用量费用通常需要等到服务结束后确定。它们都需要一个位置,保存“已经安排、尚未进入正式结算”的项目。
待出账账单(Gathering Invoice)承担这项工作。同一客户、同一法定货币最多有一张活动的待出账账单,项目可以持续加入和调整;没有剩余项目时,这张暂存对象会被删除。
达到条件的项目被收集到标准账单(Standard Invoice)。转换时保留账单行身份,但暂存表示会被标准账单行替代。标准账单随后承担数量快照、金额计算、外部同步和支付流程。
这里的 collection 指收集待出账项目、完成相关数量处理,不能看到这个词就理解成支付机构已经收款。
不同时间字段也要分开:
| 时间 | 含义 |
|---|---|
| 服务期间(ServicePeriod) | 这项服务实际对应的时间范围 |
| 可出账时间(InvoiceAt) | 待出账项目何时具备进入账单的时间条件 |
| 待出账收集时间 | 协调任务何时重新检查这些项目 |
| 标准账单收集截止时间 | 本轮数量快照和收集采用的处理截止点 |
用量账单可配置服务结束后的收集窗口,给迟到记录保留一些时间。窗口不是无限等待;最终账单内容取决于该轮实际采用的边界。
计费配置
生成账单还需要知道由谁提供服务、由哪个集成计算税务、如何正式出具单据、通过什么渠道收款。这些由计费配置(Billing Profile)和客户覆盖配置共同决定。
Billing Profile 选择税务、账单与支付应用,并配置自动推进、草稿等待、付款期限等规则。客户可以选择特定配置,或覆盖支持的工作流选项。
标准账单创建时会保存当时适用的客户、供应方、工作流配置和应用引用。以后客户改名、修改地址或切换支付集成,既有账单不应追随当前配置重新解释自己的历史。
账单行下面还有计算明细。例如,Token 阶梯价可以展开为免费段和付费段。收费条目与计算明细分别服务于“这张单据收哪项费用”和“这个金额怎样得到”,明细不再反过来成为订阅条款的来源。
状态推进
一张标准账单需要依次完成本地计算、外部同步和支付,单个数据库事务无法覆盖所有外部操作。OpenMeter 用状态机保存每一步的进展,并设置可重试的失败状态。
下面是主要阶段的简化图,省略了校验、审批、删除和失败分支:
1 | Draft |
源码中的具体状态比这张图更细,例如:
1 | StandardInvoiceStatusIssuingLineFinalization StandardInvoiceStatus = "issuing.line_finalization" |
这些中间状态使重试可以针对失败步骤继续,而不必从头重复已经稳定完成的处理。issued 表示账单已经出具,paid 才表示付款流程到达已付款状态;费用算完、账单发出与款项到账分别有自己的完成点。
业务校验也区分警告和关键问题。警告可以伴随可用结果继续推进,关键问题会阻止相应步骤,依赖超时等系统故障则走操作失败处理。业务上能够解释的“数据暂缺”,不一定应该被抹平成一个通用内部错误。
账单通知是另一套异步流程。账单状态产生事件,通知规则匹配后保存历史内容,再安排 webhook 投递。通知未送达并不自动意味着账单生成失败,收到通知也不能代替查询账单实际支付状态。
账务模型
价值记录
客户充值五十美元、使用服务、余额到期、收到退款或完成支付,都需要说明价值从哪里来、到哪里去。只更新一个 balance 字段,无法充分保留这些关系,也很难纠正已经发生的操作。
Ledger 保存不可变的交易组、交易和分录。每笔交易中的有符号金额必须按币种身份分别平衡。校验器先按币种归集,再检查总和是否为零;正负分录共同表达一次价值变化,而不是凭空增减余额。
常见账户用途包括:
| 账户类型 | 业务含义 |
|---|---|
customer_fbo |
客户持有的预付价值;名称不代表真实银行托管账户 |
customer_receivable |
客户应付的价值及其授权阶段 |
customer_accrued |
已确认消耗、尚未确认为收入的价值 |
earnings |
已确认的业务收入 |
wash |
与外部支付或现金边界相关的对方科目 |
breakage |
到期或作废的未用额度 |
平衡只是基本条件。账户之间允许怎样流转、是否保留币种和来源信息,还要通过额外规则校验。实际记账地址是子账户,币种、功能或套餐限制、优先级、成本和税务维度会参与其身份。
因此,同样显示为五美元的两份余额也未必能合并:一份可能只能抵扣某项功能,另一份没有限制;它们还可能来自不同购买。结算时需要根据用途筛选合格来源。
以消耗四单位预付价值为例,可以用下面的简化分录说明其含义。图中省略来源、税务和其他路由维度:
1 | customer_fbo -4 |
客户可用价值减少四单位,同时进入已消耗、待确认收入的科目,合计仍为零。后续何时确认收入、怎样处理支付或到期,由相应生命周期决定,不能在余额减少时就把所有步骤视为已经完成。
余额与修正
账务余额是对分录的投影。客户侧余额视图还可能结合尚未入账的费用影响,因此“页面余额”“已入账余额”和“某项功能剩余额度”要分别理解。
例如,功能权益还有 98,800 Token,并不意味着账务账户中有 98,800 个美元或可通用的点数。相反,客户账务余额充足,也不必然意味着某项 boolean 权益已开启。
费用生命周期决定什么时候产生经济效果,Ledger 的适配层负责把效果转成合法分录。CommitGroup 会在事务中锁定受影响账户并原子保存交易组,但它本身不会把同样的重复调用自动去重。调用领域仍需保存执行结果引用,使消息重试与生命周期重入安全。
发生纠正时,系统依据原分录生成抵消和替代记录,保留来源及修正关系。已经发生的历史因此仍能解释,而不是直接把旧分录改成另一个数值。
这种历史约束也适用于预付额度的到期:到期记录采用自己的记账生效时间。查询到期之前的余额,不应提前包含未来的过期影响;查询到期之后,则要反映未使用部分已经失效。
完整示例
回到开头的 AI 套餐。为避免时间和价格规则混杂,假设客户从十月一日零点开始,按自然月对齐,没有中途变更、税费、其他优惠或账务预付余额,也不开启周期内分次出账。
产品明确配置两张费率卡:平台费每月 20 美元、周期开始时收费;Token 服务每月授予十万 Token,采用软限制,价格为首十万免费、超出部分每 Token 0.00001 美元。
服务开通
Acme 创建客户记录,关联 workspace-a 和 workspace-b。订阅工作流从套餐生成该客户的 Spec,保存十月份开始的服务安排,并按 Token 费率卡的模板创建计量权益及默认授予。
计费同步随后根据已提交的订阅建立相应费用或待出账项目。平台费具备预先收费条件,可以在开通阶段进入账单流程;Token 服务则保留本周期的用量收费安排。
此时,“权益已经建立”和“平台费已经付款”仍是两件事,实际支付进度由费用和账单各自的状态表示。
使用过程
模型网关为 workspace-a 上报 1,200 Token。待事件写入可供查询后,各领域分别使用这份事实:
- 计量:按客户归属查询,可将这个工作区的用量计入 Acme。
- 权益:在示例没有其他用量的前提下,十万授予剩余 98,800 Token,允许访问。
- 通知:若重新计算产生的值事件满足提醒规则,保存相应通知并进入投递流程。
- 计费:Token 价格仍按其服务期间和收费时间推进;已存在的平台费账单继续自己的生命周期。
使用事件使查询和计算输入发生变化,并不需要让一条事件同步完成全部收费操作。
周期结算
到了本周期结束后的配置收集时点,两个工作区在十月份合计消耗十二万 Token,且这十二万都落在本轮可见的用量范围内。下表列出这份用量对应的结果,其中权益一行描述十月周期结束、尚未重置时的计算状态:
| 模型 | 结果 |
|---|---|
| 计量器 | Acme 本周期用量 120,000 Token |
| 十月权益计算 | 重置前余额零,超额 20,000 Token,软限制继续允许访问 |
| Token 价格 | 100,000 × 0 + 20,000 × 0.00001 = 0.20 美元 |
| Token 费用 | 完成相应用量快照、定价和结算推进 |
| 标准账单 | 收集符合条件的 Token 项目,计算并向外部应用同步 |
| 支付与账务 | 随生命周期记录出具、授权和结算对应的事实 |
本周期的平台费与 Token 费合计 20.20 美元,但平台费在期初、Token 费用在期后具备收费条件,可能分别形成账单。这个合计不表示系统必然在月底生成一张 20.20 美元的账单。
十一月的权益按新使用周期及重置规则计算。即使用量账单仍在处理,当前可用额度也不能继续用十月重置前的结果表示。
如果客户下月升级套餐,订阅需要明确新条款及生效时间,再由计费同步整理相应收费对象。已经出具的十月账单保留其历史条款和结果。
从这个例子可以看到,OpenMeter 反复区分几类事实:实际消耗、客户条款、访问政策、费用计算和价值结算。它们通过清晰的引用、时间边界和生命周期联系起来,使同一份用量既能解释“还可使用多少”,也能解释“这笔费用从何而来”。
概念关系总览
前面的例子涉及定义、使用和结算几个过程。下面按这些过程整理概念之间的关系;表中的“包含”“关联”“生成”分别表示不同关系,不要求所有对象在同一步创建。
客户与产品
| 关系 | 具体含义 |
|---|---|
| 命名空间(Namespace)划分客户(Customer)、套餐(Plan)等数据的所属范围 | 本文的 AI 服务可以在同一个命名空间中管理多个客户及其可购买的套餐。 |
| 客户(Customer)在业务应用中拥有工作区(Workspace) | Acme 可以分别为研发和客服建立工作区。工作区由 AI 服务自身管理,OpenMeter 记录对应的客户与用量归属。 |
| 工作区(Workspace)的标识作为事件归属键(Subject),关联到客户(Customer) | 网关按工作区上报使用事件(Usage Event),客户用量归属(CustomerUsageAttribution)将多个工作区的使用记录归入同一客户。 |
| 计量器(Meter)从使用事件(Usage Event)中统计用量(Usage) | 例如提取每次调用的 Token 数,按客户和时间范围求和。 |
| 功能(Feature)按需关联计量器(Meter) | 文本生成关联 Token 计量器;只需控制是否开放的审计日志可以不关联计量器。 |
| 套餐(Plan)包含套餐阶段(Plan Phase),套餐阶段包含费率卡(RateCard) | 定义可供客户采用的服务规则,例如先试用七天,再正式收费。 |
| 费率卡(RateCard)组合功能(Feature)、权益模板(EntitlementTemplate)和价格(Price) | 它描述一项服务提供什么、授予什么权益、怎样收费。各项内容按服务需要配置,例如固定平台费可以没有功能权益。 |
订阅与访问
| 关系 | 具体含义 |
|---|---|
| 客户(Customer)通过订阅(Subscription)采用套餐(Plan)中的条款 | 套餐提供可销售的定义,订阅保存这位客户的开始时间、周期和具体服务安排;定制订阅也可以没有目录套餐引用。 |
| 订阅(Subscription)包含订阅阶段(Subscription Phase),订阅阶段组织服务条目(Subscription Item) | 从套餐创建时,将套餐阶段的规则落实为客户自己的时间表,条目保存采用的费率卡;同一服务中途调整时,用条目版本保留新旧安排。 |
| 订阅(Subscription)依据权益模板(EntitlementTemplate)生成客户权益(Entitlement) | 套餐定义模板,创建订阅时才为具体客户建立相应权益,并使其有效期与服务条目对齐。权益也可以直接创建。 |
| 权益(Entitlement)连接客户(Customer)与功能(Feature) | 它表达某位客户在某段时间是否可以使用这项功能,以及相关配置或额度状态。 |
| 计量权益(Metered Entitlement)拥有额度授予(Grant),额度计算(Credit)结合用量(Usage)计算权益余额(Balance) | 多份授予按优先级和有效期消耗,未覆盖的用量记为超额用量(Overage)。 |
| 权益余额(Balance)与软限制(Soft Limit)共同决定访问结果(HasAccess) | 硬限制在余额耗尽后返回不可访问,软限制继续允许访问;实际调用是否放行,由业务应用使用这个结果决定。 |
因此,“套餐产生权益”可以更准确地展开为:套餐(Plan)中的费率卡(RateCard)定义权益模板(EntitlementTemplate),订阅(Subscription)再将模板落实为某位客户(Customer)的权益(Entitlement)。
收费与结算
| 关系 | 具体含义 |
|---|---|
| 订阅计费同步(Subscription sync)将订阅(Subscription)的服务安排转成费用(Charge)或兼容账单行(Invoice Line) | 它比较目标和现状,整理相应周期的收费对象;已有对象继续由原有后端管理。 |
| 用量费用(Usage-based Charge)结合用量(Usage)与价格(Price)计算金额,并保存执行批次(Realization Run) | 批次记录本轮计算边界、结果及相关引用,支持累计计算和后续结算。 |
| 费用(Charge)按结算模式(Settlement Mode)使用账务余额(Customer Credit Balance) | 例如先抵扣合格的预付价值,再将未覆盖部分交给账单流程。这里的账务余额与功能额度余额分别计算。 |
| 待出账账单(Gathering Invoice)暂存账单行(Invoice Line),符合条件的行被收集到标准账单(Standard Invoice) | 前者保存待处理项目,后者进入计算、出具和支付流程。一位客户可以先后产生多张标准账单。 |
| 计费配置(Billing Profile)选择税务应用(Tax App)、账单应用(Invoicing App)和支付应用(Payment App) | 标准账单保存当时适用的配置与应用引用,并据此推进后续流程。 |
| 费用(Charge)的生命周期通过账务记录(Ledger)保存相关价值变化 | 授予预付价值、消耗、应收和支付等效果以分录留痕;账单(Invoice)同时保留面向客户的结算单据及其状态。 |
| 权益值事件(Entitlement Value Event)或账单事件(Invoice Event)经过通知规则(Notification Rule)匹配,生成通知事件(Notification Event) | 通知事件保存历史内容,再通过通知渠道(Channel)安排投递;源事件本身不等于已经生成或送达一条通知。 |
在这些关系中,同一份用量(Usage)有两个主要用途:与额度授予(Grant)一起计算权益(Entitlement)的可用额度,与价格(Price)一起计算费用(Charge)。免费用量、访问限制和预付价值需要分别配置,才能共同实现产品对客户的承诺。
小结
OpenMeter 围绕客户(Customer)组织用量与商业关系:计量器(Meter)统计实际消耗,功能(Feature)、费率卡(RateCard)和套餐(Plan)定义可出售的服务,订阅(Subscription)将这些规则落实为客户自己的时间表。
服务开通后,权益(Entitlement)和额度授予(Grant)回答客户能用什么、还剩多少额度;价格(Price)与费用(Charge)计算应收金额,账单(Invoice)推进出账和支付,账务记录(Ledger)保留价值变化及其来源。功能额度、免费用量和预付余额分别建模,需要通过明确的配置共同实现产品规则。
这些模型还共同保留了时间信息:条款何时生效、额度何时有效、用量属于哪个期间、费用已经执行到哪一步。理解这些边界,才能在套餐变更、周期重置和重复处理时,继续解释客户当时享有什么服务,以及每笔费用如何产生。