Token 实时计数与成本计算实操方法(AI 大模型评测平台的开销控制)

上千次大模型调用一天跑下来,成本却按”次数”记账,月底账单必然对不上。当时对比过几种方案:靠模型厂商控制台看用量,数据分散在 OpenAI、Claude、国内各家平台,没法按用户汇总;在业务代码里手动记录,又容易漏,没人保证每次调用都走同一段代码。最后我从架构上把”调用即计数、按模型计价、按用户预算预警”做成一条完整链路,让每一次大模型调用都有账可查、有上限可控。这套链路跑到现在,成本核算误差基本可以忽略,下面把设计过程完整记下来。

一、为什么 token 计数不能省

不同模型的计费单位差别很大:OpenAI 按 1k token 计、Claude 按 input/output 分开计、国内模型按字符计。如果只是粗略”按调用次数计费”,平台的成本预估会偏差 3-5 倍。精确到 token 后,才能告诉用户”这次调用花了你 0.0123 元”。

刚开始做的时候,产品经理提过”按次数算就行,用户也看不懂 token”。我坚持按 token 计价,理由是评测平台的核心价值就是比出模型的真实性价比——如果连成本都算不准,用户拿什么数据选模型?按 token 计费不仅让账单可解释,还能在评测报告里直接展示”每 1k token 的性价比”,这部分数据后来成了平台很有说服力的功能之一。

二、计费模型维护

计费要有依据,平台用一张 model_price 表存每个模型的价目:

CREATE TABLE model_price (
  id            BIGINT PRIMARY KEY AUTO_INCREMENT,
  model_code    VARCHAR(64) NOT NULL UNIQUE,
  input_price   DECIMAL(10,6) NOT NULL,  -- 每 1k token 的价格(元)
  output_price  DECIMAL(10,6) NOT NULL,
  currency      VARCHAR(8)   NOT NULL DEFAULT 'CNY',
  effective_at  DATETIME     NOT NULL,
  UNIQUE KEY uk_model_code (model_code)
);

后端启动时把价目加载到内存 Caffeine 缓存,10 分钟过期或主动 invalidate,保证价格变更后能快速生效。

这个”内存缓存 + 定时失效”的设计是被线上事故逼出来的。最早每次算费都查数据库,高峰期几百个并发调用,价目表成了热点查询,数据库扛不住。后来改成启动时全量加载到内存,查询变纯内存操作。effective_at 字段是另一个教训:模型厂商调价是常态,留了生效时间,历史上每笔调用都能还原到当时的真实价格,后面核算老账单时少了很多扯皮。

价目表加载进内存后,我还给它加了一层兜底:缓存里查不到模型时,回源查数据库并塞回缓存,避免数据库刚更新、缓存还没刷新的空窗期把请求打到 DB。这两个小设计合在一起,价格查询这一环基本没再出过问题。

三、调用拦截与计数

计数不能散落在业务代码里,所以做在 OpenAI/Claude/国内模型的统一 Client 拦截层:

public class TokenCountInterceptor {
    public ChatResult call(ChatRequest req) {
        long before = System.currentTimeMillis();
        ChatResult res = provider.chat(req);
        long cost = System.currentTimeMillis() - before;

        // 1) 拿用量(不同 provider 字段不同)
        TokenUsage u = res.getUsage();
        long input = u.getPromptTokens();
        long output = u.getCompletionTokens();

        // 2) 算钱
        ModelPrice p = priceService.get(req.getModelCode());
        BigDecimal fee = p.getInputPrice().multiply(BigDecimal.valueOf(input))
                          .add(p.getOutputPrice().multiply(BigDecimal.valueOf(output)))
                          .divide(BigDecimal.valueOf(1000), 6, RoundingMode.HALF_UP);

        // 3) 写记录
        usageLogService.log(req.getUserId(), req.getModelCode(), 
                             input, output, fee, cost, "ok");

        // 4) 触发预算检查
        budgetService.check(req.getUserId());

        return res;
    }
}

这段代码解决”每次调用都可靠地记录用量并算费”的问题。拦截器位置很关键:所有模型调用都经过统一 Client,在这个位置做计数,天然覆盖全量请求,业务代码完全无感知。算费统一用 BigDecimal 做,杜绝 float 精度问题;费用计算在写入前就完成,所以日志里每行记录的费用和实际扣费永远一致。

拦截器还有个设计细节:计数和算费放在 provider 调用成功返回之后,而不是调用之前预估。之前考虑过用输入字符数预估费用,实时性强但偏差大,流式输出时尤其离谱;实际以厂商返回的 usage 为准,账目才经得起对账。上线后我们拿日志里的 fee 和月底厂商账单对了一次,金额误差基本来自浮点四舍五入,可以忽略不计。

四、响应里的 usage 怎么拿

不同厂商的用量字段命名差异很大,这是接入多家模型时绕不开的适配工作。下面整理了一下各家字段:

