Administrator
发布于 2024-11-03 / 11894 阅读
135

大模型应用成本控制:Token 经济的实践

10 月账单 11.7 万,调用量才涨了 40%

11 月 1 号早上收到云厂商账单:10 月大模型调用费用 11.73 万元。9 月是 4.31 万,涨了 172%。但同期我们的业务量只涨了 40%,这中间肯定有浪费。

我花了两天做了一次完整的用量审计,最后把 11 月的账单压到了 5.1 万。过程记录下来。

先把钱花在哪儿了拆清楚

第一件事是补监控。之前的埋点只有一个总调用量,没法归因。我加了按场景、租户、模型三个维度的 token 统计:

public class TokenMeter {
    private final MeterRegistry registry;

    public void record(String scene, String model, String tenant, Usage u) {
        registry.counter("llm.token.total",
                "scene", scene, "model", model, "tenant", tenant)
                .increment(u.totalTokens());
        registry.counter("llm.cost.cny",
                "scene", scene, "model", model, "tenant", tenant)
                .increment(PRICING.get(model).costOf(u));   // 分单位累加
    }
}

跑了一天,Prometheus 里按场景聚合出来的分布让我有点意外:

topk(8, sum by (scene) (llm_cost_cny))
# 单日费用(元)
batch_sku_describe      2146.73
cs_chat                 1203.15
doc_summary              987.42
intent_classify          842.91    # 意图分类也用了大模型?
query_rewrite            611.08    # 问题改写
ticket_extract           402.55
other                    198.30

三个问题浮出水面:

  1. batch_sku_describe(商品文案批量生成)占了 36%,而它的业务价值最低
  2. intent_classify(意图分类)花了 842 元,这是个可以不用大模型的活
  3. cs_chat 单次成本高得离谱,平均每次 0.041 元,而输出只有 400 多个 token

cs_chat 为什么这么贵

查了请求日志,问题在上下文。我们当时是把 rerank 前的 top 50 个 chunk 全塞进了 Prompt,想着"给模型更多资料更准"。算一下:每个 chunk 平均 340 token,50 个就是 1.7 万 token。

平均单次请求 token 构成(优化前):
  系统提示词        320
  历史对话(10 轮)  1860
  检索上下文        17120   ← 问题在这
  用户问题            28
  输出                412
  合计             19740

输入是输出的 47 倍。而当时我们用的是国内某家的大模型,输入 4 元/百万 token,输出 16 元/百万 token,单次成本 = 19328/1e6×4 + 412/1e6×16 ≈ 0.084 元。注意这个数,输入占了 84% 的成本

手段一:上下文压缩

第一刀砍在检索上下文上。做法很直接:rerank 之后只取 top 5,并且给每个 chunk 加一个 60 字的摘要,摘要由小模型生成一次缓存起来。

List<Chunk> buildContext(List<Chunk> candidates, String question) {
    List<Chunk> top5 = reranker.rerank(question, candidates, 5);
    // 用摘要替代全文,命中需要细节时再按 chunkId 回捞
    return top5.stream()
               .map(c -> new Chunk(c.id(), summarizer.get(c.id()), c.score()))
               .toList();
}

第二刀砍在历史对话上。超过 6 轮的历史用模型做一次压缩,保留实体和结论:

if (history.size() > 6) {
    String compressed = cheapModel.chat("""
        把以下对话压缩成 100 字以内的摘要,保留:用户提到的商品、订单号、已给出的结论。
        %s
        """.formatted(format(history.subList(0, history.size() - 4))));
    history = concat(summaryOf(compressed), last4Turns(history));
}

改完之后:

平均单次请求 token 构成(优化后):
  系统提示词        186
  历史摘要 + 近 4 轮  410
  检索上下文(摘要)  980
  用户问题            28
  输出                412
  合计              2016

单次成本从 0.084 元降到 0.0098 元,降了 88%。

代价:准确率掉了 2.1 个点

这里必须说清楚副作用。砍上下文不是白拿的,我们用那 150 条测试集跑了一遍:

