Java AI项目监控实战:5类指标+4条告警打造可靠防线
1. 为什么说这是Java人的自救能力先讲一个让我半夜惊醒的真实事故。我们有一套AI客服系统上线两周所有系统层面的监控每天都是绿灯CPU 20%、内存平稳、接口成功率99.9%。结果有一天凌晨两点用户投诉突然炸了反馈清一色是AI越回越离谱。查了几小时发现上游大模型服务在夜间灰度上线了一个跑偏的Prompt版本导致回答质量骤降但接口HTTP状态码一直是200传统Java监控完全没看出来。那一刻我意识到Java人做AI项目最大的认知鸿沟不是调不通大模型接口而是监控体系还停留在HTTP状态码CPU内存的老三层。传统Web项目里接口返回200基本等于成功但在AI项目里200只是开始返回内容的质量、Token的消耗、检索的命中率、异步队列的积压这些才是真正决定业务成败的指标而它们几乎不会直接暴露在HTTP状态码里。为什么说这是80%Java人要学的自救能力因为大量Java工程师的技术训练都集中在Spring Boot、微服务、MySQL、Redis这些确定性系统上面对AI项目这种外部依赖黑盒化、输出本身不确定、成本随调用量暴涨的新场景手里拿着旧地图当然找不到新大陆。不是Java技术不够用而是监控思维没有跟着更新。这套方案就是帮你把监控思维的缺口快速补上5类指标解决看什么的问题4条告警解决怎么判定出了问题的问题覆盖AI项目从调用链路到业务质量的全貌。不管你是在传统Java团队里接了一个RAG项目还是在做AI Agent、AI客服、知识库问答这类应用只要生产环境里跑了真正给用户用的大模型接口这套埋点监控思路就值得抄一遍。我尽量用最直接的方式讲里面所有指标名、PromQL表达式、阈值设计都是我在线上环境实际跑过、调过、被坑过之后沉淀下来的。2. 从零搭一套AI项目监控骨架选型、架构与埋点姿势2.1 技术选型为什么是Micrometer Prometheus GrafanaJava生态里做监控有很多路可以走我第一版试过自己写日志采集解析第二版试过直接在用代码里拼Prometheus格式的文本输出最后都推倒重来了。现在这套组合是我认为Java团队接入成本最低、生态最成熟、后续扩展空间最大的方案。组件角色选型理由MicrometerJava侧指标门面Spring Boot 2.x/3.x原生集成一个API同时支持Prometheus、OpenTelemetry等多种后端Prometheus指标采集与存储云原生事实标准Pull模型天然适合Java应用TSDB存储够用Grafana可视化面板面板生态丰富Spring Boot默认指标有现成模板自定义SQL式查询灵活Alertmanager告警路由与通知原生支持Prometheus告警可对接企业微信、钉钉、飞书机器人我的选择逻辑很简单Micrometer负责把埋点这件事变得无侵入业务代码里只需要注入MeterRegistry就能打指标Prometheus是Java工程师最熟悉的监控后端Spring Boot Actuator本身就把/actuator/prometheus端点暴露好了Grafana和Alertmanager负责最后两公里的展示和告警。2.2 一个指标从埋点到告警的完整数据流用你最熟悉的场景来说明这套骨架是怎么转起来的。假设我想统计每次大模型调用的耗时整个数据流是这样的业务代码里通过Micrometer的Timer记录耗时Micrometer将指标缓存在应用内存中Prometheus每隔15秒可配置请求一次/actuator/prometheus端点把指标拉取走Prometheus将指标存储在本地TSDB并不断评估告警规则Grafana通过PromQL查询指标绘制面板Alertmanager在告警规则触发后把通知推送到企业微信群机器人或者值班系统这个链路看起来长但每一步都是标准协议不需要自研通信机制排障也容易。我见过团队自己用Redis定时任务做一个类似的耗时统计中心刚开始觉得灵活后来发现采集端和展示端完全割裂指标稍微多一点就乱套了。Micrometer这套链路最大的优势是规范指标名、标签、数据类型、采集频率都有约定俗成的标准团队里任何人接手都能快速上手。2.3 Spring Boot项目里接入监控的最少配置如果你的项目是Spring Boot接入这套骨架的开销其实非常小。我以Spring Boot 3.x Java 17为例先加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency dependency groupIdio.micrometer/groupId artifactIdmicrometer-registry-prometheus/artifactId /dependency然后在application.yml里确认端点暴露management: endpoints: web: exposure: include: health,info,prometheus metrics: tags: application: ai-service启动之后访问http://localhost:8080/actuator/prometheus就能看到JVM、CPU、内存、HTTP请求等一系列默认指标。注意management.metrics.tags.application这个配置非常关键它会给所有指标自动打上一个applicationai-service的标签后面在Prometheus里多服务聚合时全靠这个标签区分来源。不过说实话Actuator默认提供的指标只是系统级的地基。真正值钱的是接下来要在业务代码里埋的5类指标那才是直接透视AI项目健康状况的X光片。3. 5类核心指标逐个数每一类都对应一个线上事故3.1 调用层指标请求量、错误率、延迟分位数调用层指标是所有AI项目监控的地基但它和传统Java监控有一个关键差异埋点位置不同。很多Java工程师习惯在Controller入口埋点做AI项目时也这么做结果发现大模型接口超时了但Controller的指标还显示一切正常。原因很简单大模型调用往往封装在Service层或者独立的LLMClient里你不在这里埋点指标就看不到真正的瓶颈。我强烈建议在大模型调用的统一出口处埋点。假设有一个LlmClient类负责所有对外部大模型API的请求在它的核心方法里做埋点效果远好过在Controller入口埋。需要记录的指标有这几类指标名类型含义llm_requests_totalCounter大模型请求总量标签带model、scene、envllm_requests_error_totalCounter大模型请求错误量标签带error_typellm_request_duration_secondsTimer/Histogram全量请求耗时分布llm_ttft_secondsTimer/Histogram首Token延迟流式接口专用埋点代码大致这样Component public class LlmClient { private final MeterRegistry meterRegistry; public ChatResponse chat(ChatRequest request) { // 使用Timer.Sample测量全链路耗时 Timer.Sample sample Timer.start(meterRegistry); try { ChatResponse response doChat(request); recordTokenUsage(request, response); return response; } catch (Exception e) { meterRegistry.counter(llm_requests_error_total, model, request.getModel(), error_type, e.getClass().getSimpleName()) .increment(); throw e; } finally { sample.stop(meterRegistry.timer(llm_request_duration_seconds, model, request.getModel(), scene, request.getScene())); } } }看到这段代码你可能觉得平平无奇但有一个细节是很多团队会忽略的流式接口的耗时统计和普通接口完全是两码事。普通接口用全量耗时就行流式接口必须单独统计首Token延迟TTFT和总生成时长。我在早期就踩过这个坑某次线上流式接口用户普遍反映转圈很久才出第一个字但全量耗时的P99却完全正常直到把TTFT单独拿出来看才发现是上游服务的首包延迟已经恶化到10秒以上。配套的PromQL查询在Grafana里可以直接做面板# 错误率 sum(rate(llm_requests_error_total[5m])) by (model) / sum(rate(llm_requests_total[5m])) by (model) # P99耗时 histogram_quantile(0.99, sum(rate(llm_request_duration_seconds_bucket[5m])) by (le))3.2 模型消耗指标Token数与成本的精细核算如果一个AI项目的老板只关心两个数字一个是用户量另一个就是Token消耗。Token就是钱尤其是接GPT-4o甚至更大规模的模型时一个不合理的配置可能让你一夜之间烧掉一大笔费用。我见过真实的案例某团队把线上模型的max_tokens从512误改成4096流量没涨成本却翻了四倍整整三天没人发现直到月底账单出来才傻眼。Token指标的埋点位置就在LLM调用的返回逻辑里现在主流大模型API的响应体里都会带usage信息包含prompt_tokens和completion_tokens把这些值累加到Counter上即可private void recordTokenUsage(ChatRequest request, ChatResponse response) { TokenUsage usage response.getUsage(); meterRegistry.counter(llm_tokens_total, model, request.getModel(), type, prompt) .increment(usage.getPromptTokens()); meterRegistry.counter(llm_tokens_total, model, request.getModel(), type, completion) .increment(usage.getCompletionTokens()); }很多人在这个环节只埋了总Token数忽略了model、type这两个标签。我的经验是如果不按模型区分你就无法定位是哪一个模型在烧钱不区分prompt和completion你也不知道是输入太长还是输出不受控制。这两个标签是成本分析的最小维度必须要加上。成本换算我建议放在Grafana面板层做不要写死在业务代码里。因为模型价格时常调整把价格写进代码意味着每次调价都要发版。在Grafana里用模板变量维护一个价目表查询时动态换算就行。一个典型的成本面板查询# 按模型统计最近1小时的Token消耗增量 sum(increase(llm_tokens_total{typecompletion}[1h])) by (model)做Token指标时有个心理准备Prometheus从应用拉取指标是周期性的两次拉取之间的请求量可能不被记录所以精确到个位数的Token统计是做不到的。这在监控场景下完全没关系我们要看的是成本趋势只要误差比例稳定环比和同比的结论就依然可靠。3.3 异步与队列指标积压才是真凶AI项目里大量任务走异步链路比如批量文档向量化、AI日报生成、长文本总结、Agent的多步工具调用。这些任务的共同特点是单次执行耗时长如果流量波动一小波队列就可能在几分钟内积压数万条消息。最可怕的是我在多个项目里发现Java开发对队列积压的感知几乎为零直到用户反馈为什么我的任务提交了一小时还没跑完才去查消费日志。所以异步链路的指标必须单独成类不能混在接口层指标里。核心要监控的包括指标名类型说明async_queue_depthGauge队列当前积压数按队列名区分async_task_totalCounter任务提交总量async_task_completed_totalCounter任务完成总量async_task_duration_secondsTimer单任务处理耗时async_thread_pool_activeGauge线程池活跃线程数async_task_rejected_totalCounter任务被拒绝数量这套指标和前面埋点方式一样核心区别在于你要在自定义线程池和队列层做埋点。我建议在配置ThreadPoolTaskExecutor时把队列深度和拒绝次数单独埋出来而不是等到RejectedExecutionException打日志。这里有一个Java特有的坑Spring的Async默认使用SimpleAsyncTaskExecutor每次调用都会新建线程根本不走队列你说它能有多少并发能力。线上必须自定义一个ThreadPoolTaskExecutor设置核心线程数、最大线程数、队列容量和拒绝策略否则队列指标无从谈起。PromQL里最常用的一条队列积压告警语句max(async_queue_depth) by (queue_name) 1000这个阈值要根据消费能力来定。比如消费速率是200条/分钟队列积压1000意味着会延迟5分钟才处理完如果业务SLA是2分钟那1000就太宽松了。先算清楚吞吐量再定积压阈值这是异步监控的基本功。3.4 RAG检索质量指标向量库不能当黑盒百分之八十的Java团队做AI项目起步都是从RAG开始的一个知识库一个向量库一个大模型接口拼起来就是一个问答应用。大家把大量精力花在Prompt模板和模型选型上却很少人给向量检索这一层做监控。我负责任地说RAG项目里用户体验崩塌的第一大原因不是模型不行而是检索没召回到正确的上下文。向量库检索指标的核心就两类性能指标和质量指标。性能方面监控rag_retrieval_duration_seconds用法和前面LLM调用耗时的Timer一样不再赘述。质量方面的指标更有意思我实际项目里用的有三个// 总检索次数 Counter.builder(rag_query_total) .tag(collection, collectionName) .register(meterRegistry) .increment(); // 检索后实际命中非空结果的数量 Counter.builder(rag_hit_total) .tag(collection, collectionName) .register(meterRegistry) .increment(); // 检索相似度分数使用Gauge记录最近一次的平均相似度 Gauge.builder(rag_similarity_score, scoreSupplier) .tag(collection, collectionName) .register(meterRegistry);很多人只看前两个第三个相似度分数才是更值得长期观察的指标。相似度分值的长期趋势能反映知识库的健康程度。如果知识库新增了一批文档但平均相似度反而下降了说明新文档的分块策略有问题或者Embedding效果不理想。这些都是模型输出质量的前置信号在用户骂街之前就能提前发现。RAG检索最怕的静默失败是接口正常返回但检索到的是空结果。这种情况LLM不会报错它会一本正经地告诉你根据当前信息我无法回答。所以你必须在代码里判断检索结果是否为空单独埋一个rag_empty_total指标。后面讲告警时会专门说这条因为它能救你于水火。3.5 资源与JVM指标AI项目特有的内存压力最后这类指标看似传统但在AI项目里会呈现出新的特点。Java服务接了大模型接口之后内存压力和多线程压力是显著上升的。一个核心原因是大模型的响应体一言不合就几千字有的甚至上万字如果团队图省事没有限流也没有限制响应体大小几句话的并发请求就能让内存飙升。JVM层面的指标Spring Boot Actuator默认已经暴露了大部分不需要重新埋点直接引用这些现成指标名就行指标含义关注点jvm_memory_used_bytes堆内存使用是否存在缓慢涨高的内存泄漏jvm_gc_pause_secondsGC暂停时间GC频繁会导致接口响应变慢jvm_threads_live_threads存活线程数线程数异常增长通常是线程池配置问题system_cpu_usage系统CPU使用率CPU飙高需要结合GC和线程栈分析有几个坑我专门提醒一下。GC指标不要只看耗时要看jvm_gc_pause_seconds的P99和频率。AI项目的大响应体会迅速填满年轻代如果Survivor区设置不合理对象会频繁晋升到老年代进而频繁触发Full GC。我在一个项目里遇到过JVM每分钟Full GC三次的情况原因就是大String对象的分配路径被打爆了。另外一个Java特有的隐患是用默认的application/json反序列化大模型超长响应很容易触发大量短生命周期对象分配。监听JVM指标的目的不是看表面而是要捕捉这种流量没变但内存曲线缓慢爬坡的苗头这是OOM事故前最典型的信号。4. 4条告警规则的阈值设计让监控从装饰变成防线指标是看见了问题告警才是主动出击。前面5类指标埋下去之后如果只做几个Dashboard那只算完成了百分之三十。真正要下功夫的是告警规则的设计。针对AI项目我最终收敛出了4条核心告警每一条都经历过调整阈值、降噪、再调整的过程。在展开之前先强调一个总原则告警规则的复杂度要匹配团队的实际运维能力。一个三十人的研发团队告警规则写得越多越容易疲劳。4条规则是我实践下来覆盖核心风险又不会让人麻木的平衡点。4.1 可用性告警宁可错杀也不放过可用性告警是兜底的规则很简单错误率超过阈值持续一段时间就触发。但AI项目的可用性告警有一个Java人容易忽略的细节就是错误类型必须拆开看。大模型接口的错误有HTTP 5xx、超时、限流、空响应几种它们的处理策略完全不同。如果不拆告警消息就只能告诉你错了但不能告诉你该往哪个方向查。我的PromQL写法# 基础错误率告警 sum(rate(llm_requests_error_total[5m])) by (model) / sum(rate(llm_requests_total[5m])) by (model) 0.05这个规则加了两个附加条件才真正好用。一是for: 10m持续触发10分钟才告警避免瞬时抖动造成骚扰。二是叠加最小请求量条件比如sum(rate(llm_requests_total[5m])) 1这样半夜流量低谷时哪怕只有两三次失败也不会触发100%的错误率误报这个条件是我被半夜告警吵醒三次之后总结出来的。另外强烈建议在告警规则里对空响应单独计数。前面提到的RAG空检索就是典型例子LLM没挂、HTTP 200但返回内容为空或拒答这类业务错误在传统错误率告警里永远是漏网之鱼。我单独配置了一条sum(rate(llm_requests_empty_total[5m])) by (model) / sum(rate(llm_requests_total[5m])) by (model) 0.01这条规则管用的原因在于它能捕捉到模型侧静默退化。有一次上游大模型服务端改了默认参数导致短文本请求偶尔返回空内容接口成功率依然在99%以上但这条告警第一时间就响了。4.2 延迟劣化告警百分之99的耐心线延迟告警的陷阱在于很多人会用平均值来设置阈值。平均值是最毒的告警指标因为1%的慢请求和99%的正常请求混在一起平均值根本看不出问题来。AI项目的体验反馈机制里用户对延迟极其敏感等5秒就关页面是常态。所以延迟告警的锚点必须放在P99上。histogram_quantile(0.99, sum(rate(llm_request_duration_seconds_bucket[5m])) by (le, model)) 15但Prometheus的Histogram有一个偏差问题必须正视。默认bucket结构往往不适合AI场景的延迟分布我在Spring Boot里用的是bucket长度从0.1秒到60秒的直方图Bot服务端如果不自定义bucket延迟数据的精度会很难看。在配置里加上management: metrics: distribution: percentiles-histogram: llm.request.duration.seconds: true slo: llm.request.duration.seconds: 2,5,10,15,30,60另外流式接口的TTFT单独再配一条延迟告警。P99全量延迟告警负责兜底TTFT告警负责精准捕捉用户感知到的卡顿。流式体验里第一个字的感觉最影响满意度这一条只要超过5秒持续一段时间就该触发。毕竟你让用户等5秒才出第一个字后面的内容再准确用户也已经烦躁了。4.3 成本趋势告警防患于流水成本告警是AI项目特有的规则传统Java项目里几乎见不到。我喜欢把它定义为环比基线告警不是设定一个绝对阈值而是对比一定时间窗口内的Token消耗趋势出现明显异常时就告警。因为不同模型的用量在一天内有高峰低谷单一绝对阈值很难覆盖全场景。我的实践是先用过去7天同一时段的Token消耗做基线再与当前1小时的增量对比。# 当前1小时Token消耗 sum(increase(llm_tokens_total{typecompletion}[1h])) by (model) # 基线值过去7天同一小时的平均Token消耗 * 1.5 sum(avg_over_time( 7天基线向量[1h] )) by (model) * 1.5真正完整的告警规则要配合Record Rules因为一段PromQL里同时写当前值和7天前的基线表达式会长得吓人。我在Prometheus里先把7天基线提前计算出指标node_ref_tokens_baseline再在告警规则里引用它这样规则清晰后面也好维护。成本告警的阈值设定不宜太紧。Token消耗的波动天然比接口错误率大因为用户对话内容长度差异很大1小时内多几个长文档问答请求消耗就会涨30%。我今天给的1.5倍基线是一个参照值你们要根据业务波动特征来调。总体原则是宁可漏掉小波动也不要在群里刷屏因为成本告警一旦变成狼来了等真正的大额烧钱事故真的发生时就没人理会了。4.4 数据质量与静默失败告警最难做的一类最后一类告警是AI项目最核心也最难设计的数据质量告警。它的难度在于什么算质量差是需要业务定义的。对客服场景来说拒答率升高、空响应率升高、负面情绪占比升高等都算质量下降但这些指标的表现形式不是HTTP错误而藏在业务逻辑的返回结果里。我的建议是不用把所有质量问题一股脑塞到告警里先做两个最可量化的第一个是空响应率告警前面已经给过PromQL。第二个是负面反馈率告警结合用户反馈埋点sum(rate(user_feedback_negative_total[5m])) / sum(rate(user_feedback_total[5m])) 0.1这个规则能精准反映用户对AI回答不满意的比例。但要注意的是负面反馈率是一个滞后指标用户点不喜欢按钮的时候说明问题已经发生了。所以这类告警我不建议作为唯一防线它更适合用来驱动质量复盘会议而不是靠它做实时止损。数据质量告警的另一层设计是切换的频率。模型版本升级、Prompt模板调整、知识库新文档导入这些操作之后24小时内我都建议主动拉高数据质量告警的敏感度从1.5倍基线调到1.2倍。因为在关键变更期间宁可多收到几条告警也要保证任何质量劣化都能第一时间发现这是监控策略里难得应该宁滥勿缺的场景。5. 踩坑实录监控从上线到真正好用的三个关键教训5.1 埋点位置和标签设计高基数标签能把Prometheus搞炸监控上线初期我犯了所有新手都会犯的错误为了让指标更精细化把userId和requestId直接当标签打到了Counter上。刚开始一两天一切正常到了第三天Prometheus的内存涨了4倍查询速度明显下降。后来才反应过来Prometheus的内存消耗与标签基数成正比userId的取值是无限增长的等于我需要为每一个用户维护一条时序数据这是Prometheus最不擅长的事。正确的姿势是标签只打在取值有限、可枚举的维度上比如model大模型名、scene业务场景、error_type错误类型、env环境这些。需要区分某个具体请求的信息交给链路追踪系统去做不要塞进监控指标里。如果你确实需要按用户维度看调用量可以用Grafana的日志关联或者APM系统做而不是硬塞给测量数据。还有一个埋点位置的问题。很多团队的埋点只做了Controller层导致指标看到的都是接口层延迟但AI项目的性能瓶颈在LLM调用层和向量检索层。我后来把埋点下沉到LlmClient和RagClient的封装层这样不管哪一层慢一眼就能在面板上定位。Controller层保留传统HTTP指标做总览业务边界层的指标做解剖这个逻辑可以复制到任何Java项目里。5.2 Histogram bucket设置不合理P99完全是错的前面提过Histogram的bucket配置这里展开说。Prometheus的histogram_quantile是基于直方图bucket估算的分位数如果bucket划分不贴合实际数据分布算出来的P99是失真的。我踩过的具体问题是用默认bucket每个数量级分几档在AI服务里大量请求耗时在0.5秒~5秒之间而默认bucket在这个区间划分太粗算出来的P99偏大导致告警一条接一条误报。解决方式是在可视化面板上先看llm_request_duration_seconds_bucket的实际分布观察耗时集中在哪个区间再针对性地配置SLO bucket。我的配置里SLO设为2,5,10,15,30,60用Le标签可以算任意分位数。每一个Java项目都是一个独立的分布不要copy一份过去这是耗时类监控的通用规律。5.3 告警疲劳一个月几十条告警之后团队选择静默监控系统上线第一个月我的告警配置可以用灾难来形容。各种规则加起来有三十多条有些规则的for时长只设了30秒导致一条轻微的耗时抖动都能触发告警。后来群里平均一天收到几十条警告大家都把钉钉群设为免打扰真正的线上故障反而被淹没在噪音里。痛定思痛之后我砍掉了三分之二的规则保留了最核心的覆盖率最广的几条同时明确了三个降噪原则条件组合优先同一个指标同时满足多个条件才触发告警级别分层P0直接骚扰值班主任P1发到值班群P2沉淀为日报每条告警必须带runbook写清楚收到这条告警之后先看什么、再执行什么操作。降噪后的效果非常明显。从一天几十条告警降到平均每天不到两条有效告警有一个月甚至只收到过一条真实故障提醒。那个月大家唯一的感受是世界安静了但该发现的问题一个都没漏。监控不是用来刷存在感的是把人力聚焦到真正值得处理的事件上。这套方案从设计到稳定我在真实项目里调了大概三个星期最大的心得是起步不要贪多。如果你现在刚开始做AI项目的埋点监控我建议只做最基础的调用层指标和Token消耗指标配好可用性告警和成本趋势告警让团队先适应看到指标→相信指标→依赖指标的过程再逐步把RAG检索质量和队列积压监控补进来。一步到位的结果往往是一堆精心设计的规则躺在配置里无人看管。先从能救命的开始监控是自己长出来的系统。