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

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

#mermaid-svg-DaQ29pHXuXpwKjjp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DaQ29pHXuXpwKjjp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DaQ29pHXuXpwKjjp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DaQ29pHXuXpwKjjp .error-icon{fill:#552222;}#mermaid-svg-DaQ29pHXuXpwKjjp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DaQ29pHXuXpwKjjp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DaQ29pHXuXpwKjjp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DaQ29pHXuXpwKjjp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DaQ29pHXuXpwKjjp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DaQ29pHXuXpwKjjp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DaQ29pHXuXpwKjjp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DaQ29pHXuXpwKjjp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DaQ29pHXuXpwKjjp .marker.cross{stroke:#333333;}#mermaid-svg-DaQ29pHXuXpwKjjp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DaQ29pHXuXpwKjjp p{margin:0;}#mermaid-svg-DaQ29pHXuXpwKjjp .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DaQ29pHXuXpwKjjp .cluster-label text{fill:#333;}#mermaid-svg-DaQ29pHXuXpwKjjp .cluster-label span{color:#333;}#mermaid-svg-DaQ29pHXuXpwKjjp .cluster-label span p{background-color:transparent;}#mermaid-svg-DaQ29pHXuXpwKjjp .label text,#mermaid-svg-DaQ29pHXuXpwKjjp span{fill:#333;color:#333;}#mermaid-svg-DaQ29pHXuXpwKjjp .node rect,#mermaid-svg-DaQ29pHXuXpwKjjp .node circle,#mermaid-svg-DaQ29pHXuXpwKjjp .node ellipse,#mermaid-svg-DaQ29pHXuXpwKjjp .node polygon,#mermaid-svg-DaQ29pHXuXpwKjjp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DaQ29pHXuXpwKjjp .rough-node .label text,#mermaid-svg-DaQ29pHXuXpwKjjp .node .label text,#mermaid-svg-DaQ29pHXuXpwKjjp .image-shape .label,#mermaid-svg-DaQ29pHXuXpwKjjp .icon-shape .label{text-anchor:middle;}#mermaid-svg-DaQ29pHXuXpwKjjp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DaQ29pHXuXpwKjjp .rough-node .label,#mermaid-svg-DaQ29pHXuXpwKjjp .node .label,#mermaid-svg-DaQ29pHXuXpwKjjp .image-shape .label,#mermaid-svg-DaQ29pHXuXpwKjjp .icon-shape .label{text-align:center;}#mermaid-svg-DaQ29pHXuXpwKjjp .node.clickable{cursor:pointer;}#mermaid-svg-DaQ29pHXuXpwKjjp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DaQ29pHXuXpwKjjp .arrowheadPath{fill:#333333;}#mermaid-svg-DaQ29pHXuXpwKjjp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DaQ29pHXuXpwKjjp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DaQ29pHXuXpwKjjp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DaQ29pHXuXpwKjjp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DaQ29pHXuXpwKjjp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DaQ29pHXuXpwKjjp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DaQ29pHXuXpwKjjp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DaQ29pHXuXpwKjjp .cluster text{fill:#333;}#mermaid-svg-DaQ29pHXuXpwKjjp .cluster span{color:#333;}#mermaid-svg-DaQ29pHXuXpwKjjp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DaQ29pHXuXpwKjjp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DaQ29pHXuXpwKjjp rect.text{fill:none;stroke-width:0;}#mermaid-svg-DaQ29pHXuXpwKjjp .icon-shape,#mermaid-svg-DaQ29pHXuXpwKjjp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DaQ29pHXuXpwKjjp .icon-shape p,#mermaid-svg-DaQ29pHXuXpwKjjp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DaQ29pHXuXpwKjjp .icon-shape .label rect,#mermaid-svg-DaQ29pHXuXpwKjjp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DaQ29pHXuXpwKjjp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DaQ29pHXuXpwKjjp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DaQ29pHXuXpwKjjp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 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 网关 (计量中间件) 客户端 #mermaid-svg-G1ZPZIyCOG80hMkt{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-G1ZPZIyCOG80hMkt .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-G1ZPZIyCOG80hMkt .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-G1ZPZIyCOG80hMkt .error-icon{fill:#552222;}#mermaid-svg-G1ZPZIyCOG80hMkt .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-G1ZPZIyCOG80hMkt .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-G1ZPZIyCOG80hMkt .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-G1ZPZIyCOG80hMkt .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-G1ZPZIyCOG80hMkt .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-G1ZPZIyCOG80hMkt .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-G1ZPZIyCOG80hMkt .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-G1ZPZIyCOG80hMkt .marker{fill:#333333;stroke:#333333;}#mermaid-svg-G1ZPZIyCOG80hMkt .marker.cross{stroke:#333333;}#mermaid-svg-G1ZPZIyCOG80hMkt svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-G1ZPZIyCOG80hMkt p{margin:0;}#mermaid-svg-G1ZPZIyCOG80hMkt .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-G1ZPZIyCOG80hMkt text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-G1ZPZIyCOG80hMkt .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-G1ZPZIyCOG80hMkt .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-G1ZPZIyCOG80hMkt .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-G1ZPZIyCOG80hMkt .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-G1ZPZIyCOG80hMkt #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-G1ZPZIyCOG80hMkt .sequenceNumber{fill:white;}#mermaid-svg-G1ZPZIyCOG80hMkt #sequencenumber{fill:#333;}#mermaid-svg-G1ZPZIyCOG80hMkt #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-G1ZPZIyCOG80hMkt .messageText{fill:#333;stroke:none;}#mermaid-svg-G1ZPZIyCOG80hMkt .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-G1ZPZIyCOG80hMkt .labelText,#mermaid-svg-G1ZPZIyCOG80hMkt .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-G1ZPZIyCOG80hMkt .loopText,#mermaid-svg-G1ZPZIyCOG80hMkt .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-G1ZPZIyCOG80hMkt .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-G1ZPZIyCOG80hMkt .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-G1ZPZIyCOG80hMkt .noteText,#mermaid-svg-G1ZPZIyCOG80hMkt .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-G1ZPZIyCOG80hMkt .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-G1ZPZIyCOG80hMkt .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-G1ZPZIyCOG80hMkt .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-G1ZPZIyCOG80hMkt .actorPopupMenu{position:absolute;}#mermaid-svg-G1ZPZIyCOG80hMkt .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-G1ZPZIyCOG80hMkt .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-G1ZPZIyCOG80hMkt .actor-man circle,#mermaid-svg-G1ZPZIyCOG80hMkt line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-G1ZPZIyCOG80hMkt :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 推理执行中... 计量中间件提取 usage 生成 UsageEvent par 异步处理 POST /v1/chat/completions 转发请求 响应(含 usage 字段) 发送 UsageEvent (异步、不阻塞响应) 返回响应 消费事件 增量更新实时用量 (ZSET/Hash) 写入明细记录 查询实时用量(毫秒级) 查询历史账单(秒级)

