乐于分享
好东西不私藏

AI调用监控:追踪Token消耗与响应时间

AI调用监控:追踪Token消耗与响应时间

去年双11前夕,我们公司的AI客服系统突然被财务拉去问了一嘴–这个月AI调用费怎么比上个月多了37%?财务要的是"按部门归因",但我们代码里根本没有token计数的埋点,日志里只记了"请求成功/失败"。花了一整周才把账单拉出来对着算,最后发现是某条RAG查询链路没有做prompt缓存,同一份合同被不同用户反复查询,每次都重新走一遍Embedding+召回+LLM,token消耗翻了5倍。

这件事之后我们决定把AI调用的监控做得更细。这篇讲怎么用量化指标(token消耗、响应时间、成本归因)监控AI调用,覆盖Micrometer + Prometheus + Grafana,以及Spring AI 1.0自带的Observability支持。

Spring AI Observability自动埋点

Spring AI 1.0+内置了Observability支持,基于Micrometer的ObservationRegistry自动采集指标。启用方式很简单,在pom.xml加:

<dependency>    <groupId>org.springframework.ai</groupId>    <artifactId>spring-ai-starter-observability</artifactId></dependency>

然后在yml里打开:

management:  observations:    key-values:      application: contract-review-service      environment: productionspring:  ai:    openai:      observations:        enabled: true        include-input: true      # 把user message也纳入span tag        include-output: true     # 把assistant response也纳入span tag    zhipuai:      observations:        enabled: true

开启后,每次ChatClient调用自动产生一个Micrometer Observation,标签包含ai.modelai.providerhttp.status等。在Prometheus里可以直接查:

# 每秒AI调用量rate(ai_chat_client_calls_total[5m])# 按模型分组的调用量rate(ai_chat_client_calls_total[5m]) by (ai_model)# P95延迟histogram_quantile(0.95, rate(ai_chat_client_calls_duration_seconds_bucket[5m]))

但原生Observability有个问题:它不自动暴露token消耗ai_chat_client_calls_duration_seconds记录的是延迟,ai_chat_client_calls_total记录的是调用次数,但input tokens和output tokens需要自己埋。

Token消耗埋点:核心代码

我们在MultiProviderChatClientchatWithMeta方法里,把token信息手动打点。这是整个监控体系的核心,每个AI调用都会经过这里:

