一次 LLM 请求的真实成本,可能来自多次模型调用、降级模型、外部工具和后台对账,而不是最终响应中的一组 Token。本文用 Node.js 实现不可变成本事件账本,在请求、用户和功能维度完成归因,并加入软预算告警与硬预算熔断。
Token 统计为什么不等于请求成本
如果只保存最终模型返回的 usage,很容易漏掉同一次业务请求中的其他费用。典型链路可能是:主模型第一次超时但已经产生 Token,第二次重试仍失败,随后切换备用模型;模型又调用了付费搜索工具,最后结果被写入缓存。
| 场景 | 是否产生费用 | 账本处理方式 |
|---|---|---|
| 模型成功调用 | 通常是 | 记录输入、输出 Token 和价格版本 |
| 超时或失败重试 | 取决于供应商计费结果 | 每次尝试单独记录,不能只保留成功调用 |
| 模型切换 | 是 | 保留实际模型、供应商和调用序号 |
| 缓存命中 | 通常无模型费 | 记录零金额事件,便于解释成本下降 |
| 工具调用 | 可能是 | 按次、按量或按工具自己的账单记录 |
| 后台账单修正 | 是 | 追加差额事件,不覆盖原始估算 |
因此,归因的中心应当是业务 requestId,而不是某一次供应商 API 调用。每个请求还要携带 userId、feature 等维度,才能回答“哪个用户花了钱”“摘要功能本周成本是多少”。
需要注意,失败调用是否计费、缓存是否收费以及 Token 口径都由实际供应商决定。工程实现应保存原始用量和账单来源,不能假设所有平台规则相同。
设计不可变的成本事件与价格快照
本文采用追加写事件,而不是反复更新一条“请求总成本”。一个实用的事件至少包含:
requestId:贯穿入口请求、重试、工具和对账;type:如model.call、tool.call、cost.adjustment;provider、model、attempt:说明费用在哪里产生;userId、feature:支持业务维度聚合;usage、amountMicros、currency:保存用量和整数金额;priceVersion、occurredAt:锁定调用发生时适用的价格;status、source:区分估算、结算以及数据来源。
示例使用微美元整数,即一美元等于 1,000,000 amountMicros,避免直接累加浮点数。生产环境如果金额可能超过 JavaScript 安全整数范围,应改用 BigInt、定点十进制库或数据库的 DECIMAL。
价格表也必须版本化。调价后不能用今天的价格重新计算上个月事件,而应按事件发生时间选择当时已生效的版本。价格配置最好经过发布审批,并保存供应商、币种、计价单位和来源链接。下文中的 demo-v1、demo-v2 仅用于演示,不代表任何真实模型报价。
异步对账同样采用追加事件:在线链路先写估算金额,稍后拿到准确用量或供应商账单后,再写入 实际金额 - 估算金额。这样既保留当时的决策依据,也能让所有事件之和收敛到结算成本。
可运行的 Node.js 成本账本
下面是一个仅依赖 Node.js 内置模块的完整示例,要求 Node.js 20 或更高版本。它提供 POST /chat 和 GET /costs,模拟重试、模型降级、缓存、工具费用和异步对账,并把事件追加到 cost-events.ndjson。
// server.js
const http = require('node:http');
const fs = require('node:fs');
const { randomUUID } = require('node:crypto');
const FILE = './cost-events.ndjson';
const events = fs.existsSync(FILE) && fs.readFileSync(FILE, 'utf8').trim()
? fs.readFileSync(FILE, 'utf8').trim().split('\n').map(JSON.parse)
: [];
const ledger = {
append(event) {
const row = { id: randomUUID(), currency: 'USD', occurredAt: new Date().toISOString(), ...event };
events.push(row);
fs.appendFileSync(FILE, JSON.stringify(row) + '\n');
return row;
},
total(test = () => true) {
return events.filter(test).reduce((sum, e) => sum + e.amountMicros, 0);
},
group(field) {
return events.reduce((out, e) => {
const key = e[field] || 'unknown';
out[key] = (out[key] || 0) + e.amountMicros;
return out;
}, {});
}
};
// 演示价格,不对应任何真实供应商。
const prices = [
{ version: 'demo-v1', model: 'demo-primary', effectiveFrom: '2025-01-01', inputPerMTok: 1_000_000, outputPerMTok: 2_000_000 },
{ version: 'demo-v2', model: 'demo-primary', effectiveFrom: '2025-07-01', inputPerMTok: 900_000, outputPerMTok: 1_800_000 },
{ version: 'demo-v1', model: 'demo-fallback', effectiveFrom: '2025-01-01', inputPerMTok: 1_500_000, outputPerMTok: 3_000_000 },
{ version: 'demo-v1', model: 'weather-tool', effectiveFrom: '2025-01-01', fixedMicros: 500 }
];
function quote(model, usage, at = new Date()) {
const price = prices
.filter(p => p.model === model && new Date(p.effectiveFrom) <= at)
.sort((a, b) => new Date(b.effectiveFrom) - new Date(a.effectiveFrom))[0];
if (!price) throw new Error(`missing price for ${model}`);
const amountMicros = price.fixedMicros ?? Math.round(
((usage.inputTokens || 0) * price.inputPerMTok +
(usage.outputTokens || 0) * price.outputPerMTok) / 1_000_000
);
return { amountMicros, priceVersion: price.version };
}
const reservations = new Map();
const limits = { softMicros: 30_000, hardMicros: 50_000 };
const budget = {
authorize(requestId, userId, reserveMicros) {
const spent = ledger.total(e => e.userId === userId);
const held = [...reservations.values()]
.filter(r => r.userId === userId)
.reduce((sum, r) => sum + r.amountMicros, 0);
const projected = spent + held + reserveMicros;
if (projected > limits.hardMicros) throw Object.assign(new Error('hard budget exceeded'), { statusCode: 429 });
reservations.set(requestId, { userId, amountMicros: reserveMicros });
if (projected >= limits.softMicros) {
ledger.append({ requestId, userId, feature: 'chat', type: 'budget.warning', amountMicros: 0, status: 'settled', source: 'budget', meta: { projected } });
}
},
hold(requestId, amountMicros) {
const current = reservations.get(requestId);
if (current) reservations.set(requestId, { ...current, amountMicros });
},
release(requestId) {
reservations.delete(requestId);
}
};
const cache = new Map();
async function simulatedModel(model, prompt, attempt) {
const usage = { inputTokens: Math.ceil(prompt.length / 4), outputTokens: 40 + attempt * 8 };
await new Promise(resolve => setTimeout(resolve, 20));
if (model === 'demo-primary' && prompt.includes('fallback')) throw Object.assign(new Error('simulated primary failure'), { usage });
if (prompt.includes('retry') && attempt === 1) throw Object.assign(new Error('simulated retry'), { usage });
return { text: `response from ${model}`, usage };
}
async function callModel(ctx, model, attempt) {
const estimated = { inputTokens: Math.ceil(ctx.prompt.length / 4), outputTokens: 64 };
const at = new Date();
let result;
try {
result = await simulatedModel(model, ctx.prompt, attempt);
return result.text;
} catch (error) {
result = { usage: error.usage || estimated };
throw error;
} finally {
const estimate = quote(model, estimated, at);
ledger.append({ ...ctx.base, type: 'model.call', provider: 'simulated', model, attempt, usage: estimated, ...estimate, status: 'estimated', source: 'application' });
const actualUsage = result?.usage || estimated;
ctx.settlements.push(new Promise(resolve => setTimeout(() => {
const actual = quote(model, actualUsage, at);
ledger.append({ ...ctx.base, type: 'cost.adjustment', provider: 'simulated', model, attempt, usage: actualUsage, priceVersion: actual.priceVersion, amountMicros: actual.amountMicros - estimate.amountMicros, status: 'settled', source: 'reconciliation' });
resolve();
}, 100)));
}
}
async function execute(ctx) {
if (ctx.prompt.includes('weather')) {
const tool = quote('weather-tool', {});
ledger.append({ ...ctx.base, type: 'tool.call', provider: 'simulated', model: 'weather-tool', usage: {}, ...tool, status: 'settled', source: 'application' });
}
for (const [model, attempts] of [['demo-primary', 2], ['demo-fallback', 1]]) {
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
const text = await callModel(ctx, model, attempt);
cache.set(ctx.prompt, text);
return text;
} catch {}
}
}
throw new Error('all models failed');
}
async function readJson(req) {
let body = '';
for await (const chunk of req) body += chunk;
return JSON.parse(body || '{}');
}
const server = http.createServer(async (req, res) => {
res.setHeader('content-type', 'application/json; charset=utf-8');
if (req.method === 'GET' && req.url === '/costs') {
return res.end(JSON.stringify({ totalMicros: ledger.total(), byUser: ledger.group('userId'), byFeature: ledger.group('feature'), events }, null, 2));
}
if (req.method !== 'POST' || req.url !== '/chat') {
res.statusCode = 404;
return res.end(JSON.stringify({ error: 'not found' }));
}
let requestId;
try {
const { userId, prompt, feature = 'chat' } = await readJson(req);
if (!userId || typeof prompt !== 'string' || prompt.length > 2000) throw Object.assign(new Error('invalid userId or prompt'), { statusCode: 400 });
requestId = randomUUID();
const base = { requestId, userId, feature };
if (cache.has(prompt)) {
ledger.append({ ...base, type: 'cache.hit', amountMicros: 0, status: 'settled', source: 'cache' });
return res.end(JSON.stringify({ requestId, cached: true, text: cache.get(prompt) }));
}
const reserveMicros = 8_000;
budget.authorize(requestId, userId, reserveMicros);
const ctx = { base, prompt, settlements: [] };
try {
const text = await execute(ctx);
res.end(JSON.stringify({ requestId, cached: false, text }));
} finally {
const booked = ledger.total(e => e.requestId === requestId);
budget.hold(requestId, Math.max(0, reserveMicros - booked));
Promise.allSettled(ctx.settlements).finally(() => budget.release(requestId));
}
} catch (error) {
if (requestId) budget.release(requestId);
res.statusCode = error.statusCode || 502;
res.end(JSON.stringify({ requestId, error: error.message }));
}
});
server.listen(3000, () => console.log('http://localhost:3000'));
启动并测试:
node server.js
curl -X POST http://localhost:3000/chat \
-H 'content-type: application/json' \
-d '{"userId":"u-1","feature":"assistant","prompt":"retry and weather"}'
curl http://localhost:3000/costs
把提示词改成包含 fallback,可以观察主模型两次失败后切换备用模型;再次发送完全相同的提示词,则会产生 cache.hit。等待约 100 毫秒后查询 /costs,还能看到异步追加的差额事件。
聚合、异步对账与预算护栏
请求成本等于相同 requestId 下所有金额事件之和,包括负数调整。用户和功能成本则分别按 userId、feature 分组。生产查询通常交给 PostgreSQL、ClickHouse 或数据仓库完成,NDJSON 只适合本地演示和低并发调试。
软预算与硬预算的目的不同:
| 机制 | 行为 | 适用场景 |
|---|---|---|
| 软预算 | 记录告警但继续服务 | 趋势异常、接近月度额度 |
| 硬预算 | 请求执行前拒绝 | 防止失控任务继续消费 |
| 预留额度 | 暂时占用最坏情况成本 | 避免并发请求同时穿透上限 |
示例在调用前预留 8,000 微美元,在线估算入账后只保留剩余额度,异步对账结束再释放。硬预算要真正可靠,预留值必须来自可执行的上界,例如输入长度限制、max_tokens、最大重试次数、允许调用的工具及其次数;如果工具费用没有上限,就不能声称熔断是严格的。
当前预算状态只存在单进程内存中,多实例部署会发生竞态。实际系统应使用数据库事务、带条件的原子更新或 Redis Lua 脚本完成“检查加预留”,并为预留设置过期时间。月度预算还要明确账期时区、退款处理、共享缓存归属和内部补贴规则。
供应商账单往往晚于在线请求。对账任务应使用稳定的供应商调用 ID 去重,把账单行关联到成本事件;无法关联的费用进入待处理账户,而不是随意分摊。调整事件还应带上对账批次、原始账单位置和幂等键,确保任务重跑不会重复入账。
总结
- 成本归因应围绕业务请求展开,每次重试、模型切换和工具调用都生成独立事件;
- 缓存命中也值得记录,它解释了为什么请求存在但模型费用为零;
- 价格必须版本化,并按事件发生时间选择快照,历史成本不能套用当前价格;
- 在线估算与供应商结算之间的差异,应通过不可变调整事件异步对账;
- 请求、用户和功能成本都可由同一事件账本聚合得到;
- 软预算负责告警,硬预算依赖原子预留以及输入、输出、重试和工具次数的明确上限。
这套实现没有绑定具体 LLM SDK。接入真实供应商时,只需在适配层读取其真实 usage、调用 ID 和账单数据,并保留同样的事件边界与预算流程。
评论