🚀 Spring AI + Langfuse:让你的 Agent 不再“黑盒”的可观测实践

0 阅读4分钟

做过 Spring AI / MCP / Tool Calling 的同学,应该都经历过这种时刻:

🤔 “AI 明明跑了,但它到底干了啥?” 🤔 “为什么这次回答变了?” 🤔 “tool 调了没?参数是什么?”

日志翻半天,还是一头雾水。

如果你也有这种感觉,那你缺的不是日志,而是—— 👉 AI 可观测性

今天我们就用 Langfuse + Spring AI,把 AI 系统从“黑盒”变成“透明”。


🧠 一、为什么要用 Langfuse?

先说结论:

❗没有 Langfuse 的 AI 系统,本质是“不可调试系统”


🔍 传统可观测工具的问题

我们熟悉的:

  • 日志(Log)
  • OpenTelemetry(Trace)
  • APM(SkyWalking / Jaeger)

能解决的问题是:

  • 接口慢不慢
  • 有没有报错
  • 调用链是什么

但 AI 系统的问题是👇


🤖 AI 场景的新问题

你真正关心的是:

  • Prompt 是什么?
  • LLM 为什么这么回答?
  • Tool 调了几次?
  • MCP 哪一步出错?
  • Token 花在哪?

👉 这些问题,传统工具完全看不到


🧩 Langfuse 解决什么?

Langfuse 本质是:

💡 AI 行为级可观测平台

它能记录:

  • Prompt / Completion(输入输出)
  • Tool 调用链
  • Agent 执行路径(DAG)
  • Token 消耗
  • 每一步 latency

🎯 一句话总结

OTel 告诉你“发生了什么”,Langfuse 告诉你“AI 为什么这么做”。


🧱 二、Langfuse 部署(超简单)

直接 Docker 启动:

git clone https://github.com/langfuse/langfuse
cd langfuse
docker compose up -d

访问:

http://localhost:3000

image-20260428141446855


🔑 初始化

第一次进入:

  1. 创建账号
  2. 创建 Project
  3. 获取:
    • Public Key
    • Secret Key

👉 后面 Spring AI 会用到


⚙️ 三、Spring AI 集成 Langfuse

核心思路很简单:

Spring AI → OpenTelemetry → OTLP Export → Langfuse

📦 1️⃣ 引入依赖

<!-- AI observation模块 -->
		<dependency>
			<groupId>org.springframework.ai</groupId>
			<artifactId>spring-ai-autoconfigure-model-chat-observation</artifactId>
			<version>${spring-ai.version}</version>
		</dependency>

		<dependency>
			<groupId>org.springframework.ai</groupId>
			<artifactId>spring-ai-autoconfigure-model-embedding-observation</artifactId>
			<version>${spring-ai.version}</version>
		</dependency>

		<dependency>
			<groupId>org.springframework.ai</groupId>
			<artifactId>spring-ai-autoconfigure-model-image-observation</artifactId>
			<version>${spring-ai.version}</version>
		</dependency>

		<dependency>
			<groupId>org.springframework.ai</groupId>
			<artifactId>spring-ai-autoconfigure-vector-store-observation</artifactId>
			<version>${spring-ai.version}</version>
		</dependency>
		<dependency>
			<groupId>io.opentelemetry.instrumentation</groupId>
			<artifactId>opentelemetry-spring-boot-starter</artifactId>
			<version>2.9.0</version>
		</dependency>
		<dependency>
			<groupId>io.opentelemetry.semconv</groupId>
			<artifactId>opentelemetry-semconv</artifactId>
			<version>1.25.0-alpha</version>
		</dependency>

		<dependency>
			<groupId>io.micrometer</groupId>
			<artifactId>micrometer-tracing-bridge-otel</artifactId>
			<version>1.3.4</version>
			<exclusions>
				<exclusion>
					<artifactId>slf4j-api</artifactId>
					<groupId>org.slf4j</groupId>
				</exclusion>
				<exclusion>
					<artifactId>opentelemetry-semconv</artifactId>
					<groupId>io.opentelemetry.semconv</groupId>
				</exclusion>
			</exclusions>
		</dependency>

支持聊天模型、图片模型、嵌入模型、向量存储应用的观测!


🔗 2️⃣ 配置 OTel → Langfuse

spring:
  ai:
    # Chat config items
    chat:
      client:
        observations:
          # default value is false.
          log-prompt: true
          log-completion: true
      observations:
        log-prompt: true
        log-completion: true
        include-error-logging: true

    # vector store config items
    vectorstore:
      observations:
        log-query-response: true

    # tools config items
    tools:
      observations:
        # default value is false.
        include-content: true

    image:
      observations:
        log-prompt: true

🔥 四、关键:让 AI “说人话”(记录 input / output)

⚠️ 重点来了: 默认情况下,你什么都看不到


❌ 问题 1:Chat 没 input / output

现象:

  • trace 有
  • LLM 调用了
  • 但 Langfuse 里是空的 😅

🎯 原因:

OTel 默认只记录“调用”,不记录“内容”


✅ 解决方案:

通过实现ObservationFilter过滤器记录:

import io.micrometer.common.KeyValue;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationFilter;
import org.springframework.ai.chat.observation.ChatModelObservationContext;
import org.springframework.ai.content.Content;
import org.springframework.ai.observation.ObservabilityHelper;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;

import java.util.List;
@Component
public class ChatModelCompletionContentObservationFilter implements ObservationFilter {

