☰
Spring AI 2.0 + LangChain4j 1.19 可观测性实战:OTel GenAI 语义约定源码级落地与 TaoToken 统一接入
2026/10/4 13:29:02 网站建设 项目流程

1. 从一次线上事故说起:为什么 Java AI 应用需要 OTel GenAI 语义约定

去年底我帮一个做智能客服的团队排查问题,他们的 Spring AI 应用上线两个月,月度 token 账单从 8 万涨到 40 多万,但业务方坚称调用量没变。运维翻了一周日志,每个业务线都说自己没动过 Prompt。最后定位到某个夜班风控工具把系统提示词改成了"请详细列举所有交易合规要点",每条 prompt 多塞了 800 多 token,每天夜里跑三万次。问题本身不复杂,复杂的是——他们根本没有按业务线切分的 token 用量维度,所有调用被聚合到全公司一个指标里,谁消耗了多少完全看不出来。

这就是 OTel GenAI 语义约定要解决的核心问题。OpenTelemetry 的 GenAI Semantic Conventions 给 LLM 调用定义了一套与厂商无关的统一 telemetry 词汇,任何框架只要按这个规范埋点,Prometheus、Grafana、Jaeger 上就能直接汇聚、对比、告警。Spring AI 2.0.0 GA 把这套约定塞进了 Micrometer Observation API,LangChain4j 1.19.0 则把 ChatModelListener 拆成了 Metrics 和 Observation 两条线。两个框架走的路不同,但目标一致:让 Java 工程师在 AI 上线后能"救活"系统。

这篇文章面向的是正在用 Spring AI 或 LangChain4j 做企业级 AI 应用的 Java 后端。我会从 OTel GenAI 的指标定义讲到两个框架的源码级落地,给出可复制的配置片段、Span 属性映射表和验证动作,最后说明如何通过 TaoToken 统一 Key 和 API 通道完成接入与链路验证。如果你正在为 AI 应用的账单、延迟、流式首字这些问题头疼,这篇应该能直接拿去用。

OTel GenAI 语义约定目前处于 Development 状态,稳定版是 v1.36.0,发展版已演进到 v1.41。对 Java 工程师最关键的是客户端侧的两类核心指标。第一个是gen_ai.client.token.usage,类型是 Histogram,单位是 token,用来记录输入和输出的 token 数量。它的必需标签包括gen_ai.operation.name(操作名,常见值是 chat、text_completion、generate_content)、gen_ai.provider.name(厂商标识,注意gen_ai.system已被废止,统一改为这个)、gen_ai.token.type(值必须是 input 或 output,每条记录只描述一个方向)、以及gen_ai.request.model和gen_ai.response.model。

这里有个重要边界:当系统同时返回 used tokens 和 billable tokens 时,必须记录 billable tokens,也就是账单口径。当 instrumentation 不能方便地拿到 input/output 计数时,可以让用户启用 offline token counting,否则不得上报该指标。桶边界也必须遵循指数级上界:[1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864]。因为 token 分布是重尾分布,Claude、GPT 的长上下文 200K+ 是常态,如果错把 duration 的桶布局套到 token 直方图上,长尾细节会全部丢失。

第二个核心指标是gen_ai.client.operation.duration,类型是 Histogram,单位是秒,记录 GenAI 操作的端到端耗时。必需标签同上,但没有gen_ai.token.type,桶边界按时间错位:[0.01, 0.02, 0.04, ..., 81.92]。v1.41 还新增了两个流式专用的衍生指标:gen_ai.client.operation.time_to_first_chunk(首字延迟,TTFT)和gen_ai.client.operation.time_per_output_chunk(每 chunk 间隔)。这两个指标是解决流式响应"首字慢"问题的关键——SSE 流式响应里第一个字之前到底走了几步,得有一个独立指标来描述。

Span Attributes 方面,OTel GenAI 把属性明确分成两个等级。低基数属性适合做 metrics 标签,包括gen_ai.operation.name、gen_ai.provider.name、gen_ai.request.model。高基数属性只能放 trace span attribute,包括gen_ai.usage.input_tokens、gen_ai.usage.output_tokens、gen_ai.response.finish_reasons、server.address、server.port。这条规则是防止基数爆炸的核心。如果你把 user_id 拼进gen_ai.provider.name,一周之内 Prometheus 的 series 数量会从一万条膨胀到几百万条,内存直接 OOM。

