把每笔 AI 开销记到具体客户头上:外部用户 ID、generation 回查、每单毛利
一人公司接单,报价通常按人天或者按件。AI 进来之后多了一项成本,它有几个让人难受的特点:每单不一样、跟客户打交道的时间不成比例、而且账单上只有一个总数。
你最后会知道这个月总共花了 3200 元,但说不出张总那单是赚是赔。
这不是记账问题,是定价问题。不知道单笔成本,你就没法判断哪类活该涨价、哪类客户该拒。
做法分三步
第一步:给每个客户一个内部 ID。
不要用工号以外的任何真实标识。手机号、邮箱、公司全称都别写进去——这个 ID 会进第三方系统的日志。用 c_0007 这种内部编号,映射表留在你自己手里。
第二步:调用时带上它,并把回执 ID 存下来。
OpenRouter 兼容 OpenAI 的请求体,里面那个 user 字段会被记进这次调用的记录。同时,每次响应都会返回一个 id,形如 gen-3bhGkxlo4XFrqiabUM7NDtwDzWwG。这两个东西凑在一起,就是归因的全部依据。
先手动发一次请求,回查确认 external_user 里确实是你要的那个 ID,再批量接进生产。这一步别跳,各家对这个字段的处理并不一致。
第三步:隔天回查真实成本,而不是当时估算。
响应里的 usage 是当时算出来的,它不含缓存折扣,也不反映实际路由到了哪家供应商。真正结算口径要等这次调用落定之后去查。
脚本一:调用时落一条记录
// scripts/ai-cost/log-call.mjs
// 在业务代码里 import 这个 send(),替换你原来直接 fetch 的地方
import { appendFileSync, mkdirSync } from 'node:fs';
const QUEUE = 'ai-cost/queue.jsonl';
mkdirSync('ai-cost', { recursive: true });
export async function send({ clientId, jobId, body }) {
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ ...body, user: clientId }),
});
if (!res.ok) throw new Error(`openrouter ${res.status}: ${await res.text()}`);
const data = await res.json();
appendFileSync(QUEUE, JSON.stringify({
clientId,
jobId,
genId: data.id,
model: data.model,
at: new Date().toISOString(),
estPrompt: data.usage?.prompt_tokens ?? null,
estCompletion: data.usage?.completion_tokens ?? null,
}) + '\n');
return data;
}
关键点:user: clientId 那一句,和把 data.id 写进队列的那一句。少了任何一个,后面都归不了因。
流式调用也一样——data.id 在流的第一个 chunk 里就有了,别等到结束才取。
脚本二:回查并汇总
// scripts/ai-cost/by-client.mjs
// 用法:OPENROUTER_API_KEY=xxx node scripts/ai-cost/by-client.mjs
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
const KEY = process.env.OPENROUTER_API_KEY;
if (!KEY) { console.error('需要 OPENROUTER_API_KEY'); process.exit(1); }
if (!existsSync('ai-cost/queue.jsonl')) { console.error('没有队列文件'); process.exit(1); }
const rows = readFileSync('ai-cost/queue.jsonl', 'utf8')
.split('\n').filter(Boolean).map(JSON.parse);
const settle = new Map();
const out = [];
for (const r of rows) {
const res = await fetch(`https://openrouter.ai/api/v1/generation?id=${encodeURIComponent(r.genId)}`, {
headers: { authorization: `Bearer ${KEY}` },
});
if (!res.ok) {
out.push({ ...r, error: `${res.status}` });
continue;
}
const g = await res.json().then((x) => x.data).catch(() => null);
if (!g) { out.push({ ...r, error: 'no data' }); continue; }
settle.set(r.genId, true);
out.push({
clientId: r.clientId,
jobId: r.jobId,
day: r.at.slice(0, 10),
model: g.model,
provider: g.provider_name,
externalUser: g.external_user,
cost: g.total_cost,
upstream: g.upstream_inference_cost,
cacheDiscount: g.cache_discount,
promptTokens: g.tokens_prompt,
completionTokens: g.tokens_completion,
cachedTokens: g.native_tokens_cached,
latency: g.latency,
});
}
const head = 'day,clientId,jobId,model,provider,externalUser,cost,upstream,cacheDiscount,promptTokens,completionTokens,cachedTokens,latency';
writeFileSync('ai-cost/raw.csv', [head, ...out.map((o) => [
o.day ?? '', o.clientId, o.jobId, o.model ?? '', o.provider ?? '', o.externalUser ?? '',
o.cost ?? '', o.upstream ?? '', o.cacheDiscount ?? '',
o.promptTokens ?? '', o.completionTokens ?? '', o.cachedTokens ?? '', o.latency ?? '',
].join(','))].join('\n'));
const byClient = new Map();
for (const o of out) {
if (typeof o.cost !== 'number') continue;
const cur = byClient.get(o.clientId) ?? { cost: 0, calls: 0, cached: 0, prompt: 0 };
cur.cost += o.cost; cur.calls += 1;
cur.cached += o.cachedTokens ?? 0; cur.prompt += o.promptTokens ?? 0;
byClient.set(o.clientId, cur);
}
console.log('clientId,calls,cost_usd,cached_tokens,cache_hit_share');
for (const [id, v] of [...byClient].sort((a, b) => b[1].cost - a[1].cost)) {
const share = v.prompt + v.cached ? (v.cached / (v.prompt + v.cached) * 100).toFixed(1) : '0.0';
console.log(`${id},${v.calls},${v.cost.toFixed(4)},${v.cached},${share}%`);
}
const left = rows.filter((r) => !settle.has(r.genId)).length;
console.log(`\nsettled ${settle.size}/${rows.length}, pending ${left}`);
跑完你会拿到两张表:ai-cost/raw.csv 是逐笔明细,控制台那张是每个客户的总花费、调用次数和缓存命中占比。
最后那一列是这份表最有用的东西。缓存命中占比低的客户,说明你每次都在重新发送整个前缀——通常意味着系统提示词或上下文拼装里有变量在动,把那些变量挪到末尾就能吃上折扣。
怎么知道自己做对了
5 条,全过才算数:
- 随机抽 5 个客户,你能在 30 秒内说出他们上个月各自花了多少。 说不出就是没建成。
externalUser那一列和clientId完全对得上。 出现 null 或者串号,说明user字段没生效,回去查第一步。raw.csv里有至少 3 个不同的 provider。 只有一家说明你没走自动路由,或者量太小;三笔以上来自不同供应商,才能看出真实的单价波动。cacheDiscount至少有 20% 的行不是空的。 全是 null 意味着你一分缓存折扣都没拿到,这是白送的钱。- 你拿这份表改过一次报价或者拒过一个客户。 建完不看、看完了不动作,这套东西就是装饰。
几个坑
- generation 记录不是永久可查的。 别攒一个月再回查,隔天或者每周跑一次最稳。脚本末尾的
pending就是提醒你有多少条已经查不到了。 - 一个请求可能被路由到多家供应商重试。
provider_responses里会有好几个,成本一律以total_cost为准,不要用upstream_inference_cost去加总。 total_cost和你当时估算的差很多,通常是好事。 差在缓存折扣和供应商比你选的便宜。差得特别大反而要查一下是不是被路由到了你不想用的量产品质档。- 别把客户真名写进
external_user。 这个字段会落到第三方的日志里。内部编号加本地映射表,是唯一干净的做法。 - 这套只覆盖走 OpenRouter 的调用。 直连 OpenAI、Anthropic 或者国内厂商的部分要另开一列,否则你的”每客户成本”是残缺的,比不记更危险——你会以为它是全的。
最后一句
这套东西建起来一个下午,之后每周五分钟。
它换来的不是省下的那点钱,是一个判断能力:当某个客户说”这单能不能便宜点”的时候,你知道便宜多少是在赔钱,而不是靠感觉接或者靠感觉拒。