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. 自测

  1. 三个成本乘数里,哪一个最容易被忽视,为什么它和重试策略相乘?
  2. 为什么"预扣"比"事后记账"更适合做 token 限流?
  3. 前缀缓存对提示顺序有什么硬要求?
  4. 拒绝响应里为什么要带下次可用时间?
  5. 历史裁剪后为什么必须显式告诉模型"已省略若干条"?
  6. 成本打点只按租户一个维度,会漏掉哪两类问题?
S22-X06 · 成本与配额控制:Token 预算、限流与降级
https://www.clxhxhhr.top/posts/5738/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。