LLM 成本治理:从 token 账单到线上熔断,团队应该先做哪三件事?

LLM 成本治理:从 token 账单到线上熔断,团队应该先做哪三件事?

上周一早上,值班同学在支付群里贴了一张截图:周末两天,LLM API 的账单从日均 420 美元跳到了 4,860 美元。最先被怀疑的是一次模型涨价,后来才发现原因更朴素:新上线的「对话总结」功能把每次会话的完整历史、三段 RAG 召回和系统提示词一起塞回模型;一个重试中间件又在超时后原样补发。少数重度用户的一次长会话,能够连续触发十几次 128K 上下文调用。

更糟的是,当时团队只有供应商控制台的日账单:知道贵了,却不知道谁、什么任务、哪种模型、哪条链路在花钱。我们临时把入口限流,误伤了正常用户;第二天换了便宜模型,又让需要结构化抽取的任务错误率上升。这个事故让我确认:LLM 成本治理不是「选更便宜的模型」,而是一套能观测、能归因、能约束、能优雅降级的线上系统。

本文给出一个从零可落地的优先级:先把 Usage API 接进来建立事实层;再把每一笔成本拆到任务、用户、模型;最后把预算变成线上熔断器。顺序不能反:没有观测的熔断是盲飞,没有归因的预算只会变成一把误伤业务的总闸门。

下面以 Python/FastAPI 为例,示例使用虚拟的 provider client。计费单价、Usage API 字段和模型名要以你的供应商实时文档及合同为准。

先明确:成本不是一个数字

一次 LLM 调用的直接成本可以写成:

cost = input_tokens × input_unit_price + output_tokens × output_unit_price + cached_tokens × cache_unit_price

但线上治理还要额外记录:请求失败是否仍计费、重试次数、工具调用、图像/音频 token、异步批处理折扣和缓存命中。若只在应用侧按「请求数 × 均价」估算,通常会漏掉长上下文和异常重试这两个最大变量。

我建议每个调用至少携带以下维度:

维度 示例 用途
tenant / workspace acme-prod 组织或租户预算
user_id u_1842 防止单个用户拖垮公共池
task_type chat_summary 找到高成本产品功能
model gpt-4.1-mini 比较模型单价和质量
request_id UUID 对账、重试去重
route premium / fallback 验证降级效果

不要把完整 prompt、用户输入或模型输出写入成本事件;它们既不利于聚合,也会给日志系统引入隐私与合规风险。记录 token、哈希、长度和业务维度足够了。

第一件事:接入 Usage API,做「供应商账单」与「应用账本」双向对账

供应商 Usage API 是最终账单的权威来源,适合按小时或天拉取聚合用量;应用侧埋点则能提供 task、用户、路由等供应商看不到的业务维度。两者不是替代关系。

先建一张不可变的 llm_usage_event 表。每次模型调用结束(成功或失败)写一行;同时每小时拉取供应商 Usage API,落到 provider_usage_hourly。定时任务计算差异率,超过阈值就报警。

python 复制代码
# usage_sync.py
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal
import hashlib

PRICE = {
    # 示例价格:请用合同/实时价目表配置,不要写死在业务代码里
    "gpt-4.1-mini": {"input": Decimal("0.00000040"), "output": Decimal("0.00000160")},
    "gpt-4.1": {"input": Decimal("0.00000200"), "output": Decimal("0.00000800")},
}

@dataclass(frozen=True)
class UsageEvent:
    request_id: str
    tenant_id: str
    user_id: str | None
    task_type: str
    model: str
    input_tokens: int
    output_tokens: int
    cached_tokens: int = 0
    retry_no: int = 0
    status: str = "ok"

def estimate_cost(e: UsageEvent) -> Decimal:
    p = PRICE[e.model]
    return (Decimal(e.input_tokens) * p["input"] +
            Decimal(e.output_tokens) * p["output"])