方案单次成本模型平均分规则通过率
top50 全文0.0840 元4.4194.0%
top10 全文0.0221 元4.3893.3%
top5 摘要0.0098 元4.2991.3%
top5 摘要 + 回捞0.0113 元4.3793.3%

最后一行的"回捞"是说:模型在回答时如果需要某个 chunk 的完整内容,可以输出一个 [[fetch:chunkId]] 标记,系统再把全文塞回去生成一次。我们用这个把准确率补了回来,代价是约 13% 的请求会触发二次调用。

手段二:模型分级路由

意图分类、问题改写、文档打标这些任务,根本不需要最强的模型。我们建了一张路由表:

public enum Task { INTENT, REWRITE, EXTRACT, SUMMARIZE, GENERATE, REASON }

public ModelSpec route(Task task, int inputTokens) {
    return switch (task) {
        case INTENT   -> ModelSpec.of("qwen-turbo",  0.3, 2.0);   // 元/百万 token
        case REWRITE  -> ModelSpec.of("qwen-plus",   4.0, 16.0);
        case EXTRACT  -> ModelSpec.of("qwen-plus",   4.0, 16.0);
        case SUMMARIZE-> ModelSpec.of("qwen-plus",   4.0, 16.0);
        case GENERATE -> inputTokens > 8000
                            ? ModelSpec.of("qwen-long", 0.5, 2.0)   // 长文本便宜
                            : ModelSpec.of("qwen-max", 20.0, 60.0);
        case REASON   -> ModelSpec.of("qwen-max",   20.0, 60.0);
    };
}

这里有个坑:小模型不是简单地"便宜但笨",它在特定任务上可能完全不可用。我们第一版把所有 EXTRACT 任务都切到了 turbo,结果 JSON 输出的格式错误率从 1.2% 涨到 18%,反而要额外做重试,成本没省还变慢了。

后来定了个规矩:每个任务切换模型前,必须在测试集上验证格式正确率不低于 99%。最后 EXTRACT 留在 plus,只有 INTENT 用了 turbo。

手段三:缓存复用

这块我在《多级缓存应对 AI 应用的高延迟》那篇里写过,这里只补成本账。客服场景的缓存命中率是 34.7%(精确)+ 16.8%(语义),合计 51.5%,直接砍掉一半调用。

另外加了一层Embedding 缓存。批量生成商品文案时,同一个 SKU 的描述反复入库导致重复 embedding:

// 按内容哈希去重,重复率 23%
String ck = "emb:" + DigestUtils.sha256Hex(normalize(text));
float[] vec = embCache.get(ck, k -> embeddingClient.embed(text));

那个 batch_sku_describe 场景之所以占 36% 的费用,就是因为运营每周上传的 Excel 里有大量重复行,我们没去重就全量调用。加了哈希缓存之后,这个场景日费用从 2146 元降到 780 元。

手段四:配额与熔断

成本控制最怕的是"失控"——某个死循环或者被刷接口导致一夜之间烧掉几万块。我们加了三层保护:

@Component
public class BudgetGuard {
    // 按租户的日预算,超过就降级到便宜模型
    public void check(String tenant) {
        double used = redis.opsForValue().increment("cost:" + tenant + ":" + today(), 0);
        double quota = quotaService.of(tenant);
        if (used > quota * 0.8) {
            alertService.warn(tenant, used, quota);
        }
        if (used > quota) {
            throw new BudgetExceededException(tenant, quota);
        }
    }
}
  1. 单请求上限max_tokens 硬限制,生成任务最多 2000 token,超了直接截断并告警。
  2. 租户日配额:超 80% 发告警,超 100% 熔断。
  3. 单用户频率限制:1 分钟最多 20 次,防止被刷。

这三层上线第二周就起作用了。有个内部用户把我们的接口接到了他的爬虫任务里,一天调了 14 万次,触发配额熔断,否则那天要多花三千多块。

最终账目

措施10 月(元/天)11 月(元/天)降幅
客服对话120334271.6%
批量文案214778063.7%
文档摘要98751148.2%
意图分类8434794.4%
问题改写61120366.8%
合计约 3900约 170056.4%

写在后面

现在回头看,《大模型应用成本控制:Token 经济的实践》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。

参考