2. TaoToken 统一接入:一个 Key 打通 Spring AI 与 LangChain4j

在讲两个框架的源码落地之前,先解决接入层的问题。Spring AI 和 LangChain4j 各自支持多家模型厂商,但企业级应用往往需要统一管理 Key、统一计费口径、统一链路追踪。TaoToken 提供的就是这样一个统一通道,官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,API 端点是 https://taotoken.net/api。

它的价值在于:你不需要为每个框架、每个模型单独配置不同的 base URL 和 Key。Spring AI 的 OpenAI starter 和 LangChain4j 的 OpenAiChatModel 都可以指向同一个兼容端点,用同一个 Key 调用不同厂商的模型。这对可观测性建设特别重要——因为所有调用都经过同一个通道,gen_ai.provider.name和gen_ai.request.model的标签值可以保持稳定,不会因为切换厂商导致指标断裂。

先拿 Key。访问 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 创建 API Key,建议按环境分 Key,开发、测试、生产各一个,方便在 Prometheus 里按 Key 维度切分用量。拿到 Key 后,Spring AI 的配置如下:

spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: gpt-4o-mini

LangChain4j 的配置:

OpenAiChatModel model = OpenAiChatModel.builder() .baseUrl("https://taotoken.net/api") .apiKey(System.getenv("TAOTOKEN_API_KEY")) .modelName("gpt-4o-mini") .build();

这里有个细节要注意:Spring AI 的base-url不要带/v1后缀,框架会自动拼接。LangChain4j 的baseUrl同理。如果你用的是 OpenAI 兼容模式,路径是/v1/chat/completions,TaoToken 的端点已经做了兼容处理。

模型选择上,TaoToken 支持 GPT、Claude、Qwen、DeepSeek 等主流模型。你可以在模型对话页面 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 先验证模型可用性,再接入代码。对于长期编码和 Agent 场景,Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 提供了更稳定的配额和更低的单位成本。

接入完成后,所有调用都会带上统一的gen_ai.provider.name标签。你可以在控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 查看用量明细,按模型、按时间段切分。这一步做完,可观测性的数据源就统一了,接下来才是框架层的埋点。

3. Spring AI 2.0 可观测性源码级落地:从 Observation 到 Metrics

Spring AI 2.0.0 GA 把可观测性作为一等公民内置进了 Micrometer Observation 体系。它不是单独写一套 telemetry,而是复用了 Spring Boot 3.4 全家桶的 ObservationRegistry,让 Chat 调用产出 Metrics、Traces、Logs 三件事共用一个 Context。下面按源码路径拆解关键类。

AiProvider枚举,路径在spring-ai-commons/.../observation/conventions/AiProvider.java,枚举了所有支持的厂商:OPENAI、ANTHROPIC、GOOGLE、MISTRAL_AI、DEEPSEEK、BEDROCK、MINIMAX、OLLAMA 等。这个枚举保证 high/low cardinality 标签在所有厂商下都是已知枚举值,不会出现provider=Minimax-Compatible-Http-Client-1.2这样的爆炸字符串。

AiObservationMetricNames常量类,路径在spring-ai-commons/.../observation/conventions/AiObservationMetricNames.java,定义了全部 metric 名称常量:GEN_AI_CLIENT_OPERATION_DURATION = "gen_ai.client.operation.duration"、GEN_AI_CLIENT_TOKEN_USAGE = "gen_ai.client.token.usage"、DB_VECTOR_CLIENT_OPERATION_DURATION = "db.vector.client.operation.duration"。为什么需要这个类而不是字符串字面量?为了避免任何书写漂移。所有 ChatModel 实现都引用同一个常量,确保 OpenAI、Anthropic、Mistral 三个厂商在 Prometheus 上能合并到同一个 metric name 下做聚合。

AiTokenType枚举,路径在spring-ai-commons/.../observation/conventions/AiTokenType.java,枚举 INPUT、OUTPUT、TOTAL。这决定了gen_ai.client.token.usage上的gen_ai.token.type标签永远是 input、output、total 之一。

ChatModelObservationContext是关键类,路径在spring-ai-model/.../chat/observation/ChatModelObservationContext.java。这是每次 chat 调用产生的上下文包,重要字段包括 Prompt prompt、ChatResponse response、String provider、String requestModel、String responseModel、ChatOptions modelOptions、Usage usage。Context 用 Builder 模式构造:

