Spring AI Alibaba 接入 Arize Phoenix 可观测性平台

0 阅读5分钟

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 statementCreate connectionRollback 等)噪音:

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 的 ReactAgentboundedElastic 线程池执行 LLM 和 Tool 调用。OTel Context 不会自动跨线程传播,需要:

  1. 在 reactive 回调(subscribe/onNext)中手动设置 SessionTraceContextparentContext.makeCurrent()
  2. 使用 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

  1. 打开 http://localhost:6006
  2. 点击项目名(如 spring-ai-alibaba-react
  3. Traces tab → 点击某条 trace → 查看瀑布图

查看 Sessions

  1. Sessions tab → 看到所有 session 及其 turn 数、token 总量
  2. 点击 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.javaOTel SDK 初始化、ObservationRegistry、过滤器、IdGenerator
config/SessionTraceContext.javaThreadLocal 持有 sessionId + parent context
config/AgentTracer.java创建 CHAIN 根 span、包装 TOOL span
config/ContextPropagatingInstrumentor.java跨线程 context 传播
application.ymlPhoenix endpoint、项目名、观测开关

故障排查

问题原因解决
Phoenix 中看不到 trace端口不通或未启动确认 curl http://localhost:4317 可达
所有 span 独立、无层级Reactive 线程 context 丢失确保在 subscribe 回调中设置了 parentContext.makeCurrent()
Session 为空Controller 没设置 SessionTraceContext.set(sessionId)在所有入口加上
DB 噪音 spanFilteringSpanExporter 未生效确认 span 有 openinference.span.kind 属性
Root Spans 里看不到 Agent用了 remote parent 导致不是 rootsetNoParent() + 自定义 IdGenerator