Provider 用量字段 备注
OpenAI 兼容 usage.prompt_tokens / completion_tokens 大部分国产模型兼容
Claude usage.input_tokens / output_tokens 字段名不同,要适配
通义/文心/智谱 自定义 usage.prompt_tokens 多数参考 OpenAI 字段
Gemini usageMetadata.promptTokenCount/candidatesTokenCount 命名风格不同

平台做了 UsageExtractor 适配器,把各 provider 归一化到 TokenUsage。

这个适配器踩过的坑是”部分模型不返回 usage”。早期接入某国产模型时,它在某些流式场景下直接不吐 usage 字段,拿到 null 就 NPE,一次调用挂了整个评测任务。后来适配器对所有 provider 都做了空值兜底,拿不到 usage 就标记 usage_unavailable,费用按字符数估算,绝不让计数环节把正常调用拖崩。

另一个容易忽视的点是流式接口的 usage 出现在最后一个 chunk,适配器必须等完整响应才能取值,这也直接影响了后面预算检查的触发时机。这些问题都是实际接入过程中一个个发现的,适配器也因此改了三轮才算稳定。

五、成本入库与聚合

每次调用产生一行 model_usage_log:

字段 类型 说明
id bigint 主键
user_id bigint 调用方
model_code varchar 模型编码
input_tokens int 输入 token
output_tokens int 输出 token
fee decimal(10,6) 实际费用
duration_ms int 耗时
status varchar ok/error
created_at datetime 调用时间

单表写入量大,月分表(model_usage_log_202608)保证查询性能。聚合查询走 ClickHouse 或定时归档到宽表。

分表是上线三个月后加的。最初单表几百万行还能撑,到千万级之后,按 user_id 查月度账单的 SQL 开始明显变慢,慢查询日志里天天见。改成按月分表后,查询天然落在当月分区,加上 user_id + created_at 联合索引,账单查询回到毫秒级。聚合报表则定时把当月明细滚到宽表,统计口径固定,避免每次报表都全表扫。

六、预算预警机制

成本控制光记账不够,还要在超支前拦住用户。预算按用户配额实现,每用户有日/月预算。budgetService.check 流程:

  1. 查用户 user.budget_daily 与 user.budget_monthly;
  2. 用 Redis INCRBYFLOAT 累加本次费用,O(1) 高性能;
  3. 累加后查 Redis 中 user:budget:daily:{userId} 与 user:budget:monthly:{userId};
  4. 超阈值推 Kafka 事件,告警服务发邮件/站内信。
public void check(long userId) {
    BigDecimal today = redis.incrbyfloat(dayKey(userId), fee)
                            .setScale(6, RoundingMode.HALF_UP);
    if (today.compareTo(user.getBudgetDaily()) > 0) {
        alertService.send(userId, "日预算已超", today);
    }
}

这套流程用 Redis 累加而不是每次查 MySQL,是想扛住评测高峰期的调用频率。当时也评估过用数据库行级锁或者 SELECT … FOR UPDATE 做,并发一高延迟就上来;Redis INCRBYFLOAT 单键原子累加,性能稳、实现也简单。告警走 Kafka 解耦,就算告警服务暂时不可用,也不影响主链路计费。

七、踩过的坑

整个链路跑下来,很隐蔽的问题几乎都出现在”计费数据一致性”上。下面几条是平台实测踩过的,写在这里给后人避坑:

  • 流式调用 token 取不到:SSE 流式返回时 usage 在最后一个 chunk 才出现,拦截器要 collect 完所有 chunk 再算费用,不能按 chunk 实时累加。
  • 价格变动后老调用计费错乱:老调用按调用时刻的价格算,不要回查最新价目。
  • decimal 精度:用 BigDecimal 存费用,MySQL 配 DECIMAL(10,6),禁止 float。
  • Redis INCRBYFLOAT 浮点累积误差:一天千次调用误差约 1e-9,可接受;月报生成时用 SQL SUM(fee) 重新算。

这些坑处理完后,计费链路的准确性经受住了月结考验,对账基本一次通过。复盘下来,计费这类功能最重要的不是技术多炫,而是每个环节都留痕、可回溯——调用有日志、价格有生效时间、费用有明细,出了问题能回答”这笔钱怎么来的”,比任何优化都重要。

常见问题(FAQ)

Q1:流式响应怎么准确统计 token?

必须等 SSE 全部接收完,再从最后一个 chunk 读 usage。中途断流用 output_tokens_estimate 按字符数估算。

Q2:为什么不用消息队列异步写日志?

评估过 Kafka,但拦截器是同步链路,异步会丢失”调用即失败”上下文。当前直接写 MySQL 性能够(单条 INSERT 约 1ms),后面量大再换。

Q3:免费模型要不要也写日志?

写。日志是产品运营决策依据,即使不收费也要算调用次数、响应时长,给”是否值得接入”提供数据。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部