ChatModelObservationContext context = ChatModelObservationContext.builder() .prompt(prompt) .provider(AiProvider.OPENAI.value()) .requestModel("gpt-4o") .build();

DefaultChatModelObservationConvention是最复杂的类,路径在spring-ai-model/.../chat/observation/DefaultChatModelObservationConvention.java。它实现了 KeyValuesConvention、ObservationConvention、ChatModelObservationConvention,定义了所有 ChatModel 调用在 OTel 上的属性切分。getLowCardinalityKeyValues返回aiOperationType(固定值 chat)、aiProvider(从 AiProvider 枚举取)、requestModel、responseModel。getHighCardinalityKeyValues返回usageInputTokens、usageOutputTokens、usageTotalTokens、requestTemperature、requestMaxTokens、requestTopP、requestTopK、requestFrequencyPenalty、requestPresencePenalty、requestStopSequences、requestTools、responseFinishReasons、responseId。这种 split 让 Prometheus 永远不要吃 high cardinality 字段做 series。

Context 只是数据载体,真正把数据送出的是三个 handler,都实现ObservationHandler<ChatModelObservationContext>。ChatModelMeterObservationHandler在onStop时调用 Micrometer 的 MeterRegistry 把 context 翻译成两条 metric:

Timer.builder(GEN_AI_CLIENT_OPERATION_DURATION) .tags(provider, model, operation_name, finish_reason) .register(registry) .record(elapsed); DistributionSummary.builder(GEN_AI_CLIENT_TOKEN_USAGE) .tags(provider, model, operation_name, token_type) .register(registry) .record(usage.inputTokens / outputTokens / totalTokens);

ChatModelCompletionObservationHandler负责把 ChatResponse 包装的 token usage 投递到下游,比如业务方在 Advisor 里拿到的chatResponse.getMetadata().getUsage()。ChatModelPromptContentObservationHandler可选开启,默认关闭,把 prompt 和 completion 完整内容写到独立的 SamplingSpanEvent,不放到 span attribute 上。这一步专门为了解决"prompt 不能放 metrics,但出问题时需要留痕"的矛盾。

Spring AI 2.0.0-RC1 有个关键修复:Streaming Span Hierarchy。release notes 明确写了 "Fix span hierarchy in streaming paths for all remaining chat models"。根因是TracingObservationHandler.getParentSpan()之前在 streaming 路径里把 Spring MVC 线程上的 span 当父节点,导致流式 chunk 的 observation span 父子错位。修复后所有 ChatModel 的 streaming 路径统一使用Observation.createNotStarted()加在 stream 终止时stop(),并正确读取当前 Micrometer Tracing 上下文。这正好解决了流式响应 TTFT 不上报的问题。

开启可观测性的 pom.xml 依赖:

<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>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency>

application.yml 配置:

management: endpoints: web: exposure: include: health, prometheus, metrics metrics: tags: application: ai-customer-service tracing: sampling: probability: 1.0 spring: ai: chat: client: observations: log-prompt: false log-completion: false observations: include-error-logging: true

注意log-prompt这条属性把 prompt 完整写入日志,生产环境必须 false。我在客户现场见过上线三天被 cursor 拼成 SSE 流日志后因 N+1 写盘把硬盘打满的事故。

自定义 Advisor 追踪对话轮次:

@Component public class AiAuditAdvisor implements CallAdvisor { private static final Logger log = LoggerFactory.getLogger(AiAuditAdvisor.class); @Override public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) { String traceId = Span.current().getSpanContext().getTraceId(); long startNanos = System.nanoTime(); try { ChatClientResponse response = chain.nextCall(request); ChatResponse chatResp = response.chatResponse(); if (chatResp != null && chatResp.getMetadata() != null && chatResp.getMetadata().getUsage() != null) { Usage u = chatResp.getMetadata().getUsage(); long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000L; log.info("ai.call traceId={} latencyMs={} inputTokens={} outputTokens={} totalTokens={}", traceId, elapsedMs, u.getPromptTokens(), u.getCompletionTokens(), u.getTotalTokens()); } return response; } catch (Exception ex) { log.error("ai.call traceId={} FAILED after {}ms", traceId, (System.nanoTime() - startNanos) / 1_000_000L, ex); throw ex; } } @Override public String getName() { return "AiAuditAdvisor"; } @Override public int getOrder() { return Ordered.LOWEST_PRECEDENCE; } }

