AI应用的可观测性:LLM调用日志、Token统计、成本控制
AI应用的可观测性:LLM调用日志、Token统计、成本控制
适读人群:Java后端工程师、AI应用运维、技术管理者 | 阅读时长:约18分钟 | 依赖:Spring AI 1.0、Micrometer、Grafana
开篇故事
我们团队上线了一个面向内部用户的AI助手,前两个月一切正常。第三个月,月底财务对账,OpenAI账单比预算超了240%。我当时吓出一身冷汗,赶紧查原因。
花了两天时间翻日志,才找到问题:一个使用Agent功能的用户,写了一个脚本在循环调用AI接口,一天调用了上千次,每次都带着大量历史上下文,单用户消耗了整月30%的API费用。更糟糕的是,如果有完善的监控,当天就能发现这个异常,但我们当时对LLM的调用几乎是黑盒的——知道有调用,不知道谁在调、调了什么、消耗了多少。
那次事件之后,我花了一个月给AI应用加上了完整的可观测性体系:每次调用都有日志可查、每个用户每天的Token消耗有实时统计、超出阈值自动告警、成本超标自动限流。今天把这套方案整理出来。
一、核心问题分析
AI应用的可观测性有三个关键维度:
1. 调用追踪:每次LLM调用是谁发起的、输入是什么、输出是什么、耗时多少、有没有报错?
2. Token监控:input token多少、output token多少、总费用是多少?按用户、按功能、按时间维度分析。
3. 质量监控:响应延迟分布(P50/P95/P99)、错误率、用户满意度信号(点赞/点踩)。
AI应用的可观测性和传统应用的区别在于:
- LLM调用的延迟差异巨大(500ms到30秒),需要专门的延迟分布分析
- Token消耗不均匀(有些请求消耗100 token,有些消耗10000),平均值没意义
- 内容质量无法用传统指标衡量,需要结合业务反馈
- 成本与调用量是线性关系,需要实时成本告警
二、原理深度解析
2.1 可观测性体系架构
三、完整代码实现
3.1 LLM调用日志Advisor(Spring AI拦截器)
@Component
public class LlmObservabilityAdvisor implements RequestResponseAdvisor {
private static final Logger log = LoggerFactory.getLogger(LlmObservabilityAdvisor.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
private final LlmCallLogRepository callLogRepository;
private final MeterRegistry meterRegistry;
private final TokenCostService tokenCostService;
public LlmObservabilityAdvisor(LlmCallLogRepository callLogRepository,
MeterRegistry meterRegistry,
TokenCostService tokenCostService) {
this.callLogRepository = callLogRepository;
this.meterRegistry = meterRegistry;
this.tokenCostService = tokenCostService;
}
@Override
public AdvisedRequest adviseRequest(AdvisedRequest request,
Map<String, Object> context) {
// 记录请求开始时间
context.put("llm_start_time", System.currentTimeMillis());
context.put("llm_request_id", UUID.randomUUID().toString());
// 记录用户信息(从请求上下文获取)
String userId = (String) context.getOrDefault("user_id", "anonymous");
String feature = (String) context.getOrDefault("feature", "default");
context.put("llm_user_id", userId);
context.put("llm_feature", feature);
return request;
}
@Override
public ChatResponse adviseResponse(ChatResponse response,
Map<String, Object> context) {
long startTime = (Long) context.getOrDefault("llm_start_time",
System.currentTimeMillis());
long latencyMs = System.currentTimeMillis() - startTime;
String requestId = (String) context.get("llm_request_id");
String userId = (String) context.getOrDefault("llm_user_id", "anonymous");
String feature = (String) context.getOrDefault("llm_feature", "default");
// 提取Token使用信息
Usage usage = response.getMetadata().getUsage();
int promptTokens = usage != null ? usage.getPromptTokens().intValue() : 0;
int completionTokens = usage != null ? usage.getGenerationTokens().intValue() : 0;
int totalTokens = promptTokens + completionTokens;
// 计算本次调用成本
String model = (String) context.getOrDefault("llm_model", "gpt-4o");
double costUsd = tokenCostService.calculateCost(model,
promptTokens, completionTokens);
// 1. 写入结构化日志(供Elasticsearch索引)
LlmCallLog callLog = LlmCallLog.builder()
.requestId(requestId)
.userId(userId)
.feature(feature)
.model(model)
.promptTokens(promptTokens)
.completionTokens(completionTokens)
.totalTokens(totalTokens)
.costUsd(costUsd)
.latencyMs(latencyMs)
.success(true)
.timestamp(LocalDateTime.now())
.build();
callLogRepository.save(callLog);
// 2. 更新Prometheus指标
recordMetrics(userId, feature, model, promptTokens, completionTokens,
costUsd, latencyMs, true);
log.info("[LLM调用] requestId={}, user={}, feature={}, model={}, " +
"tokens={}/{}/{}, cost=${:.4f}, latency={}ms",
requestId, userId, feature, model,
promptTokens, completionTokens, totalTokens,
costUsd, latencyMs);
return response;
}
@Override
public void handleError(Throwable error, AdvisedRequest request,
Map<String, Object> context) {
long startTime = (Long) context.getOrDefault("llm_start_time",
System.currentTimeMillis());
long latencyMs = System.currentTimeMillis() - startTime;
String userId = (String) context.getOrDefault("llm_user_id", "anonymous");
String feature = (String) context.getOrDefault("llm_feature", "default");
String model = (String) context.getOrDefault("llm_model", "gpt-4o");
// 记录错误日志
log.error("[LLM调用失败] user={}, feature={}, latency={}ms, error={}",
userId, feature, latencyMs, error.getMessage(), error);
recordMetrics(userId, feature, model, 0, 0, 0, latencyMs, false);
}
private void recordMetrics(String userId, String feature, String model,
int promptTokens, int completionTokens,
double costUsd, long latencyMs, boolean success) {
Tags tags = Tags.of("user_id", userId, "feature", feature,
"model", model, "success", String.valueOf(success));
// 调用计数
meterRegistry.counter("llm.calls.total", tags).increment();
// Token消耗
meterRegistry.counter("llm.tokens.prompt", tags).increment(promptTokens);
meterRegistry.counter("llm.tokens.completion", tags).increment(completionTokens);
// 成本统计(USD转换为分,避免float精度问题)
meterRegistry.counter("llm.cost.cents",
Tags.of("feature", feature, "model", model))
.increment(costUsd * 100);
// 延迟分布
meterRegistry.timer("llm.latency", tags)
.record(latencyMs, TimeUnit.MILLISECONDS);
}
}3.2 Token成本计算服务
@Service
public class TokenCostService {
// 各模型价格(USD per 1K tokens,截至2024年)
private static final Map<String, ModelPricing> MODEL_PRICING = Map.of(
"gpt-4o", new ModelPricing(0.005, 0.015), // input/output per 1K tokens
"gpt-4o-mini", new ModelPricing(0.00015, 0.0006),
"gpt-4-turbo", new ModelPricing(0.01, 0.03),
"text-embedding-3-small", new ModelPricing(0.00002, 0),
"text-embedding-3-large", new ModelPricing(0.00013, 0),
"whisper-1", new ModelPricing(0, 0) // 按分钟计费,单独处理
);
public double calculateCost(String model, int promptTokens, int completionTokens) {
ModelPricing pricing = MODEL_PRICING.getOrDefault(model,
new ModelPricing(0.01, 0.03)); // 未知模型用默认价格
return (promptTokens * pricing.getInputPricePerK() / 1000.0) +
(completionTokens * pricing.getOutputPricePerK() / 1000.0);
}
/**
* 查询用户当日累计费用
*/
public DailyCostSummary getDailyCost(String userId, LocalDate date) {
List<LlmCallLog> logs = callLogRepository
.findByUserIdAndDate(userId, date);
double totalCost = logs.stream()
.mapToDouble(LlmCallLog::getCostUsd).sum();
int totalTokens = logs.stream()
.mapToInt(LlmCallLog::getTotalTokens).sum();
int callCount = logs.size();
return new DailyCostSummary(userId, date, totalCost, totalTokens, callCount);
}
@Data
@AllArgsConstructor
static class ModelPricing {
private double inputPricePerK;
private double outputPricePerK;
}
}3.3 用户配额管理与限流
@Service
public class UserQuotaService {
private static final Logger log = LoggerFactory.getLogger(UserQuotaService.class);
private final UserQuotaRepository quotaRepository;
private final RedisTemplate<String, String> redisTemplate;
private final TokenCostService costService;
// 默认配额(每用户每天)
@Value("${quota.default.daily-tokens:100000}")
private int defaultDailyTokens;
@Value("${quota.default.daily-cost-usd:5.0}")
private double defaultDailyCostUsd;
public UserQuotaService(UserQuotaRepository quotaRepository,
RedisTemplate<String, String> redisTemplate,
TokenCostService costService) {
this.quotaRepository = quotaRepository;
this.redisTemplate = redisTemplate;
this.costService = costService;
}
/**
* 检查用户是否超出配额(调用前检查)
*/
public QuotaCheckResult checkQuota(String userId) {
UserQuotaConfig config = quotaRepository.findByUserId(userId)
.orElse(UserQuotaConfig.defaultConfig(userId,
defaultDailyTokens, defaultDailyCostUsd));
// 从Redis获取今日已用量
String todayKey = "quota:" + userId + ":" + LocalDate.now();
String usedTokensStr = redisTemplate.opsForValue().get(todayKey + ":tokens");
int usedTokens = usedTokensStr != null ? Integer.parseInt(usedTokensStr) : 0;
if (usedTokens >= config.getDailyTokenLimit()) {
log.warn("用户{}今日Token配额已耗尽:{}/{}", userId,
usedTokens, config.getDailyTokenLimit());
return QuotaCheckResult.exceeded("今日Token配额已耗尽,请明日再试");
}
// 检查成本配额
String usedCostStr = redisTemplate.opsForValue().get(todayKey + ":cost");
double usedCost = usedCostStr != null ? Double.parseDouble(usedCostStr) : 0;
if (usedCost >= config.getDailyCostLimitUsd()) {
log.warn("用户{}今日成本配额已耗尽:${:.4f}/${:.4f}", userId,
usedCost, config.getDailyCostLimitUsd());
return QuotaCheckResult.exceeded("今日使用额度已达上限");
}
return QuotaCheckResult.allowed(
config.getDailyTokenLimit() - usedTokens,
config.getDailyCostLimitUsd() - usedCost);
}
/**
* 更新用户已用配额(调用后更新)
*/
public void updateUsage(String userId, int tokensUsed, double costUsd) {
String todayKey = "quota:" + userId + ":" + LocalDate.now();
// 原子递增
redisTemplate.opsForValue().increment(todayKey + ":tokens", tokensUsed);
// 成本用字符串存储避免精度问题(转换为微分)
long costMicro = (long)(costUsd * 1_000_000);
redisTemplate.opsForValue().increment(todayKey + ":cost_micro", costMicro);
// 设置过期时间(2天,避免key堆积)
redisTemplate.expire(todayKey + ":tokens", Duration.ofDays(2));
redisTemplate.expire(todayKey + ":cost_micro", Duration.ofDays(2));
}
@Data
public static class QuotaCheckResult {
private final boolean allowed;
private final int remainingTokens;
private final double remainingCostUsd;
private final String reason;
static QuotaCheckResult allowed(int tokens, double cost) {
return new QuotaCheckResult(true, tokens, cost, null);
}
static QuotaCheckResult exceeded(String reason) {
return new QuotaCheckResult(false, 0, 0, reason);
}
}
}3.4 Grafana监控指标定义
@Configuration
public class LlmMetricsConfig {
@Bean
public MeterRegistryCustomizer<MeterRegistry> metricsCustomizer() {
return registry -> {
// 注册LLM相关的自定义指标
// 1. 成本Gauge(实时统计今日总成本)
Gauge.builder("llm.cost.today.usd", () -> getTodayTotalCostUsd())
.description("今日LLM总成本(USD)")
.register(registry);
// 2. Token消耗Rate
// 使用Counter即可,Grafana用rate()函数计算速率
};
}
private double getTodayTotalCostUsd() {
// 实际实现从数据库或Redis查询
return 0;
}
}3.5 成本告警配置(Prometheus AlertManager规则)
# prometheus_alerts.yml
groups:
- name: llm_cost_alerts
rules:
# 日成本超过预算80%时告警
- alert: LlmDailyCostHigh
expr: sum(increase(llm.cost.cents[24h])) / 100 > 80 * on() (daily_budget_usd * 0.8)
for: 5m
labels:
severity: warning
annotations:
summary: "LLM今日成本超过预算80%"
description: "今日LLM累计成本 {{ $value }} USD,接近每日预算上限"
# 单用户每小时Token消耗异常高
- alert: LlmUserAbnormalUsage
expr: sum by (user_id) (rate(llm.tokens.prompt[1h])) > 50000
for: 5m
labels:
severity: critical
annotations:
summary: "用户{{ $labels.user_id }}Token消耗异常"
description: "过去1小时消耗超过50000 tokens,疑似异常调用"
# P99延迟超过10秒
- alert: LlmHighLatency
expr: histogram_quantile(0.99, rate(llm.latency.bucket[5m])) > 10000
for: 2m
labels:
severity: warning
annotations:
summary: "LLM响应P99延迟过高"
description: "P99延迟 {{ $value }}ms,超过10秒阈值"
# 错误率超过5%
- alert: LlmHighErrorRate
expr: sum(rate(llm.calls.total{success="false"}[5m])) /
sum(rate(llm.calls.total[5m])) > 0.05
for: 2m
labels:
severity: critical
annotations:
summary: "LLM调用错误率超过5%"四、效果评估与优化
可观测性体系上线后的实际价值:
| 事件类型 | 体系建立前平均发现时间 | 体系建立后平均发现时间 |
|---|---|---|
| 单用户异常高消耗 | 月底账单时(约30天) | 1小时内(自动告警) |
| API服务故障 | 用户投诉时(约2小时) | 2分钟内(错误率告警) |
| Prompt变更导致质量下降 | 用户反馈(约1-3天) | 次日(通过满意度指标) |
| 成本超预算趋势 | 月底 | 实时预测(线性外推) |
成本控制效果:加入用户配额和自动限流后,月均LLM费用下降了38%,主要来自两块:防止了异常高消耗(减少25%)和用户有了配额意识后更合理地使用(减少13%)。
五、踩坑实录
坑1:Spring AI的Advisor没有统一的用户上下文传递机制
RequestResponseAdvisor的context是per-request的,但userId这样的信息需要从HTTP请求上下文传入。我一开始用ThreadLocal传递,但在异步Stream调用时,Flux在不同线程执行,ThreadLocal值丢失了。改用了context的params机制——在每次调用时通过.advisors(a -> a.param("user_id", userId))显式传入,解决了跨线程的问题。
坑2:成本统计用double类型导致精度丢失
累计统计cost时用的是Redis的INCRBYFLOAT,但浮点数的精度问题导致长期累计后有误差。比如0.00150000 x 1000次累加,实际结果可能是1.4999999而不是1.5。改成微分存储(乘以100万转整数),用INCRBY命令,精度问题完全消除。
坑3:Prometheus的高基数标签导致性能问题
早期我把prompt_text(输入文本)也作为标签加进了metric,以为方便后续分析。结果每条不同的prompt都是一个独立的时间序列,Prometheus的内存暴涨,查询变得极慢(这就是著名的"高基数标签"问题)。标签必须只用枚举值有限的维度,比如user_id、feature、model,文本内容只能记录在日志里,通过Elasticsearch查询。
六、总结
AI应用的可观测性是一个往往被推迟到"出问题后再做"的领域,但付出的代价是账单爆炸或故障失控。从一开始就把LLM调用日志、Token统计、成本告警做进去,边际成本很低,但收益是长期的。
特别是成本控制这块,Token消耗的分布极不均匀,"少数用户消耗大多数Token"的帕累托规律在AI应用里非常明显。有了精细的用户级统计,才能做到公平分配和精准控制。