这个架构的关键设计是计量和响应解耦:网关在返回响应给客户端之前,只做一件同步的事------把用量事件写入 Kafka。Kafka 写入是异步且极快的(单毫秒级),不会明显增加延迟。后续的聚合、入库都是异步消费,不阻塞请求路径。

2.2 UsageEvent 的数据结构

每一条用量记录都是一个 UsageEvent,它是整个计量系统的原子单元:

go 复制代码
// 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 价格表设计

价格表是计费的基准,必须支持模型级别、租户级别的差异化定价:

go 复制代码
// 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 的核心价值在于:

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

3.2 事件分区策略

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

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

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

3.3 聚合服务设计

聚合服务消费 Kafka 事件,做三件事:
#mermaid-svg-Yid7QCbYVKtTv359{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Yid7QCbYVKtTv359 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Yid7QCbYVKtTv359 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Yid7QCbYVKtTv359 .error-icon{fill:#552222;}#mermaid-svg-Yid7QCbYVKtTv359 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Yid7QCbYVKtTv359 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Yid7QCbYVKtTv359 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Yid7QCbYVKtTv359 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Yid7QCbYVKtTv359 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Yid7QCbYVKtTv359 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Yid7QCbYVKtTv359 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Yid7QCbYVKtTv359 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Yid7QCbYVKtTv359 .marker.cross{stroke:#333333;}#mermaid-svg-Yid7QCbYVKtTv359 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Yid7QCbYVKtTv359 p{margin:0;}#mermaid-svg-Yid7QCbYVKtTv359 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Yid7QCbYVKtTv359 .cluster-label text{fill:#333;}#mermaid-svg-Yid7QCbYVKtTv359 .cluster-label span{color:#333;}#mermaid-svg-Yid7QCbYVKtTv359 .cluster-label span p{background-color:transparent;}#mermaid-svg-Yid7QCbYVKtTv359 .label text,#mermaid-svg-Yid7QCbYVKtTv359 span{fill:#333;color:#333;}#mermaid-svg-Yid7QCbYVKtTv359 .node rect,#mermaid-svg-Yid7QCbYVKtTv359 .node circle,#mermaid-svg-Yid7QCbYVKtTv359 .node ellipse,#mermaid-svg-Yid7QCbYVKtTv359 .node polygon,#mermaid-svg-Yid7QCbYVKtTv359 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Yid7QCbYVKtTv359 .rough-node .label text,#mermaid-svg-Yid7QCbYVKtTv359 .node .label text,#mermaid-svg-Yid7QCbYVKtTv359 .image-shape .label,#mermaid-svg-Yid7QCbYVKtTv359 .icon-shape .label{text-anchor:middle;}#mermaid-svg-Yid7QCbYVKtTv359 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Yid7QCbYVKtTv359 .rough-node .label,#mermaid-svg-Yid7QCbYVKtTv359 .node .label,#mermaid-svg-Yid7QCbYVKtTv359 .image-shape .label,#mermaid-svg-Yid7QCbYVKtTv359 .icon-shape .label{text-align:center;}#mermaid-svg-Yid7QCbYVKtTv359 .node.clickable{cursor:pointer;}#mermaid-svg-Yid7QCbYVKtTv359 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Yid7QCbYVKtTv359 .arrowheadPath{fill:#333333;}#mermaid-svg-Yid7QCbYVKtTv359 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Yid7QCbYVKtTv359 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Yid7QCbYVKtTv359 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Yid7QCbYVKtTv359 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Yid7QCbYVKtTv359 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Yid7QCbYVKtTv359 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Yid7QCbYVKtTv359 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Yid7QCbYVKtTv359 .cluster text{fill:#333;}#mermaid-svg-Yid7QCbYVKtTv359 .cluster span{color:#333;}#mermaid-svg-Yid7QCbYVKtTv359 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Yid7QCbYVKtTv359 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Yid7QCbYVKtTv359 rect.text{fill:none;stroke-width:0;}#mermaid-svg-Yid7QCbYVKtTv359 .icon-shape,#mermaid-svg-Yid7QCbYVKtTv359 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Yid7QCbYVKtTv359 .icon-shape p,#mermaid-svg-Yid7QCbYVKtTv359 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Yid7QCbYVKtTv359 .icon-shape .label rect,#mermaid-svg-Yid7QCbYVKtTv359 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Yid7QCbYVKtTv359 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Yid7QCbYVKtTv359 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Yid7QCbYVKtTv359 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Kafka