def to_row(e: UsageEvent) -> dict:
    # request_id 是幂等键;重复写入必须 upsert,而不是累计两次
    return {
        "request_id": e.request_id,
        "tenant_id": e.tenant_id,
        "user_id": e.user_id,
        "task_type": e.task_type,
        "model": e.model,
        "input_tokens": e.input_tokens,
        "output_tokens": e.output_tokens,
        "retry_no": e.retry_no,
        "status": e.status,
        "cost_usd": str(estimate_cost(e)),
        "recorded_at": datetime.now(timezone.utc).isoformat(),
    }

# provider.list_usage(start, end, group_by=["model"]) 返回供应商聚合数据
# 用同一时区、同一结算窗口做对账,避免跨整点产生假差异。
def reconcile(provider_total: Decimal, app_total: Decimal) -> Decimal:
    if provider_total == 0:
        return Decimal("0") if app_total == 0 else Decimal("1")
    return abs(provider_total - app_total) / provider_total

这个阶段的交付标准很具体:能在仪表盘回答「昨天哪个模型、哪个任务的 token 和成本最高」,并且应用账本与供应商账单的小时级差异连续一周低于 3%。差异大时优先检查时区、流式调用结束事件、失败请求、重试幂等和缓存 token 的计价规则。

不要只盯总额,要看四个异常信号

  1. 单位任务成本 P95:均值平稳但 P95 翻倍,常意味着少数超长上下文。
  2. 输入/输出 token 比 :输入异常高,多半是历史消息、RAG 或系统提示词膨胀;输出异常高,检查 max_tokens 和循环式 agent。
  3. 重试成本占比:网络重试、429 重试、解析失败重试必须单列。
  4. 账单差异率:应用侧漏埋点或供应商口径变了,不能用估算替代对账。

第二件事:按任务 / 用户 / 模型拆分预算,让成本真正可归因

很多团队第一版只设置「本月 2 万美元总预算」。这只能保护财务,不能指导工程决策:客服摘要和付费报告共用一个池,低价值的批量任务照样能抢走高价值请求的额度。

更实用的是三层预算:

  • 任务预算 :如 chat_summary 每日 120 美元,report_generation 每日 300 美元;超过后先降级或排队。
  • 用户/租户预算:免费用户日额度低,企业租户按合同配置;需要区分用户可见额度与内部成本护栏。
  • 模型预算:昂贵模型单独设上限,阻止路由错误把所有请求打到旗舰模型。

预算应该有「软阈值」与「硬阈值」。例如 70% 记录预警、85% 切换节省型路由、100% 拒绝非关键任务。关键业务可保留一个小的 emergency pool,但必须审计并告警。

python 复制代码
# budget.py
from decimal import Decimal
from enum import StrEnum
from dataclasses import dataclass

class Decision(StrEnum):
    ALLOW = "allow"
    FALLBACK = "fallback"
    QUEUE = "queue"
    REJECT = "reject"

@dataclass(frozen=True)
class Budget:
    limit_usd: Decimal
    warn_ratio: Decimal = Decimal("0.70")
    fallback_ratio: Decimal = Decimal("0.85")

def decide(budget: Budget, spent: Decimal, estimated: Decimal, critical: bool) -> Decision:
    projected = spent + estimated
    ratio = projected / budget.limit_usd if budget.limit_usd else Decimal("1")
    if ratio < budget.warn_ratio:
        return Decision.ALLOW
    if ratio < budget.fallback_ratio:
        return Decision.ALLOW  # 同时发预警,不改变体验
    if ratio < Decimal("1"):
        return Decision.FALLBACK
    # 关键请求允许进入小型应急预算;普通请求排队/拒绝
    return Decision.QUEUE if critical else Decision.REJECT

# Redis 原子计数的键建议按 UTC 自然日滚动
# cost:{date}:task:{task_type}
# cost:{date}:tenant:{tenant_id}
# cost:{date}:model:{model}

预算核算不要在调用后才检查。要先做一次预估扣减,结束后用实际 token 做差额结算;否则并发高时,一百个请求会同时读到「还剩 10 美元」,再一起冲破上限。Redis Lua、数据库条件更新或专门的配额服务都可以,关键是「检查 + 预留」必须原子化。

