Skip to content

成本治理与降级:把账单管在预算内 ​

1. 本节产出 ​

一套成本治理:按租户/用户的 Token 配额、实时成本统计、预算告警、超预算自动降级。并且能回答「一次问答到底多少钱」这个老板必问的问题。

2. 前置依赖 ​

3. 为什么成本治理必须在上线前做 ​

一个真实场景:系统上线两周,财务拿着云服务账单来问「这个月 AI 花了 4 万,谁在用?」。此时你:

问题能不能回答
哪个租户花得最多?答不上(没按租户统计)
哪类请求最贵?答不上(没分类统计)
有没有异常调用?答不上(没基线)
明天会不会超预算?答不上(没配额)

四个都答不上,这就是「成本事故」。而且它比技术故障更难处理——技术故障会报警,成本超标是月底才知道。

核心原则:成本必须可归因、可预测、可限制。 三件事缺一件,迟早出事。

4. 核心原理 ​

4.1 一次 RAG 问答的成本构成 ​

总成本 = 查询向量化 + 检索(可忽略) + 精排 + 生成 + 查询改写(可选)

典型数值(单次,中档模型):
  查询向量化     0.00002 元   (可忽略)
  Rerank        0.003 元     (20 个候选)
  生成           0.005 元     (输入 2000 + 输出 400 Token)
  ─────────────────────────
  合计          约 0.008 元

生成是大头,精排次之。所以降本的第一优先级是:

  1. 缓存(02-15):直接省掉全流程,命中率 40% 就是省 40%
  2. 压缩上下文:少塞几个片段、片段切小一点
  3. 模型分级:简单问题用便宜模型
  4. 精排按需:只对低置信度查询做 Rerank

4.2 三级配额模型 ​

级别限制对象作用
全局整个系统每日/每月上限兜底,防止失控
租户每个租户的月度预算商用必需,也是计费依据
用户单个用户的日请求数防滥用、防脚本刷

配额超限后的行为(按严重程度递进):

80% → 告警通知
95% → 自动降级(切便宜模型 / 关闭 Rerank / 减少 topK)
100% → 限流拒绝,返回「本月额度已用完」

95% 的自动降级是关键设计:它让系统在预算耗尽前先「变省」,而不是突然不可用。

4.3 统计口径 ​

必须能按以下维度出报表:
  ├─ 按租户
  ├─ 按用户
  ├─ 按功能(问答/灌库/改写/精排)
  ├─ 按模型
  └─ 按时间(日/月)

灌库成本要单独统计。它是一次性大额支出,混在问答成本里会让日常曲线看不懂。

4.4 降级策略的设计 ​

降级档措施成本降幅质量损失
轻微关闭查询改写~5%小
中等关闭 Rerank,topK 5→3~35%中
重度切轻量模型~70%明显
极限只返回检索结果,不生成~85%大(但仍有信息)

最后一档很实用:不生成答案,直接列出相关文档片段。用户仍然能得到信息,系统成本降到接近零。这比「额度用完请下月再来」好得多。

5. 代码走查 ​

5.1 用量采集 ​

java
// src/main/java/com/example/rag/metrics/TokenMeter.java
@Component
public class TokenMeter {

    private final MeterRegistry meters;
    private final UsageRepository repo;

    public void record(UsageEvent e) {
        // 实时指标(用于告警面板)
        meters.counter("ai.tokens",
                "tenant", e.tenantId(),
                "type", e.type(),        // QUERY / INGEST / RERANK / REWRITE
                "model", e.model()).increment(e.totalTokens());

        // 明细落库(用于对账与报表)
        repo.save(e);
    }

    public record UsageEvent(String tenantId, String userId, String type,
                             String model, int promptTokens, int completionTokens,
                             Instant at) {
        public int totalTokens() { return promptTokens + completionTokens; }
    }
}

两条路都要:实时指标用于告警,明细落库用于对账。只做前者,月底对不上账;只做后者,无法实时告警。

5.2 配额检查 ​

java
// src/main/java/com/example/rag/quota/QuotaManager.java
@Service
public class QuotaManager {