usage-events
Consumer Group

(按 TenantID 分区)

  1. 写明细

PostgreSQL

(每一行事件)
2. 更新实时用量

Redis

(累计计数器)
3. 更新聚合表

PostgreSQL

(按小时/天/月汇总)

go 复制代码
// 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 维护实时计数器:

go 复制代码
// 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 中间件整体结构

go 复制代码
// 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。

go 复制代码
// 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 可能短暂不可用。计量中间件必须有降级方案:

go 复制代码
// 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 价格表服务

go 复制代码
// 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 双向对账模型

#mermaid-svg-mCfiO6QphG8h1k3e{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-mCfiO6QphG8h1k3e .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-mCfiO6QphG8h1k3e .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-mCfiO6QphG8h1k3e .error-icon{fill:#552222;}#mermaid-svg-mCfiO6QphG8h1k3e .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-mCfiO6QphG8h1k3e .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-mCfiO6QphG8h1k3e .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-mCfiO6QphG8h1k3e .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-mCfiO6QphG8h1k3e .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-mCfiO6QphG8h1k3e .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-mCfiO6QphG8h1k3e .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-mCfiO6QphG8h1k3e .marker{fill:#333333;stroke:#333333;}#mermaid-svg-mCfiO6QphG8h1k3e .marker.cross{stroke:#333333;}#mermaid-svg-mCfiO6QphG8h1k3e svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-mCfiO6QphG8h1k3e p{margin:0;}#mermaid-svg-mCfiO6QphG8h1k3e .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-mCfiO6QphG8h1k3e .cluster-label text{fill:#333;}#mermaid-svg-mCfiO6QphG8h1k3e .cluster-label span{color:#333;}#mermaid-svg-mCfiO6QphG8h1k3e .cluster-label span p{background-color:transparent;}#mermaid-svg-mCfiO6QphG8h1k3e .label text,#mermaid-svg-mCfiO6QphG8h1k3e span{fill:#333;color:#333;}#mermaid-svg-mCfiO6QphG8h1k3e .node rect,#mermaid-svg-mCfiO6QphG8h1k3e .node circle,#mermaid-svg-mCfiO6QphG8h1k3e .node ellipse,#mermaid-svg-mCfiO6QphG8h1k3e .node polygon,#mermaid-svg-mCfiO6QphG8h1k3e .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-mCfiO6QphG8h1k3e .rough-node .label text,#mermaid-svg-mCfiO6QphG8h1k3e .node .label text,#mermaid-svg-mCfiO6QphG8h1k3e .image-shape .label,#mermaid-svg-mCfiO6QphG8h1k3e .icon-shape .label{text-anchor:middle;}#mermaid-svg-mCfiO6QphG8h1k3e .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-mCfiO6QphG8h1k3e .rough-node .label,#mermaid-svg-mCfiO6QphG8h1k3e .node .label,#mermaid-svg-mCfiO6QphG8h1k3e .image-shape .label,#mermaid-svg-mCfiO6QphG8h1k3e .icon-shape .label{text-align:center;}#mermaid-svg-mCfiO6QphG8h1k3e .node.clickable{cursor:pointer;}#mermaid-svg-mCfiO6QphG8h1k3e .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-mCfiO6QphG8h1k3e .arrowheadPath{fill:#333333;}#mermaid-svg-mCfiO6QphG8h1k3e .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-mCfiO6QphG8h1k3e .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-mCfiO6QphG8h1k3e .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mCfiO6QphG8h1k3e .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-mCfiO6QphG8h1k3e .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mCfiO6QphG8h1k3e .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-mCfiO6QphG8h1k3e .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-mCfiO6QphG8h1k3e .cluster text{fill:#333;}#mermaid-svg-mCfiO6QphG8h1k3e .cluster span{color:#333;}#mermaid-svg-mCfiO6QphG8h1k3e div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-mCfiO6QphG8h1k3e .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-mCfiO6QphG8h1k3e rect.text{fill:none;stroke-width:0;}#mermaid-svg-mCfiO6QphG8h1k3e .icon-shape,#mermaid-svg-mCfiO6QphG8h1k3e .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mCfiO6QphG8h1k3e .icon-shape p,#mermaid-svg-mCfiO6QphG8h1k3e .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-mCfiO6QphG8h1k3e .icon-shape .label rect,#mermaid-svg-mCfiO6QphG8h1k3e .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mCfiO6QphG8h1k3e .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-mCfiO6QphG8h1k3e .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-mCfiO6QphG8h1k3e :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 数据源3:财务系统
数据源2:推理引擎日志
数据源1:计量系统
漏计
重复
偏差
余额异常
PostgreSQL

