做过 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
🔑 初始化
第一次进入:
- 创建账号
- 创建 Project
- 获取:
- 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();
}
}
}
然后你就能看到模型的输入与输出内容了:
❌ 问题 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;
}
}
然后就能看到输入与输出了:
💣 六、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 让一切串起来