    /** 返回当前应采用的运行档位 */
    public Tier check(String tenantId) {
        long used = repo.monthlyTokens(tenantId);
        long limit = props.limitFor(tenantId);
        double ratio = used / (double) limit;

        if (ratio >= 1.0) {
            alert(tenantId, "额度已用尽");
            return Tier.BLOCKED;
        }
        if (ratio >= 0.95) {
            alert(tenantId, "额度即将用尽,已自动降级");
            return Tier.DEGRADED_HEAVY;
        }
        if (ratio >= 0.80) {
            alert(tenantId, "额度使用 80%");
            return Tier.DEGRADED_LIGHT;
        }
        return Tier.NORMAL;
    }

    public enum Tier { NORMAL, DEGRADED_LIGHT, DEGRADED_HEAVY, BLOCKED }
}

5.3 按档位调整链路 ​

java
// src/main/java/com/example/rag/chain/AdaptiveRagChain.java
@Service
public class AdaptiveRagChain {

    public Answer answer(String question, String tenantId, List<String> acl) {
        QuotaManager.Tier tier = quota.check(tenantId);

        if (tier == QuotaManager.Tier.BLOCKED) {
            return Answer.quotaExceeded();
        }

        RagSettings s = switch (tier) {
            case NORMAL         -> RagSettings.full();          // 改写 + 混合 + 精排
            case DEGRADED_LIGHT -> RagSettings.noRewrite();     // 关改写
            case DEGRADED_HEAVY -> RagSettings.economy();       // 关改写+精排,topK=3,轻量模型
            case BLOCKED        -> throw new IllegalStateException();
        };

        return ragChain.answerWith(question, tenantId, acl, s);
    }
}

5.4 成本报表 ​

java
// src/main/java/com/example/rag/metrics/CostReporter.java
@Service
public class CostReporter {

    /** 月度成本报表:按租户 × 类型 */
    public List<CostRow> monthly(YearMonth month) {
        return jdbc.query("""
                SELECT tenant_id, type, model,
                       SUM(prompt_tokens)     AS pt,
                       SUM(completion_tokens) AS ct
                  FROM ai_usage
                 WHERE at >= ? AND at < ?
                 GROUP BY tenant_id, type, model
                 ORDER BY pt + ct DESC
                """, rowMapper, month.atDay(1), month.plusMonths(1).atDay(1));
    }
}

6. 跑起来 ​

bash
git checkout ch02-18-cost
mvn spring-boot:run
bash
# 1. 单次成本查询
curl "http://localhost:8080/api/ask" -d '{"q":"年假怎么算","tenantId":"acme"}' -v
# 期望响应头:X-Cost-Tokens: 2431  X-Cost-Yuan: 0.0081

# 2. 配额状态
curl http://localhost:8080/api/quota/acme
# 期望:{"used":1245000,"limit":5000000,"ratio":0.249,"tier":"NORMAL"}

# 3. 模拟超配额(把测试租户额度调到很低)
curl -X POST http://localhost:8080/api/admin/quota \
  -d '{"tenantId":"demo","limit":10000}'
curl "http://localhost:8080/api/ask" -d '{"q":"年假怎么算","tenantId":"demo"}'
# 期望:降级档生效,日志显示 DEGRADED_HEAVY,仍返回答案

# 4. 成本报表
curl "http://localhost:8080/api/cost/monthly?month=2026-10"
检查项通过标准
单次成本可见响应头或日志带 Token 与估算金额
配额可查能看到已用/上限/档位
降级可用超配额后仍返回结果(质量下降但不报错)
报表可用能按租户和类型出数据
告警80%/95% 触发通知

7. 生产避坑 ​

  1. 不要用估算值计费,只用厂商返回的 usage。估算用于预算告警和前端提示,对账必须用真实返回值。两者混用会导致月底和账单对不上,而排查这类问题极其耗时。
  2. 灌库成本要单独统计并单独设额度。一次全量重灌可能花掉几千元,如果和问答共用额度,会在某天突然触发全局限流,影响所有租户的正常使用。
  3. 降级必须是「变省」而不是「不可用」。直接拒绝服务会让用户在月底集体无法使用,体验极差。正确设计是逐级降级,最后一档「只返回检索片段」仍然有价值。

8. 延伸与锚点 ​