lua 复制代码
-- reserve.lua:用 Redis EVAL 执行
-- KEYS[1]=日预算已用金额(微美元整数) ARGV[1]=上限 ARGV[2]=预估消耗
local used = tonumber(redis.call('GET', KEYS[1]) or '0')
local limit = tonumber(ARGV[1])
local reserve = tonumber(ARGV[2])
if used + reserve > limit then
  return {0, used}
end
redis.call('INCRBY', KEYS[1], reserve)
redis.call('EXPIRE', KEYS[1], 172800)
return {1, used + reserve}

金额不要用 float 存储;示例中使用 Decimal,Redis 里使用微美元整数。否则大量小数累积会让临界预算出现肉眼难查的穿透。

第三件事:把预算接成线上熔断器,而不是一页没人看的报表

成本熔断器的目标不是「服务一律不可用」,而是在预算压力下把系统从昂贵路径切到可控路径。常见动作有四类:

触发条件 首选动作 适用任务 不应这么做
单用户突发 限速、缩短上下文、提示稍后重试 聊天、试用 全局关停
任务预算 85% 路由到小模型、降低 max_tokens 摘要、分类 静默降低关键抽取质量
任务预算 100% 入队异步、返回缓存结果 批处理、报告 无限重试
模型异常涨价/错误率 临时摘流、切备用模型 有兼容路由 把同样请求放大重试

下面是一个可运行的 FastAPI 骨架。它在调用前预估 token 成本、原子预留预算、根据决定选择模型;调用结束后记录真实 usage 并结算差额。示例的 llm_client 可替换为任何 SDK。

python 复制代码
# app.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from decimal import Decimal
from uuid import uuid4
from budget import Budget, decide, Decision
from usage_sync import UsageEvent, estimate_cost

app = FastAPI()
BUDGET = Budget(limit_usd=Decimal("120"))
spent_today = Decimal("0")  # 生产环境替换为 Redis 原子预留

class Ask(BaseModel):
    tenant_id: str
    user_id: str | None = None
    task_type: str = "chat_summary"
    prompt: str
    critical: bool = False

