2686 字
约 8 分钟
18
S22-X06 · 成本与配额控制:Token 预算、限流与降级
AI 应用的成本曲线和传统接口不一样:普通接口的成本随 QPS 线性走,AI 接口的成本随输入长度 × 输出长度 × 调用次数三个乘数走。一次对话里夹带一份 30 页的文档,单次成本就可能超过平时一整天。所以控制点必须前移到装配之前,而不是等到月底看账单。
1. 成本结构与三个乘数
| 乘数 | 来源 | 放大效应 | 控制点 |
|---|---|---|---|
| 输入长度 | 系统提示 + 素材 + 历史 + 用户输入 | 每轮都重复付费 | 裁剪、摘要、前缀缓存 |
| 输出长度 | 生成 token | 流式时无法预知 | maxTokens、缩短格式要求 |
| 调用次数 | 多轮对话、工具往返、重试 | 与重试策略相乘 | 轮数上限、重试预算 |
| 常见"成本尖刺" | 触发方式 | 典型倍数 |
|---|---|---|
| 整篇文档塞进上下文 | PDF 全量注入 | 数十倍 |
| 历史不裁剪 | 每轮带上全部对话 | 随轮数线性增长 |
| 工具未限制往返 | 模型反复调工具 | 数倍 |
| 重试叠加 | 模型侧 × 工具侧 | 相乘 |
| 同一问题重复问 | 无缓存 | 按重复次数 |
结论:成本治理的性价比排序是 裁剪 > 缓存 > 限流 > 降级。限流只挡住超额的部分,裁剪和缓存降低的是每一次调用的基线。
2. 预算分层:租户 / 用户 / 会话
| 层级 | 额度对象 | 超限后的用户体验 | 适合的额度粒度 |
|---|---|---|---|
| 租户 | 团队 / 客户 | 整体降级或拒绝新请求 | 日预算 + 月预算 |
| 用户 | 账号 | 提示"今日额度已用完" | 日预算 |
| 会话 | 单次对话 | 提示"本轮过长,请新建会话" | token 上限 |
app:
budget:
tenant:
daily-tokens: 20000000 # 占位值,按实际账单价换算
monthly-tokens: 400000000
user:
daily-tokens: 200000
session:
max-input-tokens: 8000
max-output-tokens: 1500
max-rounds: 20
on-exceed: DEGRADE # QUEUE | DEGRADE | REJECT
三级配额必须同时生效:只限租户,单个用户就能把团队额度吃光;只限用户,几十个用户就能把账单打穿。
3. 估算方法
预算是必须在下单前算的,而真实用量只有调用后才拿得到。所以需要两套数:估算值用于准入判断,实际值用于记账与校正。
| 方法 | 做法 | 误差来源 | 用途 |
|---|---|---|---|
| 按字符估 | chars / 3(中文)/chars / 4(英文) |
分词器差异、代码与符号 | 准入初筛 |
| 按分词器精确计 | 用模型同款 tokenizer 计数 | 版本升级后可能变 | 关键路径的准入 |
| 历史均值 | 按接口统计 P50 / P95 用量 | 输入分布变化 | 容量规划 |
| 返回用量 | 响应里的 usage 字段 | 无(权威值) | 记账、校正系数 |
public int estimateTokens(String text) {
if (text == null || text.isEmpty()) return 0;
int cjk = 0, other = 0;
for (int i = 0; i < text.length(); i++) {
if (text.charAt(i) >= 0x4E00 && text.charAt(i) <= 0x9FFF) cjk++; else other++;
}
return (int) Math.ceil(cjk / 1.6 + other / 4.0); // 经验系数,按当前模型核对
}
校正比公式更重要:定期用"实际 usage / 估算值"的比值回归一次系数,比追求一开始就估准要现实得多。
4. 限流的三个维度
| 维度 | 单位 | 挡什么 | 实现 |
|---|---|---|---|
| QPS | 请求/秒 | 突发流量、脚本刷 | 令牌桶 |
| 并发 | 在途请求数 | 慢请求堆积 | 信号量 |
| token/分钟 | token/分 | 单个超大请求 | 滑动窗口 + 预扣 |
public final class TokenWindowLimiter {
private final Map<String, Deque<long[]>> windows = new ConcurrentHashMap<>(); // key: tenantId
private final long limitPerMinute;
public TokenWindowLimiter(long limitPerMinute) { this.limitPerMinute = limitPerMinute; }
public boolean tryAcquire(String tenantId, int estimatedTokens) {
long now = System.currentTimeMillis();
Deque<long[]> dq = windows.computeIfAbsent(tenantId, k -> new ArrayDeque<>());
synchronized (dq) {
while (!dq.isEmpty() && now - dq.peekFirst()[0] > 60_000) dq.pollFirst();
long used = dq.stream().mapToLong(a -> a[1]).sum();
if (used + estimatedTokens > limitPerMinute) return false; // 超限即拒,不做排队
dq.addLast(new long[]{now, estimatedTokens});
return true;
}
}
}
关键在"预扣":请求进门前按估算值扣额度,响应回来后再用真实 usage 补差。只按实际值记账的限流永远慢半拍,等发现超了已经花完了。
5. 超限的三档处理
| 档位 | 做法 | 适用 | 副作用 |
|---|---|---|---|
| 排队 | 进本地队列,超过等待上限则淘汰 | 交互容忍度高、可异步 | 队列本身会放大延迟与内存 |
| 降级到小模型 | 换更便宜的模型,或缩短输出 | 质量可妥协的场景 | 口径与质量不一致 |
| 直接拒绝 | 返回剩余额度与下次可用时间 | 硬预算、计费敏感 | 体验差,但可预期 |
public CompletionStage<Answer> handle(String tenantId, Request req) {
int est = estimator.estimateTokens(req.prompt());
if (!limiter.tryAcquire(tenantId, est)) {
long retryAfter = limiter.secondsUntilAvailable(tenantId, est);
return CompletableFuture.completedFuture(
Answer.rejected("额度已用尽", retryAfter)); // 三档:拒绝 + 下次可用时间
}
if (budget.remaining(tenantId) < est * 2) {
return smallModel.answer(req).thenApply(a -> a.withDegraded(true)); // 二档:降级
}
return primaryModel.answer(req); // 正常路径
}
拒绝响应里必须带下次可用时间与当前额度水位,否则用户只会反复重试,把限流变成压测。
6. 缓存复用
| 缓存类型 | 命中条件 | 收益 | 前提 |
|---|---|---|---|
| 完全相同请求 | 提示 + 参数 + 模型全等 | 直接省一次调用 | 提示必须可哈希 |
| 前缀缓存 | 请求前缀逐字节一致 | 只对命中部分计费更低 | 稳定前缀在前,变动在后 |
| 结果缓存 | 业务层 key(如订单号 + 问题类型) | 省调用,但可能过期 | 需要 TTL 与失效策略 |
// 前缀稳定是前提:系统提示与固定约束放最前,动态内容一律放后面
String request = systemPrompt + "\n" + tenantRules + "\n" + retrieval + "\n" + userInput;
String cacheKey = sha256(model + "|" + systemPrompt + "|" + tenantRules); // 只哈希稳定前缀
前缀缓存对"顺序"极其敏感:往系统提示里插入一个时间戳,命中率立刻归零。动态内容必须集中在尾部,这是前缀缓存唯一的设计要求。
7. 长会话的成本治理
| 手段 | 做法 | 注意 |
|---|---|---|
| 摘要压缩 | 超过 N 轮后把早期对话压成摘要 | 摘要本身也是输入,要计入预算 |
| 历史裁剪 | 只保留最近 K 轮 + 摘要 | K 太小会丢上下文 |
| 分段会话 | 按主题强制新开会话 | 需要前端配合提示 |
| 结果替换 | 早期工具结果替换为"已查询"标记 | 避免重复注入大结果 |
| 预算触发 | 会话 token 达阈值时切换策略 | 阈值要低于模型上限并留余量 |
public List<Message> trim(List<Message> history, int maxInputTokens) {
List<Message> kept = new ArrayList<>();
int used = 0;
for (int i = history.size() - 1; i >= 0; i--) { // 从最新往回保留
Message m = history.get(i);
int cost = estimator.estimateTokens(m.content());
if (used + cost > maxInputTokens) break;
kept.add(0, m);
used += cost;
}
if (kept.size() < history.size()) {
kept.add(0, Message.system("[已省略早期 " + (history.size() - kept.size()) + " 条历史]"));
}
return kept;
}
省略必须显式告知模型,否则它会把"没说过"当成"没发生",答出前后矛盾的内容。
8. 成本可观测
| 维度 | 打点字段 | 能回答的问题 |
|---|---|---|
| 租户 | tenantId、日累计 token、金额 | 谁在花钱 |
| 接口 | 路径、模型、P95 输入/输出长度 | 哪个功能贵 |
| 模型 | model、单价、命中缓存比例 | 换模型能省多少 |
| 结果 | 降级次数、拒绝次数、重试次数 | 省下来的钱是否转成了体验损失 |
public void record(CallContext ctx, Usage usage) {
Counter.builder("ai.tokens").tag("tenant", ctx.tenantId())
.tag("api", ctx.api()).tag("model", ctx.model())
.tag("kind", "input").register(registry).increment(usage.inputTokens());
Counter.builder("ai.tokens").tag("tenant", ctx.tenantId())
.tag("api", ctx.api()).tag("model", ctx.model())
.tag("kind", "output").register(registry).increment(usage.outputTokens());
if (ctx.degraded()) Counter.builder("ai.degraded").tag("tenant", ctx.tenantId())
.register(registry).increment();
}
三个维度缺一不可:只有租户维度能看账单,看不到"哪个接口贵";只有接口维度能看热点,看不到"谁在超支"。
9. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 月底账单远超预算 | 只按请求数限流,没按 token 限 | 加 token/分钟与日预算 |
| 限流后成本没降 | 重试放大了调用量 | 重试纳入预算,且不重试非幂等工具 |
| 流式输出成本失控 | 输出长度无法预估 | 设 maxTokens,长文分段落生成 |
| 缓存命中率极低 | 前缀里有时间戳 / traceId | 动态内容移到末尾 |
| 降级后投诉变多 | 降级阈值过于激进 | 分级阈值 + 灰度阈值 |
| 用户反复重试 | 拒绝响应没给下次可用时间 | 响应补 Retry-After 与额度水位 |
10. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 内部工具、调用量极小 | 只做日志与月度对账 | 配额系统的维护成本更高 |
| 结果必须绝对准确 | 不降级,直接拒绝并转人工 | 小模型的口径风险高于成本收益 |
| 输入天然巨大(长文档分析) | 离线批处理 + 分段摘要 | 在线链路不适合承载 |
| 多租户且计费敏感 | 预扣 + 分摊账单 + 审计 | 事后统计无法约束 |
| 高并发短请求 | QPS 限流为主 | token 维度收益不明显 |
11. 自测
- 三个成本乘数里,哪一个最容易被忽视,为什么它和重试策略相乘?
- 为什么"预扣"比"事后记账"更适合做 token 限流?
- 前缀缓存对提示顺序有什么硬要求?
- 拒绝响应里为什么要带下次可用时间?
- 历史裁剪后为什么必须显式告诉模型"已省略若干条"?
- 成本打点只按租户一个维度,会漏掉哪两类问题?
S22-X06 · 成本与配额控制:Token 预算、限流与降级
https://www.clxhxhhr.top/posts/5738/ 评论
0 条
还没有评论,先写一条吧。