去年双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-serviceenvironment: productionspring:ai:openai:observations:enabled: trueinclude-input: true # 把user message也纳入span taginclude-output: true # 把assistant response也纳入span tagzhipuai:observations:enabled: true
开启后,每次ChatClient调用自动产生一个Micrometer Observation,标签包含ai.model、ai.provider、http.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消耗埋点:核心代码
我们在MultiProviderChatClient的chatWithMeta方法里,把token信息手动打点。这是整个监控体系的核心,每个AI调用都会经过这里:
@Component@RequiredArgsConstructorpublic class TokenMeteringAdvisor implements ChatModelRequestContextContributor {private final MeterRegistry meterRegistry;@Overridepublic void contribute(ChatModelRequestContext context, ChatRequest request) {// 在请求发出前记录模型名,作为tagString 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里加userId和apiEndpoint标签,每次调用时从Spring RequestContextHolder里取:
public class CostAttributionAdvisor implements ChatModelRequestContextContributor {private final MeterRegistry meterRegistry;@Overridepublic 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-performancerules:- alert: AiP95LatencyHighexpr: histogram_quantile(0.95, rate(ai_latency_seconds_bucket[5m])) > 8for: 10mlabels:severity: warningannotations:summary: "AI P95延迟超过8秒,持续10分钟"description: "当前P95={{ $value }}s, 分组: {{ $labels.provider }}, {{ $labels.model }}"- alert: AiP99LatencyCriticalexpr: histogram_quantile(0.99, rate(ai_latency_seconds_bucket[5m])) > 15for: 5mlabels:severity: criticalannotations:summary: "AI P99延迟超过15秒,需要立即排查"- alert: AiErrorRateHighexpr: |sum(rate(ai_errors_total[5m])) /sum(rate(ai_calls_total[5m])) > 0.05for: 5mlabels:severity: warningannotations:summary: "AI错误率超过5%"- alert: AiTokenSpendSpikeexpr: |sum(increase(ai_tokens_input_total[1h])) by (provider) > 5000000for: 1hlabels:severity: infoannotations:summary: "1小时内input token消耗超过500万,关注成本"- alert: AiTokenEfficiencyLowexpr: ai_token_efficiency < 0.05for: 30mlabels:severity: infoannotations: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;}
EmbeddingMetering里也加了counter,才补齐了这块数据。验证方法很简单:导出Grafana的每日token消耗,和provider的账单对比,误差控制在5%以内就算合格。
实时监控vs历史分析
我们的监控体系分两层:
实时监控:Grafana + Prometheus,看当前QPS、延迟、错误率、token消耗速率。用于发现即时问题。历史分析:MySQL + 定时同步,看日/周/月的token消耗趋势、成本变化。用于财务对账和容量规划。两层数据的关联字段是date(YYYY-MM-DD),可以按天聚合,也可以按周聚合。我们在MySQL里还加了provider、model、endpoint几个维度,方便财务按不同维度拆分成本。
CREATE TABLE ai_daily_metrics (id BIGINT AUTO_INCREMENT PRIMARY KEY,date DATE NOT NULL,provider VARCHAR(50) NOT NULL,model VARCHAR(100) NOT NULL,endpoint VARCHAR(200),input_tokens BIGINT NOT NULL,output_tokens BIGINT NOT NULL,total_cost_usd DECIMAL(10,4) NOT 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数估算不准的问题?
夜雨聆风