    @Override
    public Observation.Context map(Observation.Context context) {
        if (!(context instanceof ChatModelObservationContext chatModelObservationContext)) {
            return context;
        }

        var prompts = processPrompts(chatModelObservationContext);
        var completions = processCompletion(chatModelObservationContext);

        chatModelObservationContext.addHighCardinalityKeyValue(new KeyValue() {
            @Override
            public String getKey() {
                return "gen_ai.prompt";
            }

            @Override
            public String getValue() {
                return ObservabilityHelper.concatenateStrings(prompts);
            }
        });

        chatModelObservationContext.addHighCardinalityKeyValue(new KeyValue() {
            @Override
            public String getKey() {
                return "gen_ai.completion";
            }

            @Override
            public String getValue() {
                return ObservabilityHelper.concatenateStrings(completions);
            }
        });

        return chatModelObservationContext;
    }

    private List<String> processPrompts(ChatModelObservationContext chatModelObservationContext) {
        return CollectionUtils.isEmpty((chatModelObservationContext.getRequest()).getInstructions()) ? List.of() : (chatModelObservationContext.getRequest()).getInstructions().stream().map(Content::getText).toList();
    }

    private List<String> processCompletion(ChatModelObservationContext context) {
        if (context.getResponse() != null && (context.getResponse()).getResults() != null && !CollectionUtils.isEmpty((context.getResponse()).getResults())) {
            return !StringUtils.hasText((context.getResponse()).getResult().getOutput().getText()) ? List.of() : (context.getResponse()).getResults().stream().filter((generation) -> generation.getOutput() != null && StringUtils.hasText(generation.getOutput().getText())).map((generation) -> generation.getOutput().getText()).toList();
        } else {
            return List.of();
        }
    }
}

然后你就能看到模型的输入与输出内容了:

image-20260428143007936


❌ 问题 2:Tool 没 input / output(高频坑)

现象:

  • tool 调用了
  • 但参数、返回值全没

🎯 原因:

👉 Tool 没被“包装埋点”


✅ 正确做法(重点)

针对所有的Tool添加input与output

import com.slkj.slkj.common.core.util.json.JsonUtils;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;

public class ObservedToolCallback implements ToolCallback {

    private final ToolCallback delegate;
    private final Tracer tracer;

    public ObservedToolCallback(ToolCallback delegate, Tracer tracer) {
        this.delegate = delegate;
        this.tracer = tracer;
    }

    // 必须透传
    @Override
    public ToolDefinition getToolDefinition() {
        return delegate.getToolDefinition();
    }

    @Override
    public ToolMetadata getToolMetadata() {
        return delegate.getToolMetadata();
    }

    // 默认入口
    @Override
    public String call(String toolInput) {
        return doCall(toolInput, null);
    }

    // 覆盖默认方法(关键!)
    @Override
    public String call(String toolInput, ToolContext toolContext) {
        return doCall(toolInput, toolContext);
    }

    // 统一处理逻辑
    private String doCall(String toolInput, ToolContext toolContext) {

        String toolName = safeToolName();

        Span span = tracer.spanBuilder("tool:" + toolName).startSpan();

        try (Scope scope = span.makeCurrent()) {

            // 👉 Langfuse input
            span.setAttribute("langfuse.observation.input", toJson(toolInput));
            span.setAttribute("langfuse.tool.name", toolName);

            String result;

            if (toolContext != null) {
                result = delegate.call(toolInput, toolContext);
            } else {
                result = delegate.call(toolInput);
            }

            // 👉 Langfuse output
            span.setAttribute("langfuse.observation.output", toJson(result));

            return result;

        } catch (Exception e) {

            // 👉 错误一定要记录
            span.recordException(e);
            span.setStatus(StatusCode.ERROR, e.getMessage());

            throw e;

        } finally {
            span.end();
        }
    }

    private String safeToolName() {
        try {
            return delegate.getToolDefinition().name();
        } catch (Exception e) {
            return delegate.getClass().getSimpleName();
        }
    }

    private String toJson(Object obj) {
        return JsonUtils.toJsonString(obj);
    }
}

👉 如果你用了 Spring:

使用BeanPostProcessor来加载这个封装后的WrappedToolCallbackProvider

import io.opentelemetry.api.trace.Tracer;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.stereotype.Component;

@Component
public class ToolCallbackObservationPostProcessor implements BeanPostProcessor {

    @Autowired
    private Tracer tracer;

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {

        if (bean instanceof ToolCallbackProvider provider) {
            return new WrappedToolCallbackProvider(provider, tracer);
        }

        return bean;
    }
}

然后就能看到输入与输出了:

image-20260428142914989


💣 六、OTel 导出失败常见问题总结

这个你已经踩过很多了,我帮你统一收敛👇


❌ 1. endpoint 写错

/api/public/otel   ✅
/v1/traces         ❌(很多人写这个)

❌ 2. header 没带 key

Langfuse 必须:

Authorization: Basic xxx

❌ 3. 采样率太低

otel:
  traces:
    sampler: always_on

否则你会以为:

“trace 丢了” 其实是: 👉 “根本没采”


❌ 4. 应用关闭没 flush

表现:

  • 本地 OK
  • 停服务 trace 丢

🧠 七、最佳实践(强烈建议)

统一三层埋点:


🧩 1️⃣ Chat 层

记录:

  • prompt
  • completion

🧩 2️⃣ Tool 层

记录:

  • input / output
  • latency

🧩 3️⃣ MCP 层

记录:

  • request / response
  • traceId

🎯 八、最终效果(理想状态)

Langfuse 中你会看到:

User Request
 ├── LLM Call
 │     ├── input
 │     ├── output
 │
 ├── Tool: queryUser
 │     ├── input
 │     ├── output
 │
 └── MCP Service
       ├── request
       ├── response

👉 这时候你已经不是在“调 AI”了,而是在:

🔍 调试一个可解释系统


🚀 总结

如果只记一句话:

✅ Spring AI 让 AI 跑起来 ✅ Langfuse 让 AI 被看懂 ✅ OpenTelemetry 让一切串起来