关键点:Spring AI 2.0 用的是CallAdvisor、ChatClientRequest、ChatClientResponse、Usage.getCompletionTokens(),这些是 GA API。网上很多 1.x 时代的教程还在用CallAroundAdvisor、getGenerationTokens(),会编译不过。

4. LangChain4j 1.19 双监听器架构:ChatModelListener 到 Micrometer

如果说 Spring AI 走的是 Observation API 一条路,LangChain4j 走的是 Listener 双轨路:原生的MicrometerMetricsChatModelListener加新出的ObservationChatModelListener,覆盖不同精细度的诉求。

LangChain4j 1.19 把所有 ChatModel 和 StreamingChatModel 的可观测性收敛到ChatModelListener一个接口,三个 default 方法:

public interface ChatModelListener { default void onRequest(ChatModelRequestContext requestContext) {} default void onResponse(ChatModelResponseContext responseContext) {} default void onError(ChatModelErrorContext errorContext) {} }

三个 Context 都是只读快照。onRequest拿到的 ChatRequest 在onResponse时挂在responseContext.chatRequest()同一个引用上。这点很关键——很多团队的埋点实现用 ThreadLocal 在 listener 之间传数据,但 listener 执行线程不一定跟业务线程一致,ThreadLocal 方案在并发场景会丢数据。

MicrometerMetricsChatModelListener包路径是dev.langchain4j.micrometer.metrics.listeners.MicrometerMetricsChatModelListener,标注@Experimental。构造方式:

MicrometerMetricsChatModelListener listener = new MicrometerMetricsChatModelListener(meterRegistry); AzureOpenAiChatModel chatModel = AzureOpenAiChatModel.builder() .baseUrl("https://taotoken.net/api") .apiKey(System.getenv("TAOTOKEN_API_KEY")) .modelName("gpt-4o-mini") .listeners(List.of(listener)) .build();

输出指标名严格遵循 OTel GenAI 语义约定:gen_ai.client.token.usage,标签包括gen_ai.operation.name、gen_ai.provider.name、gen_ai.request.model、gen_ai.response.model、gen_ai.token.type。注意它不自动报gen_ai.client.operation.duration,这个 metric 需要在业务层用ctx.executionDuration()自己Timer.record()。历史值桶边界不会自动配置,需要在 MeterRegistry 级别用MeterFilter.maximumExpectedValue或DistributionStatisticConfig配置,否则在 Grafana 上会出现"500ms 这一栏没有数"的尴尬。

ObservationChatModelListener是完整版,1.19 引入,包路径dev.langchain4j.observation.listener.ObservationChatModelListener,同样@Experimental。需要新增依赖:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-observation</artifactId> <version>1.19.0</version> </dependency>

注册 Bean:

@Configuration public class ObservationConfig { @Bean public ObservationChatModelListener observationChatModelListener( ObservationRegistry observationRegistry, MeterRegistry meterRegistry) { return new ObservationChatModelListener(observationRegistry, meterRegistry); } }

它输出的是完整三件套:Metrics 包括gen_ai.client.token.usage和gen_ai.client.operation.duration,Traces 把 listener 周期包裹在 Observation 里产生 trace span,Logs 在错误路径自动 append 到 EventLog。对比起来,MicrometerMetricsChatModelListener适合"已经有了一套 Tracer,我只要 metrics"的场景,ObservationChatModelListener适合"我想要开箱即用的 metrics 加 trace"的一体化场景。

Spring Boot 集成配置的 pom.xml:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-observation</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency>

application.yml:

management: endpoints: web: exposure: include: health, prometheus, metrics langchain4j: open-ai: chat-model: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} model-name: gpt-4o-mini log-requests: false log-responses: false

访问http://localhost:8080/actuator/metrics/gen_ai.client.token.usage即可看到 token 分布,按?tag=gen_ai.token.type:input或?tag=gen_ai.token.type:output切分。

5. 常见报错排查:401、local proxy failed、reading choices、OAuth

这一节整理我在实际项目中遇到的真实报错和排查路径。每个报错都给出症状、根因和修复动作。

401 Unauthorized。症状是启动后第一次调用就返回 401,日志里能看到gen_ai.provider.name=openai但gen_ai.response.finish_reasons为空。根因通常是 Key 没读到环境变量,或者 base URL 拼错了。检查TAOTOKEN_API_KEY是否在启动环境里,Spring AI 的base-url不要带/v1,LangChain4j 的baseUrl同理。如果用的是 TaoToken,确认 Key 是从 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 创建的,没有多余空格。

