![[AI工程] Spring AI 第十三篇:给 AI 应用装上仪表盘,用 TaoToken 统一观测 Token 消耗](http://pic.xiahunao.cn/yaotu/[AI工程] Spring AI 第十三篇:给 AI 应用装上仪表盘,用 TaoToken 统一观测 Token 消耗)
1. 为什么你的 Spring AI 应用需要一个仪表盘很多同学把 Spring AI 接进项目之后第一反应是能跑通就行。等到线上跑了两周老板问一句这个月 AI 花了多少钱、哪个功能最费 Token你打开日志文件一行行 grep才发现根本答不上来。这不是你能力问题而是链路本身是黑盒一次用户提问背后可能是 1 次记忆读取 3 次向量检索 2 次模型调用 1 次工具执行Token 花在哪、慢在哪、哪一步把不相关资料塞进了 prompt传统 APM 一概看不见。Spring AI 2.0 把可观测性做成了默认就有但要会用只要模型 starter 在 classpath 上观测自动配置就在可如果你手工ChatClient.builder(chatModel)造客户端拿到的是ObservationRegistry.NOOP一个 span 都不会有。我第一版仪表盘就是这么空的排查了半天才发现是构造方式的问题。这一篇从源码把可观测性的形状钉下来五个观测点各自叫什么名字、带哪些 tag、哪些是 Timer 哪些是 Counter、Prometheus 里该怎么写 PromQL、内容日志要开哪个开关以及它在什么情况下会静默失效最后给出 Grafana 面板与成本告警的落地配法。适合已经用 Spring AI 跑通对话、准备上生产观测的 Java 后端也适合正在做 AI 应用成本治理、需要按用户/功能归因 Token 的工程同学。核心检索词就三个Spring AI 可观测性、Token 消耗监控、Micrometer 指标接入。我试过最省事的路径不引入任何观测专用 starter只补 actuator Prometheus registry tracing bridge 三件套然后把ChatClient的构造方式改对仪表盘当天就有数。下面按这个顺序展开。2. TaoToken 作为统一观测入口的前置准备在讲 Micrometer 配置之前得先把观测入口这件事说清楚。你的 Spring AI 应用最终要调用某个模型服务如果每个环境、每个模型、每个同事各自持有一把 Key那么指标里的gen_ai.system、gen_ai.request.model这些 tag 会变得非常混乱——同一个功能在不同人机器上打到不同 provider仪表盘按 provider 分组时根本对不上账。我的做法是让所有模型调用走同一条 API 通道用统一的 Base URL 和 Key这样指标里的 provider/model tag 天然收敛成本归因才有意义。TaoToken 在这里扮演的就是这个统一入口一个 Key 覆盖多种模型Base URL 固定Spring AI 侧只需要改spring.ai.openai.base-url和api-key两个配置项观测维度立刻从每人一套变成全局一套。具体准备三样东西第一一个可用的 API Key。到控制台创建注意 Key 只在创建时完整显示一次复制后立刻存进配置中心或环境变量别写死在application.yml里提交到 Git。第二确认 Base URL。OpenAI 兼容协议下 Spring AI 的配置项是spring.ai.openai.base-url指向https://taotoken.net/api。注意这里不要带任何查询参数Spring AI 会自己在后面拼/v1/chat/completions。第三选一个 Model ID 并记下来。这个值会直接进gen_ai.request.modeltag是后面 PromQL 按模型分组的关键。建议在配置里用一个常量管理别在代码里散落字符串。如果你用的是 Spring AI Alibaba 的 DashScope starter配置项换成spring.ai.dashscope.api-keyBase URL 走spring.ai.dashscope.base-url其余观测逻辑完全一致——因为观测是 Micrometer 层的事跟具体模型 starter 无关。这里有个容易踩的坑有人为了统一入口自己写了个转发 Controller把请求先打到本地再转发出去。这样做会让gen_ai.client.operation的耗时包含两段网络P99 直接失真。正确做法是在 Spring AI 的 HTTP 客户端层配置 Base URL让观测点包住真实的模型调用。准备好 Key、Base URL、Model ID 之后先别急着配指标用一条 curl 确认通道是通的curl -s https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: qwen-plus, messages: [{role: user, content: ping}], max_tokens: 8 } | jq .choices[0].message.content, .usage返回里能看到usage.prompt_tokens和usage.completion_tokens说明通道正常而且这两个字段正是后面 Token Counter 的数据来源。如果这里就报 401先解决鉴权别往下走——观测配置再对请求本身失败也只会看到错误率 100%。3. 可复制的 Micrometer 指标配置与 Actuator 暴露这一节是全文最需要照抄的部分。先补依赖Boot 4 把 metrics 模块从 actuator 里拆出来了所以两个都要显式加!-- 指标出口版本交给 Boot / Spring AI BOM 管理别自己写 version -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency dependency groupIdio.micrometer/groupId artifactIdmicrometer-registry-prometheus/artifactId /dependency !-- 链路Boot 4 也可直接用官方 OpenTelemetry starter二选一 -- dependency groupIdio.micrometer/groupId artifactIdmicrometer-tracing-bridge-otel/artifactId /dependency dependency groupIdio.opentelemetry/groupId artifactIdopentelemetry-exporter-otlp/artifactId /dependency有个结论可以直接省你一次搜索不需要专门的observability starter。本地依赖树里看到的链路是spring-ai-starter-model-ollama→spring-ai-autoconfigure-model-ollama→spring-ai-autoconfigure-model-chat-observation还有...-embedding-observation。也就是说引了模型 starterChatObservationAutoConfiguration就已经在 classpath 上了。接下来是最关键的 NOOP 陷阱。源码事实ChatClientAutoConfiguration与ChatClient接口// 自动配置注入的 Builder拿到真实 registry ChatClient.Builder builder chatClientBuilderFactory.getBuilder() .chatModel(chatModel) .observationRegistry(observationRegistry.getIfUnique(() - ObservationRegistry.NOOP)); // ← 关键 // 静态工厂写死 NOOP static Builder builder(ChatModel chatModel) { return builder(chatModel, ObservationRegistry.NOOP, null, null); }翻译成人话ChatClient 怎么造有没有 span / 指标注入ChatClient.BuilderBean自动配置那个再.build()全链路都有ChatClient.builder(chatModel).build()自己 newNOOP只有模型内部自己那层可能有ChatClient.builder(chatModel, observationRegistry, null, null)手动补上即可所以前面几篇文章里的写法要改把所有ChatClient.builder(chatModel)…换成注入ChatClient.Builder。Bean ChatClient airlineClient(ChatClient.Builder chatClientBuilder, // ← 不是 ChatModel VectorStore vectorStore, ChatMemory chatMemory, BookingTools bookingTools) { return chatClientBuilder // 自动配置已带好 ObservationRegistry .defaultSystem(你是航司客服助手) .defaultAdvisors( MessageChatMemoryAdvisor.builder(chatMemory).build(), QuestionAnswerAdvisor.builder(vectorStore).searchRequest(searchRequest).build(), SimpleLoggerAdvisor.builder().build()) .defaultTools(bookingTools) .build(); }顺带一个同源坑ObservationRegistry用的是getIfUnique(...)。如果你工程里有多个 registry Bean比如同时手工声明过它会退回 NOOP 而不是报错——排查时先确认容器里ObservationRegistry只有一个。然后是application.yml最小可用集management: endpoints: web: exposure: include: health,info,metrics,prometheus metrics: tags: application: ${spring.application.name} # 所有指标自带这个 tag distribution: percentiles-histogram: spring.ai.chat.client: true # 没有它就没有 *_bucketP99 算不出来 gen_ai.client.operation: true tracing: sampling: probability: 1.0 # 开发环境全采生产建议 0.05~0.1 otlp: tracing: endpoint: http://localhost:4318/v1/traces spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: qwen-plus chat: observations: include-error-logging: true # 出错时在 span 上记错误日志 chat: client: observations: log-prompt: false # 内容类开关默认全 false log-completion: false vectorstore: observations: log-query-response: false # 打开会把召回文档打进日志 threads: virtual: enabled: true # 并发/并行链路下线程开销更小这里有个高频疑问值得单独说为什么把log-prompt设成true却什么都没打出来ChatObservationAutoConfiguration里的内容 handler 分成两组配置Configuration ConditionalOnClass(Tracer.class) ConditionalOnBean(Tracer.class) // ← 关键类在 bean 也要在 static class TracerPresentObservationConfiguration { ... } Configuration ConditionalOnMissingClass(io.micrometer.tracing.Tracer) // ← 类完全不在 static class TracerNotPresentObservationConfiguration { ... }于是存在一个中间态classpath 上有Tracer类引了 tracing bridge但容器里没有TracerBean没配 exporter 或被条件关掉——两组都不匹配log-prompt静默失效不报错也不打日志。要么把 tracing 配完整要么把 bridge 依赖去掉。4. 一次请求后如何验证仪表盘数据正确上报配完别急着开 Grafana先用三个端点确认数据形状。# 1) 有哪些指标 curl -s localhost:8080/actuator/metrics | jq -r .names[] | grep -E spring.ai|gen_ai|db.vector # 2) 某个指标有哪些 tag这一步能确认 gen_ai.system 到底打的是什么值 curl -s localhost:8080/actuator/metrics/gen_ai.client.token.usage | jq .availableTags # 3) 按 tag 取值 curl -s localhost:8080/actuator/metrics/spring.ai.chat.client?tagspring.ai.chat.client.stream:true | jq .发一次真实请求带工具调用的那种最能验证链路然后对照下表判断想确认的事看哪里判断标准ChatClient 是否可观测/actuator/metrics里有没有spring.ai.chat.client没有 → 回到第 3 节的 NOOP每请求模型调用几次gen_ai.client.operation次数 ÷spring.ai.chat.client次数稳定 1.5 说明工具/重试在吃成本检索是否被重复触发单 trace 内db.vector.client.operation个数期望 1出现 2 说明 RAG 在工具循环内提问的 provider taggen_ai.system实际值DashScope 通道打的是dashscope慢在哪一段span 耗时占比Memory / RAG / Tool / Model模型生成通常 70%关于gen_ai.system补一句Spring AI 自己的AiProvider枚举里没有dashscope这个值只有anthropic、bedrock_converse、deepseek、google_genai、mistral_ai、ollama、onnx、openai、spring_ai、vertex_ai等但 Spring AI Alibaba 侧自带了AiProvider.DASHSCOPE(dashscope)并把它传进ChatModelObservationContext.builder().provider(PROVIDER_NAME)。按 provider 分账前务必先用上面第 2 条命令确认你实际拿到的值别照抄别人的 tag 值。一次带工具的提问trace 长这样spring.ai.chat.client (chat_client, streamtrue) ├─ spring.ai.advisor nameMessageChatMemoryAdvisor order-2147483448 ├─ spring.ai.advisor nameRetrievalAugmentationAdvisor order-2147483198 │ └─ db.vector.client.operation milvus similarity_search top_k20 threshold0.55 ├─ spring.ai.advisor nameToolCallingAdvisor order-2147483148 ← 循环体 │ ├─ gen_ai.client.operation chat qwen-plus 第 1 次模型调用 │ ├─ spring.ai.tool execute_tool getBookingDetails │ ├─ db.vector.client.operation milvus similarity_search ← RAG 在循环内又跑了一次 │ └─ gen_ai.client.operation chat qwen-plus 第 2 次模型调用 └─ spring.ai.advisor nameSimpleLoggerAdvisor order1000这张图最有价值的地方db.vector.client.operation在一次请求里出现 2 次就说明 RAG 在工具循环内侧把RetrievalAugmentationAdvisor的 order 提到 Memory 与 ToolCalling 之间后这个数应该变成 1。不用读源码Dashboard 上就能验证架构决策。另外两个 span 也值得单独看spring.ai.advisor带spring.ai.advisor.order直接把它显示出来链路顺序一目了然。工具循环的多轮调用发生在ai-advisor-前缀的线程池上RetrievalAugmentationAdvisor默认ThreadPoolTaskExecutorcore 4 / max 16并挂了ContextPropagatingTaskDecorator。正因为有这个 decorator多查询并行检索的子 span 才仍挂在同一条 trace 上如果你自己换掉 taskExecutor 而没换 decorator会看到链路在并行处断成孤儿 trace。5. 本篇常见报错与排查对照这一节按真实报错来遇到哪个查哪个。401 Unauthorized / invalid_api_key先确认TAOTOKEN_API_KEY环境变量在启动进程里可见System.getenv打一行日志验证再确认base-url没有多余斜杠或路径。Spring AI 会拼/v1/chat/completions如果你写成https://taotoken.net/api/v1最终路径会变成/api/v1/v1/chat/completions报 404 而不是 401别搞混。local proxy failed / connection refused这类错误通常出现在你本地配了 HTTP 代理但代理没起。检查http_proxy/https_proxy环境变量Spring AI 的 RestClient 会读系统代理。清掉环境变量再试。reading choices / Cannot deserialize value of type...choices说明返回体不是 OpenAI 兼容格式多半是 Base URL 指错了端点比如指到了网页地址而不是 API 地址。用第 2 节那条 curl 对比返回结构能快速定位。OAuth / token expired如果你用的是需要 OAuth 的通道注意 access token 有有效期Spring AI 不会自动刷新。观测上表现为错误率周期性尖刺配合include-error-logging: true能在 span 上看到具体错误。指标里没有spring.ai.chat.client99% 是 NOOP 问题回到第 3 节检查ChatClient是不是注入Builder造的。剩下 1% 是MeterRegistryBean 不存在——ModelUsageMetricsGenerator是ConditionalOnBean(MeterRegistry.class)没引micrometer-registry-prometheus就不会注册。/actuator/prometheus404management.endpoints.web.exposure.include里没写prometheus或者 registry 依赖没加。histogram_quantile返回空percentiles-histogram没开指标里只有_count/_sum/_max没有_bucket。log-prompt设了 true 没输出第 3 节讲的 Tracer 中间态要么补全 tracing要么去掉 bridge。工具参数/结果没进 span需要注册ToolCallingContentObservationFilterBean ObservationFilter toolCallContentFilter() { return new ToolCallingContentObservationFilter(); // 加 spring.ai.tool.call.arguments / .result 高基数 tag }指标基数爆炸导致 Prometheus 变慢检查有没有把userId、conversationId当 tag 打进指标。正确做法是conversationId用userId:sessionId形式只进 span指标侧只保留功能维度。CC Switch / Cline MCP / Codex auth.json 场景如果你同时在用这些工具做本地开发注意它们的配置三件套要写全——Base URLhttps://taotoken.net/api、Key、Model ID缺一个都会表现为连接失败或模型不存在。观测上这类问题会体现为gen_ai.request.modeltag 为空或异常值。6. 从指标到成本告警PromQL 与 Grafana 落地流传最广的那组 PromQL 在这个版本上基本取不到数逐条修正。面板指标PromQLQPS请求维度ChatClient Timersum(rate(spring_ai_chat_client_seconds_count[5m])) by (application)模型调用次数成本维度ChatModel Timersum(rate(gen_ai_client_operation_seconds_count[5m])) by (gen_ai_response_model)每请求模型调用数健康度二者比值rate(gen_ai_client_operation_seconds_count[5m]) / rate(spring_ai_chat_client_seconds_count[5m])P99 端到端ChatClient 直方图histogram_quantile(0.99, sum by (le) (rate(spring_ai_chat_client_seconds_bucket[5m])))Token 消耗输入/输出分账Token Countersum by (gen_ai_token_type) (rate(gen_ai_client_token_usage_total[5m]))两处必须注意*_bucket需要先打开percentiles-histogram第 3 节 yml 里那两行没开的话只有_count/_sum/_maxhistogram_quantile返回空于是面板没数据。指标名转换规则点 → 下划线Timer 加单位后缀_seconds_*Counter 加_total。所以spring.ai.chat.client→spring_ai_chat_client_seconds_count不是旧教程里的spring_ai_chat_client_operation_count。关于成本框架不给你金钱指标只给你 Token。顺带一个源码细节AiObservationMetricNames里除了TOKEN_USAGE还定义了OPERATION_DURATION(gen_ai.client.operation.duration)但我在本地所有 2.0.x / 1.1.x 的 jar 字节码里搜这个名字只找到枚举定义处没有任何调用点——也就是说它目前是个预留位。所以成本 Token × 单价单价建议放配置而不是写死在 PromQL 里cost: unit-price-per-1k: input.qwen-plus: 0.008 output.qwen-plus: 0.02单价会随厂商调价我这里只是示例结构不要把任何具体数字当成报价——上线前请以官方控制台的当前价格为准并把它做成可热更的配置或常量表。Grafana 里两条常用查询# 各模型的输入 Token 日消耗千 Token sum by (gen_ai_response_model) (increase(gen_ai_client_token_usage_total{gen_ai_token_typeinput}[1d])) / 1000 # 每请求平均 Token用来抓被工具循环放大的成本 sum(rate(gen_ai_client_token_usage_total{gen_ai_token_typetotal}[5m])) / sum(rate(spring_ai_chat_client_seconds_count[5m]))告警我配三条阈值按自己基线调告警条件示意第一响应查什么每请求平均 Token 连续 30 分钟 基线 2 倍成本突增单 trace 里gen_ai.client.operation次数、db.vector.client.operation次数每请求模型调用数 3循环失控工具描述是否让模型反复重试returnDirect能不能收口检索退化similaritySearch P95 或返回文档数突增embedding 模型/分块是否被改动、阈值是否被下调按用户/功能归因要自己补 tag// 给所有指标加业务 tag一次配置全局生效 Bean MeterRegistryCustomizerMeterRegistry businessTags(Value(${spring.application.name}) String app) { return registry - registry.config().commonTags(application, app, team, airline-ai); }想细到用户别把userId塞成 tag基数爆炸Prometheus 会被打死。我的做法conversationId用userId:sessionId形式它以高基数 tag 进 span成本归因去 trace/日志侧算指标侧只保留功能维度。上线前我会过一遍这张自查表ChatClient 是注入 Builder 造的吗/actuator/metrics里五个名字都在吗/actuator/prometheus能拉到文本吗tag 里有application吗三个关键 Timer 开了percentiles-histogram吗P99 有数吗链路采样率是否按环境区分并行检索子 span 还挂在同一 trace 上吗每请求平均 Token 有基线值和告警吗单价从配置读吗隐私内容开关全 false 吗错误日志里不带完整 prompt 吗工具参数 filter 只在预发开吗单请求db.vector.client.operation次数 1 吗每请求模型调用数稳定吗。一句话收束Spring AI 的观测点名字和 tag 就是它的 API——先记住spring.ai.chat.client、spring.ai.advisor、gen_ai.client.operation、spring.ai.tool、db.vector.client.operation五个名字和那个唯一的 Token Counter仪表盘才不会出现面板有数、结论没有。如果你还没配好统一入口建议先去控制台创建 Key 并确认 Base URL 与 Model ID 三件套齐全再回到本文第 3 节照抄配置接入过程中遇到指标缺失或 tag 异常可以对照接入文档逐项核对也可以直接在模型对话里贴出你的application.yml片段让我帮你看。长期做 Agent 与编码类应用的同学用 Coding Plan 把多模型调用收敛到一条通道观测维度会干净很多。