AI 应用的 OpenTelemetry 接入——标准化追踪不依赖厂商
AI 应用的 OpenTelemetry 接入——标准化追踪不依赖厂商
我们最早的 AI 应用监控,是用 Datadog 做的。接入很快,文档详细,SDK 一加上去,trace、log、metric 全都有了。
但半年后,我们开始考虑迁移到自建的监控栈。主要原因是成本——我们的 AI 服务每秒产生的 span 数量不小,Datadog 按 span 收费,每个月的账单已经比 OpenAI 的费用还高了。
迁移的时候我才发现,我们在 Datadog 的追踪代码里用的全是 Datadog 自己的 API:DDTracer、Span.log(),以及各种 Datadog 特有的 tag 名称。要换到 Jaeger,不是改个配置就行,几乎需要重写所有追踪代码。
这件事让我对可观测性技术选型有了新认识:应该用标准,而不是用厂商的实现。
OpenTelemetry(OTel)就是这个标准。
OpenTelemetry 是什么,为什么要用它
OpenTelemetry 是 CNCF 的可观测性标准,由 OpenTracing 和 OpenCensus 合并而来。它定义了三件事:
API:你在代码里调用的接口(创建 Span、记录属性、注入上下文等)。这些 API 是稳定的、不依赖任何具体实现的。
SDK:API 的具体实现,包括采样策略、上下文传播、导出逻辑。你用 OTel Java SDK,但不依赖任何特定的后端。
OTLP(OpenTelemetry Protocol):数据导出协议。把 trace/metric/log 数据从 SDK 发到收集器的标准协议。
用 OTel 的好处是:你的追踪代码只依赖 OTel API,后端可以任意切换。今天用 Jaeger,明天换 Zipkin,后天接 Grafana Tempo,只需要改导出配置,不需要改业务代码。
这不是理论优势,是我们实际经历的切肤之痛告诉我的。
AI Span 的语义约定
在讲代码之前,需要先了解 OTel 对 AI 操作的语义约定。OTel 社区(GenAI Working Group)正在制定 AI 相关的标准属性,目前还在草案阶段,但主流的 AI 框架已经在跟进实现。
核心属性约定(gen_ai.* 命名空间):
# LLM 调用相关
gen_ai.system = "openai" / "anthropic" / "azure" / ...
gen_ai.operation.name = "chat" / "text_completion" / "embeddings"
gen_ai.request.model = "gpt-4o"
gen_ai.request.temperature = 0.7
gen_ai.request.max_tokens = 2048
gen_ai.response.model = "gpt-4o-2024-08-06" # 实际使用的模型版本
gen_ai.response.finish_reasons = ["stop"]
gen_ai.usage.input_tokens = 1024
gen_ai.usage.output_tokens = 256
# RAG 相关
gen_ai.retrieval.source = "milvus:knowledge-base"
gen_ai.retrieval.top_k = 5
gen_ai.retrieval.similarity_threshold = 0.75
# Agent 相关
gen_ai.agent.id = "customer-service-agent"
gen_ai.tool.name = "search_knowledge_base"
gen_ai.tool.call.id = "call_abc123"这套命名约定的意义在于,不同的 AI 框架(Spring AI、LangChain4j、LlamaIndex)如果都遵循这个约定,你在 Grafana 或者 Jaeger 里看到的 AI Span 格式是一致的,可以写通用的仪表盘和告警规则。
Spring AI + OpenTelemetry 的接入
Spring AI 1.0 开始原生支持 OTel,内置了对 LLM 调用的自动追踪。先看基础配置:
Maven 依赖:
<dependencies>
<!-- Spring AI 核心 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<!-- Spring Boot Actuator(必须,OTel 自动配置依赖它)-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- OTel 自动配置 -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<!-- OTLP 导出器,把数据发到 OTel Collector -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
<!-- 可选:用于本地开发,把 trace 打印到日志 -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-logging</artifactId>
<scope>test</scope>
</dependency>
</dependencies>application.yml 配置:
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o
temperature: 0.7
# 开启 Spring AI 的自动追踪(默认开启)
chat:
observations:
include-input: true # 在 span 里记录输入(注意:生产环境可能要关,防止日志泄露)
include-output: true # 在 span 里记录输出
include-error-logging: true
management:
tracing:
sampling:
probability: 1.0 # 开发环境全量采样
otlp:
tracing:
endpoint: http://otel-collector:4318/v1/traces
metrics:
export:
url: http://otel-collector:4318/v1/metrics
metrics:
distribution:
percentiles-histogram:
# 为 AI 调用延迟开启直方图,才能计算精确的 P95/P99
http.server.requests: true
gen.ai.client.operation.duration: true配置完成后,Spring AI 的每次 LLM 调用都会自动生成一个 Span,包含所有标准的 gen_ai.* 属性。
自定义 AI Span:追踪 RAG 的完整链路
自动追踪只覆盖了 LLM 调用本身,一个完整的 RAG 请求还包括向量检索、文档重排等步骤,需要手动创建 Span。
@Service
@Slf4j
public class RAGService {
private final ChatClient chatClient;
private final VectorStore vectorStore;
private final Tracer tracer; // io.micrometer.tracing.Tracer
public RAGResponse query(String userQuery) {
// 创建 RAG 请求的根 Span
Span ragSpan = tracer.nextSpan()
.name("rag.query")
.tag("gen_ai.operation.name", "rag")
.tag("rag.query", userQuery)
.start();
try (Tracer.SpanInScope scope = tracer.withSpan(ragSpan)) {
// Step 1: 查询重写(子 Span)
String rewrittenQuery = rewriteQuery(userQuery);
// Step 2: 向量检索(子 Span)
List<Document> docs = retrieveDocs(rewrittenQuery);
// Step 3: 生成回答(Spring AI 会自动创建这个 Span)
String answer = generateAnswer(userQuery, docs);
ragSpan.tag("rag.retrieved_docs_count", String.valueOf(docs.size()));
ragSpan.tag("gen_ai.operation.outcome", "success");
return new RAGResponse(answer, docs);
} catch (Exception e) {
ragSpan.tag("error", "true");
ragSpan.tag("error.message", e.getMessage());
throw e;
} finally {
ragSpan.end();
}
}
private String rewriteQuery(String originalQuery) {
Span rewriteSpan = tracer.nextSpan()
.name("rag.query_rewrite")
.tag("gen_ai.operation.name", "query_rewrite")
.start();
long startTime = System.currentTimeMillis();
try (Tracer.SpanInScope scope = tracer.withSpan(rewriteSpan)) {
String rewritten = chatClient.prompt()
.system("将用户问题改写为更适合向量检索的形式,只输出改写后的问题,不要解释")
.user(originalQuery)
.call()
.content();
rewriteSpan.tag("rag.original_query", originalQuery);
rewriteSpan.tag("rag.rewritten_query", rewritten);
return rewritten;
} finally {
rewriteSpan.tag("latency_ms", String.valueOf(System.currentTimeMillis() - startTime));
rewriteSpan.end();
}
}
private List<Document> retrieveDocs(String query) {
Span retrieveSpan = tracer.nextSpan()
.name("rag.retrieve")
.tag("gen_ai.operation.name", "retrieval")
.tag("gen_ai.retrieval.source", "milvus:knowledge-base")
.start();
long startTime = System.currentTimeMillis();
try (Tracer.SpanInScope scope = tracer.withSpan(retrieveSpan)) {
SearchRequest request = SearchRequest.query(query)
.withTopK(5)
.withSimilarityThreshold(0.75);
List<Document> docs = vectorStore.similaritySearch(request);
retrieveSpan.tag("gen_ai.retrieval.top_k", "5");
retrieveSpan.tag("gen_ai.retrieval.returned_count", String.valueOf(docs.size()));
if (!docs.isEmpty()) {
// 记录最高相似度分数
double maxScore = docs.stream()
.mapToDouble(d -> (Double) d.getMetadata().getOrDefault("score", 0.0))
.max()
.orElse(0.0);
retrieveSpan.tag("gen_ai.retrieval.max_score", String.format("%.3f", maxScore));
}
return docs;
} finally {
retrieveSpan.tag("latency_ms", String.valueOf(System.currentTimeMillis() - startTime));
retrieveSpan.end();
}
}
private String generateAnswer(String query, List<Document> docs) {
// 构建 RAG Prompt
String context = docs.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n\n---\n\n"));
String systemPrompt = """
你是一个知识库问答助手。请根据以下参考资料回答用户的问题。
如果资料中没有相关信息,请明确说明"我在知识库中没有找到相关信息",不要编造答案。
参考资料:
""" + context;
// Spring AI 会自动为这次 LLM 调用创建 gen_ai.* 属性的 Span
return chatClient.prompt()
.system(systemPrompt)
.user(query)
.call()
.content();
}
}OTel Collector 配置
OTel Collector 是数据的中转站,接收来自应用的 OTLP 数据,然后转发给各种后端。
# otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
# 内存限制,防止 Collector OOM
memory_limiter:
check_interval: 5s
limit_mib: 512
spike_limit_mib: 128
# 批量发送,减少网络请求
batch:
timeout: 5s
send_batch_size: 256
# 采样:AI span 全量保留,其他 span 按 10% 采样
probabilistic_sampler:
hash_seed: 22
sampling_percentage: 10
# 过滤掉敏感数据(Prompt 内容可能包含 PII)
# 生产环境必须做这一步
attributes:
actions:
- key: "gen_ai.prompt"
action: delete
- key: "llm.prompts"
action: delete
exporters:
# 发送到 Jaeger
jaeger:
endpoint: jaeger:14250
tls:
insecure: true
# 同时发送 metrics 到 Prometheus
prometheus:
endpoint: "0.0.0.0:9090"
# 发送 logs 到 Loki
loki:
endpoint: http://loki:3100/loki/api/v1/push
# Debug 用,输出到控制台
logging:
verbosity: detailed
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch, attributes]
exporters: [jaeger]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [prometheus]
logs:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [loki]OTel 数据采集链路架构
为什么 OTel 比厂商 SDK 更值得投入
说几个具体的比较:
Datadog SDK vs OTel:Datadog 的 Java Agent 功能强大,自动插桩覆盖面广。但如果你想自定义 AI Span 的属性,只能用 Datadog 的 DDTracer.activateSpan() 等私有 API。切换到 OTel 之后,同样的功能用标准的 Tracer.nextSpan() 实现,代码更清晰,迁移成本几乎为零。
SkyWalking vs OTel:SkyWalking 的 Java Agent 是字节码增强,部分场景的 AI 框架支持不及时(比如 Spring AI 新版本发布后,SkyWalking 的插件可能要滞后几个月才更新)。OTel 由于是标准,Spring AI 官方会主动支持它,通常新版本发布时 OTel 集成就是同步的。
OTel 的缺点:配置项多、学习曲线陡。OTel Collector 本身也需要运维,不是"装上就能用"。对于小团队,如果没有运维人力,直接用 Grafana Cloud 或者其他 SaaS 托管 OTel Collector 可能是更实用的选择。
实际效果
接入 OTel 之后,我们能做到的事情:
在 Jaeger 里搜索一个用户的请求,能看到完整的链路:HTTP 入口 → RAG 请求 → 查询重写(Span)→ 向量检索(Span)→ LLM 生成(自动 Span,带 gen_ai.* 属性)→ HTTP 响应。
在 Grafana 里写了一个 AI 调用的延迟分布图,按 gen_ai.request.model 分组,可以实时看到不同模型的 P95/P99 延迟。
从 Jaeger 里能看到 LLM 调用的 gen_ai.usage.input_tokens 和 gen_ai.usage.output_tokens,直接在 trace 里做成本估算,不需要另外查日志。
这些都是基于标准的 OTel,哪天换后端,这些仪表盘和告警规则绝大部分可以复用。