@Component@RequiredArgsConstructorpublic class TokenMeteringAdvisor implements ChatModelRequestContextContributor {    private final MeterRegistry meterRegistry;    @Override    public void contribute(ChatModelRequestContext context, ChatRequest request) {        // 在请求发出前记录模型名,作为tag        String model = request.getModel() != null ? request.getModel() : "unknown";        context.getTags().add(Tag.of("ai.model", model));        context.getTags().add(Tag.of("ai.request.type""chat"));    }}

然后在MultiProviderChatClient里:

@Overridepublic AiResponse chatWithMeta(String userMessage) {    String provider = properties.getDefaultProvider();    long start = System.currentTimeMillis();    try {        ChatClient client = getClient(provider);        ChatResponse response = client.prompt()            .user(userMessage)            .call()            .chatResponse();        AssistantMessage assistant = response.getResult().getOutput();        Usage usage = response.getMetadata().getUsage();        int inputTokens = usage.getPromptTokens();        int outputTokens = usage.getCompletionTokens();        long latencyMs = System.currentTimeMillis() - start;        // 埋点:token消耗(按provider+model分组)        meterRegistry.counter("ai.tokens.input",            "provider", provider,            "model", response.getMetadata().getModel())            .increment(inputTokens);        meterRegistry.counter("ai.tokens.output",            "provider", provider,            "model", response.getMetadata().getModel())            .increment(outputTokens);        // 埋点:延迟(用timer,自动生成分桶)        meterRegistry.timer("ai.latency",            "provider", provider,            "model", response.getMetadata().getModel())            .record(latencyMs, TimeUnit.MILLISECONDS);        // 埋点:调用次数        meterRegistry.counter("ai.calls",            "provider", provider,            "model", response.getMetadata().getModel())            .increment();        // 埋点:单次调用的token效率(output/input)        if (inputTokens > 0) {            double efficiency = (double) outputTokens / inputTokens;            meterRegistry.gauge("ai.token.efficiency",                Tags.of("provider", provider, "model", response.getMetadata().getModel()),                efficiency);        }        return new AiResponse(assistant.getText(), inputTokens, outputTokens,            latencyMs, provider, response.getMetadata().getModel());    } catch (Exception e) {        meterRegistry.counter("ai.errors",            "provider", provider,            "error_type", e.getClass().getSimpleName())            .increment();        throw handleException(provider, e);    }}

关键设计点:

用counter而不是gauge记录token消耗–counter是累加的,适合记录"消耗了多少token";gauge适合记录"当前状态"(比如队列长度)。延迟用timer–timer会自动生成分桶,可以直接查P95/P99,不用自己算。token效率gauge–output/input比率能反映模型"输出质量",太低的比率(比如<0.1)说明模型在啰嗦,需要优化prompt。

这些counter和timer会自动被Micrometer暴露到/actuator/prometheus端点,Prometheus拉取后就可以在Grafana里做可视化。

成本归因:按用户/接口维度统计

光知道token总量不够,要知道"哪个业务场景最烧钱"。我们的做法是在AiResponse里加userIdapiEndpoint标签,每次调用时从Spring RequestContextHolder里取:

public class CostAttributionAdvisor implements ChatModelRequestContextContributor {    private final MeterRegistry meterRegistry;    @Override    public void contribute(ChatModelRequestContext context, ChatRequest request) {        ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();        if (attrs != null) {            HttpServletRequest req = attrs.getRequest();            String userId = req.getHeader("X-User-Id");            String endpoint = req.getRequestURI();            if (userId != null) {                context.getTags().add(Tag.of("user.id", userId));            }            if (endpoint != null) {                context.getTags().add(Tag.of("api.endpoint", endpoint));            }        }    }}

然后Prometheus里可以这样查某个接口的日均token消耗:

# 按接口分组的日均input token消耗sum(rate(ai_tokens_input{api_endpoint=~"/api/.*"}[24h])) by (api_endpoint)# 按用户分组的日均成本(假设GPT-4o-mini输入$0.15/1M token)sum(rate(ai_tokens_input{model="gpt-4o-mini"}[24h])) by (user_id) * 0.15 / 1000000# 按provider分组的成本占比sum(rate(ai_tokens_input[24h])) by (provider) / sum(rate(ai_tokens_input[24h]))

我们把这套查询做成了Grafana Dashboard,每个业务接口一块卡片,显示:日均调用量、P95延迟、日均token消耗、日均成本(美元)。财务对账时直接截图,不用再手工算。

P95/P99延迟告警规则

AI调用的延迟分布和传统HTTP服务不一样–LLM推理的尾部延迟(tail latency)经常拉高P99,但P95相对平稳。我们设置的告警规则:

# alert.rules.ymlgroups:  - name: ai-performance    rules:      - alert: AiP95LatencyHigh        expr: histogram_quantile(0.95, rate(ai_latency_seconds_bucket[5m])) > 8        for: 10m        labels:          severity: warning        annotations:          summary: "AI P95延迟超过8秒,持续10分钟"          description: "当前P95={{ $value }}s, 分组: {{ $labels.provider }}, {{ $labels.model }}"      - alert: AiP99LatencyCritical        expr: histogram_quantile(0.99, rate(ai_latency_seconds_bucket[5m])) > 15        for: 5m        labels:          severity: critical        annotations:          summary: "AI P99延迟超过15秒,需要立即排查"      - alert: AiErrorRateHigh        expr: |          sum(rate(ai_errors_total[5m])) /          sum(rate(ai_calls_total[5m])) > 0.05        for: 5m        labels:          severity: warning        annotations:          summary: "AI错误率超过5%"      - alert: AiTokenSpendSpike        expr: |          sum(increase(ai_tokens_input_total[1h])) by (provider) > 5000000        for: 1h        labels:          severity: info        annotations:          summary: "1小时内input token消耗超过500万,关注成本"      - alert: AiTokenEfficiencyLow        expr: ai_token_efficiency < 0.05        for: 30m        labels:          severity: info        annotations:          summary: "Token效率低于5%,模型输出过于啰嗦"

告警触发后走飞书机器人通知,critical级别的直接@值班人。

P95和P99的阈值选择逻辑:

  P95 > 8秒:说明有10%的请求超过8秒,用户体验明显变差,需要排查。 P99 > 15秒:说明有1%的请求超过15秒,可能是上游模型抖动或网络问题,需要立即介入。 错误率 > 5%:说明有1/20的请求失败,可能是限流、超时或模型异常。

Grafana面板配置详解

我们的Grafana Dashboard有这几块,每块的Prometheus查询和配置都讲清楚:

1. 总览卡片(Stat Panel)

显示核心指标,用Stat面板+阈值颜色:

{  "datasource": "Prometheus",  "targets": [    {      "expr": "sum(rate(ai_calls_total[5m]))",      "legendFormat""QPS"    },    {      "expr": "histogram_quantile(0.95, rate(ai_latency_seconds_bucket[5m]))",      "legendFormat""P95"    },    {      "expr": "sum(rate(ai_errors_total[5m])) / sum(rate(ai_calls_total[5m]))",      "legendFormat""错误率"    },    {      "expr": "sum(increase(ai_tokens_input_total[24h])) * 0.15 / 1000000 + sum(increase(ai_tokens_output_total[24h])) * 0.60 / 1000000",      "legendFormat""今日成本($)"    }  ],  "fieldConfig": {    "defaults": {      "color": { "mode": "thresholds" },      "thresholds": {        "steps": [          { "color": "green""value": null },          { "color": "yellow""value"0.05 },          { "color": "red""value"0.1 }        ]      }    }  }}

2. 按模型分组的延迟分布(Time Series)

折线图,X轴是时间,Y轴是秒,GPT-4o-mini、glm-4-plus、qwen-max各一条线:

histogram_quantile(0.95,  sum(rate(ai_latency_seconds_bucket[5m]))  by (le, model))

3. 按provider分组的token消耗(Stacked Bar)

堆叠柱状图,显示input/output token各多少:

sum(rate(ai_tokens_input[1h])) by (provider)sum(rate(ai_tokens_output[1h])) by (provider)

4. 按接口分组的成本占比(Pie Chart)

饼图,看哪个业务接口最烧钱:

sum(rate(ai_tokens_input{api_endpoint!=""}[24h])) by (api_endpoint)

5. 调用成功率趋势(Time Series)

折线图,区分timeout/429/500不同错误类型:

sum(rate(ai_errors_total[5m])) by (error_type)

一个容易被忽略的点:Embedding调用的监控

RAG链路里有两步AI调用:Embedding(把文本转向量)和LLM(生成回答)。很多团队只监控LLM的token消耗,忘了Embedding也会烧钱。我们的做法是给Embedding Client也打同样的点:

@Componentpublic class EmbeddingMetering {    private final MeterRegistry meterRegistry;    private final VectorStore vectorStore;    public EmbeddingResult embedAndIndex(String text, String docId) {        long start = System.currentTimeMillis();        try {            EmbeddingResult result = vectorStore.embed(new Document(text));            Usage usage = result.getMetadata().getUsage();            meterRegistry.counter("ai.embeddings.tokens.input")                .increment(usage.getPromptTokens());            meterRegistry.timer("ai.embeddings.latency")                .record(System.currentTimeMillis() - start, TimeUnit.MILLISECONDS);            return result;        } catch (Exception e) {            meterRegistry.counter("ai.embeddings.errors")                .increment();            throw e;        }    }}

Embedding模型的单价通常比LLM便宜很多(text-embedding-3-small是$0.02/1M token),但量大的话也是钱。我们项目里Embedding调用量是LLM的3倍(每次RAG召回都要Embedding查询),这个成本不能漏。

监控数据的准确性验证

我们上线监控后,财务发现Grafana上的token数和实际账单对不上。排查后发现是两个原因:

不同provider的token计数方式不同:OpenAI的prompt_tokens包含了system prompt,而智谱的usage.prompt_tokens不包含。我们在MultiProviderChatClient里做了统一转换:
privateintnormalizeInputTokens(String provider, int rawTokens) {    // 智谱的usage不包含system prompt,需要加上    if ("zhipuai".equalsIgnoreCase(provider)) {        // 估算system prompt的token数(约500 token)        return rawTokens + 500;    }    return rawTokens;}
Embedding的token计数也缺失:我们最初只监控了LLM的token,忘了Embedding。后来在EmbeddingMetering里也加了counter,才补齐了这块数据。

验证方法很简单:导出Grafana的每日token消耗,和provider的账单对比,误差控制在5%以内就算合格。

实时监控vs历史分析

我们的监控体系分两层:

实时监控:Grafana + Prometheus,看当前QPS、延迟、错误率、token消耗速率。用于发现即时问题。历史分析:MySQL + 定时同步,看日/周/月的token消耗趋势、成本变化。用于财务对账和容量规划。

两层数据的关联字段是date(YYYY-MM-DD),可以按天聚合,也可以按周聚合。我们在MySQL里还加了providermodelendpoint几个维度,方便财务按不同维度拆分成本。

CREATE TABLE ai_daily_metrics (    id BIGINT AUTO_INCREMENT PRIMARY KEY,    date DATE NOT NULL,    provider VARCHAR(50NOT NULL,    model VARCHAR(100NOT NULL,    endpoint VARCHAR(200),    input_tokens BIGINT NOT NULL,    output_tokens BIGINT NOT NULL,    total_cost_usd DECIMAL(10,4NOT NULL,    call_count INT NOT NULL,    avg_latency_ms DECIMAL(10,2),    p95_latency_ms DECIMAL(10,2),    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,    UNIQUE KEY uk_date_provider_model (date, provider, model));

这个表的结构比较简单,但够用。每月财务导出一张CSV,和provider的账单对账,误差通常在2%以内。

容量规划:从监控数据到预算

监控数据最大的价值之一是帮助做容量规划。我们每季度会根据过去3个月的数据,预测下季度的AI调用量和成本:

# 过去30天日均调用量avg(rate(ai_calls_total[1d]))# 过去30天日均token消耗avg(rate(ai_tokens_input[1d])) + avg(rate(ai_tokens_output[1d]))# 按模型分组的日均成本sum(rate(ai_tokens_input[1d])) by (model) * 0.15 / 1000000 + sum(rate(ai_tokens_output[1d])) by (model) * 0.60 / 1000000

基于这些数据,我们可以回答:

  下季度需要申请多少API quota? 是否需要切换到更便宜的模型(比如从GPT-4o切换到GPT-4o-mini)? 是否需要增加缓存命中率来降低成本?

异常检测:自动发现监控数据异常

除了看面板,我们还做了自动异常检测。当某个指标的偏离度超过阈值时,自动发告警:

@Component@RequiredArgsConstructorpublic class AnomalyDetector {    private final MeterRegistry meterRegistry;    private final AlertManager alertManager;    @Scheduled(fixedRate = 300000)  // 每5分钟检查一次    public void checkAnomalies() {        // 检查token消耗是否异常激增        Double currentRate = meterRegistry.find("ai.tokens.input")            .timeSeries()            .stream()            .map(TimeSeries::lastValue)            .findFirst()            .orElse(0.0);        // 和过去7天同一时段对比        Double historicalAvg = getHistoricalAverage("ai.tokens.input"7);        double deviation = (currentRate - historicalAvg) / historicalAvg;        if (deviation > 0.5) {  // 超过50%的偏离            alertManager.sendAlert("Token消耗异常激增: " + String.format("%.1f%%", deviation * 100));        }        // 检查错误率是否异常        Double errorRate = meterRegistry.find("ai.errors")            .counter()            .count() / meterRegistry.find("ai.calls")            .counter()            .count();        if (errorRate > 0.1) {  // 错误率超过10%            alertManager.sendAlert("AI错误率异常: " + String.format("%.1f%%", errorRate * 100));        }    }    private Double getHistoricalAverage(String metric, int days) {        // 从MySQL查询历史数据        String sql = "SELECT AVG(input_tokens + output_tokens) / ? AS avg_rate FROM ai_daily_metrics WHERE date >= ?";        return jdbcTemplate.queryForObject(sql, Double.class, days, LocalDate.now().minusDays(days));    }}

这样可以在异常发生的早期就发现,不用等到财务来问。

监控做得细了,财务对账就不需要再扯皮

我们这套监控体系上线后,财务每月对账只需要看一眼Grafana Dashboard,不用再看日志手工算。AI调用的成本从“黑盒”变成了“透明账本”。

还有一个意外收获:监控数据帮助我们发现了几个性能瓶颈。比如我们发现某个接口的P99延迟经常在晚上9点到10点之间飙升,排查后发现是那时段有大批量合同审查任务在跑,占用了大量线程。后来我们给批量任务加了独立的线程池,P99延迟直接降了一半。

你们项目里AI调用的监控是怎么做的?token消耗有按业务场景归因吗?有没有遇到过智谱token数估算不准的问题?

监控做得细了,财务对账就不需要再扯皮

我们这套监控体系上线后,财务每月对账只需要看一眼Grafana Dashboard,不用再看日志手工算。AI调用的成本从"黑盒"变成了"透明账本"。

还有一个意外收获:监控数据帮助我们发现了几个性能瓶颈。比如我们发现某个接口的P99延迟经常在晚上9点到10点之间飙升,排查后发现是那时段有大批量合同审查任务在跑,占用了大量线程。后来我们给批量任务加了独立的线程池,P99延迟直接降了一半。

你们项目里AI调用的监控是怎么做的?token消耗有按业务场景归因吗?有没有遇到过智谱token数估算不准的问题?