LLM 账单来了,却不知道哪个功能在烧钱:Cost Attribution 工程实践

每个月 LLM 账单 6 万块,CFO 来问你"AI 写作功能花了多少钱",你答不上来。这不是成本控制问题,是工程问题。


一、痛点:账单只有 API key,没有业务维度

你上线了一个 AI 产品,有三个核心功能:

  • chat:对话助手
  • summarize:文档摘要
  • codegen:代码生成

月底 大模型账单来了,写着:输入 1.2 亿 tokens,输出 3800 万 tokens,合计 $2,340

问题来了:

  1. 哪个功能最贵?
  2. 某个 VIP 用户是不是在滥用 codegen
  3. A/B 测试中的新 prompt 让成本涨了多少?
  4. 后端有一个每天定时跑的摘要 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

不要过度设计。先从 featureuser_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 个致命问题:

  1. Streaming 丢数据 :流式响应的 usage 数据在最后一个 SSE chunk 的 [DONE] 消息里,处理不对就漏计
  2. 重试翻倍:请求失败重试,两次都计费,attribution 却可能只记一次
  3. Tool calling 乘数:一次用户请求触发 5 次 tool call,每次都是独立 LLM 调用,应用层容易漏
  4. 异常路径:超时、客户端断连、服务崩溃,正常 logging 路径失效
  5. 多实例一致性:水平扩展后多个实例各自累加,汇总有竞态

正确做法:在请求路径中的单点(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_tokenscache_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_keyclient_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_idfeature 等信息都没了。

正确做法:在入队时,把 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 来问你才开始。你能回答"某个功能花了多少钱"的那一天,比你回答"我们整体花了多少钱"更有价值。


参考资料

相关推荐
000037991 小时前
Beat做歌、Sample素材创作工具实测:从找伴奏到写完完整作品
人工智能·ai编程
不好听6131 小时前
LLM 是怎么"随机"说话的 —— Temperature、Top-K、Top-P 详解
llm
一起努力啊~2 小时前
DataWhale组队学习笔记--llm-algo-leetcode(五)
笔记·学习·llm
To_OC9 小时前
大模型蒸馏是啥?说白了就是大厨带徒弟的学问
人工智能·llm·agent
寅时码10 小时前
React 之死·终章:一个 useRef,把闭包陷阱、依赖数组、漫天 rerender 全送走
前端·react.js·ai编程
陳陈陳10 小时前
从“胡说八道”到“妙笔生花”:我用LangChain手搓了一个可控AI写作流(Temperature+TopK调参指南)
langchain·llm
腻害兔12 小时前
【若依项目-产品经理视角】RuoYi-Vue-Pro 源码拆解:字典、短信、邮件、通知——后台系统的“基础设施四件套“!
java·前端·vue.js·产品经理·ai编程
冬奇Lab12 小时前
AI 评测系列(03):LLM-as-Judge——让 LLM 评价 LLM 的正确姿势
人工智能·llm
腻害兔13 小时前
【若依项目-产品经理视角】RuoYi-Vue-Pro 源码拆解:支付模块 yudao-module-pay,一个让产品经理都看懂的支付中台设计
java·前端·vue.js·产品经理·ai编程