每个月 LLM 账单 6 万块,CFO 来问你"AI 写作功能花了多少钱",你答不上来。这不是成本控制问题,是工程问题。
一、痛点:账单只有 API key,没有业务维度
你上线了一个 AI 产品,有三个核心功能:
chat:对话助手summarize:文档摘要codegen:代码生成
月底 大模型账单来了,写着:输入 1.2 亿 tokens,输出 3800 万 tokens,合计 $2,340。
问题来了:
- 哪个功能最贵?
- 某个 VIP 用户是不是在滥用
codegen? - A/B 测试中的新 prompt 让成本涨了多少?
- 后端有一个每天定时跑的摘要 job,它占了多少?
答案:你不知道。因为 LLM provider 的账单只按 API key 计费,不知道是哪行代码、哪个用户、哪个功能触发的。
这就是 Cost Attribution(成本归因)要解决的问题:把每一笔 token 消耗,打上业务标签,让你能回答"某个功能花了多少钱"。
二、架构概览:三层 Attribution 链路
一个生产可用的 Cost Attribution 系统由三层组成:
ini
┌─────────────────────────────────────────────────┐
│ 业务代码层 │
│ 每次 LLM 调用携带元数据: │
│ feature=chat, user_id=u123, team=growth │
└────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ LLM Gateway 层 │
│ 拦截请求,提取 metadata + token usage │
│ 计算估算成本,写入 Trace Store │
└────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Cost Aggregation 层 │
│ 按 feature/user/team/model 聚合 │
│ 生成每日/每月成本报表 │
│ 触发预算告警 │
└─────────────────────────────────────────────────┘
这三层的核心思路:在请求入口打标,在 gateway 层采集,在 store 层聚合。不要在应用层手工累加 token,那条路充满陷阱。
三、第一层:请求元数据标注
3.1 定义你的 Attribution 维度
不同规模的团队需要的维度不同:
| 团队规模 | 推荐维度 |
|---|---|
| 1-5 人 | feature + env |
| 5-20 人 | feature + user_id + env |
| 20+ 人 | feature + user_id + team + experiment_id + env |
| 多租户 SaaS | 以上 + tenant_id + pricing_plan |
不要过度设计。先从 feature 和 user_id 开始,够用再加。
3.2 通过 HTTP Header 传递元数据
大多数 LLM Gateway(LiteLLM、Portkey、自建)都支持通过自定义 header 传递元数据:
typescript
// lib/llm-client.ts
import OpenAI from "openai"; // 使用兼容接口
interface LLMCallContext {
feature: string;
userId?: string;
team?: string;
experimentId?: string;
traceId?: string; // 绑定到 APM trace
}
function buildHeaders(ctx: LLMCallContext): Record<string, string> {
return {
"X-Attribution-Feature": ctx.feature,
"X-Attribution-User": ctx.userId ?? "anonymous",
"X-Attribution-Team": ctx.team ?? "unknown",
"X-Attribution-Experiment": ctx.experimentId ?? "",
"X-Trace-Id": ctx.traceId ?? crypto.randomUUID(),
};
}
export async function callLLM(
params: OpenAI.ChatCompletionCreateParams,
ctx: LLMCallContext
) {
const client = new OpenAI({ // 指向国产模型 Gateway
baseURL: process.env.LLM_GATEWAY_URL, // 指向 gateway,不直接打 Anthropic
defaultHeaders: buildHeaders(ctx),
});
return client.messages.create(params);
}
调用方式:
typescript
// 对话功能
const response = await callLLM(
{ model: "deepseek-chat", messages, max_tokens: 1024 },
{ feature: "chat", userId: req.user.id, team: "product" }
);
// 定时摘要 job
const summary = await callLLM(
{ model: "qwen-turbo", messages: summaryPrompt, max_tokens: 512 },
{ feature: "summarize-job", userId: "system", team: "infra" }
);
3.3 Request Body 里传 metadata(Portkey 风格)
如果你用 Portkey,可以直接在请求体里加 metadata 字段,不需要改 header:
python
# Python 示例
from portkey_ai import Portkey
client = Portkey(
api_key="YOUR_PORTKEY_KEY",
virtual_key="YOUR_ANTHROPIC_VIRTUAL_KEY",
metadata={
"feature": "chat",
"user_id": user_id,
"team": "growth",
"_user": user_id, # Portkey 原生 per-user cost tracking 字段
}
)
response = client.chat.completions.create(
model="deepseek-chat",
messages=messages
)
Portkey 会自动把 metadata 存入 log,并在 dashboard 上支持按任意 metadata key 过滤和聚合成本。
四、第二层:LLM Gateway 采集
4.1 为什么不能在应用层手动累加
你可能想:"我在每次 API 调用后,把 usage.input_tokens 写到 Redis 不就行了?"
这条路有 5 个致命问题:
- Streaming 丢数据 :流式响应的 usage 数据在最后一个 SSE chunk 的
[DONE]消息里,处理不对就漏计 - 重试翻倍:请求失败重试,两次都计费,attribution 却可能只记一次
- Tool calling 乘数:一次用户请求触发 5 次 tool call,每次都是独立 LLM 调用,应用层容易漏
- 异常路径:超时、客户端断连、服务崩溃,正常 logging 路径失效
- 多实例一致性:水平扩展后多个实例各自累加,汇总有竞态
正确做法:在请求路径中的单点(Gateway)做采集,应用层只负责打标签。
4.2 自建 Gateway 的采集中间件
如果你用 Express + http-proxy 自建了一个轻量 Gateway:
typescript
// gateway/middleware/cost-attribution.ts
import { Request, Response, NextFunction } from "express";
import { db } from "../db";
interface TokenUsage {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
}
// 从不同 provider 的响应中提取 token usage
function extractUsage(responseBody: unknown, provider: string): TokenUsage {
const body = responseBody as Record<string, unknown>;
if (provider === "deepseek" || provider === "qwen") {
const usage = body.usage as Record<string, number> | undefined;
return {
inputTokens: usage?.input_tokens ?? 0,
outputTokens: usage?.output_tokens ?? 0,
cacheReadTokens: usage?.cache_read_input_tokens ?? 0,
cacheWriteTokens: usage?.cache_creation_input_tokens ?? 0,
};
}
if (provider === "openai-compat") {
const usage = body.usage as Record<string, unknown> | undefined;
const promptDetails = usage?.prompt_tokens_details as Record<string, number> | undefined;
return {
inputTokens: (usage?.prompt_tokens as number) ?? 0,
outputTokens: (usage?.completion_tokens as number) ?? 0,
cacheReadTokens: promptDetails?.cached_tokens ?? 0,
cacheWriteTokens: 0,
};
}
return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
}
// 成本估算(单位:美分,避免浮点误差)
const PRICING_CENTS_PER_MILLION: Record<string, {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}> = {
"deepseek-chat": { input: 300, output: 1500, cacheRead: 30, cacheWrite: 375 },
"qwen-turbo": { input: 80, output: 400, cacheRead: 8, cacheWrite: 100 },
"qwen-max": { input: 250, output: 1000, cacheRead: 125, cacheWrite: 0 },
"qwen-plus": { input: 15, output: 60, cacheRead: 8, cacheWrite: 0 },
};
function estimateCostCents(usage: TokenUsage, model: string): number {
const pricing = PRICING_CENTS_PER_MILLION[model];
if (!pricing) return 0;
return Math.round(
(usage.inputTokens * pricing.input +
usage.outputTokens * pricing.output +
usage.cacheReadTokens * pricing.cacheRead +
usage.cacheWriteTokens * pricing.cacheWrite) /
1_000_000
);
}
export function costAttributionMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
const startTime = Date.now();
// 从 header 提取 attribution 元数据
const attribution = {
feature: req.headers["x-attribution-feature"] as string ?? "unknown",
userId: req.headers["x-attribution-user"] as string ?? "anonymous",
team: req.headers["x-attribution-team"] as string ?? "unknown",
experimentId: req.headers["x-attribution-experiment"] as string ?? "",
traceId: req.headers["x-trace-id"] as string ?? crypto.randomUUID(),
};
// 从请求体提取 model 信息
const requestBody = req.body as Record<string, unknown>;
const model = requestBody?.model as string ?? "unknown";
const provider = detectProvider(model);
// 拦截响应
const originalJson = res.json.bind(res);
res.json = (responseBody: unknown) => {
const latencyMs = Date.now() - startTime;
// 异步写入,不阻塞响应
setImmediate(async () => {
try {
const usage = extractUsage(responseBody, provider);
const costCents = estimateCostCents(usage, model);
await db.costEvents.insert({
timestamp: new Date(),
traceId: attribution.traceId,
feature: attribution.feature,
userId: attribution.userId,
team: attribution.team,
experimentId: attribution.experimentId,
model,
provider,
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheReadTokens: usage.cacheReadTokens,
cacheWriteTokens: usage.cacheWriteTokens,
estimatedCostCents: costCents,
latencyMs,
statusCode: res.statusCode,
});
} catch (err) {
// 采集失败不影响主链路,只记录错误
console.error("[cost-attribution] write failed:", err);
}
});
return originalJson(responseBody);
};
next();
};
}
function detectProvider(model: string): string {
if (model.startsWith("deepseek-")) return "deepseek";
if (model.startsWith("qwen-")) return "qwen";
if (model.startsWith("glm-")) return "zhipu";
return "unknown";
}
4.3 Streaming 响应的 token 采集
流式响应是采集的大坑。你需要从最后一个 chunk 里拿 usage:
typescript
// gateway/middleware/streaming-cost.ts
async function interceptStreamingResponse(
proxyRes: IncomingMessage,
attribution: Attribution,
model: string
): Promise<void> {
let accumulatedUsage: TokenUsage | null = null;
return new Promise((resolve) => {
const chunks: string[] = [];
proxyRes.on("data", (chunk: Buffer) => {
const text = chunk.toString();
chunks.push(text);
// 解析 SSE events,找最后一个含 usage 的 message_delta 或 message_stop
const lines = text.split("\n");
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6);
if (data === "[DONE]") continue;
try {
const event = JSON.parse(data) as Record<string, unknown>;
// DeepSeek/Qwen streaming(与 OpenAI 兼容格式): message_delta 事件携带 usage
if (event.type === "message_delta") {
const usage = event.usage as Record<string, number> | undefined;
if (usage?.output_tokens) {
accumulatedUsage = {
inputTokens: 0, // 在 message_start 里
outputTokens: usage.output_tokens,
cacheReadTokens: 0,
cacheWriteTokens: 0,
};
}
}
// DeepSeek/Qwen streaming(与 OpenAI 兼容格式): message_start 事件携带 input usage
if (event.type === "message_start") {
const msg = event.message as Record<string, unknown> | undefined;
const usage = msg?.usage as Record<string, number> | undefined;
if (usage) {
accumulatedUsage = {
...accumulatedUsage,
inputTokens: usage.input_tokens ?? 0,
cacheReadTokens: usage.cache_read_input_tokens ?? 0,
cacheWriteTokens: usage.cache_creation_input_tokens ?? 0,
} as TokenUsage;
}
}
} catch {
// 忽略解析失败
}
}
});
proxyRes.on("end", async () => {
if (accumulatedUsage) {
const costCents = estimateCostCents(accumulatedUsage, model);
await writeCostEvent(attribution, model, accumulatedUsage, costCents);
}
resolve();
});
});
}
五、第三层:成本聚合与查询
5.1 数据库 schema 设计
用 TimescaleDB(或 ClickHouse 的类似设计)存储事件流:
sql
-- 使用 TimescaleDB hypertable,按时间分区
CREATE TABLE llm_cost_events (
timestamp TIMESTAMPTZ NOT NULL,
trace_id TEXT NOT NULL,
feature TEXT NOT NULL,
user_id TEXT NOT NULL,
team TEXT NOT NULL,
experiment_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
input_tokens INT NOT NULL DEFAULT 0,
output_tokens INT NOT NULL DEFAULT 0,
cache_read_tokens INT NOT NULL DEFAULT 0,
cache_write_tokens INT NOT NULL DEFAULT 0,
-- 存分(cent)而不是美元,避免浮点精度问题
estimated_cost_cents INT NOT NULL DEFAULT 0,
latency_ms INT,
status_code SMALLINT,
-- 额外维度,JSON 存放不固定的 metadata
extra_meta JSONB
);
SELECT create_hypertable('llm_cost_events', 'timestamp');
-- 常用聚合维度建索引
CREATE INDEX ON llm_cost_events (feature, timestamp DESC);
CREATE INDEX ON llm_cost_events (user_id, timestamp DESC);
CREATE INDEX ON llm_cost_events (team, timestamp DESC);
CREATE INDEX ON llm_cost_events (model, timestamp DESC);
5.2 常用聚合查询
按功能查本周成本:
sql
SELECT
feature,
SUM(input_tokens) AS total_input_tokens,
SUM(output_tokens) AS total_output_tokens,
SUM(estimated_cost_cents) AS total_cost_cents,
COUNT(*) AS request_count,
AVG(latency_ms) AS avg_latency_ms
FROM llm_cost_events
WHERE timestamp >= NOW() - INTERVAL '7 days'
GROUP BY feature
ORDER BY total_cost_cents DESC;
找出本月花费最高的 Top 10 用户:
sql
SELECT
user_id,
SUM(estimated_cost_cents) / 100.0 AS cost_usd,
SUM(input_tokens + output_tokens) AS total_tokens,
COUNT(DISTINCT DATE(timestamp)) AS active_days
FROM llm_cost_events
WHERE
timestamp >= DATE_TRUNC('month', NOW())
AND user_id != 'system' -- 排除后台 job
GROUP BY user_id
ORDER BY cost_usd DESC
LIMIT 10;
A/B 实验成本对比:
sql
SELECT
experiment_id,
model,
SUM(estimated_cost_cents) / 100.0 AS cost_usd,
COUNT(*) AS requests,
SUM(output_tokens) / COUNT(*)::float AS avg_output_tokens
FROM llm_cost_events
WHERE
experiment_id IN ('exp-prompt-v1', 'exp-prompt-v2')
AND timestamp >= '2026-07-01'
GROUP BY experiment_id, model
ORDER BY experiment_id, model;
5.3 实时预算告警
typescript
// jobs/budget-alert.ts
import { db } from "../db";
import { notify } from "../notify";
interface BudgetRule {
dimension: "feature" | "team" | "user_id";
value: string;
dailyLimitCents: number;
weeklyLimitCents: number;
}
const BUDGET_RULES: BudgetRule[] = [
{ dimension: "feature", value: "codegen", dailyLimitCents: 5000, weeklyLimitCents: 25000 },
{ dimension: "team", value: "growth", dailyLimitCents: 10000, weeklyLimitCents: 50000 },
];
async function checkBudgets() {
for (const rule of BUDGET_RULES) {
const today = await db.query(
`SELECT SUM(estimated_cost_cents) as total
FROM llm_cost_events
WHERE ${rule.dimension} = $1
AND timestamp >= DATE_TRUNC('day', NOW())`,
[rule.value]
);
const todayCents = today.rows[0].total ?? 0;
if (todayCents > rule.dailyLimitCents * 0.8) {
await notify.alert({
title: `LLM 成本预警:${rule.dimension}=${rule.value}`,
message: `今日已消耗 $${(todayCents / 100).toFixed(2)},` +
`达到日预算 $${(rule.dailyLimitCents / 100).toFixed(2)} 的 ${Math.round(todayCents / rule.dailyLimitCents * 100)}%`,
severity: todayCents > rule.dailyLimitCents ? "critical" : "warning",
});
}
}
}
// 每 5 分钟检查一次
setInterval(checkBudgets, 5 * 60 * 1000);
六、五个工程陷阱
陷阱 1:Tool Calling 的成本乘数问题
用户发一条消息,触发了 5 次 tool call,每次 tool call 都是独立的 LLM 请求。你的 attribution 应该把这 5 次请求都归到同一个用户请求上,而不是分散记录。
错误做法:每次 LLM 调用都独立归因,导致同一个用户操作产生 5 条 attribution 记录,均匀分摊。
正确做法 :引入 root_trace_id,绑定到最顶层的用户请求。Tool call 链路中的所有子调用共享同一个 root_trace_id:
typescript
// 用户请求进来时生成 root trace ID
const rootTraceId = crypto.randomUUID();
// 第一次 LLM 调用
await callLLM(params, { ...ctx, traceId: rootTraceId });
// tool call 回调后的第二次 LLM 调用,保留同一个 rootTraceId
await callLLM(toolResultParams, { ...ctx, traceId: rootTraceId });
// 聚合时按 root_trace_id group by,而不是按单次请求
陷阱 2:Prompt Cache 让成本计算失真
Prompt Cache 命中时,cached input tokens 的计价通常是 uncached 的 10%(DeepSeek)或更低(Qwen)。如果你简单地用 input_tokens * 单价 计算,命中 cache 时会高估成本。
正确做法 :分别记录 cache_read_tokens 和 cache_write_tokens,用不同单价计费:
typescript
// 不要这样(高估成本)
const cost = usage.input_tokens * INPUT_PRICE_PER_TOKEN;
// 要这样(准确)
const cost =
usage.cache_read_tokens * CACHE_READ_PRICE + // 折扣价
usage.cache_write_tokens * CACHE_WRITE_PRICE + // 写入缓存有额外成本
(usage.input_tokens - usage.cache_read_tokens - usage.cache_write_tokens)
* INPUT_PRICE + // 真正的非缓存输入
usage.output_tokens * OUTPUT_PRICE;
陷阱 3:重试导致成本翻倍但只记一次
请求超时,客户端重试了 2 次才成功。Provider 实际收了 3 次的费,但你的 attribution 只记录了最后一次成功的请求。
正确做法 :在 Gateway 层记录所有请求(包括失败的),并用同一个 idempotency_key 或 client_request_id 关联:
typescript
// 应用层传递幂等 key
const idempotencyKey = `${userId}-${featureId}-${Date.now()}`;
const response = await callLLM(params, {
...ctx,
idempotencyKey, // 即使重试,key 不变
});
// Gateway 层:记录所有尝试
await db.costEvents.insert({
...attribution,
idempotencyKey,
attempt: retryCount,
succeeded: statusCode === 200,
});
// 查询时:按 idempotency_key group by,SUM 所有 attempt 的成本
陷阱 4:后台异步 Job 的 attribution 断链
用户点击"生成报告",后端把任务推入队列,Worker 异步执行时调用 LLM。到了 Worker 里,原始 HTTP 请求的上下文已经丢失------user_id、feature 等信息都没了。
正确做法:在入队时,把 attribution 元数据序列化进任务 payload:
typescript
// 入队时携带 attribution
await queue.push("generate-report", {
reportId,
userId: req.user.id,
// ↓ 这里是关键:把 attribution 存进 job payload
attribution: {
feature: "report-generation",
userId: req.user.id,
team: req.user.team,
traceId: req.headers["x-trace-id"],
triggeredAt: new Date().toISOString(),
},
});
// Worker 里,从 job payload 取出 attribution
async function processJob(job: ReportJob) {
const { reportId, attribution } = job;
const result = await callLLM(promptParams, {
feature: attribution.feature,
userId: attribution.userId,
team: attribution.team,
traceId: attribution.traceId, // 和用户原始请求同一 trace
});
}
陷阱 5:多租户 SaaS 的成本分摊边界
如果你的产品是 SaaS,每个租户共享同一套 LLM API key,但你需要知道每个租户花了多少(用于计费、用量限制、毛利分析)。
关键设计决策:tenant_id 必须是 attribution 维度之一,且不能被租户伪造。
typescript
// ❌ 错误:从请求 body 取 tenant_id(租户可伪造)
const tenantId = req.body.tenantId;
// ✅ 正确:从已认证的 JWT/session 里取
const tenantId = req.auth.tenantId; // 来自服务端验证过的 token
// Gateway 层:从请求的认证上下文注入,不信任客户端传入的 header
const attributionTenantId = await resolveAuthenticatedTenantId(req);
七、完整的 Attribution 数据流(总结)
把以上内容串起来,完整的数据流是:
bash
用户请求
│
├─ 应用层:生成 rootTraceId,从认证上下文取 userId/tenantId
│ 构建 attribution header,注入 LLM 调用
│
├─ LLM Gateway:
│ ├─ 提取 attribution header
│ ├─ 代理请求到 provider
│ ├─ 拦截响应(含 streaming)
│ ├─ 提取 token usage(区分 cache/uncached)
│ ├─ 估算成本(分级别计价)
│ └─ 异步写 cost event(不阻塞主链路)
│
├─ TimescaleDB / ClickHouse:
│ ├─ 存储 cost events(按时间分区)
│ ├─ 按 feature/user/team/experiment 聚合
│ └─ 驱动实时预算告警
│
└─ Dashboard / Report:
├─ 每日/每周成本报表
├─ 功能级 ROI 分析
└─ 异常消费用户识别
八、什么时候开始做 Cost Attribution
立即开始 ,哪怕只做最轻量的版本:在每次 LLM 调用时加一个 feature 标签,写到 log 里。这是零成本的改动,但给你一个月后查问题提供了基础。
按需演进:
| 阶段 | 做什么 | 触发条件 |
|---|---|---|
| Day 1 | feature 标签 + console log |
刚上 LLM 功能 |
| Month 1 | Gateway 采集 + 简单 SQL 查询 | 月账单 > $500 |
| Month 3 | TimescaleDB + 预算告警 | 月账单 > $2,000 |
| Month 6 | 多租户 attribution + ROI 分析 | SaaS 需要客户级计费 |
不要等到 CFO 来问你才开始。你能回答"某个功能花了多少钱"的那一天,比你回答"我们整体花了多少钱"更有价值。