上千次大模型调用一天跑下来,成本却按”次数”记账,月底账单必然对不上。当时对比过几种方案:靠模型厂商控制台看用量,数据分散在 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 流程:
- 查用户
user.budget_daily与user.budget_monthly; - 用 Redis
INCRBYFLOAT累加本次费用,O(1) 高性能; - 累加后查 Redis 中
user:budget:daily:{userId}与user:budget:monthly:{userId}; - 超阈值推 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:免费模型要不要也写日志?
写。日志是产品运营决策依据,即使不收费也要算调用次数、响应时长,给”是否值得接入”提供数据。