usage_records 表
推理引擎

request_log 表
Payment

订单与充值
对账服务

Reconciliation Job

(每日凌晨运行)
对比检查
补录用量事件
标记重复事件

退款修正
告警 + 人工审核
冻结账户

对账服务的核心 SQL 逻辑------找出"推理引擎有记录但计量系统没有"的请求:

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

go 复制代码
// 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 成本分析:平台赚不赚钱

计费系统不仅要算"向用户收多少",还要算"平台花了多少",才能知道利润。这是平台运营的核心报表:

go 复制代码
// 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 核心表结构

sql 复制代码
-- 用量明细表(每一行一条事件)
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 余额扣减的原子性

余额扣减必须原子,否则会出现"超扣"问题(余额已经是负的了还在计费):

go 复制代码
// 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 发生,聚合到第二天------用户看到的日用量和自己的感知对不上。

go 复制代码
// 正确做法:存储 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 做唯一约束------重复插入会被数据库拒绝:

sql 复制代码
ALTER TABLE billing.usage_records
    ADD CONSTRAINT uniq_event_id UNIQUE (event_id);

插入时用 ON CONFLICT (event_id) DO NOTHING,保证重复事件不会重复计费。

7.4 免费额度的处理

大多数平台会给新用户免费额度(比如"注册送 50 万 Token")。免费额度的计量和付费额度走同一条管道,但在计费环节标记为"免费抵扣",不扣余额:

go 复制代码
// 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 拓扑如何影响调度决策?我们将设计多模型调度器、模型加载状态机,以及拓扑感知的调度策略。


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

相关推荐
我是慎独1 小时前
人工智能:现代方法读书笔记(八)
人工智能
不是光头 强2 小时前
Java 后端 AI 技术选型与学习路线
java·人工智能·学习
liutao8412042 小时前
TrainEye源码解读-01. 登录认证 · 前端篇
人工智能
船厂电气自动化ai大模型2 小时前
AI大模型与数学第42课:泰勒级数完整展开(神经网络近似核心)
人工智能·python·深度学习·算法·机器学习
老郑聊AI业财智造2 小时前
Qwen技术架构与源码深度剖析
人工智能·语言模型·架构·系统架构·软件工程
甲维斯2 小时前
Opus5自主解决Qwen3.8 27B本地接入Claude Code的BUG!
人工智能
晴天162 小时前
智能体 Harness 工程指南-Day24
人工智能
格林威2 小时前
C#图像像素放大:邻域平均、双线性插值实现像素放大的C#实现代码
开发语言·人工智能·数码相机·计算机视觉·c#·视觉检测·工业相机
阿里云大数据AI技术2 小时前
一句话即可用好 MaxCompute:AI 全能搭子 MaxAgent 来了——会运维、能分析
人工智能·agent