一次 LLM 请求的真实成本,可能来自多次模型调用、降级模型、外部工具和后台对账,而不是最终响应中的一组 Token。本文用 Node.js 实现不可变成本事件账本,在请求、用户和功能维度完成归因,并加入软预算告警与硬预算熔断。

Token 统计为什么不等于请求成本

如果只保存最终模型返回的 usage,很容易漏掉同一次业务请求中的其他费用。典型链路可能是:主模型第一次超时但已经产生 Token,第二次重试仍失败,随后切换备用模型;模型又调用了付费搜索工具,最后结果被写入缓存。

场景是否产生费用账本处理方式
模型成功调用通常是记录输入、输出 Token 和价格版本
超时或失败重试取决于供应商计费结果每次尝试单独记录,不能只保留成功调用
模型切换保留实际模型、供应商和调用序号
缓存命中通常无模型费记录零金额事件,便于解释成本下降
工具调用可能是按次、按量或按工具自己的账单记录
后台账单修正追加差额事件,不覆盖原始估算

因此,归因的中心应当是业务 requestId,而不是某一次供应商 API 调用。每个请求还要携带 userIdfeature 等维度,才能回答“哪个用户花了钱”“摘要功能本周成本是多少”。

需要注意,失败调用是否计费、缓存是否收费以及 Token 口径都由实际供应商决定。工程实现应保存原始用量和账单来源,不能假设所有平台规则相同。

设计不可变的成本事件与价格快照

本文采用追加写事件,而不是反复更新一条“请求总成本”。一个实用的事件至少包含:

  • requestId:贯穿入口请求、重试、工具和对账;
  • type:如 model.calltool.callcost.adjustment
  • providermodelattempt:说明费用在哪里产生;
  • userIdfeature:支持业务维度聚合;
  • usageamountMicroscurrency:保存用量和整数金额;
  • priceVersionoccurredAt:锁定调用发生时适用的价格;
  • statussource:区分估算、结算以及数据来源。

示例使用微美元整数,即一美元等于 1,000,000 amountMicros,避免直接累加浮点数。生产环境如果金额可能超过 JavaScript 安全整数范围,应改用 BigInt、定点十进制库或数据库的 DECIMAL

价格表也必须版本化。调价后不能用今天的价格重新计算上个月事件,而应按事件发生时间选择当时已生效的版本。价格配置最好经过发布审批,并保存供应商、币种、计价单位和来源链接。下文中的 demo-v1demo-v2 仅用于演示,不代表任何真实模型报价。

异步对账同样采用追加事件:在线链路先写估算金额,稍后拿到准确用量或供应商账单后,再写入 实际金额 - 估算金额。这样既保留当时的决策依据,也能让所有事件之和收敛到结算成本。

可运行的 Node.js 成本账本

下面是一个仅依赖 Node.js 内置模块的完整示例,要求 Node.js 20 或更高版本。它提供 POST /chatGET /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 下所有金额事件之和,包括负数调整。用户和功能成本则分别按 userIdfeature 分组。生产查询通常交给 PostgreSQL、ClickHouse 或数据仓库完成,NDJSON 只适合本地演示和低并发调试。

软预算与硬预算的目的不同:

机制行为适用场景
软预算记录告警但继续服务趋势异常、接近月度额度
硬预算请求执行前拒绝防止失控任务继续消费
预留额度暂时占用最坏情况成本避免并发请求同时穿透上限

示例在调用前预留 8,000 微美元,在线估算入账后只保留剩余额度,异步对账结束再释放。硬预算要真正可靠,预留值必须来自可执行的上界,例如输入长度限制、max_tokens、最大重试次数、允许调用的工具及其次数;如果工具费用没有上限,就不能声称熔断是严格的。

当前预算状态只存在单进程内存中,多实例部署会发生竞态。实际系统应使用数据库事务、带条件的原子更新或 Redis Lua 脚本完成“检查加预留”,并为预留设置过期时间。月度预算还要明确账期时区、退款处理、共享缓存归属和内部补贴规则。

供应商账单往往晚于在线请求。对账任务应使用稳定的供应商调用 ID 去重,把账单行关联到成本事件;无法关联的费用进入待处理账户,而不是随意分摊。调整事件还应带上对账批次、原始账单位置和幂等键,确保任务重跑不会重复入账。

总结

  • 成本归因应围绕业务请求展开,每次重试、模型切换和工具调用都生成独立事件;
  • 缓存命中也值得记录,它解释了为什么请求存在但模型费用为零;
  • 价格必须版本化,并按事件发生时间选择快照,历史成本不能套用当前价格;
  • 在线估算与供应商结算之间的差异,应通过不可变调整事件异步对账;
  • 请求、用户和功能成本都可由同一事件账本聚合得到;
  • 软预算负责告警,硬预算依赖原子预留以及输入、输出、重试和工具次数的明确上限。

这套实现没有绑定具体 LLM SDK。接入真实供应商时,只需在适配层读取其真实 usage、调用 ID 和账单数据,并保留同样的事件边界与预算流程。