async def call_model(model: str, prompt: str) -> tuple[str, int, int]:
    # 替换为真实 SDK:从响应 usage 中读取 input/output tokens
    output = f"[{model}] {prompt[:40]}..."
    return output, max(1, len(prompt) // 4), 80

@app.post("/ask")
async def ask(req: Ask):
    global spent_today
    preferred = "gpt-4.1"
    # 保守预估:输入字符/4 + 预留输出 500 token
    provisional = UsageEvent(str(uuid4()), req.tenant_id, req.user_id,
                             req.task_type, preferred, len(req.prompt)//4, 500)
    action = decide(BUDGET, spent_today, estimate_cost(provisional), req.critical)

    if action == Decision.REJECT:
        raise HTTPException(429, "今日该任务额度已用尽,请明天再试")
    if action == Decision.QUEUE:
        return {"status": "queued", "reason": "budget_protection"}

    model = "gpt-4.1-mini" if action == Decision.FALLBACK else preferred
    reserved = estimate_cost(UsageEvent(provisional.request_id, req.tenant_id,
                            req.user_id, req.task_type, model,
                            provisional.input_tokens, provisional.output_tokens))
    spent_today += reserved  # 生产中改为 reserve.lua
    try:
        text, input_tokens, output_tokens = await call_model(model, req.prompt)
        actual = UsageEvent(provisional.request_id, req.tenant_id, req.user_id,
                            req.task_type, model, input_tokens, output_tokens)
        spent_today += estimate_cost(actual) - reserved
        # db.upsert_usage(to_row(actual));并投递 metrics
        return {"status": "ok", "route": action, "model": model, "answer": text}
    except Exception:
        # 失败也要记录;是否退回预留取决于供应商是否计费
        raise HTTPException(502, "模型服务暂不可用")

启动并试跑:

bash 复制代码
python -m venv .venv && source .venv/bin/activate
pip install fastapi 'uvicorn[standard]' pydantic
uvicorn app:app --reload
curl -X POST http://127.0.0.1:8000/ask \
  -H 'content-type: application/json' \
  -d '{"tenant_id":"demo","user_id":"u-1","prompt":"把这段会议记录总结成三点"}'

边界条件:这些坑会让「看起来正确」的方案失效

流式响应与取消。 用户断开不等于供应商停止生成。若 SDK 支持 cancellation,要主动取消上游;否则仍需以最终 usage 回执结算。没有最终 usage 时先保留预估成本,待账单对账回补。

缓存与批处理。 Prompt cache 命中、批处理折扣和异步任务的单价都可能不同。成本事件必须有 price_versioncache_tokensbatch 标记;不要把所有 input token 套同一单价。

多 Agent 链路。 一个用户请求可能展开成 planner、retriever、writer、judge 多次调用。父 trace_id 用来计算端到端成本,子 request_id 用来防重复扣费;预算可限制「一次工作流最多 N 次模型调用」。

质量与公平。 熔断降级前先做离线评测:摘要、分类通常适合小模型,但合规审核、结构化抽取或重要客服答复可能不能静默降级。向用户明确告知「已切换快速模式」通常比悄悄降低质量更诚实。

预算重置与时区。 用 UTC 统一结算,展示层再转本地时区。每月预算不要依赖 cron 清零;使用带日期/月度窗口的 key,避免任务漏跑导致永久封禁。

一张落地对照表:先做什么,后做什么

阶段 交付物 验收指标 常见误区
第 1 周:可见 Usage API 拉取、应用埋点、小时对账 差异率 <3%,能按模型看成本 只看供应商总账单
第 2 周:可归因 task/user/model 三维账本与预算 Top 10 成本来源可解释 把 prompt 明文塞进日志
第 3 周:可控制 预留扣减、降级/排队/拒绝策略 压测下预算不穿透 调用完成后才扣额
持续优化:可经营 质量、延迟、成本三联指标 降本不造成核心指标回退 只以最低单价选模型

结语:治理的第一目标是可预测,不是极限省钱

LLM 成本天然波动:用户输入变长、模型定价变动、Agent 增加工具链,都会改变曲线。真正成熟的系统不承诺「永远最便宜」,而是让每一笔钱可追溯、每个阈值有业务含义、每次超额都有可预期的退路。

如果只能在本周做三件事,就按这个顺序:接 Usage API 做对账;给每次调用打上任务/用户/模型标签并实施预算;把预算接到预留扣减与分级熔断。 这样下次账单异常时,团队不必在深夜猜测「是不是模型又涨价了」,而是能在几分钟内定位:哪条链路变贵、该降级什么、以及哪些用户体验绝不能牺牲。

相关推荐
武子康1 小时前
打断不是听到声音就闭嘴:语音 Agent 的话权状态怎样真正收敛
人工智能·llm·agent
动物园猫1 小时前
桑叶病害目标检测数据集:16,000张图像 | 目标检测
人工智能·目标检测·计算机视觉
金融小师妹1 小时前
多因子智能建模:国内黄金需求结构调整与供给变化的AI分析框架
大数据·人工智能
科技新资讯1 小时前
AI写小说软件FeelFish 4.0实测,多部门协作重塑创作全流程
人工智能
IT_陈寒1 小时前
Vite打包时静态资源404?加个斜杠就能解决
前端·人工智能·后端
码农小旋风1 小时前
26个PPT生成Skill,我做了一次系统梳理
人工智能·ppt
ccLianLian1 小时前
机器学习和深度学习
人工智能·深度学习·机器学习
许泽宇的技术分享2 小时前
我拆了一个 Agent 仓库,发现真正难的从来不是让 AI 会写代码
人工智能
webor20062 小时前
<六>ChatGPT到底叫什么?——语言模型
人工智能·ai·语言模型·chatgpt·claude