【AIaaS 全栈架构师】第 36 篇:Token 计费系统——LLM 时代的精细化计量架构
系列定位:AIaaS 全栈架构师教程,技术栈以 Go 为主。本篇聚焦平台核心服务之三——Token 计费系统,解决"用户用了多少 Token、该收多少钱、平台成本怎么核算"的商业闭环问题。
本篇你将学到
- 理解 LLM 计费与传统 API 计费的本质差异,掌握输入/输出/思考 Token 分别计价的模型
- 设计实时计量采集架构:从推理引擎到账单的完整数据管道
- 搭建 Kafka → 流式聚合 → 计费落库的数据管道,保证"每一条 Token 都不漏计"
- 用 Go 实现计量中间件:在网关层无侵入地采集 Token 用量
- 设计对账(Reconciliation)机制,发现计费异常与成本泄漏
- 理解多维成本分析:GPU 折旧、推理时长、Token 单价的换算关系
上一篇我们建立了多租户架构,解决了"谁能用、能用多少"的问题。这一篇要回答商业上最关键的一问:用了多少,收多少钱? 这不是简单的"单价 × 数量"——LLM 的计费复杂度远超传统 API,因为一次推理请求可能涉及三种 Token(输入、输出、思考)、多租户分摊、多模型异构成本,还要保证计费数据零丢失。Token 计费系统的健壮性,直接决定平台能不能赚钱。
一、为什么 LLM 计费与传统 API 计费完全不同
1.1 传统 API 计费的简单模型
传统 API 计费非常直接:按调用次数计费,或者按流量计费。一次请求就是一次请求,成本和收入都很容易核算。
| 维度 | 传统 API | LLM API |
|---|---|---|
| 计费单位 | 调用次数 / 流量字节 | Token(三种类型分别计价) |
| 单次请求成本差异 | 小(基本固定) | 巨大(差 100-1000 倍) |
| 成本透明度 | 调用方可知 | 调用方不可预知输出长度 |
| 计费时机 | 请求前可确定 | 请求后才知准确用量 |
| 成本构成 | 带宽 + 计算 | GPU 折旧 + 电力 + 网络 + 存储 |
| 对账复杂度 | 低 | 高(涉及多租户、多模型、多区域) |
1.2 三种 Token 的定价差异
现代 LLM(尤其是推理模型,如 o1/o3 系列、DeepSeek-R1)引入了"思考 Token"——模型在输出最终答案前,会先生成一段内部推理过程。这三种 Token 的定价完全不同:
| Token 类型 | 说明 | 相对单价 | 为什么要分别计价 |
|---|---|---|---|
| 输入 Token(Input/Prompt) | 用户发送的 prompt + 历史 | 1x(基准) | 只做一次 prefill 计算 |
| 输出 Token(Output/Completion) | 模型生成的最终回复 | 3-5x | 每个 token 都要走 decode,算力消耗大 |
| 思考 Token(Reasoning/Thinking) | 推理模型的内部思维链 | 2-4x | 占用 decode 算力但不返回给用户 |
以某主流模型为例,官方定价(每百万 Token):
| 模型 | 输入 | 输出 | 思考 |
|---|---|---|---|
| 标准对话模型 | ¥18 | ¥72 | — |
| 推理模型 | ¥24 | ¥96 | ¥48 |
| 轻量模型 | ¥0.5 | ¥2 | — |
注意推理模型的思考 Token 单价介于输入和输出之间——它消耗了 decode 算力,但对用户而言是"看不见的",所以定价策略需要平衡用户体验和平台成本。
1.3 计费的三个核心难题
难题一:不漏计。高并发下,每秒可能产生数万条用量记录。任何一条丢失都意味着收入流失。这要求计量管道至少有"至少一次(at-least-once)"的投递保证,且要有幂等去重。
难题二:实时又准确。用户希望在控制台实时看到用量和费用,但实时系统和高准确性的对账系统是矛盾的。解法是"双层架构"——实时层保证低延迟展示,离线层保证最终一致。
难题三:异常检测。如果推理引擎报告的 Token 数和模型实际消耗对不上(比如 bug 导致 output token 少报了),平台就在"隐性补贴"用户。必须有对账机制发现这种成本泄漏。
二、实时计量采集架构
2.1 整体架构总览
计量系统不是单一的组件,而是一条从推理引擎到用户账单的数据管道:
这个架构的关键设计是计量和响应解耦:网关在返回响应给客户端之前,只做一件同步的事——把用量事件写入 Kafka。Kafka 写入是异步且极快的(单毫秒级),不会明显增加延迟。后续的聚合、入库都是异步消费,不阻塞请求路径。
2.2 UsageEvent 的数据结构
每一条用量记录都是一个 UsageEvent,它是整个计量系统的原子单元:
// UsageEvent 单次推理的用量记录
type UsageEvent struct {
// —— 标识 ——
EventID string `json:"event_id"` // 全局唯一 ID(UUID),用于幂等
RequestID string `json:"request_id"` // 请求追踪 ID
TenantID string `json:"tenant_id"` // 租户 ID
APIKeyID string `json:"api_key_id"` // API Key ID
UserID string `json:"user_id"` // 用户 ID(可选)
// —— 模型信息 ——
ModelID string `json:"model_id"` // 模型标识,如 "glm-4-flash"
Provider string `json:"provider"` // 提供方:self-hosted / openai / azure
// —— Token 用量(核心)——
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
ReasoningTokens int `json:"reasoning_tokens"` // 思考 token(推理模型)
CachedInputTokens int `json:"cached_input_tokens"` // KV Cache 命中部分
// —— 计费金额(预计算)——
InputCost float64 `json:"input_cost"` // 输入费用(元)
OutputCost float64 `json:"output_cost"` // 输出费用(元)
ReasoningCost float64 `json:"reasoning_cost"` // 思考费用(元)
TotalCost float64 `json:"total_cost"` // 总费用(元)
// —— 元数据 ——
Latency int `json:"latency_ms"` // 推理延迟(毫秒)
IsStream bool `json:"is_stream"` // 是否流式
Region string `json:"region"` // 区域
Timestamp time.Time `json:"timestamp"` // 事件时间
}
几个设计要点:
- EventID 用 UUID:保证全局唯一,下游消费时可做幂等去重。即使 Kafka 重投,也不会重复计费。
- 费用在网关预计算:而不是在聚合服务计算。这样即使价格表变了,历史事件的费用也已固化,不会回溯变化。
- CachedInputTokens 单独记录:Prompt Cache 命中的部分通常打折计价(比如 1 折),必须和普通输入 Token 区分。
2.3 价格表设计
价格表是计费的基准,必须支持模型级别、租户级别的差异化定价:
// PriceTable 价格表
type PriceTable struct {
ModelID string `json:"model_id"`
// 每 1M Token 的单价(元)
InputPricePerM float64 `json:"input_price_per_m"`
OutputPricePerM float64 `json:"output_price_per_m"`
ReasoningPricePerM float64 `json:"reasoning_price_per_m"`
// Prompt Cache 命中的折扣(0-1)
CacheDiscount float64 `json:"cache_discount"` // 如 0.1 表示 1 折
}
// CalcCost 根据用量和价格表计算费用
func CalcCost(usage UsageEvent, pt PriceTable) UsageEvent {
perM := 1_000_000.0
usage.InputCost = float64(usage.InputTokens-usage.CachedInputTokens) * pt.InputPricePerM / perM
usage.InputCost += float64(usage.CachedInputTokens) * pt.InputPricePerM * pt.CacheDiscount / perM
usage.OutputCost = float64(usage.OutputTokens) * pt.OutputPricePerM / perM
usage.ReasoningCost = float64(usage.ReasoningTokens) * pt.ReasoningPricePerM / perM
usage.TotalCost = usage.InputCost + usage.OutputCost + usage.ReasoningCost
return usage
}
实际生产中,价格表存储在数据库,并支持:
- 租户级覆盖:大客户可以谈专属折扣,覆盖默认价
- 时间版本:价格会调整,历史账单必须用当时的价格
- 阶梯定价:用量越大单价越低(月度结算时回溯计算)
三、计量数据管道:Kafka → 聚合 → 计费
3.1 为什么用 Kafka 做中间层
直接让网关写数据库不行吗?技术上可以,工程上不行:
| 方案 | 问题 |
|---|---|
| 网关直写 PostgreSQL | 高并发下 DB 扛不住;写入失败影响请求路径 |
| 网关直写 Redis | Redis 不是持久存储,重启丢数据 |
| 网关写 Kafka | 解耦 + 持久化 + 削峰 + 可重放 |
Kafka 的核心价值在于:
- 持久化:消息落盘,即使聚合服务宕机几小时,数据也不丢。
- 削峰填谷:推理高峰期事件量可能是平时的 10 倍,Kafka 做缓冲,聚合服务按自己的速度消费。
- 可重放:如果聚合逻辑有 bug,修完后可以从头重新消费,重建账单。
3.2 事件分区策略
Kafka Topic 的分区(Partition)决定了并行度和顺序保证。用量事件的分区策略:
// 按 TenantID 分区——保证同一租户的事件落到同一分区
// 这样聚合服务的一个 consumer 实例处理一个租户的所有事件,
// 不需要跨实例协调,且保证租户内的事件顺序。
func partitionKey(event UsageEvent) string {
return event.TenantID
}
为什么按 TenantID 而不是 RequestID?因为计费聚合的核心维度是租户——一个租户的累计用量、月度账单、配额扣减,都需要顺序处理。按租户分区后,每个 consumer 实例独立处理一批租户,互不干扰。
3.3 聚合服务设计
聚合服务消费 Kafka 事件,做三件事:
// AggregateWorker 聚合服务消费者
type AggregateWorker struct {
consumer sarama.ConsumerGroup
db *sql.DB
redis *redis.Client
handler *UsageHandler
}
func (w *AggregateWorker) Run(ctx context.Context, topics []string) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
// Consume 会自动处理 rebalance、offset 提交
err := w.consumer.Consume(ctx, topics, w.handler)
if err != nil {
log.Printf("consume error: %v", err)
time.Sleep(time.Second)
}
}
}
}
// UsageHandler 实现 sarama.ConsumerGroupHandler
type UsageHandler struct {
db *sql.DB
redis *redis.Client
}
func (h *UsageHandler) ConsumeClaim(sess sarama.ConsumerGroupSession,
claim sarama.ConsumerGroupClaim) error {
batch := make([]UsageEvent, 0, 100)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
flush := func() {
if len(batch) == 0 {
return
}
if err := h.batchInsert(sess.Context(), batch); err != nil {
log.Printf("batch insert failed: %v", err)
return // 不提交 offset,下次重试
}
h.updateRedisCounters(batch)
// 批量提交 offset
for _, msg := range claim.Messages()[:len(batch)] {
sess.MarkMessage(msg, "")
}
batch = batch[:0]
}
for {
select {
case msg, ok := <-claim.Messages():
if !ok {
flush()
return nil
}
var event UsageEvent
if err := json.Unmarshal(msg.Value, &event); err != nil {
log.Printf("unmarshal error: %v", err)
sess.MarkMessage(msg, "") // 跳过坏消息
continue
}
batch = append(batch, event)
if len(batch) >= 100 {
flush()
}
case <-ticker.C:
flush()
case <-sess.Context().Done():
flush()
return nil
}
}
}
这个消费者有两个关键的工程优化:
批量写入(Batch Insert)。不是每条事件都写一次数据库,而是攒够 100 条或 500 毫秒后批量写入。这能把数据库 IOPS 降一个数量级。
Offset 提交时机。只有在数据库写入成功后才提交 Kafka offset。如果写失败,不提交——下次重启后从上次位置重新消费,保证不丢数据。
3.4 Redis 实时计数器
用户控制台要实时显示"今天用了多少 Token、花了多少钱",这个查询不能走数据库(太慢)。用 Redis 维护实时计数器:
// updateRedisCounters 更新 Redis 中的实时用量
func (h *UsageHandler) updateRedisCounters(events []UsageEvent) {
ctx := context.Background()
pipe := h.redis.Pipeline()
for _, e := range events {
// Key 格式:usage:{tenant_id}:{date}
date := e.Timestamp.Format("20060102")
key := fmt.Sprintf("usage:%s:%s", e.TenantID, date)
// HINCRBY 增加各维度计数
pipe.HIncrBy(ctx, key, "input_tokens", int64(e.InputTokens))
pipe.HIncrBy(ctx, key, "output_tokens", int64(e.OutputTokens))
pipe.HIncrBy(ctx, key, "reasoning_tokens", int64(e.ReasoningTokens))
pipe.HIncrBy(ctx, key, "request_count", 1)
// 浮点费用用 HINCRBYFLOAT
pipe.HIncrByFloat(ctx, key, "total_cost", e.TotalCost)
// 设置当天过期时间(7天后自动清理)
pipe.Expire(ctx, key, 7*24*time.Hour)
// 维护一个按租户排序的日消耗 ZSET(控制台仪表盘用)
dailyKey := fmt.Sprintf("usage_daily:%s", date)
pipe.ZIncrBy(ctx, dailyKey, e.TotalCost, e.TenantID)
}
_, _ = pipe.Exec(ctx)
}
这里用 Redis Pipeline 把多个命令打包一次发送,减少网络往返。对于高吞吐场景,还可以进一步用 Lua 脚本在服务端原子执行。
四、Go 实现计量中间件
计量中间件嵌入在 API 网关层,对推理响应做"无侵入"的 Token 采集。它的核心原则是:绝不阻塞或失败响应。即使计量系统挂了,推理请求也必须正常返回。
4.1 中间件整体结构
// MeteringMiddleware 计量中间件
type MeteringMiddleware struct {
producer sarama.SyncProducer // Kafka 生产者
pricer *Pricer // 价格表服务
logger *zap.Logger
metrics *MeteringMetrics // Prometheus 指标
failCount atomic.Int64 // 计量失败计数
}
// Wrap 包装一个 http.Handler,注入计量逻辑
func (m *MeteringMiddleware) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. 提取请求上下文(认证中间件已注入)
ctx, ok := getRequestContext(r)
if !ok {
next.ServeHTTP(w, r)
return
}
// 2. 记录开始时间
start := time.Now()
// 3. 包装 ResponseWriter 以捕获响应内容
rec := &meteringResponseWriter{
ResponseWriter: w,
statusCode: 200,
body: &bytes.Buffer{},
}
// 4. 执行下游
next.ServeHTTP(rec, r)
// 5. 异步采集用量(关键:不阻塞响应)
// 响应已经发给客户端了,下面在 goroutine 里处理
if rec.statusCode == 200 {
go m.collectUsage(rec.body.Bytes(), ctx, start)
}
})
}
4.2 流式与非流式的差异处理
非流式响应和流式(SSE)响应的采集方式完全不同:
非流式:响应体是完整 JSON,直接解析 usage 字段即可。
流式:Token 用量在最后一个 SSE 事件中才返回,需要累积所有 chunk。
// meteringResponseWriter 捕获响应内容
type meteringResponseWriter struct {
http.ResponseWriter
statusCode int
body *bytes.Buffer
isStream bool
}
// 对于流式响应,需要特殊处理——改用"边转发边累积"的策略
// 实际生产中,流式计量中间件会更复杂,需要解析 SSE 事件流
func (m *MeteringMiddleware) collectUsage(body []byte, ctx *RequestContext, start time.Time) {
defer func() {
if rv := recover(); rv != nil {
m.failCount.Add(1)
m.logger.Error("usage collection panic",
zap.Any("panic", rv),
zap.String("request_id", ctx.RequestID))
}
}()
// 解析响应体,提取 usage
var resp struct {
Model string `json:"model"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
CompletionTokensDetails struct {
ReasoningTokens int `json:"reasoning_tokens"`
} `json:"completion_tokens_details"`
PromptTokensDetails struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
} `json:"usage"`
}
if err := json.Unmarshal(body, &resp); err != nil {
m.logger.Error("failed to parse response body for usage",
zap.Error(err),
zap.String("request_id", ctx.RequestID))
return
}
// 如果响应里没有 usage 字段(某些推理引擎不返回),跳过
if resp.Usage.PromptTokens == 0 && resp.Usage.CompletionTokens == 0 {
m.metrics.NoUsageResponse.Inc()
return
}
// 构造 UsageEvent
event := UsageEvent{
EventID: uuid.NewString(),
RequestID: ctx.RequestID,
TenantID: ctx.TenantID,
APIKeyID: ctx.APIKeyID,
UserID: ctx.UserID,
ModelID: resp.Model,
InputTokens: resp.Usage.PromptTokens,
OutputTokens: resp.Usage.CompletionTokens,
ReasoningTokens: resp.Usage.CompletionTokensDetails.ReasoningTokens,
CachedInputTokens: resp.Usage.PromptTokensDetails.CachedTokens,
Latency: int(time.Since(start).Milliseconds()),
Timestamp: start,
}
// 查价格表并计算费用
pt := m.pricer.GetPrice(event.ModelID, event.TenantID)
event = CalcCost(event, pt)
// 发送到 Kafka
payload, _ := json.Marshal(event)
msg := &sarama.ProducerMessage{
Topic: "usage-events",
Key: sarama.StringEncoder(event.TenantID), // 按租户分区
Value: sarama.ByteEncoder(payload),
}
_, _, err := m.producer.SendMessage(msg)
if err != nil {
m.failCount.Add(1)
m.logger.Error("failed to send usage event to kafka",
zap.Error(err),
zap.String("event_id", event.EventID))
// 降级策略:写入本地文件,由独立进程重试
m.fallbackToFile(event)
return
}
m.metrics.EventsEmitted.Inc()
}
4.3 降级策略:绝不丢数据
Kafka 可能短暂不可用。计量中间件必须有降级方案:
// fallbackToFile 当 Kafka 不可用时,写入本地文件
// 一个独立进程(replay-agent)会读取这些文件并重新投递到 Kafka
func (m *MeteringMiddleware) fallbackToFile(event UsageEvent) {
// 文件按小时滚动,便于 replay-agent 处理
filename := fmt.Sprintf("/var/log/usage-fallback/%s.log",
time.Now().Format("20060102-15"))
line, _ := json.Marshal(event)
line = append(line, '\n')
// O_APPEND 模式,多协程安全追加
f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
m.logger.Error("fallback file write failed", zap.Error(err))
return
}
defer f.Close()
_, _ = f.Write(line)
}
这是一个"最大化不丢数据"的设计——Kafka 挂了写文件,文件也写不进去就用日志兜底(至少事后能从推理引擎的日志重建)。对于计费这种"钱"相关的数据,怎么强调容错都不过分。
4.4 Pricer 价格表服务
// Pricer 管理价格表,支持热更新
type Pricer struct {
mu sync.RWMutex
tables map[string]PriceTable // key: model_id
tenantOverrides map[string]map[string]PriceTable // key1: tenant_id, key2: model_id
}
func NewPricer(db *sql.DB) *Pricer {
p := &Pricer{
tables: make(map[string]PriceTable),
tenantOverrides: make(map[string]map[string]PriceTable),
}
// 启动时加载,并定期热更新
p.reload(db)
go p.watchUpdates(db)
return p
}
func (p *Pricer) GetPrice(modelID, tenantID string) PriceTable {
p.mu.RLock()
defer p.mu.RUnlock()
// 优先查租户专属价
if overrides, ok := p.tenantOverrides[tenantID]; ok {
if pt, ok := overrides[modelID]; ok {
return pt
}
}
// 回退到默认价
if pt, ok := p.tables[modelID]; ok {
return pt
}
// 找不到价格表——告警并返回零价(不阻塞计费管道)
return PriceTable{}
}
五、对账与成本分析
5.1 为什么需要对账
计量管道再健壮,也可能出现以下问题:
| 异常类型 | 表现 | 原因 |
|---|---|---|
| 漏计 | 推理引擎日志有请求,但用量事件没产生 | Kafka 写入失败 + 降级文件也丢失 |
| 重复计费 | 同一请求出现两条用量事件 | 网关重试 + Kafka 至少一次投递 |
| 用量偏差 | 事件记录 1000 token,引擎实际消耗 1200 token | 推理引擎 usage 统计 bug |
| 价格错误 | 事件金额和当前价格表对不上 | 价格表热更新时序问题 |
对账系统就是为了发现并修正这些问题。
5.2 双向对账模型
对账服务的核心 SQL 逻辑——找出"推理引擎有记录但计量系统没有"的请求:
-- 找漏计的请求:引擎日志有,但 usage_records 没有
SELECT e.request_id, e.tenant_id, e.model_id, e.created_at
FROM inference_engine.request_log e
LEFT JOIN billing.usage_records u
ON e.request_id = u.request_id
WHERE e.created_at >= $1 -- 对账区间开始
AND e.created_at < $2 -- 对账区间结束
AND e.status = 'success'
AND u.event_id IS NULL; -- 计量系统没有对应记录
-- 找重复计费:同一 request_id 有多条 usage 记录
SELECT request_id, COUNT(*) as cnt
FROM billing.usage_records
WHERE timestamp >= $1 AND timestamp < $2
GROUP BY request_id
HAVING COUNT(*) > 1;
5.3 Go 实现对账 Job
// ReconciliationJob 对账任务
type ReconciliationJob struct {
db *sql.DB
logger *zap.Logger
alert Alerter
}
func (j *ReconciliationJob) Run(ctx context.Context, start, end time.Time) error {
j.logger.Info("reconciliation started",
zap.Time("start", start), zap.Time("end", end))
// 1. 检查漏计
missing, err := j.findMissingEvents(ctx, start, end)
if err != nil {
return err
}
if len(missing) > 0 {
j.logger.Warn("found missing events", zap.Int("count", len(missing)))
j.alert.Send(ctx, Alert{
Level: "warning",
Title: "发现漏计事件",
Body: fmt.Sprintf("时间区间 [%s, %s) 内有 %d 条请求未产生用量事件", start, end, len(missing)),
})
// 尝试从推理引擎日志重建用量事件
for _, m := range missing {
j.reconstructEvent(ctx, m)
}
}
// 2. 检查重复
duplicates, err := j.findDuplicates(ctx, start, end)
if err != nil {
return err
}
for _, d := range duplicates {
// 保留最早的一条,其余标记为 duplicate
j.markDuplicates(ctx, d.RequestID)
}
// 3. 余额异常检查
anomalies := j.findBalanceAnomalies(ctx, start, end)
for _, a := range anomalies {
j.alert.Send(ctx, Alert{
Level: "critical",
Title: "余额异常",
Body: fmt.Sprintf("租户 %s 余额异常:%s", a.TenantID, a.Reason),
})
}
j.logger.Info("reconciliation completed")
return nil
}
func (j *ReconciliationJob) findMissingEvents(ctx context.Context,
start, end time.Time) ([]MissingRecord, error) {
query := `
SELECT e.request_id, e.tenant_id, e.model_id, e.created_at
FROM inference_engine.request_log e
LEFT JOIN billing.usage_records u ON e.request_id = u.request_id
WHERE e.created_at >= $1 AND e.created_at < $2
AND e.status = 'success'
AND u.event_id IS NULL`
rows, err := j.db.QueryContext(ctx, query, start, end)
if err != nil {
return nil, err
}
defer rows.Close()
var results []MissingRecord
for rows.Next() {
var r MissingRecord
if err := rows.Scan(&r.RequestID, &r.TenantID, &r.ModelID, &r.CreatedAt); err != nil {
return nil, err
}
results = append(results, r)
}
return results, nil
}
5.4 成本分析:平台赚不赚钱
计费系统不仅要算"向用户收多少",还要算"平台花了多少",才能知道利润。这是平台运营的核心报表:
// CostAnalysis 成本分析
type CostAnalysis struct {
// 收入侧
Revenue struct {
InputTokens int64
OutputTokens int64
ReasoningTokens int64
TotalRevenue float64
}
// 成本侧
Cost struct {
GPUHours float64 // GPU 总使用时长
ElectricityKWh float64 // 电力消耗
NetworkTrafficGB float64
TotalCost float64
}
// 利润
GrossMargin float64 // 毛利率
}
// 每日成本分析报表 SQL
const dailyPnLSQL = `
WITH usage AS (
SELECT
model_id,
SUM(input_tokens) AS input_toks,
SUM(output_tokens) AS output_toks,
SUM(reasoning_tokens) AS reason_toks,
SUM(total_cost) AS revenue
FROM billing.usage_records
WHERE timestamp >= $1 AND timestamp < $2
GROUP BY model_id
),
cost AS (
SELECT
model_id,
SUM(gpu_seconds) / 3600.0 AS gpu_hours,
SUM(gpu_seconds) / 3600.0 * $3 AS gpu_cost, -- $3 = 每 GPU 小时折旧
SUM(watt_seconds) / 3600000.0 AS kwh,
SUM(watt_seconds) / 3600000.0 * $4 AS elec_cost -- $4 = 每度电费
FROM metrics.gpu_utilization
WHERE timestamp >= $1 AND timestamp < $2
GROUP BY model_id
)
SELECT
u.model_id,
u.input_toks, u.output_toks, u.reason_toks,
u.revenue,
c.gpu_hours,
c.gpu_cost,
c.kwh,
c.elec_cost,
u.revenue - c.gpu_cost - c.elec_cost AS profit,
CASE WHEN u.revenue > 0
THEN (u.revenue - c.gpu_cost - c.elec_cost) / u.revenue
ELSE 0
END AS margin
FROM usage u
JOIN cost c ON u.model_id = c.model_id
ORDER BY margin ASC; -- 按毛利率升序,最先看到最亏的模型`
这张报表揭示了一个关键事实:不同模型的利润率天差地别。小模型(如轻量对话模型)毛利率可能超过 80%,因为它们推理快、单 GPU 吞吐高;而大参数模型可能毛利率不到 20%,因为 GPU 利用率低、显存浪费大。基于这张报表,平台可以动态调整价格策略。
六、数据库表设计
6.1 核心表结构
-- 用量明细表(每一行一条事件)
CREATE TABLE billing.usage_records (
event_id UUID PRIMARY KEY,
request_id VARCHAR(64) NOT NULL,
tenant_id VARCHAR(64) NOT NULL,
api_key_id VARCHAR(64) NOT NULL,
model_id VARCHAR(64) NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
reasoning_tokens INTEGER DEFAULT 0,
cached_tokens INTEGER DEFAULT 0,
input_cost NUMERIC(12,6) NOT NULL,
output_cost NUMERIC(12,6) NOT NULL,
reasoning_cost NUMERIC(12,6) DEFAULT 0,
total_cost NUMERIC(12,6) NOT NULL,
latency_ms INTEGER,
is_stream BOOLEAN DEFAULT FALSE,
timestamp TIMESTAMPTZ NOT NULL,
-- 按时间分区(关键!月度分区避免大表性能问题)
CONSTRAINT chk_cost CHECK (total_cost >= 0)
) PARTITION BY RANGE (timestamp);
-- 创建月度分区
CREATE TABLE billing.usage_records_202607
PARTITION OF billing.usage_records
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
-- 租户维度按小时聚合表(控制台图表用)
CREATE TABLE billing.usage_hourly (
tenant_id VARCHAR(64) NOT NULL,
model_id VARCHAR(64) NOT NULL,
hour_bucket TIMESTAMPTZ NOT NULL, -- 截断到整点
input_tokens BIGINT DEFAULT 0,
output_tokens BIGINT DEFAULT 0,
total_cost NUMERIC(14,6) DEFAULT 0,
request_count INTEGER DEFAULT 0,
PRIMARY KEY (tenant_id, model_id, hour_bucket)
);
-- 账户余额表
CREATE TABLE billing.accounts (
tenant_id VARCHAR(64) PRIMARY KEY,
balance NUMERIC(14,4) NOT NULL DEFAULT 0, -- 当前余额
credit_limit NUMERIC(14,4) NOT NULL DEFAULT 0, -- 信用额度
total_charged NUMERIC(14,4) NOT NULL DEFAULT 0, -- 累计消费
total_recharge NUMERIC(14,4) NOT NULL DEFAULT 0, -- 累计充值
status VARCHAR(16) DEFAULT 'active', -- active/frozen/closed
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 必要的索引
CREATE INDEX idx_usage_tenant_time ON billing.usage_records (tenant_id, timestamp DESC);
CREATE INDEX idx_usage_model_time ON billing.usage_records (model_id, timestamp DESC);
6.2 为什么用时间分区
用量明细表是增长最快的表——一个中等规模平台,每天可能产生数千万条记录。如果不分区,单表几个月就到几十亿行,查询性能急剧下降。
按月分区后,查询单月数据只扫描一个分区(几千万行),而跨月查询可以并行扫描多个分区。老分区还可以归档到冷存储(如 S3),降低数据库压力。
6.3 余额扣减的原子性
余额扣减必须原子,否则会出现"超扣"问题(余额已经是负的了还在计费):
// ChargeBalance 扣减余额,返回扣减后的余额
// 使用 SELECT ... FOR UPDATE 保证并发安全
func ChargeBalance(ctx context.Context, db *sql.DB,
tenantID string, amount float64) (float64, error) {
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelReadCommitted,
})
if err != nil {
return 0, err
}
defer tx.Rollback()
// 锁定行并查询余额
var balance, creditLimit float64
err = tx.QueryRowContext(ctx,
`SELECT balance, credit_limit
FROM billing.accounts
WHERE tenant_id = $1
FOR UPDATE`, // 行锁,防止并发扣减
tenantID).Scan(&balance, &creditLimit)
if err != nil {
return 0, err
}
newBalance := balance - amount
// 检查是否超出信用额度
if newBalance < -creditLimit {
return 0, ErrInsufficientBalance
}
_, err = tx.ExecContext(ctx,
`UPDATE billing.accounts
SET balance = $2, total_charged = total_charged + $3, updated_at = NOW()
WHERE tenant_id = $1`,
tenantID, newBalance, amount)
if err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return newBalance, nil
}
注意:实际生产中不会每条事件都扣余额(性能太差),而是小时级批量扣减。实时余额检查放在 Redis 层,用预扣额度的方式实现(参考第 23 篇的 TPM 配额扣减)。
七、工程实践与陷阱
7.1 计量精度的三个层次
| 层次 | 精度 | 实现 | 用途 |
|---|---|---|---|
| 实时展示 | ±5% | Redis 计数器 | 用户控制台仪表盘 |
| 明细记录 | 精确 | PostgreSQL 明细表 | 账单导出、审计 |
| 对账修正 | 权威 | 引擎日志交叉验证 | 最终结算依据 |
用户看到的实时数字允许有微小误差(因为 Redis 和 DB 有同步延迟),但最终账单必须以对账修正后的数据为准。
7.2 时区陷阱
用量记录的 timestamp 必须用 UTC 存储,展示时转换到用户时区。一个常见 bug 是:用本地时间做"按天聚合",结果一个请求在 UTC 23:59 发生,聚合到第二天——用户看到的日用量和自己的感知对不上。
// 正确做法:存储 UTC,查询时按用户时区分组
func DailyUsageByTimezone(ctx context.Context, db *sql.DB,
tenantID string, loc *time.Location) ([]DailyUsage, error) {
// 在 SQL 中用 timezone 函数转换
query := `
SELECT
(timestamp AT TIME ZONE $2)::date AS day,
SUM(input_tokens), SUM(output_tokens), SUM(total_cost)
FROM billing.usage_records
WHERE tenant_id = $1
AND timestamp >= $3 AND timestamp < $4
GROUP BY day ORDER BY day`
rows, err := db.QueryContext(ctx, query,
tenantID, loc.String(), /* ... */)
// ...
return nil, nil
}
7.3 Kafka offset 管理的陷阱
聚合服务消费 Kafka 时,offset 提交策略决定了故障恢复行为:
- 自动提交(enable.auto.commit=true):简单,但可能在"消费了但没处理完"时提交,导致丢数据。
- 手动提交(本篇方案):处理完才提交,保证 at-least-once,但可能重复消费(需要幂等)。
推荐手动提交 + 幂等写入。幂等靠 event_id 做唯一约束——重复插入会被数据库拒绝:
ALTER TABLE billing.usage_records
ADD CONSTRAINT uniq_event_id UNIQUE (event_id);
插入时用 ON CONFLICT (event_id) DO NOTHING,保证重复事件不会重复计费。
7.4 免费额度的处理
大多数平台会给新用户免费额度(比如"注册送 50 万 Token")。免费额度的计量和付费额度走同一条管道,但在计费环节标记为"免费抵扣",不扣余额:
// ApplyFreeQuota 先扣免费额度,再扣余额
func ApplyFreeQuota(ctx context.Context, db *sql.DB,
tenantID string, cost float64) error {
tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()
var freeRemaining float64
tx.QueryRowContext(ctx,
`SELECT free_quota_remaining FROM billing.accounts WHERE tenant_id = $1 FOR UPDATE`,
tenantID).Scan(&freeRemaining)
if freeRemaining >= cost {
// 全部从免费额度扣
tx.ExecContext(ctx,
`UPDATE billing.accounts SET free_quota_remaining = free_quota_remaining - $2
WHERE tenant_id = $1`, tenantID, cost)
} else {
// 先扣完免费额度,剩余扣余额
remaining := cost - freeRemaining
tx.ExecContext(ctx,
`UPDATE billing.accounts
SET free_quota_remaining = 0, balance = balance - $3
WHERE tenant_id = $1`, tenantID, freeRemaining, remaining)
}
return tx.Commit()
}
本篇小结
| 知识点 | 核心内容 |
|---|---|
| Token 计费模型 | 输入/输出/思考 Token 分别计价,思考 Token 介于输入和输出之间 |
| 三大难题 | 不漏计(Kafka 持久化)、实时又准确(双层架构)、异常检测(对账) |
| UsageEvent 结构 | 唯一 ID 幂等 + 费用预计算 + 三类 Token 分别记录 |
| 计量管道 | 网关 → Kafka(按 TenantID 分区)→ 聚合服务 → Redis(实时)+ PostgreSQL(明细) |
| 计量中间件 | 无侵入采集、异步发送、Kafka 不可用时降级写文件 |
| 批量消费 | 攒批写入 DB + 手动提交 offset + 幂等去重 |
| 对账 | 引擎日志 vs 计量系统双向比对,发现漏计/重复/偏差 |
| 成本分析 | 收入侧(Token 费用)vs 成本侧(GPU 折旧 + 电力),计算毛利率 |
| 数据库设计 | 时间分区 + 幂等唯一约束 + 余额行锁扣减 |
下篇预告
第 37 篇:推理调度进阶——多模型调度、冷启动优化与拓扑感知
计费解决了"收钱"的问题。下一篇进入推理引擎的核心难题:一张 GPU 上跑多个模型时,怎么调度?模型加载要几十秒,怎么优化冷启动?NVLink 和 NUMA 拓扑如何影响调度决策?我们将设计多模型调度器、模型加载状态机,以及拓扑感知的调度策略。
如果本篇内容对你有帮助,欢迎点赞收藏!有任何疑问,欢迎在评论区交流。

1024

被折叠的 条评论
为什么被折叠?



