月底账单是上月的三倍,但没人说得出钱花在哪。这是 AI 应用最典型的成本事故:不是单价涨了,是分母失控——输入变长、输出变长、重试变多、检索条数变多、评测轮次变多。成本控制因此不是"砍功能",而是在设计阶段把三件事定死:钱按什么口径归集、超支时系统自动做什么、降级后用户看到什么。
范围先划清:这里说的是按 API 计费的 token 账;自建推理的 GPU 与显存账是另一本,判据完全不同。
1. 成本结构:账单上的六个格子
看不到结构,就只能砍功能。先把成本拆成可归集的项:
| 成本项 | 计费口径 | 增长驱动 | 可控手段 |
|---|---|---|---|
| 输入 token | 按千 token 计 | 系统提示、检索片段、历史轮次 | 裁剪、摘要、检索条数上限 |
| 输出 token | 单价常高于输入(按厂商价目核对,常见 2–5 倍量级) | 要求长回答、JSON 冗余、重复解释 | 输出格式约束、max_tokens |
| 缓存命中 | 命中部分按折扣价 | 提示前缀是否稳定 | 前缀固定、模板版本冻结 |
| 重试 | 每次重试都计费 | 超时、限流、解析失败 | 幂等重试、失败次数上限 |
| 检索 | 存储 + 查询 QPS | top_k、过滤条件、索引规模 |
降 top_k、先过滤后检索 |
| 评测跑量 | 用例数 × 轮次 × 模型 | 回归频率、模型切换次数 | 分层用例、抽样回归 |
最小记账单元必须落在每次调用上,而不是落在"本月总消耗"上:
{
"request_id": "req_9f1c",
"tenant_id": "t_1024",
"feature": "chat.summary",
"prompt_version": "p_2026_03",
"model": "[MODEL]",
"input_tokens": "[N]",
"cached_tokens": "[N]",
"output_tokens": "[N]",
"retry_index": 0,
"latency_ms": "[N]",
"cost": "[COMPUTED]"
}
retry_index 这一列是最容易被省掉的,也是账单对不上估算的头号原因:失败的重试同样计费,而监控里通常只统计成功请求。
2. 预算的三个层级与四种动作
预算不挂在"应用"这个整体上,因为超支时没人知道该找谁。三层是能落地的最小结构:
| 层级 | 谁负责 | 观察窗口 | 超支后的默认动作 |
|---|---|---|---|
| 公司级 | 技术负责人 + 财务 | 月 | 冻结新功能上线、重谈单价 |
| 功能级 | 产品 + 功能 owner | 日 | 降级模型、收紧配额 |
| 租户/用户级 | 系统自动 | 分钟 / 日 | 排队、拒绝、提示升级 |
budget:
company:
window: month
limit: "[AMOUNT]"
on_exceed: freeze_new_feature
feature:
key: "chat.summary"
window: day
limit: "[AMOUNT]"
on_exceed: degrade_model
tenant:
window: day
limit_tokens: "[N]"
burst: "[N]/10min"
on_exceed: [queue, then_reject]
四种超支动作的取舍,按效果损失从小到大排,不要按"实现简单"排:
- 减少上下文:砍历史轮次、砍检索条数、截断工具返回。损失通常最小,且可逆。
- 降级到小模型:效果下降因任务而异。分类、抽取、摘要通常可接受;审核、金额计算、法务表述通常不可接受。
- 排队:只对可异步的入口成立(报表、批量摘要)。用户等三分钟和拿到差答案是两回事。
- 拒绝:唯一对实时交互入口也成立的动作。拒绝要给明确原因和恢复时间,否则等于把成本问题变成投诉问题。
降级链写成声明式配置,别散在 if 里:
DEGRADE_CHAIN = [
("model-large", 32_000), # 正常档
("model-large", 12_000), # 先裁上下文
("model-small", 12_000), # 再换模型
]
判断顺序很关键:先做损失小的动作。先换模型再裁上下文,等于用两次效果损失换一次本可以避免的损失。
3. 配额:硬限、软限与突发额度
配额和预算不是一回事:预算是钱,配额是速率。混在一起写,最后两个都失效。
| 维度 | 类型 | 作用 | 越界表现 |
|---|---|---|---|
| 每分钟 token | 硬限 | 防突发打爆上游速率限制 | 直接拒绝,返回可重试提示 |
| 每日 token | 软限 | 控总账 | 降级 + 通知 owner |
| 每租户并发 | 硬限 | 防单租户挤占 | 排队或拒绝 |
| 突发额度 | 令牌桶 | 允许短时冲高 | 桶空后回到基础速率 |
def admit(tenant, cost):
if not minute_bucket.try_take(tenant, cost): # 硬限:先判
return Decision.REJECT
if day_usage(tenant) + cost > soft_limit: # 软限:后判
return Decision.DEGRADE
return Decision.ALLOW
顺序写反会出一个很别扭的现象:软限已经降级,但分钟硬限仍在拒,用户同时看到"降级"和"拒绝"两种反馈。计数必须走共享存储(Redis 计数或集中限流器),本地内存计数在多实例下会把真实配额放大到实例数倍。
4. 缓存与上下文裁剪:成本收益与风险
缓存是唯一"不损失效果就能省钱"的手段,前提是命中条件足够严:
| 缓存类型 | 命中条件 | 收益 | 主要风险 |
|---|---|---|---|
| 精确缓存 | 请求体哈希一致 | 确定性高,重复问答直接归零 | 命中率低,长尾请求无效 |
| 前缀缓存 | 系统提示前缀逐字节一致 | 单价折扣,工程成本低 | 模板加时间戳或版本号即失效 |
| 语义缓存 | 向量相似度超过阈值 | 命中率高 | 答非所问,且错得很难发现 |
语义缓存的判据只有一条:有没有评测集守门。有,可以按功能白名单开;没有,先别开。强上下文依赖的场景(多轮追问、按当前页面内容作答)天然不适合语义缓存——相似问题在上下文不同时答案不同。
上下文裁剪的收益比换模型更稳,也更容易做错:
def build_context(history, files, budget_tokens):
ctx = [SYSTEM_PROMPT]
ctx += retrieve(query, top_k=min(5, budget_tokens // 800))
ctx += summarize(history) if len(history) > N_TURNS else history
ctx += [f for f in files if f.relevant] # 只带必要文件
return truncate(ctx, budget_tokens)
两个反模式:把整份文档塞进去(token 涨十倍,效果未必涨);摘要做得太早(三轮就开始摘,模型丢掉关键约束)。摘要的触发点应当由"上下文是否已超出有效窗口"决定,而不是由"省多少钱"决定。
5. 重试、失败与批处理:被低估的放大器
一次失败不是"没花钱",而是"花了钱还没结果"。设单次失败率 p、最多重试 k 次,平均每次成功请求的计费次数约为 1/(1-p) 量级——p=20% 时接近 1.25 倍,再叠加超时前的等待成本,账单会比按成功请求估算的高出一截。
控制手段:只对可重试错误重试(超时、429、5xx),解析失败不要原样重试而要改提示;重试间隔退避,且设总时长上限;在请求标签里记 retry_index,让账单能按"第几次尝试"拆开看。
批处理是另一条路:离线任务走批量接口,单价通常显著更低,代价是延迟从秒级变成小时级。判据很直接——结果是否需要即时反馈。日报摘要、历史回溯打标、评测跑量都适合;对话、审核、实时推荐都不适合。
6. 降级可用性、可观测最小集与治理落点
降级路径要先回答"效果下降是否可接受",再回答"要不要告知用户":
| 场景 | 可降级 | 是否告知 |
|---|---|---|
| 长文摘要 | 是 | 可静默,但在结果页标注模型档位 |
| 客服问答 | 是,需保留转人工 | 必须告知,否则用户会反复追问 |
| 内容审核 | 否,降级应改为"进人工队列" | 不涉及用户 |
| 金额/合同类输出 | 否 | 不涉及用户 |
可观测的最小集只有四列:tenant_id、feature、model、prompt_version,加上每次调用的 input_tokens / output_tokens。缺 prompt_version,模板一改就无法解释成本跳变;缺 feature,就只能按应用整体看总数。
治理落点:预算要挂到功能 owner,不是挂到"AI 平台组"。超支处理走变更流程(谁批、批多少、多久复盘),而不是临时口头放宽——口头放宽一次,后面所有配额都会被当成建议值。
7. 排错:五个对不上账的现象
| 现象 | 原因 | 处理 |
|---|---|---|
| 账单远高于按成功请求的估算 | 重试与失败调用计费,未归集 | 补 retry_index,按尝试次数拆分 |
| 配额没到却被拒 | 多实例本地计数,或分钟桶与日桶口径不一致 | 计数移到共享存储,统一窗口定义 |
| 缓存命中率突然掉很低 | 模板加了时间戳/请求 ID,前缀不再一致 | 前缀冻结,动态内容后置 |
| 降级后大量"答非所问"投诉 | 降级链先换模型、后裁上下文 | 调换顺序,并给降级档单独评测 |
| 成本归因不到租户 | 标签只在入口打,异步链路丢失上下文 | 标签随任务透传,异步任务带原始标签 |
8. 边界:什么时候不必上这套
| 情况 | 判断 | 做法 |
|---|---|---|
| 调用量小、单价低 | 治理成本高于省下的钱 | 只记账,不设配额 |
| 内部试验期 | 拦请求会阻断验证 | 只告警,不拦截 |
| 单模型单功能 | 三层预算退化成一层 | 只留功能级预算 |
| 强实时交互 | 排队不可用 | 只保留拒绝与降级两条路径 |
| 上游已提供组织级配额 | 重复建设 | 直接用上游配额,自建只做归集 |
一句话判据:先能回答"这笔钱属于谁、为什么花",再谈拦不拦。 归集做不到,配额只会变成随机报错。
9. 自测
- 账单比按成功请求估算高出一大截,最先该补哪个字段?
- 三层预算里,哪一层的超支动作必须是自动的,为什么?
- 分钟硬限与日软限的判定顺序写反,用户会看到什么异常组合?
- 语义缓存上线前,唯一必须准备好的东西是什么?
- 降级链为什么应当"先裁上下文、后换模型"?
- 什么情况下上配额反而增加总成本?