可觀測性實戰(zhàn):通過 Spring Boot Actuator 暴露大模型核心調(diào)用指標(biāo))
雙 11 倒計時還剩不到二十天壓測大屏上的 QPS 剛拉到五千告警群就炸了。運(yùn)維兄弟直接把電話打到我工位上“李工你們接入的大模型服務(wù)耗時一直在往上飄平均響應(yīng)時間直接飆到了八秒多下游到底在經(jīng)歷什么是網(wǎng)絡(luò)超時、Provider 限流還是生成 Token 太慢現(xiàn)在的監(jiān)控大盤全是一片灰色的調(diào)用超時根本看不出來是哪塊在卡脖子?!边@就是大模型落地的現(xiàn)實問題。寫個 Demo 的時候大家只要看到屏幕上逐字吐出回答就歡天喜地但真要把大模型推到生產(chǎn)環(huán)境尤其是面臨大促峰值流量時如果你手里沒有一套顆粒度細(xì)到毫秒級、Token 級的可觀測性體系大模型服務(wù)對你而言就是一個不可預(yù)測的黑盒。今天我們聊聊怎么把 Spring AI 接入 Spring Boot Actuator 與 Micrometer 觀測體系把 TTFT首字延遲、端到端延遲、Token 消耗、限流攔截等核心指標(biāo)徹底透明化。為什么傳統(tǒng)的 HTTP 監(jiān)控治不好大模型傳統(tǒng)微服務(wù)架構(gòu)下我們在 Spring Boot Actuator 里看http.server.requests指標(biāo)就能把下游狀態(tài)摸得七七八八。狀態(tài)碼 200 占比多少、P99 耗時多少、錯誤率多少一目了然。但在大模型調(diào)用場景下傳統(tǒng) HTTP 監(jiān)控會直接抓瞎耗時分布極度詭異一個常規(guī)查詢接口 P99 超過 500ms 就算性能隱患但大模型推理受限于 Prompt 長度和輸出長度生成 500 個 Token 耗時三五秒是家常便飯。如果混為一談監(jiān)控大盤上的 P99 指標(biāo)會被直接拉平失效。缺乏首字延遲TTFT維度在流式生成SSE場景下用戶端感知最關(guān)鍵的不是整體什么時候結(jié)束而是第一個字什么時候蹦出來。如果整體耗時三秒但首字 300ms 出來用戶體驗極其流暢如果整屏卡死 2.8 秒最后瞬間彈出體驗就是災(zāi)難。傳統(tǒng) HTTP 請求指標(biāo)只能記錄連接關(guān)閉的總耗時根本無法捕獲流式響應(yīng)的首字時間戳。計量維度從“請求數(shù)”變成“Token 數(shù)”下游供應(yīng)商是按 Prompt Tokens 和 Completion Tokens 計費的大促期間每分鐘的 Token 消耗速率直接掛鉤真金白銀。沒有 Token 吞吐指標(biāo)預(yù)算被打穿了你都不知道哪個業(yè)務(wù)方在狂刷。Spring AI 1.0 的觀測基石Micrometer ObservationSpring AI 從底層就全面擁抱了 Spring 6 引入的Micrometer Observation抽象。只要引入了spring-boot-starter-actuatorSpring AI 在執(zhí)行ChatModel.call()或ChatModel.stream()時默認(rèn)會觸發(fā)名為spring.ai.chat.client的 Observation 生命周期。但默認(rèn)的 Observation 配置相對保守生產(chǎn)環(huán)境下我們需要通過自定義ChatModelObservationConvention來擴(kuò)充業(yè)務(wù)標(biāo)簽同時補(bǔ)齊流式場景下的首字耗時統(tǒng)計。首先看 Maven 依賴依賴配置dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency dependency groupIdio.micrometer/groupId artifactIdmicrometer-registry-prometheus/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId /dependency /dependencies擴(kuò)展大模型調(diào)用的 Observation 規(guī)約為了讓暴露出來的 Prometheus 指標(biāo)包含模型名稱、業(yè)務(wù)場景以及租戶標(biāo)識我們實現(xiàn)自定義的ChatModelObservationConvention。這里有一條生死攸關(guān)的鐵律千萬不要把高基數(shù)High Cardinality數(shù)據(jù)打入指標(biāo)標(biāo)簽。像用戶 ID、訂單號、甚至 Prompt 文本哈希值如果被塞進(jìn) Meter 的 Tag 里面Prometheus 會瞬間產(chǎn)生數(shù)以百萬計的時間序列Time Series輕則 Grafana 渲染卡死重則直接導(dǎo)致 Prometheus 存儲節(jié)點 OOM 崩盤。package com.yali.monitor.observation; import io.micrometer.common.KeyValue; import io.micrometer.common.KeyValues; import org.springframework.ai.chat.observation.ChatModelObservationContext; import org.springframework.ai.chat.observation.ChatModelObservationConvention; import org.springframework.stereotype.Component; Component public class CustomChatModelObservationConvention implements ChatModelObservationConvention { private static final KeyValue STATUS_UNKNOWN KeyValue.of(call_status, UNKNOWN); private static final KeyValue STATUS_SUCCESS KeyValue.of(call_status, SUCCESS); private static final KeyValue STATUS_ERROR KeyValue.of(call_status, ERROR); Override public String getName() { // 自定義指標(biāo)前綴 return spring.ai.chat.custom; } Override public KeyValues getLowCardinalityKeyValues(ChatModelObservationContext context) { // 提取低基數(shù)標(biāo)簽業(yè)務(wù)場景、模型類型、調(diào)用狀態(tài) String scene (String) context.computeIfAbsent(business_scene, key - default_chat); String modelName context.getRequest() ! null context.getRequest().getModel() ! null ? context.getRequest().getModel() : unknown_model; KeyValue statusValue STATUS_SUCCESS; if (context.getError() ! null) { statusValue STATUS_ERROR; } return KeyValues.of( KeyValue.of(scene, scene), KeyValue.of(model, modelName), statusValue ); } Override public KeyValues getHighCardinalityKeyValues(ChatModelObservationContext context) { // 高基數(shù)數(shù)據(jù)只進(jìn)入 Tracing如 Zipkin/Tempo/SkyWalking嚴(yán)禁進(jìn)入 Prometheus Meter if (context.getRequest() ! null) { return KeyValues.of( KeyValue.of(prompt.hash, String.valueOf(context.getRequest().getInstructions().hashCode())) ); } return KeyValues.empty(); } }精準(zhǔn)捕獲流式 TTFT 與 Token 消耗流式調(diào)用場景下我們通常希望捕獲三個核心指標(biāo)ai.chat.stream.ttft從發(fā)起請求到第一個 Chunk 返回的耗時Timer。ai.chat.stream.duration整個流式生成的端到端總耗時Timer。ai.chat.tokens.consumed消耗的 Token 計數(shù)器帶typeprompt|completion標(biāo)簽。我們可以基于 AOP 或包裝ChatClient實現(xiàn)一個攔截裝飾器package com.yali.monitor.metrics; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; Component public class StreamMetricsCollector { private final MeterRegistry meterRegistry; public StreamMetricsCollector(MeterRegistry meterRegistry) { this.meterRegistry meterRegistry; } public FluxChatResponse wrapStream(FluxChatResponse rawStream, String scene, String model) { long startTime System.nanoTime(); AtomicBoolean firstTokenRecorded new AtomicBoolean(false); return rawStream .doOnNext(response - { // 記錄首字延遲 TTFT if (firstTokenRecorded.compareAndSet(false, true)) { long ttftDuration System.nanoTime() - startTime; meterRegistry.timer(ai.chat.stream.ttft, scene, scene, model, model) .record(ttftDuration, TimeUnit.NANOSECONDS); } // 累加 Token 計數(shù) if (response.getMetadata() ! null response.getMetadata().getUsage() ! null) { Long promptTokens response.getMetadata().getUsage().getPromptTokens(); Long compTokens response.getMetadata().getUsage().getGenerationTokens(); if (promptTokens ! null promptTokens 0) { meterRegistry.counter(ai.chat.tokens.consumed, scene, scene, type, prompt) .increment(promptTokens); } if (compTokens ! null compTokens 0) { meterRegistry.counter(ai.chat.tokens.consumed, scene, scene, type, completion) .increment(compTokens); } } }) .doFinally(signalType - { long totalDuration System.nanoTime() - startTime; meterRegistry.timer(ai.chat.stream.duration, scene, scene, model, model, signal, signalType.name()) .record(totalDuration, TimeUnit.NANOSECONDS); }); } }Actuator 端點與 Prometheus 抓取配置在application.yml中我們需要將指標(biāo)暴露給 Prometheus 抓取器并對 Timer 配置合理的直方圖桶Histogram Buckets。因為大模型的調(diào)用耗時分布從數(shù)百毫秒到幾十秒不等Spring Boot 默認(rèn)生成的直方圖切片在超過 1 秒后基本就沒有區(qū)分度了。為了在 Grafana 里繪制出漂亮的 TTFT 和端到端延遲熱力圖必須自定義桶間距management: endpoints: web: exposure: include: health,info,prometheus,metrics endpoint: prometheus: enabled: true health: show-details: always metrics: tags: application: ${spring.application.name} distribution: percentiles-histogram: ai.chat.stream.ttft: true ai.chat.stream.duration: true sla: ai.chat.stream.ttft: 200ms, 500ms, 1s, 2s, 5s ai.chat.stream.duration: 1s, 3s, 5s, 10s, 20s, 30s啟動應(yīng)用后直接訪問本地的 Actuator 端點GET http://localhost:8080/actuator/prometheus可以看到類似如下的標(biāo)準(zhǔn) Prometheus 文本格式# HELP ai_chat_stream_ttft_seconds Time To First Token for LLM stream # TYPE ai_chat_stream_ttft_seconds histogram ai_chat_stream_ttft_seconds_bucket{modelgpt-4o-mini,sceneorder_query,le0.2} 124.0 ai_chat_stream_ttft_seconds_bucket{modelgpt-4o-mini,sceneorder_query,le0.5} 3120.0 ai_chat_stream_ttft_seconds_bucket{modelgpt-4o-mini,sceneorder_query,le1.0} 4890.0 ai_chat_stream_ttft_seconds_count{modelgpt-4o-mini,sceneorder_query} 5000.0 ai_chat_stream_ttft_seconds_sum{modelgpt-4o-mini,sceneorder_query} 1982.35架構(gòu)師的實戰(zhàn)復(fù)盤與血淚告誡很多團(tuán)隊在雙 11 前夕搞大模型監(jiān)控往往把指標(biāo)系統(tǒng)當(dāng)成簡單的打點工具。在過去幾個大促項目的實測中我總結(jié)了兩條必須嚴(yán)防死守的紅線第一抓取超時的隱形雪崩。Prometheus 默認(rèn)抓取超時往往是 10 秒。有些人在自定義 MeterBinder 里面試圖去實時 ping 一下遠(yuǎn)程 LLM 服務(wù)的健康檢查接口。大促期間一旦下游模型供應(yīng)商延遲升高Actuator 的抓取線程直接阻塞導(dǎo)致 Prometheus 判定整個微服務(wù)節(jié)點掛死Kubernetes 的探針緊接著重啟 Pod最終引發(fā)滾動崩潰。監(jiān)控收集邏輯必須是內(nèi)存級純計算絕對禁止夾帶遠(yuǎn)程 I/O。第二Token 突增與熔斷聯(lián)動。單靠靜態(tài)告警規(guī)則很容易漏掉“慢刀子割肉”。我們在雙 11 期間把rate(ai_chat_tokens_consumed_total[1m])接入了 Sentinel 動態(tài)規(guī)則。一旦發(fā)現(xiàn)某個非核心業(yè)務(wù)場景的每分鐘 Token 消耗速率超過基線 3 倍立即無情降級到離線意圖識別緩存把大模型算力資源讓給核心的下單與售前咨詢。沒有度量就沒有掌控。把大模型的每一次呼吸都變成時序數(shù)據(jù)庫里的清晰軌跡大促之夜你才能坐在指揮室里安穩(wěn)地喝上一杯溫水。