前言
如果你在生产环境里跑过一个基于 Spring AI 的 AI 服务,大概率遇到过这几类问题:一次对话请求耗时三秒,到底是模型慢、向量库慢,还是工具调用慢?账单突然翻了三倍,是哪条业务链路在烧钱?某个用户在投诉「昨晚答案不对」,你拿什么去还原他那次请求的完整上下文?
传统 Spring 应用的答案是一套成熟的可观测性体系:Micrometer 指标、分布式追踪、结构化日志。而 AI 应用的麻烦在于,它的核心成本发生在应用边界之外——请求出网、调用第三方大模型、按 token 计费、流式输出、工具调用、向量检索——任何一个环节都可能成为瓶颈,也可能成为账单黑洞。Spring AI 没有重新发明轮子,而是把这套体系延伸到了 AI 相关的组件上。
这篇文章是对 Spring AI 官方 Observability 文档(当前版本 2.0.1)的带读。我们会一节一节拆开官方文档里的定义,搞清楚 ChatClient、ChatModel、VectorStore、工具调用各自产生什么样的 observation 和指标,token 用量是怎么度量出来的,再聊聊怎么把这些信号接到 OTLP、Prometheus,以及怎样用观测数据驱动成本控制、重试与降级策略。所有技术细节都以当前版本官方文档为准。
整体架构与核心概念
谁在观测谁
Spring AI 的可观测性建立在 Spring 生态已有的观测设施之上,官方文档开篇就点明了这一点:要启用指标和追踪,你需要参考的是 Spring Boot 的 Metrics 与 Tracing 文档,Spring AI 自身只负责在核心组件上埋点。被埋点的核心组件包括:
- ChatClient(含 Advisor)
- ChatModel
- EmbeddingModel
- ImageModel
- VectorStore
这里要先理清两层概念。第一层是 Observation(观测),来自 Micrometer Observation API,一次观测同时产生两类产物:一条 trace span(送给追踪系统)和一组 metrics(送给指标系统)。也就是说你在代码里看到的一个 gen_ai.client.operation,既是 Zipkin/Jaeger 里的一个 span,又是 Prometheus 里的一个 timer 系列。第二层是 语义约定(Semantic Conventions),Spring AI 遵循 OpenTelemetry 的 Generative AI 语义约定来命名 key,比如 gen_ai.operation.name、gen_ai.usage.input_tokens,这些名字不是随便起的,而是 OpenTelemetry 社区对「生成式 AI 应该暴露什么维度」的统一答案。这意味着你用 Spring AI 埋的点,拿到任何兼容 OTel 语义的看板里都能对上号。
基数(Cardinality):为什么文档反复区分高低基数
官方文档里每张表都分成 Low Cardinality Keys 和 High Cardinality Keys 两栏,这是理解整套设计的关键。低基数 key 是指取值集合有限的维度,比如模型名、操作名、是否流式——它们会被同时加到指标(metrics)和追踪(traces)上,因为它们不会撑爆时序数据库。高基数 key 是指取值近乎无限的维度,比如完整的对话 ID、工具调用的参数、向量查询的内容——它们只会被加到追踪上,绝不进入指标标签,否则你的 Prometheus 会在一个小时内被 cardinality 爆炸打挂。
记住这条贯穿全文的红线:低基数 key 进指标和追踪,高基数 key 只进追踪。 后面每一张 key 表都遵守这个规则。
一张图看全貌
┌──────────────────────────── 你的业务 Controller ───────────────────────────┐
│ │
│ ChatClient.call()/stream() ← spring.ai.chat.client 观测点 │
│ │ │
│ ├─ Advisor 链(ChatMemory/ToolCallingAdvisor/...) │
│ │ ← spring.ai.advisor 观测点(含内部 advisor 耗时) │
│ │ │
│ ├─ ChatModel.call()/stream() ← gen_ai.client.operation 观测点 │
│ │ │ · 低基数: gen_ai.operation.name / gen_ai.request.model │
│ │ │ · 高基数: gen_ai.usage.input_tokens / output_tokens / ... │
│ │ │ · 指标: gen_ai.client.token.usage(token 计数器) │
│ │ │ · HTTP 层: okhttp span(同步调用正确嵌套;流式调用不嵌套*) │
│ │ │ │
│ │ ├─ 工具调用 ← spring.ai.tool 观测点 │
│ │ │ · gen_ai.operation.name = execute_tool │
│ │ │ · 参数/结果默认不导出(可配置开启) │
│ │ │ │
│ │ └─ EmbeddingModel ← gen_ai.client.operation(部分厂商) │
│ │ │
│ └─ VectorStore.similaritySearch/add/delete │
│ ← db.vector.client.operation 观测点 │
│ · 低基数: db.operation.name / db.system │
│ · 高基数: db.vector.query.content / top_k / filter ... │
└────────────────────────────────────────────────────────────────────────────┘
│ │
▼ OTLP (Span/Event) ▼ /actuator/prometheus
┌───────────────┐ ┌───────────────────────┐
│ Jaeger/Tempo/ │ │ Prometheus → Grafana │
│ OTLP Collector│ │ · *_seconds_count │
└───────────────┘ │ · *_seconds_sum/max │
│ · *_active_count │
│ · gen_ai_client_ │
│ token_usage_total │
└───────────────────────┘
*流式调用时 HTTP span 会被记录,但不会挂在 chat-model span 之下,
原因是 SDK 异步流式路径会跳到 ForkJoinPool.commonPool(),
在该边界丢失了调用线程的 observation 上下文(官方文档明确说明)。
你现在应该掌握了什么: Spring AI 的可观测性 = Micrometer Observation 在五个核心组件上的埋点;每个埋点同时产出 span 与 metric;key 按基数分级,高基数绝不进指标;命名遵循 OpenTelemetry 生成式 AI 语义约定。
ChatClient:应用入口的第一扇窗
官方文档的第一个具体观测点是 spring.ai.chat.client,在 ChatClient 的 call() 或 stream() 被调用时记录。它度量的是「这次 ChatClient 调用整体花了多少时间」,并把相关追踪信息向下传播。
低基数 key 有四组,值得逐个品味:
| Key | 含义 | 取值示例 |
|---|---|---|
gen_ai.operation.name |
操作名,恒为固定值 | framework |
gen_ai.system |
系统标识,恒为 | spring_ai |
spring.ai.chat.client.stream |
响应是否为流式 | true / false |
spring.ai.kind |
Spring AI 框架 API 类型 | chat_client |
高基数 key 则包括 spring.ai.chat.client.advisors(配置的全部 advisor 列表)、spring.ai.chat.client.conversation.id(使用聊天记忆时的会话标识)、spring.ai.chat.client.tool.names(传给模型的工具名列表)。注意这三个全是诊断型信息:当你看到某个请求特别慢,这三个 key 能让你立刻知道它挂了哪些 advisor、属于哪个会话、挂了哪些工具,而不用翻代码。
实际排查时,这条 span 是你的「总入口span」。做法很直接:在追踪系统里按 spring.ai.chat.client.conversation.id 过滤,一条链路的 Advisor 耗时、模型耗时、工具耗时、向量检索耗时全部作为子 span 铺开,哪一段长一眼可见。
你现在应该掌握了什么: ChatClient 观测点回答「这次入口调用整体多快、挂了哪些 advisor、哪个会话」;它是排障时定位整条 AI 链路的第一入口。
Prompt 与 Completion 数据:默认不导出的敏感内容
官方文档在这里态度非常明确,而且在多个组件章节里重复同一段话:prompt 和 completion 数据通常体积很大,还可能包含敏感信息,因此默认不导出。Spring AI 提供的是「日志开关」,而不是把内容塞进 span。
ChatClient 层的两个配置项:
spring:
ai:
chat:
client:
observations:
log-prompt: true # 是否记录 ChatClient 的 prompt 内容,默认 false
log-completion: true # 是否记录 completion 内容,默认 false
ChatModel 层还有一组平行配置,连错误日志都有独立开关:
spring:
ai:
chat:
observations:
log-prompt: true # 记录 prompt 内容,默认 false
log-completion: true # 记录 completion 内容,默认 false
include-error-logging: true # 在观测中包含错误日志,默认 false
当追踪可用时,这些日志会带上 trace 信息,方便和 span 关联。但官方文档每次给出这些开关都会附带同一句警告:开启后存在暴露敏感或私密信息的风险,务必谨慎。
生产上的合理姿势是:开关默认关闭;只在预发或灰度环境临时打开做排障;打开时配合日志脱敏管道;排障完毕立刻关掉。把 prompt 全文长期留在日志里,等于把用户隐私和 prompt 工程成果一起存进了日志系统。
你现在应该掌握了什么: prompt/completion 属于「高敏感、高体积」数据,Spring AI 的设计是默认不导出、按需用配置开关打日志;生产环境要把它当作需要管控的敏感开关。
ChatClient Advisors:链路内部的分段计时
Advisor 是 Spring AI 的重要扩展机制(日志、记忆、工具调用、安全过滤都挂在 advisor 链上),官方文档为它单独设置了 spring.ai.advisor 观测点:当一个 advisor 被执行时记录,度量的耗时包含其内部嵌套 advisor 的时间,同时传播追踪信息。
低基数 key:gen_ai.operation.name(恒 framework)、gen_ai.system(恒 spring_ai)、spring.ai.advisor.name(advisor 名)、spring.ai.kind(advisor)。高基数 key 只有一个:spring.ai.advisor.order,即该 advisor 在链中的顺序。
这个观测点的价值在链路变长后立刻显现。假设你挂了四个 advisor:日志 advisor → 记忆读取 advisor → RAG advisor → 工具调用 advisor。没有分段观测时,你只能看到「ChatClient 调用花了 4 秒」;有了 advisor 观测点后,你能看到 RAG 那一步单独占了 2.8 秒——瓶颈定位从猜变成了读数。order 这个高基数 key 还能帮你发现「advisor 顺序配错导致记忆 advisor 跑在 RAG 之后」这类配置问题。
你现在应该掌握了什么: advisor 观测点提供了 ChatClient 链路内部的分段耗时,且包含嵌套 advisor 的时间;spring.ai.advisor.order 是检查 advisor 链顺序是否合理的依据。
ChatModel:模型的 operation、token 指标与 HTTP 层的坑
ChatModel 是最重头的观测点。官方文档说明:OpenAI 和 Anthropic 的 chat model 会同时发出 chat-model 层 + HTTP 层 两层观测,其中有一个针对流式调用的限制需要特别注意(后面踩坑章节细讲)。
gen_ai.client.operation 观测点在调用 ChatModel 的 call 或 stream 方法时记录,度量方法完成耗时;配套的 gen_ai.client.token.usage 指标度量单次模型调用的输入输出 token 数。
低基数 key 是模型身份四件套:gen_ai.operation.name(正在执行的操作名)、gen_ai.system(客户端埋点识别出的模型提供方)、gen_ai.request.model(请求发往的模型名)、gen_ai.response.model(实际生成响应的模型名)。请求模型和响应模型分开记录不是多余——有些路由层会把你请求 A 模型的流量改写到 B 模型,两个 key 一对照立刻现形。
高基数 key 阵容庞大,基本覆盖了请求参数全貌:gen_ai.request.frequency_penalty、gen_ai.request.max_tokens、gen_ai.request.presence_penalty、gen_ai.request.stop_sequences、gen_ai.request.stream(仅流式为 true 时存在)、gen_ai.request.temperature、gen_ai.request.top_k、gen_ai.request.top_p、gen_ai.response.finish_reasons(每次生成对应的停止原因)、gen_ai.response.id,以及 token 相关的五兄弟:
gen_ai.usage.input_tokens:模型输入(prompt)消耗的 token 数gen_ai.usage.output_tokens:模型输出(completion)消耗的 token 数gen_ai.usage.total_tokens:本次模型交互的 token 总数gen_ai.usage.cache_read.input_tokens:从提供方托管缓存中命中的输入 token 数gen_ai.usage.cache_creation.input_tokens:写入提供方托管缓存的输入 token 数
最后两个 cache 相关的 key 经常被忽略,但对成本优化极其关键:它们直接告诉你 prompt 缓存的命中与写入情况。长 system prompt 的场景下,缓存命中和未命中的账单差距可能是数倍。
官方文档专门提示:若要度量用户 token,应使用 ChatModel 提供的指标名 gen_ai.client.token.usage。在 Prometheus 里它展开为计数器 gen_ai_client_token_usage_total,并通过标签区分 token 类型:
| 标签 | 含义 |
|---|---|
gen_ai_token_type=input |
发给模型的 prompt token |
gen_ai_token_type=output |
模型返回的 completion token |
gen_ai_token_type=total |
输入 + 输出合计 |
接入 Prometheus 后,一条 PromQL 就能把「每分钟烧多少 token」变成面板上的曲线:
sum by (gen_ai_token_type) (
rate(gen_ai_client_token_usage_total[5m])
)
配合各厂商的 token 单价,还能进一步换算成每分钟费用。注意计数器是「按 token 类型打标签的单一指标」,而不是三个独立指标——写告警和面板时按标签聚合即可。
ChatModel 层的操作指标(与 ChatClient 层同名不同物)展开为:
gen_ai_client_operation_seconds_sum:执行 chat model 操作的总耗时gen_ai_client_operation_seconds_count:已完成的 chat model 操作数gen_ai_client_operation_seconds_max:已观测到的最大耗时gen_ai_client_operation_active_count:当前在途的模型操作数
你现在应该掌握了什么: ChatModel 观测点携带完整的采样参数与 token 明细(含缓存命中);gen_ai.client.token.usage 是按 token 类型打标签的计数器,是 token 用量与费用统计的官方数据源;active_count 与 _seconds 系列分别刻画在途并发与已完成耗时。
工具调用:execute_tool 的独立观测点
工具调用在 chat model 交互的上下文中执行,观测点名是 spring.ai.tool,度量工具调用完成的耗时并传播追踪信息。
低基数 key 五组:gen_ai.operation.name(恒 execute_tool)、gen_ai.system(恒 spring_ai)、spring.ai.kind(恒 tool_call)、spring.ai.tool.definition.name(工具名)、spring.ai.tool.type(工具类型,默认 function)。高基数 key 包括 spring.ai.tool.definition.description、spring.ai.tool.definition.schema、spring.ai.tool.call.id(模型识别出的这次工具调用 ID)、spring.ai.tool.call.arguments(工具入参,仅开启后导出)、spring.ai.tool.call.result(工具返回结果,仅开启后导出)。
官方文档对高基数数据的态度依旧:参数与结果默认不导出,需要显式开启:
spring:
ai:
tools:
observations:
include-content: true # 在观测中包含工具调用参数与结果,默认 false
这一层观测点是「AI 系统调用传统系统」的桥。模型决定调 queryOrderStatus(orderId=12345),工具执行的耗时、是否抛异常、返回了多少行数据,都会落在这条 span 上。生产上常见的一类故障——模型幻觉出不存在的工具参数导致下游报错——正是靠 spring.ai.tool.call.arguments 才能复盘(开启的前提下)。同样地,官方文档在开关旁边附带了敏感信息警告。
你现在应该掌握了什么: 工具调用有独立的 execute_tool 观测点,工具名与类型是低基数维度可直接聚合,参数与结果属于默认关闭的高基数内容;它是连接 AI 决策与传统后端行为的关键 span。
EmbeddingModel 与 ImageModel:有边界的支持范围
这里要特别强调官方文档写明的一个边界:EmbeddingModel 的可观测性目前仅支持 Mistral AI、Ollama、OpenAI 三家提供方的实现,其他提供方会在未来版本支持。ImageModel 则目前仅支持 OpenAI。
EmbeddingModel 的观测点同样是 gen_ai.client.operation,记录嵌入模型方法调用的耗时;gen_ai.client.token.usage 指标同样度量单次调用的输入输出 token。低基数 key 与 ChatModel 一致(operation name / system / request model / response model);高基数 key 是 gen_ai.request.embedding.dimensions(输出向量的维度数)、gen_ai.usage.input_tokens、gen_ai.usage.total_tokens。度量 token 仍用 gen_ai.client.token.usage 这个指标名。
ImageModel 的低基数 key 少一个 gen_ai.response.model(operation name / system / request model),高基数 key 是生成参数:gen_ai.request.image.response_format(返回格式)、gen_ai.request.image.size(如 1024x1024)、gen_ai.request.image.style。image prompt 同样默认不导出,开关为 spring.ai.image.observations.log-prompt。
写代码前先确认你用的提供方在支持列表里,否则观测点不会出现——这是很容易被漏掉的前提。
实战上还有一个隐性成本点藏在 ETL 管线里。文档入向量库的流程(切分、调 EmbeddingModel、写 VectorStore)会在短时间内产生密集的 gen_ai.client.operation(embedding 侧)与 db.vector.client.operation(add 侧)观测。批量灌库时如果开着全量采样,trace 后端会先被这批「无用户、无会话」的 span 淹没。合理做法是把灌库任务打上独立的 trace 属性并在 Collector 侧降采样,同时用 db_vector_client_operation_seconds_count{db_operation_name="add"} 监控写入吞吐,把 embedding 的 gen_ai.usage.input_tokens 合计出来核对灌库的 embedding 账单——这一步经常被漏算,导致「只统计了问答流量」的成本报表系统性偏低。
三个模型类组件的观测能力差异,汇总成一张对照表便于查阅:
| 组件 | operation 观测点 | token 指标 | 当前支持的提供方 |
|---|---|---|---|
| ChatModel | gen_ai.client.operation |
gen_ai.client.token.usage(含 input/output/total 标签) |
OpenAI、Anthropic(HTTP 双层观测;流式有嵌套限制) |
| EmbeddingModel | gen_ai.client.operation |
gen_ai.client.token.usage |
仅 Mistral AI、Ollama、OpenAI |
| ImageModel | gen_ai.client.operation |
官方文档未列出独立 token 指标 | 仅 OpenAI |
注意 ChatModel 一行的「HTTP 双层观测」是 Embedding 与 Image 章节没有提到的行为,来自官方文档 Chat Model 一节对 OpenAI 与 Anthropic 的专门说明;写统一抽象层代码时,不要把「HTTP span 一定存在且嵌套正确」作为所有模型的共同假设。
你现在应该掌握了什么: Embedding 与 Image 的观测语义与 Chat 对齐(同名 operation 观测点 + token 指标),但支持范围有限:Embedding 仅 Mistral/Ollama/OpenAI,Image 仅 OpenAI;接入前要核对提供方支持矩阵。
VectorStore:db.vector.client.operation 与检索质量信号
官方文档明确:Spring AI 中所有 VectorStore 实现都通过 Micrometer 提供了指标与分布式追踪数据(这一点和 Embedding/Image 的有限支持形成对照)。
观测点 db.vector.client.operation 在与 VectorStore 交互时记录,度量的操作包括 query、add 和 remove,并传播追踪信息。低基数 key 只有三组,但信息量很大:
db.operation.name:操作名,add/delete/query三选一db.system:由客户端埋点识别出的向量库类型,取值集合官方文档逐一列出:pg_vector、azure、cassandra、chroma、elasticsearch、milvus、neo4j、opensearch、qdrant、redis、typesense、weaviate、pinecone、oracle、mongodb、gemfire、simplespring.ai.kind:恒为vector_store
高基数 key 是检索质量信号的宝库:db.collection.name(集合名)、db.namespace、db.search.similarity_metric(相似度度量)、db.vector.dimension_count(向量维度)、db.vector.field_name、db.vector.query.content(检索内容)、db.vector.query.filter(元数据过滤条件)、db.vector.query.response.documents(返回文档,可选)、db.vector.query.similarity_threshold(相似度阈值,0.0 表示接受任意相似度或关闭过滤,1.0 表示要求精确匹配)、db.vector.query.top_k。
当 RAG 出现「答非所问」时,排查顺序通常是:先看 db.vector.query.content 确认改写后的查询是什么,再看 top_k 与 similarity_threshold 是否合理,最后看返回文档。这条 span 把 RAG 的黑盒拆成了可核对的每一步。向量检索的响应数据同样默认不导出,开关是 spring.ai.vectorstore.observations.log-query-response。
向量库操作指标展开为 db_vector_client_operation_seconds_count/sum/max/active_count,标签包含 db_operation_name(add/delete/query)、db_system(redis、chroma、pgvector 等)与 spring_ai_kind=vector_store。用它可以轻松对比「写入 QPS」与「查询 P99」:
histogram_quantile(0.99,
sum by (le, db_operation_name) (
rate(db_vector_client_operation_seconds_bucket[5m])
)
)
你现在应该掌握了什么: 所有向量库实现都有统一观测点;db.system 标签让多向量库混布的架构也能统一看板;高基数 key 完整记录了检索内容、过滤条件、top_k 与阈值,是 RAG 质量排查的第一手证据。
指标命名约定与「在途 vs 已完成」
官方文档的 More Metrics Reference 一节值得单独带读,因为它回答了一个写告警时必问的问题:为什么我在 Prometheus 里找不到 gen_ai.client.operation 这个指标?
答案是 Micrometer 到 Prometheus 的命名转换:Spring AI 使用 Micrometer,基础指标名用点号(如 gen_ai.client.operation),而 Prometheus 导出时会转成下划线并附加标准后缀:
- Timer →
<base>_seconds_count、<base>_seconds_sum、<base>_seconds_max,(在支持时)还有<base>_active_count - Counter →
<base>_total(单调递增)
| 基础指标名 | 导出的 Prometheus 时间序列 |
|---|---|
gen_ai.client.operation |
gen_ai_client_operation_seconds_count / _seconds_sum / _seconds_max / _active_count |
db.vector.client.operation |
db_vector_client_operation_seconds_count / _seconds_sum / _seconds_max / _active_count |
另一个高频误解是 active_count 与 _seconds 系列的关系,官方文档专门做了澄清:
active_count(如gen_ai_chat_client_operation_active_count)是瞬时在途操作数的 gauge,刻画并发与负载。_seconds_sum/_seconds_count/_seconds_max只统计已完成的操作:_seconds_sum / _seconds_count得平均延迟,_seconds_max是自上次抓取以来的高水位(受注册表行为影响)。
推论很实际:想做「平均延迟」用 sum/count 相除;想做「在途请求数」看 active_count;想监控 P99 则用 histogram(若注册表配置了直方图发布)。把 active_count 当成功率或把 seconds_max 当历史最大值,都会得出错误结论。
你现在应该掌握了什么: 点号基础名到 Prometheus 下划线系列的转换规则、Timer 与 Counter 的标准后缀、以及 active_count(在途)与 _seconds 系列(已完成)的本质区别。
接入 OTLP 与 Prometheus
观测点埋好了,接下来是把信号送出去。这一步本身由 Spring Boot Actuator 承担,Spring AI 不重复造轮子。以 OTLP 追踪 + Prometheus 指标的典型组合为例:
management:
endpoints:
web:
exposure:
include: health, metrics, prometheus
tracing:
sampling:
probability: 1.0 # 开发/预发全开采样;生产按流量调整
otlp:
tracing:
endpoint: http://localhost:4318/v1/traces
metrics:
export:
prometheus:
enabled: true
依赖侧,Spring Boot 3.x 项目引入 Actuator 与 OTLP 追踪桥接、Prometheus 注册表即可:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
追踪信号经 OTLP 进入 Collector,再分发到 Jaeger/Tempo;指标则由 Prometheus 抓取 /actuator/prometheus。两边在 Grafana 汇合后,一个 AI 服务的黄金信号面板就成型了:上排 gen_ai_client_token_usage_total 的速率曲线(成本),中排 gen_ai_client_operation_seconds_max 与 active_count(延迟与并发),下排 db_vector_client_operation_seconds_sum/count(检索健康度)。trace 侧则以 spring.ai.chat.client.conversation.id 作为用户投诉排查的锚点。
中间那层 Collector 建议显式配置,而不是让每个应用直连 Jaeger。一个最小可用的 Collector 配置如下:
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
exporters:
otlphttp/tempo:
endpoint: http://tempo:4318
service:
pipelines:
traces:
receivers: [otlp]
exporters: [otlphttp/tempo]
这样应用的 management.otlp.tracing.endpoint 只指向 Collector,后端替换、采样调整、属性裁剪都在 Collector 层完成,应用侧零改动。如果你还想把 span 中的高基数 key(工具参数、向量检索内容)做脱敏后再入库,Collector 的 processor 阶段也是天然的挂载点。
一次完整的场景推演
把前面所有章节串起来,走一遍真实的排障剧本。某天下午 Grafana 告警:gen_ai_client_token_usage_total 的 rate 值比平时高了四倍。排查顺序如下。
第一步,按模型维度下钻。trace 侧取若干 gen_ai.client.operation span,对比 gen_ai.request.model 与 gen_ai.response.model,确认没有发生计划外的模型切换;再看 gen_ai.usage.cache_read.input_tokens 是否骤降——如果缓存命中率掉了,说明上游改了 system prompt 导致缓存键失效,这是输入 token 暴涨最常见的原因之一。
第二步,回到 ChatClient 入口 span,看 spring.ai.chat.client.advisors 列表有没有新增 advisor;spring.ai.chat.client.conversation.id 的高频会话说明某个用户的长历史对话没有被截断,记忆 advisor 每次都把整段历史重新发给模型。
第三步,检查 gen_ai_chat_client_operation_active_count 是否同步上升。若 token 涨、并发不涨,是单次请求变胖(上下文膨胀);若两者同涨,则是流量本身上涨或重试风暴——此时再看 _seconds_max 的尖峰与 429 类错误日志(前提是你开了 spring.ai.chat.observations.include-error-logging)即可区分。
第四步,确认根因后给出治理动作:记忆 advisor 加上历史截断策略,system prompt 冻结版本以保住缓存键,重试加指数退避上限。修复一周后,同一张面板上 gen_ai_client_token_usage_total 回到基线,case 关闭。注意这四步没有一步需要翻业务代码——全部证据都来自官方文档定义的这些观测点,这正是把它们接好的回报。
你现在应该掌握了什么: 导出链路完全复用 Spring Boot Actuator:Micrometer Observation → OTLP(span)与 Prometheus(metric)→ Grafana 汇合;Collector 是脱敏与后端解耦的天然挂载点;而一次真实排障可以从 token 速率异常一路下钻到 advisor 配置。
成本与限流治理:让观测数据反过来驱动决策
官方文档本身只提供观测信号,治理策略要我们自己搭建,但每一环都建立在前面读到的指标之上。
成本治理。 token 账单 = 输入 token × 输入单价 + 输出 token × 输出单价。gen_ai_client_token_usage_total{gen_ai_token_type="input|output"} 已经把这等式左边的数据备齐,剩下的只是按模型维度加权。配合 gen_ai.usage.cache_read.input_tokens(在 trace 侧)可以验证 prompt 缓存策略是否生效;配合 spring.ai.chat.client.advisors 可以判断是不是某个 advisor(比如无脑塞全量历史的记忆 advisor)把输入 token 撑爆了。
限流与熔断。 在途并发看 gen_ai_chat_client_operation_active_count,按用户或按租户做配额时,用 ChatClient 的 advisor 机制在入口计数,超过阈值直接快速失败或切换降级模型,避免在第三方模型侧排队超时——active_count 异常持续高企往往就是上游拥塞的前兆。
重试。 模型调用天然伴随限流(429)与瞬时错误,但要重试得有据可依:gen_ai_client_operation_seconds_max 高企叠加请求数下降,说明在反复超时;重试应放在 ChatModel 调用的边界,配合指数退避和最大次数上限,避免重试风暴放大 gen_ai_client_token_usage_total。
降级。 成本告警触发后,常见降级路径是把非关键流量从旗舰模型切到便宜模型。切换点建议收敛在配置层(模型名外置到 spring.ai.openai.chat.options.model 之类的配置),并用 gen_ai.request.model 与 gen_ai.response.model 的对照验证流量真的切走了。
一个生产级 AI 服务的配置清单(以 OpenAI chat + PGVector + OTLP 为例):
spring:
ai:
openai:
chat:
options:
model: gpt-4o
temperature: 0.7
max-tokens: 2048
chat:
observations:
include-error-logging: true
vectorstore:
pgvector:
index-type: HNSW
distance-type: COSINE_DISTANCE
management:
tracing:
sampling:
probability: 0.1 # 生产按流量调低
otlp:
metrics:
endpoint: http://otel-collector:4318/v1/metrics
tracing:
endpoint: http://otel-collector:4318/v1/traces
metrics:
distribution:
percentiles-histogram:
"[gen_ai.client.operation]": true # 发布直方图以计算 P99
"[db.vector.client.operation]": true
你现在应该掌握了什么: 观测数据是治理的输入——token 计数器驱动成本核算与缓存验证,active_count 与耗时水位驱动限流/重试/降级决策;生产配置应把采样率、直方图发布与敏感日志开关一并纳入清单。
踩坑经验
以下对照全部来自官方文档明示的限制与默认行为。
❌ 错误做法 1:以为开启 Spring AI 的 starter 就自动有追踪,没配 Spring Boot 的 Tracing。
Spring AI 只负责埋点,metrics/tracing 的启用与导出归 Spring Boot 管。没引 Actuator、没配 OTLP,span 只会躺在本地。
✅ 正确做法:按 Spring Boot Metrics/Tracing 文档先打通 Actuator → OTLP → 后端的全链路,再谈 AI 组件的观测。验证方式:随便发一个请求,看追踪后端是否出现 spring.ai.chat.client span。
❌ 错误做法 2:把 _seconds_max 当「历史最大延迟」画进长期趋势图。
官方文档说明 _seconds_max 是自上次抓取以来的高水位,受注册表行为影响,抓取后被重置。
✅ 正确做法:长期趋势用 _seconds_sum / _seconds_count(平均值)或发布百分位直方图算 P99;_seconds_max 只用于短窗口内的尖峰告警。
❌ 错误做法 3:在流式调用场景下,根据 HTTP span 没挂在 chat-model span 下面,怀疑上下文传播配置错了。
官方文档明确:流式调用时 HTTP span 会被记录但不会挂在 chat-model span 之下,根因是 SDK 的异步流式路径在调用 Spring AI 的 HTTP 客户端前跳到了 ForkJoinPool.commonPool(),在该边界丢失了调用线程的 observation 上下文。这是已知限制,不是你配错了。同步调用则嵌套正常。
✅ 正确做法:接受这一边界行为,排查流式调用时把 HTTP 层 span 按时间邻近性与 trace id 人工关联,不要徒劳地调上下文传播配置。
❌ 错误做法 4:在 Prometheus 查询 gen_ai.client.operation,查不到就以为埋点没生效。
Spring AI 用的是 Micrometer 点号基础名,Prometheus 导出的是 gen_ai_client_operation_seconds_* 下划线系列。
✅ 正确做法:查 gen_ai_client_operation_seconds_count 等转换后的名字;记不准时先抓 /actuator/prometheus 端点看实际导出的时间序列名。
❌ 错误做法 5:为了排查一次用户投诉,把 log-prompt、include-content 等开关在生产长期打开。
官方文档对每一组开关都附带了同一句警告:存在暴露敏感或私密信息的风险。
✅ 正确做法:敏感内容开关默认关闭;生产排障走「临时开启 → 定向采样 → 完毕即关 → 日志脱敏」的闭环,并把这些开关纳入审计清单。
❌ 错误做法 6:给 Ollama 之外的 Embedding 模型或 Anthropic 的图像模型加观测断言,测试失败后质疑框架。
官方文档写明:EmbeddingModel 观测目前仅支持 Mistral AI、Ollama、OpenAI;ImageModel 仅支持 OpenAI。
✅ 正确做法:先对照官方支持矩阵再写测试断言;不支持的提供方要么等后续版本,要么自行在业务层补埋点。
你现在应该掌握了什么: 六个高频坑分别对应「导出链路归属」「max 指标语义」「流式上下文丢失」「指标命名转换」「敏感开关管控」「支持矩阵边界」六个知识点,全部有官方文档依据。
最佳实践清单
- 先通 Spring Boot 观测链路,再谈 AI 观测。 Spring AI 只埋点,导出全靠 Actuator;把 OTLP 与 Prometheus 打通是一切的前提。
- 记住基数分级红线。 低基数 key 同时进指标与追踪,高基数 key 只进追踪;自己写自定义埋点时也遵守同一规则,保护时序数据库。
- 费用面板从
gen_ai_client_token_usage_total起步。 按gen_ai_token_type与模型维度聚合,叠加厂商单价即可落成成本看板;用 trace 侧的 cache key 验证缓存命中率。 - 排障以
spring.ai.chat.client.conversation.id为锚。 一条 trace 拉通 advisor 分段、模型耗时、工具执行、向量检索,避免在多系统间来回跳。 - 区分在途与已完成。 并发容量看
*_active_count,延迟趋势看_seconds_sum/_count,尖峰告警看_seconds_max,三者不混用。 - 敏感内容开关纳入变更管控。
log-prompt、log-completion、include-content、log-query-response一律默认关闭,开启需审批、定向、限时。 - 流式调用的 HTTP span 关联要人工兜底。 已知
ForkJoinPool.commonPool()边界丢上下文,看板与告警逻辑不要依赖其父子关系。 - 生产发布百分位直方图。 对
gen_ai.client.operation与db.vector.client.operation开启percentiles-histogram,让 P95/P99 可计算。 - 核对提供方支持矩阵再承诺观测能力。 Embedding 三家、Image 一家,写进架构文档避免误判。
- 把治理闭环接上观测信号。 限流阈值、重试策略、降级开关的触发条件都从这套指标里取数,而不是拍脑袋。
总结
- Spring AI 的可观测性不是独立体系,而是 Micrometer Observation 在 ChatClient、ChatModel、EmbeddingModel、ImageModel、VectorStore 五个核心组件上的延伸;启用与导出走 Spring Boot 的 Metrics/Tracing 设施。
- 每个观测点同时产出 span 与 metric,key 按基数分级:低基数(模型名、操作名、db.system 等)进指标与追踪,高基数(会话 ID、token 明细、查询内容等)只进追踪——这是整套设计的保护性红线。
- token 用量的官方数据源是
gen_ai.client.token.usage,在 Prometheus 中展开为按gen_ai_token_type打标签的gen_ai_client_token_usage_total;结合缓存读写 key,它同时支撑成本核算与缓存优化。 - 需要记住三个「非直觉」事实:指标名从点号到下划线的转换、
_seconds_max的高水位语义、流式调用中 HTTP span 因ForkJoinPool.commonPool()边界而丢失父子关系。 - 敏感数据(prompt、completion、工具参数、向量响应)一律默认不导出,开关化、限时化、审计化是生产使用的唯一姿势。
- 观测的最终价值在治理:token 计数器驱动成本与降级,active_count 与耗时水位驱动限流与重试,让生产级 AI 服务的每一项决策都有据可依。
参考资料
- Spring AI Reference — Observability(本次带读主文档,版本 2.0.1):https://docs.spring.io/spring-ai/reference/observability/index.html
- Spring Boot Reference — Metrics:https://docs.spring.io/spring-boot/reference/actuator/metrics.html
- Spring Boot Reference — Tracing:https://docs.spring.io/spring-boot/reference/actuator/tracing.html
- OpenTelemetry Semantic Conventions — Generative AI:https://opentelemetry.io/docs/specs/semconv/gen-ai/
- Micrometer — Naming Meters:https://micrometer.io/docs/concepts#_naming_meters
附录:可直接落地的告警规则与自定义埋点
PromQL 告警规则示例
前面章节反复出现的指标,落到生产需要四条最小告警集。以 token 费用、模型延迟、向量检索、在途并发四个维度各取一条,全部基于本文带读过的官方时间序列:
groups:
- name: spring-ai-observability
rules:
# 1. token 费用异常:五分钟均值超过基线四倍
- alert: AiTokenUsageSpike
expr: |
sum by (gen_ai_token_type) (
rate(gen_ai_client_token_usage_total[5m])
) > 4 * avg_over_time(
sum by (gen_ai_token_type) (
rate(gen_ai_client_token_usage_total[5m])[1d:5m]
)
)
for: 15m
annotations:
summary: "AI token 用量异常上涨,请按会话 ID 下钻排查上下文膨胀"
# 2. 模型调用 P99 超过十秒
- alert: AiModelOperationSlow
expr: |
histogram_quantile(0.99,
sum by (le, gen_ai_request_model) (
rate(gen_ai_client_operation_seconds_bucket[5m])
)
) > 10
for: 10m
# 3. 向量检索 P99 超过两秒
- alert: AiVectorQuerySlow
expr: |
histogram_quantile(0.99,
sum by (le, db_operation_name) (
rate(db_vector_client_operation_seconds_bucket[5m])
)
) > 2
for: 10m
# 4. 在途模型调用持续高企,疑似上游拥塞或重试风暴
- alert: AiModelOperationsBacklog
expr: |
gen_ai_client_operation_active_count > 100
for: 15m
写这四条时务必遵守前文的两条命名规则:指标名用下划线系列而非点号基础名;_seconds_max 不进长期趋势图,只做短窗口尖峰参考。直方图桶要生效,前提是已在 management.metrics.distribution.percentiles-histogram 中为 gen_ai.client.operation 与 db.vector.client.operation 开启发布,缺了这一步第二条和第三条告警永远查不到数据——这是最容易被漏配的环节。
自定义埋点:与官方观测点保持同构
官方观测点没覆盖到的业务环节(比如你自己的缓存层、编排逻辑),建议用 Micrometer Observation 补埋,并沿用官方的两条约定:命名走 spring.ai.* 风格、key 遵守低基数进指标的规则。
@Component
class PromptCacheObservation {
private final ObservationRegistry registry;
PromptCacheObservation(ObservationRegistry registry) {
this.registry = registry;
}
<T> T observeCacheLookup(Supplier<T> lookup, boolean cacheHit) {
return Observation.createNotStarted("spring.ai.prompt.cache", registry)
.lowCardinalityKeyValue("spring.ai.kind", "prompt_cache")
.lowCardinalityKeyValue("cache.hit", String.valueOf(cacheHit))
.highCardinalityKeyValue("cache.key.hash",
DigestUtils.sha256Hex(currentPrompt()))
.observe(lookup);
}
}
注意 cache.key.hash 用的是摘要值而非原文,既保住了排障所需的区分度,又守住了敏感数据不进 span 的红线。这个自定义观测点展开后同样是 spring_ai_prompt_cache_seconds_* 系列,可直接混进前文的面板与告警组。把它与 gen_ai.usage.cache_read.input_tokens 对照,就能同时回答「应用侧缓存命中了吗」和「提供侧缓存命中了吗」两个问题,形成双层缓存的完整观测闭环。