Spring AI Alibaba 接入 Arize Phoenix 可观测性平台
概述
本文档介绍如何将 Spring AI Alibaba 应用接入 Arize Phoenix — 一个开源的 AI 可观测性平台,用于追踪、评估和调试 LLM 应用。
接入后你可以在 Phoenix UI 中看到:
- 完整的 Agent 执行链路:根 span → LLM 调用 → 工具调用(层级瀑布图)
- AI 语义属性:prompt、completion、model、token 用量、tool_calls
- Session 分组:同一会话的多轮对话归入同一 trace
- 延迟分析:每个 span 的耗时、P50/P99
架构
┌─────────────────────────────┐
│ Spring AI Alibaba App │
│ │
│ Controller │
│ └── AgentTracer │ ← 创建根 span (CHAIN)
│ ├── ChatModel.call()│ ← SpringAIInstrumentor 创建 LLM span
│ └── Tool.apply() │ ← AgentTracer.wrapTool 创建 TOOL span
└──────────────┬──────────────┘
│ OTLP gRPC (port 4317)
▼
┌─────────────────────────────┐
│ Arize Phoenix │
│ http://localhost:6006 │
└─────────────────────────────┘
前置条件
- Java 17+
- Spring Boot 3.x
- Spring AI 1.1.x
- 本地运行的 Phoenix 实例(或远程 Phoenix 地址)
1. 启动 Phoenix
# Docker 方式
docker run -p 6006:6006 -p 4317:4317 arizephoenix/phoenix:latest
# 或从源码(需要 Python 3.10+ 和 uv)
uv run phoenix serve
验证:打开 http://localhost:6006 看到 Phoenix UI。
2. 添加 Maven 依赖
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.1.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-bom</artifactId>
<version>1.49.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Boot Actuator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- OpenTelemetry SDK + OTLP Exporter -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-sdk</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
<!-- Arize OpenInference: Spring AI Instrumentation -->
<dependency>
<groupId>com.arize</groupId>
<artifactId>openinference-instrumentation-springAI</artifactId>
<version>0.1.9</version>
</dependency>
</dependencies>
注意:
openinference-instrumentation-springAI尚未发布到 Maven Central,需从 GitHub 源码 构建并安装到本地仓库:git clone https://github.com/Arize-ai/openinference.git cd openinference/java # 设置版本号 echo "version=0.1.9" > instrumentation/openinference-instrumentation-springAI/gradle.properties echo "version=0.1.9" > openinference-instrumentation/gradle.properties echo "version=0.1.9" > openinference-semantic-conventions/gradle.properties # 构建并安装到本地 Maven 仓库 ./gradlew publishToMavenLocal -x test
3. 配置类:PhoenixTracingConfig
创建 PhoenixTracingConfig.java,负责:
- 初始化 OTel SDK,配置 OTLP gRPC Exporter 指向 Phoenix
- 自定义 IdGenerator(同 session 共享 trace ID)
- 过滤 DB 噪音 span,只导出 AI 相关数据
- 注册 ObservationRegistry + OpenInference SpringAIInstrumentor
@Configuration
public class PhoenixTracingConfig {
private SdkTracerProvider tracerProvider;
@Bean
public SdkTracerProvider phoenixTracerProvider(
@Value("${phoenix.tracing.endpoint:http://localhost:4317}") String endpoint,
@Value("${phoenix.tracing.project-name:my-app}") String projectName) {
Resource resource = Resource.getDefault().merge(Resource.create(Attributes.of(
AttributeKey.stringKey("service.name"), "my-app",
AttributeKey.stringKey("openinference.project.name"), projectName)));
OtlpGrpcSpanExporter otlpExporter = OtlpGrpcSpanExporter.builder()
.setEndpoint(endpoint)
.setTimeout(Duration.ofSeconds(5))
.build();
tracerProvider = SdkTracerProvider.builder()
.setIdGenerator(new SessionAwareIdGenerator()) // 同 session 共享 trace ID
.addSpanProcessor(new SessionIdSpanProcessor()) // 注入 session.id 属性
.addSpanProcessor(BatchSpanProcessor.builder(
new FilteringSpanExporter(otlpExporter)) // 过滤 DB 噪音
.setScheduleDelay(Duration.ofSeconds(1))
.build())
.setResource(resource)
.build();
OpenTelemetrySdk.builder()
.setTracerProvider(tracerProvider)
.setPropagators(ContextPropagators.create(
W3CTraceContextPropagator.getInstance()))
.buildAndRegisterGlobal();
return tracerProvider;
}
@Bean
public ObservationRegistry phoenixObservationRegistry(SdkTracerProvider tracerProvider) {
OITracer tracer = new OITracer(
tracerProvider.get("my-app"), TraceConfig.getDefault());
ObservationRegistry registry = ObservationRegistry.create();
registry.observationConfig()
.observationHandler(new ContextPropagatingInstrumentor(tracer));
return registry;
}
@PreDestroy
public void shutdown() {
if (tracerProvider != null) {
tracerProvider.forceFlush().join(10, TimeUnit.SECONDS);
tracerProvider.shutdown().join(10, TimeUnit.SECONDS);
}
}
}
4. Session 追踪:SessionTraceContext
通过 ThreadLocal 在请求入口设置 sessionId,tracing 层自动读取并写入 span 属性。
public final class SessionTraceContext {
private static final ThreadLocal<String> SESSION_HOLDER = new ThreadLocal<>();
private static final ThreadLocal<Context> PARENT_CONTEXT_HOLDER = new ThreadLocal<>();
public static void set(String sessionId) { SESSION_HOLDER.set(sessionId); }
public static String get() { return SESSION_HOLDER.get(); }
public static void setParentContext(Context ctx) { PARENT_CONTEXT_HOLDER.set(ctx); }
public static Context getParentContext() { return PARENT_CONTEXT_HOLDER.get(); }
public static void clear() { SESSION_HOLDER.remove(); PARENT_CONTEXT_HOLDER.remove(); }
}
在 Controller 入口设置:
@PostMapping("/chat")
public SseEmitter chat(@RequestBody Map<String, String> body) {
String sessionId = body.get("sessionId");
SessionTraceContext.set(sessionId);
SseEmitter emitter = new SseEmitter(300_000L);
emitter.onCompletion(SessionTraceContext::clear);
emitter.onTimeout(SessionTraceContext::clear);
streamService.streamToEmitter(sessionId, body.get("message"), emitter);
return emitter;
}
5. Agent 层级结构:AgentTracer
在 Agent 入口创建根 span(CHAIN),使 LLM 和 Tool span 嵌套其下:
// Service 层入口
AgentTracer.SpanHandle agentSpan = AgentTracer.startAgent("RepairAgent.chat", userMessage);
Context parentContext = agentSpan.context();
// 在 reactive 回调中传播 context
disposable = agent.stream(message, config)
.subscribe(
nodeOutput -> {
SessionTraceContext.set(sessionId);
SessionTraceContext.setParentContext(parentContext);
try (var ignored = parentContext.makeCurrent()) {
handleNodeOutput(nodeOutput, emitter, ...);
}
},
error -> { agentSpan.error(error); },
() -> { agentSpan.success(); }
);
6. 工具调用 Span:AgentTracer.wrapTool
用 AgentTracer.wrapTool() 包装工具函数,自动创建 TOOL 类型子 span:
ToolCallback clarifyCb = FunctionToolCallback
.builder("clarify", AgentTracer.wrapTool("clarify",
(Function<ClarifyTool.Request, String>) clarifyTool::apply))
.description("向用户展示结构化澄清卡片")
.inputType(ClarifyTool.Request.class)
.build();
7. application.yml 配置
# Phoenix Tracing
phoenix:
tracing:
endpoint: http://localhost:4317
project-name: spring-ai-alibaba-react
# Spring AI 观测开关
spring:
ai:
chat:
observations:
log-prompt: true
log-completion: true
# 禁用 Actuator 自带的 tracing 自动配置(避免与手动 SDK 冲突)
management:
tracing:
enabled: false
8. 过滤 DB 噪音
FilteringSpanExporter 只导出带有 openinference.span.kind 属性的 span,自动过滤掉 JDBC(Execute statement、Create connection、Rollback 等)噪音:
private static class FilteringSpanExporter implements SpanExporter {
@Override
public CompletableResultCode export(Collection<SpanData> spans) {
var filtered = spans.stream()
.filter(s -> s.getAttributes().get(
AttributeKey.stringKey("openinference.span.kind")) != null)
.toList();
return filtered.isEmpty()
? CompletableResultCode.ofSuccess()
: delegate.export(filtered);
}
}
9. 同 Session 合并 Trace
自定义 IdGenerator,当 ThreadLocal 中有 sessionId 时从 sessionId 派生确定性 trace ID:
private static class SessionAwareIdGenerator implements IdGenerator {
@Override
public String generateTraceId() {
String sessionId = SessionTraceContext.get();
if (sessionId != null && !sessionId.isEmpty()) {
// SHA-256(sessionId + ":trace") 取前 16 字节
byte[] hash = MessageDigest.getInstance("SHA-256")
.digest((sessionId + ":trace").getBytes(UTF_8));
return bytesToHex(hash, 16);
}
return IdGenerator.random().generateTraceId();
}
@Override
public String generateSpanId() {
return IdGenerator.random().generateSpanId();
}
}
效果:同一个 session 的多轮对话(多次 HTTP 请求)合并到一个 trace 中:
Trace (sessionId 派生)
├── RepairAgent.chat (Turn 1)
│ ├── generate (LLM, 1,476 tokens)
│ ├── clarify (TOOL)
│ └── generate (LLM, 1,322 tokens)
└── RepairAgent.chat (Turn 2)
├── generate (LLM, 1,568 tokens)
├── createOrder (TOOL)
└── generate (LLM, 1,580 tokens)
10. Reactive 线程 Context 传播
Spring AI Alibaba 的 ReactAgent 在 boundedElastic 线程池执行 LLM 和 Tool 调用。OTel Context 不会自动跨线程传播,需要:
- 在 reactive 回调(subscribe/onNext)中手动设置
SessionTraceContext和parentContext.makeCurrent() - 使用
ContextPropagatingInstrumentor(继承SpringAIInstrumentor),在onStart前从 ThreadLocal 恢复 parent context
public class ContextPropagatingInstrumentor extends SpringAIInstrumentor {
@Override
public void onStart(Observation.Context context) {
Context parentCtx = SessionTraceContext.getParentContext();
if (parentCtx != null) {
try (Scope ignored = parentCtx.makeCurrent()) {
super.onStart(context);
}
} else {
super.onStart(context);
}
}
}
Phoenix UI 使用指南
查看 Traces
- 打开 http://localhost:6006
- 点击项目名(如
spring-ai-alibaba-react) - Traces tab → 点击某条 trace → 查看瀑布图
查看 Sessions
- Sessions tab → 看到所有 session 及其 turn 数、token 总量
- 点击 session → 右侧展示每轮对话的 Input/Output
查看 Span 详情
点击瀑布图中的 generate span 可以看到:
- Input Messages:system prompt + user message + tool results
- Output Messages:assistant response + tool_calls
- Token Usage:prompt / completion / total
- Model:GLM-5.1 等
- Invocation Parameters:temperature, max_tokens 等
关键文件清单
| 文件 | 职责 |
|---|---|
config/PhoenixTracingConfig.java | OTel SDK 初始化、ObservationRegistry、过滤器、IdGenerator |
config/SessionTraceContext.java | ThreadLocal 持有 sessionId + parent context |
config/AgentTracer.java | 创建 CHAIN 根 span、包装 TOOL span |
config/ContextPropagatingInstrumentor.java | 跨线程 context 传播 |
application.yml | Phoenix endpoint、项目名、观测开关 |
故障排查
| 问题 | 原因 | 解决 |
|---|---|---|
| Phoenix 中看不到 trace | 端口不通或未启动 | 确认 curl http://localhost:4317 可达 |
| 所有 span 独立、无层级 | Reactive 线程 context 丢失 | 确保在 subscribe 回调中设置了 parentContext.makeCurrent() |
| Session 为空 | Controller 没设置 SessionTraceContext.set(sessionId) | 在所有入口加上 |
| DB 噪音 span | FilteringSpanExporter 未生效 | 确认 span 有 openinference.span.kind 属性 |
| Root Spans 里看不到 Agent | 用了 remote parent 导致不是 root | 用 setNoParent() + 自定义 IdGenerator |