第36篇-Token计费系统-LLM时代的精细化计量

【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 计费非常直接:按调用次数计费,或者按流量计费。一次请求就是一次请求,成本和收入都很容易核算。

维度传统 APILLM 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 计费的三个核心难题

Kafka 持久化

流式聚合

对账机制

推理请求

推理引擎执行

产出用量记录
input/output/thinking tokens

难题1:
如何保证
不漏计?

计量管道

难题2:
如何实时
又准确?

计费系统

难题3:
如何发现
计费异常?

告警 + 修正

难题一:不漏计。高并发下,每秒可能产生数万条用量记录。任何一条丢失都意味着收入流失。这要求计量管道至少有"至少一次(at-least-once)"的投递保证,且要有幂等去重。

难题二:实时又准确。用户希望在控制台实时看到用量和费用,但实时系统和高准确性的对账系统是矛盾的。解法是"双层架构"——实时层保证低延迟展示,离线层保证最终一致。

难题三:异常检测。如果推理引擎报告的 Token 数和模型实际消耗对不上(比如 bug 导致 output token 少报了),平台就在"隐性补贴"用户。必须有对账机制发现这种成本泄漏。


二、实时计量采集架构

2.1 整体架构总览

计量系统不是单一的组件,而是一条从推理引擎到用户账单的数据管道:

用户控制台 计费数据库 PostgreSQL Redis (实时用量) 聚合服务 Kafka (用量事件流) 推理引擎 API 网关 (计量中间件) 客户端 用户控制台 计费数据库 PostgreSQL Redis (实时用量) 聚合服务 Kafka (用量事件流) 推理引擎 API 网关 (计量中间件) 客户端 推理执行中... 计量中间件提取 usage 生成 UsageEvent par [异步处理] POST /v1/chat/completions 转发请求 响应(含 usage 字段) 发送 UsageEvent (异步、不阻塞响应) 返回响应 消费事件 增量更新实时用量 (ZSET/Hash) 写入明细记录 查询实时用量(毫秒级) 查询历史账单(秒级)

这个架构的关键设计是计量和响应解耦:网关在返回响应给客户端之前,只做一件同步的事——把用量事件写入 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"`     // 事件时间
}

几个设计要点:

  1. EventID 用 UUID:保证全局唯一,下游消费时可做幂等去重。即使 Kafka 重投,也不会重复计费。
  2. 费用在网关预计算:而不是在聚合服务计算。这样即使价格表变了,历史事件的费用也已固化,不会回溯变化。
  3. 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 扛不住;写入失败影响请求路径
网关直写 RedisRedis 不是持久存储,重启丢数据
网关写 Kafka解耦 + 持久化 + 削峰 + 可重放

Kafka 的核心价值在于:

  1. 持久化:消息落盘,即使聚合服务宕机几小时,数据也不丢。
  2. 削峰填谷:推理高峰期事件量可能是平时的 10 倍,Kafka 做缓冲,聚合服务按自己的速度消费。
  3. 可重放:如果聚合逻辑有 bug,修完后可以从头重新消费,重建账单。

3.2 事件分区策略

Kafka Topic 的分区(Partition)决定了并行度和顺序保证。用量事件的分区策略:

// 按 TenantID 分区——保证同一租户的事件落到同一分区
// 这样聚合服务的一个 consumer 实例处理一个租户的所有事件,
// 不需要跨实例协调,且保证租户内的事件顺序。
func partitionKey(event UsageEvent) string {
    return event.TenantID
}

为什么按 TenantID 而不是 RequestID?因为计费聚合的核心维度是租户——一个租户的累计用量、月度账单、配额扣减,都需要顺序处理。按租户分区后,每个 consumer 实例独立处理一批租户,互不干扰。

3.3 聚合服务设计

聚合服务消费 Kafka 事件,做三件事:

Kafka
usage-events

Consumer Group
(按 TenantID 分区)

1. 写明细
PostgreSQL
(每一行事件)

2. 更新实时用量
Redis
(累计计数器)

3. 更新聚合表
PostgreSQL
(按小时/天/月汇总)

// 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 双向对账模型

数据源3:财务系统

数据源2:推理引擎日志

数据源1:计量系统

漏计

重复

偏差

余额异常

PostgreSQL
usage_records 表

推理引擎
request_log 表

Payment
订单与充值

对账服务
Reconciliation Job
(每日凌晨运行)

对比检查

补录用量事件

标记重复事件
退款修正

告警 + 人工审核

冻结账户

对账服务的核心 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 拓扑如何影响调度决策?我们将设计多模型调度器、模型加载状态机,以及拓扑感知的调度策略。


如果本篇内容对你有帮助,欢迎点赞收藏!有任何疑问,欢迎在评论区交流。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值