local proxy failed。症状是连接超时,日志里出现local proxy failed或connection refused。根因是本地网络配置问题,或者 base URL 指向了不可达的地址。检查https://taotoken.net/api是否可达,用 curl 测试:

curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"ping"}]}'

如果 curl 通但应用不通,检查应用的 HTTP 客户端配置,特别是超时设置。

reading choices 报错。症状是Cannot deserialize value of type ... from Array value或reading choices相关异常。根因是响应格式不匹配,通常是 base URL 指向了非 OpenAI 兼容端点,或者模型名写错了。确认gen_ai.request.model的值是 TaoToken 支持的模型 ID,比如gpt-4o-mini、claude-sonnet-4、qwen-plus。如果用的是流式响应,检查stream参数是否正确传递。

OAuth 相关报错。症状是OAuth token expired或invalid_grant。根因是某些厂商用 OAuth 而非 API Key 认证。TaoToken 统一用 API Key,不需要 OAuth 流程。如果你在代码里配置了 OAuth 相关的 client_id、client_secret,去掉它们,改用api-key配置。Spring AI 的spring.ai.openai.api-key和 LangChain4j 的.apiKey()都是直接传 Key。

指标不显示。症状是/actuator/prometheus里找不到gen_ai.client.token.usage。根因通常是 Observation 没启用,或者 handler 没注册。检查management.endpoints.web.exposure.include是否包含 prometheus,检查ObservationRegistry是否注入。Spring AI 2.0 需要spring-boot-starter-actuator和micrometer-registry-prometheus同时在 classpath。

Span 父子错位。症状是 trace 里 LLM 调用的 span 挂在错误的父节点下。根因是 streaming 路径的 span hierarchy 问题,Spring AI 2.0.0-RC1 已修复。如果你用的是更早版本,升级到 2.0.0 GA。LangChain4j 1.19 的ObservationChatModelListener内部已处理大部分跨线程传播场景。

Token 计数为 0。症状是流式响应只记录了最后一次的 tokenUsage,过程中 observer 看到的是 0。根因是 SSE chunk-by-chunk 模型,每个 chunk 上报时 tokenUsage 都没填,只有最后一个 chunk 才有。修复方式是在 ChatModel 里让最后一个 chunk 之前用getMetadata().getUsage()不为空才更新 usage 字段,或者在自己的 Advisor 里做tokenCounter.increment(lastSeenUsage.outputTokens),保证至少最终值是对的。

6. 语义一致 CTA:从验证到长期运行

配置和排查都走通之后,下一步是验证链路。打开模型对话页面 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,用同一个 Key 发一条测试消息,然后在控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 查看用量是否记录。如果 Prometheus 里能看到对应的gen_ai.client.token.usage增长,说明从应用到 TaoToken 的链路是通的。

接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,里面有各框架的配置示例和参数说明。API Keys 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,建议按环境分 Key,方便在 Grafana 里按 Key 维度切分用量。

对于长期编码和 Agent 场景,Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 提供了更稳定的配额。Claude Code 用户可以参考 https://taotoken.net/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 的接入说明。

最后给一份落地清单。Token 指标方面,Spring AI 2.0 的入口是ChatModelMeterObservationHandler加AiObservationMetricNames,LangChain4j 1.19 的入口是MicrometerMetricsChatModelListener或ObservationChatModelListener。Trace Span 方面,Spring AI 用TracingObservationHandler或Observation.createNotStarted(),LangChain4j 用ObservationChatModelListener内部封装。Prompt 日志默认关闭,Spring AI 用spring.ai.chat.observations.log-prompt,LangChain4j 用langchain4j.open-ai.chat-model.log-requests。自定义属性方面,Spring AI 继承DefaultChatModelObservationConvention重写 requestModel 和 responseModel,LangChain4j 自定义ChatModelListener。流式首字方面,Spring AI 用time_to_first_chunk(v1.41 加 2.0.0-RC1),LangChain4j 通过ctx.executionDuration()在第一个 chunk 时记录。

这套组合下来,账单可控、链路可定位、流式首字可量化,三条都不需要魔法模型或 Python 重写。Java 工程师做 AI,可观测性是第二条护城河。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询