AgentScope 2.0 × Langfuse:Agent 全链路追踪的一次讲清
前言
上一篇 [《AgentScope 2.0 工具加载机制:Skills、MCP 与自定义 Tools 的一次讲清》](file:///c:/me/kaiyuan/agenttrace/agentscope2.0demo/docs/博客-AgentScope工具加载机制.md) 解决了「让 Agent 用上工具」的问题,让 demo 跑了起来。但「能跑」只是第一步——当一个 ReAct 循环里串了多轮模型调用、若干工具执行、MCP 远程请求时,一旦回复不符合预期,光靠日志几乎无法定位问题出在哪一环。

这正是 可观测性(Observability) 要解决的事。本文以 [agentscope2.0demo](file:///c:/me/kaiyuan/agenttrace/agentscope2.0demo) 项目为示例,讲清楚如何用 OpenTelemetry + Langfuse 把 Agent 的完整执行流程可视化,涵盖:依赖与配置、SDK 初始化、Span 生成、属性增强、异步上下文传播、端到端验证,以及背后的关键设计决策。
一、为什么是 Langfuse + OpenTelemetry
1.1 Langfuse 是什么
Langfuse 是一个开源的 LLM 应用可观测平台,专注于 Agent / LLM 场景,提供:
- Trace 可视化:以树状结构展示一次 Agent 调用的全部子步骤(模型调用、工具执行);
- Session / User 维度统计:按会话、用户聚合 trace,便于排查多轮对话;
- 成本与 Token 分析:自动统计 input/output token,核算调用成本;
- Prompt 管理:回溯每轮模型调用的完整输入输出。
1.2 为什么用 OpenTelemetry 协议而不是 Langfuse SDK
Langfuse 官方支持通过 OTLP(OpenTelemetry Protocol) 接收 trace 数据,并按 GenAI 语义约定 自动映射成 Langfuse 的 Trace / Observation 模型。选择 OTLP 而非 Langfuse 原生 SDK 的理由:
| 维度 | OTLP 协议 | Langfuse SDK |
|---|---|---|
| 供应商锁定 | 无,可随时切换到 Jaeger/Tempo 等 | 强绑定 Langfuse |
| AgentScope 集成 | 内置 OtelTracingMiddleware 直接复用 | 需自己写打点 |
| 协议标准 | CNCF 标准 | 私有 |
| 传输方式 | HTTP/protobuf(Langfuse 支持) | HTTP/JSON |
简言之:用标准协议打点,用 Langfuse 展示。将来即使换后端,打点代码零改动。
1.3 集成目标
本次集成定下几个硬约束(详见 [design.md](file:///c:/me/kaiyuan/agenttrace/agentscope2.0demo/openspec/changes/integrate-langfuse-tracing/design.md)):
- 最小侵入:业务代码只加一行 middleware 注入;
- 配置驱动:开关、端点、鉴权全部走
application.yaml+ 环境变量; - 默认关闭:Langfuse 未启用时零开销自动降级;
- 可 JAR 化:结构上支持将来抽成独立
langfuse-tracing.jar,其他项目加依赖即可接入。
二、整体架构
整条追踪链路可以这样概括:
ReActAgent 推理循环
│
├─ OtelTracingMiddleware ① 生成 Span 骨架(invoke_agent / chat / execute_tool)
│ │
│ └─ 设置 gen_ai.* 语义约定属性(模型名、token 用量等)
│
└─ LangfuseEnrichmentMiddleware ② 补充 Langfuse 专有属性(input/output、sessionId/userId)
│
▼
OpenTelemetry SDK(TracerProvider + SpanProcessor)
│
▼
OtlpHttpSpanExporter(HTTP/protobuf + Basic Auth)
│
▼
Langfuse OTLP 端点(http://localhost:3000/api/public/otel)
│
▼
Langfuse UI 渲染 Trace / Observation 树
这里有一个关键设计:两个 Middleware 协作,职责分离。
OtelTracingMiddleware是 AgentScope 2.0 内置的,负责创建 Span 并设置 GenAI 标准属性——但它不感知 Langfuse;LangfuseEnrichmentMiddleware是本项目自己写的,负责补齐 Langfuse 映射所需的属性——它不创建 Span,只在已有 Span 上追加属性。
为什么要拆成两个?因为内置中间件设置的 gen_ai.* 属性,Langfuse 并不会自动当成 input/output 显示(映射规则不同,详见 第六章)。拆开后,标准属性走内置,Langfuse 专有属性走增强,互不干扰。
三、依赖与配置
3.1 Maven 依赖
在 [pom.xml](file:///c:/me/kaiyuan/agenttrace/agentscope2.0demo/pom.xml) 中引入 OpenTelemetry BOM 统一版本,仅添加 4 个 artifact:
<properties>
<opentelemetry.version>1.61.0</opentelemetry.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-bom</artifactId>
<version>${opentelemetry.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- OpenTelemetry API(OtelTracingMiddleware 编译/运行所需) -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
</dependency>
<!-- OpenTelemetry SDK(TracerProvider 实现) -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-sdk</artifactId>
</dependency>
<!-- OTLP HTTP Exporter(对接 Langfuse) -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
<!-- 语义约定(GenAI 属性映射) -->
<dependency>
<groupId>io.opentelemetry.semconv</groupId>
<artifactId>opentelemetry-semconv</artifactId>
<version>1.27.0-alpha</version>
</dependency>
</dependencies>
四个依赖的分工:
| Artifact | 作用 | 缺失会怎样 |
|---|---|---|
opentelemetry-api | 提供 Span、Context 等 API | 编译不过 |
opentelemetry-sdk | 提供 SdkTracerProvider 等实现 | 运行时 OtelTracingMiddleware 自动降级为 no-op |
opentelemetry-exporter-otlp | 把 Span 通过 OTLP HTTP 发出去 | Span 生成但不导出 |
opentelemetry-semconv | GenAI 语义约定常量 | 属性名硬编码,不影响运行 |
用 BOM 而非
opentelemetry-spring-boot-starter,是为了避免 starter 引入大量自动装配逻辑,保持「最小依赖闭环」。
3.2 application.yaml 配置
在 [application.yaml](file:///c:/me/kaiyuan/agenttrace/agentscope2.0demo/src/main/resources/application.yaml) 中新增 langfuse 命名空间:
langfuse:
enabled: true # 总开关
endpoint: ${LANGFUSE_ENDPOINT:http://localhost:3000/api/public/otel} # OTLP 端点
public-key: ${LANGFUSE_PUBLIC_KEY:} # 鉴权公钥
secret-key: ${LANGFUSE_SECRET_KEY:} # 鉴权私钥
几个要点:
enabled是总开关:设为false时整个TracingConfig不装配(靠@ConditionalOnProperty),SDK 不初始化,OtelTracingMiddleware自动降级为 no-op,零开销;- 所有敏感字段都支持环境变量覆盖:
${ENV:default}语法让本地默认值与生产环境变量无缝切换; - 端点固定为
/api/public/otel:这是 Langfuse 接收 OTLP 数据的入口,代码里还会拼接/v1/traces。
四、OpenTelemetry SDK 初始化(TracingConfig)
[TracingConfig.java](file:///c:/me/kaiyuan/agenttrace/agentscope2.0demo/src/main/java/com/conca/chat/config/TracingConfig.java) 是整个追踪链路的起点,负责把 OpenTelemetry SDK 装配到 Spring 容器:
@Configuration
@ConditionalOnProperty(name = "langfuse.enabled", havingValue = "true", matchIfMissing = false)
public class TracingConfig {
@Value("${langfuse.endpoint:http://localhost:3000/api/public/otel}")
private String langfuseEndpoint;
@Value("${langfuse.public-key:}")
private String publicKey;
@Value("${langfuse.secret-key:}")
private String secretKey;
@Bean
public OpenTelemetry openTelemetry() {
// 1. 构建 Basic Auth Header
Map<String, String> headers = new HashMap<>();
if (publicKey != null && !publicKey.isBlank()
&& secretKey != null && !secretKey.isBlank()) {
String auth = Base64.getEncoder()
.encodeToString((publicKey + ":" + secretKey).getBytes());
headers.put("Authorization", "Basic " + auth);
}
headers.put("x-langfuse-ingestion-version", "4"); // 启用实时摄取
// 2. 构建 OTLP HTTP Exporter
String traceEndpoint = langfuseEndpoint + "/v1/traces";
OtlpHttpSpanExporter spanExporter = OtlpHttpSpanExporter.builder()
.setEndpoint(traceEndpoint)
.setHeaders(() -> headers)
.build();
// 3. 构建 TracerProvider(SimpleSpanProcessor 同步导出)
this.tracerProvider = SdkTracerProvider.builder()
.addSpanProcessor(SimpleSpanProcessor.create(spanExporter))
.build();
// 4. 注册全局 OpenTelemetry 实例
return OpenTelemetrySdk.builder()
.setTracerProvider(tracerProvider)
.buildAndRegisterGlobal();
}
@PreDestroy
public void shutdown() {
if (tracerProvider != null) {
tracerProvider.close(); // 关闭时 flush 残留 Span
}
}
}
4.1 逐段拆解
① Basic Auth 鉴权
Langfuse 的 OTLP 端点要求 Basic Auth,格式是 Base64(publicKey:secretKey)。这两个 key 在 Langfuse 项目设置页生成。这里手动构建 Header 而非用环境变量 OTEL_EXPORTER_OTLP_HEADERS,是为了更显式、更易调试。
额外加的 x-langfuse-ingestion-version: 4 头会启用 Langfuse 的实时摄取,trace 数据一发送就能在 UI 看到,不用等批处理。
② OTLP HTTP Exporter
Langfuse 只支持 OTLP over HTTP(不支持 gRPC),所以用 OtlpHttpSpanExporter。端点拼成 .../api/public/otel/v1/traces,这是 OTLP 标准 trace 路径。默认协议是 http/protobuf,比 JSON 体积更小。
③ SimpleSpanProcessor
SpanProcessor 有两种:
| Processor | 行为 | 适用场景 |
|---|---|---|
SimpleSpanProcessor | Span 结束后同步、立即导出 | 开发调试、低吞吐 |
BatchSpanProcessor | 攒批异步导出 | 生产高吞吐 |
demo 用 SimpleSpanProcessor 是为了调试时能在 Langfuse 即时看到 trace;生产环境建议换 BatchSpanProcessor 提升性能。
④ buildAndRegisterGlobal()
注册全局 OpenTelemetry 实例后,OtelTracingMiddleware 内部用 GlobalOpenTelemetry.get() 就能拿到它。这就是「业务代码不感知 SDK 初始化」的关键。
⑤ @PreDestroy flush
应用关闭时调用 tracerProvider.close(),把内存里还没导出的 Span 强制 flush 出去,避免丢失尾部 trace。
4.2 降级机制
@ConditionalOnProperty(name = "langfuse.enabled", havingValue = "true", matchIfMissing = false) 是降级开关:
enabled=false(或未配置)→TracingConfig整个 Bean 不创建 → 全局OpenTelemetry未注册 →OtelTracingMiddleware内部检测到无 SDK,直接短路到next.apply(input),零开销。
这意味着把 enabled 设为 false 后,代码里残留的 .middleware(new OtelTracingMiddleware()) 也完全无害。
五、AgentScope 内置 OtelTracingMiddleware
5.1 它做了什么
OtelTracingMiddleware(io.agentscope.core.tracing)是 AgentScope 2.0 内置的,实现 MiddlewareBase 接口,在三个生命周期位置自动创建 Span:
| Hook | 触发时机 | 创建的 Span | 设置的标准属性 |
|---|---|---|---|
onAgent | Agent 一次完整调用(call/streamEvents) | invoke_agent <name> | gen_ai.agent.name、agentscope.agent.reply_id |
onModelCall | 每次 LLM API 调用 | chat <model> | gen_ai.request.model、gen_ai.usage.input_tokens、gen_ai.usage.output_tokens |
onActing | 每次工具执行(含 MCP) | execute_tool <name> | gen_ai.tool.name、gen_ai.tool.call.id |
生成的 Span 树结构:
invoke_agent chat-agent ← Trace 根(onAgent)
├── chat qwen/qwen3.7-max ← 第一轮模型调用(onModelCall)
├── execute_tool get_current_datetime ← 工具执行(onActing)
├── chat qwen/qwen3.7-max ← 第二轮模型调用(基于工具结果再推理)
└── (循环直到模型不再调工具或达 maxIters)
这个层级结构完美匹配 ReAct 循环的「思考 → 行动 → 观察 → 再思考」语义。
5.2 它的局限
OtelTracingMiddleware 设置的是 GenAI 语义约定属性(gen_ai.*),但 Langfuse 的 input/output 映射规则与之不完全一致:
| Langfuse 字段 | 期望的 OTel 属性 | OtelTracingMiddleware 设置了吗 |
|---|---|---|
| Trace.input | langfuse.trace.input | ✗ |
| Trace.output | langfuse.trace.output | ✗ |
| Trace.sessionId | langfuse.session.id | ✗ |
| Trace.userId | langfuse.user.id | ✗ |
| Observation.input | langfuse.observation.input 或 gen_ai.prompt | ✗(只设了 token 数) |
| Observation.output | langfuse.observation.output 或 gen_ai.completion | ✗ |
结果就是:直接用内置中间件,Langfuse UI 里 trace 结构能出来,但 input/output/sessionId/userId 全是空的——只能看到一堆空壳 span,看不到实际对话内容。
这就是下一章要解决的问题。
六、LangfuseEnrichmentMiddleware:补齐 Langfuse 属性
[LangfuseEnrichmentMiddleware.java](file:///c:/me/kaiyuan/agenttrace/agentscope2.0demo/src/main/java/com/conca/chat/config/LangfuseEnrichmentMiddleware.java) 是本项目自己写的中间件,专门补齐 Langfuse 映射所需的属性。它不创建 Span,只在前一个中间件(OtelTracingMiddleware)已创建的 Span 上追加属性。
6.1 三个 Hook 各补什么
onAgent(Trace 根)
public Flux<AgentEvent> onAgent(Agent agent, RuntimeContext ctx,
AgentInput input, Function<AgentInput, Flux<AgentEvent>> next) {
return Flux.deferContextual(ctxView -> {
Span span = resolveSpan(ctxView);
// 1. 会话/用户标识(缺陷 2)
if (ctx.getSessionId() != null) {
span.setAttribute("langfuse.session.id", ctx.getSessionId());
}
if (ctx.getUserId() != null) {
span.setAttribute("langfuse.user.id", ctx.getUserId());
}
// 2. trace input(用户原始消息)
span.setAttribute("langfuse.trace.input", serializeMessages(input.msgs()));
// 3. trace name
span.setAttribute("langfuse.trace.name",
agent.getName() + " - " + ctx.getSessionId());
// 4. 捕获 trace output(Agent 最终回复)
AtomicReference<String> traceOutput = new AtomicReference<>();
return next.apply(input)
.doOnNext(event -> {
if (event instanceof AgentResultEvent are) {
traceOutput.set(serializeMsg(are.getResult()));
}
})
.doOnComplete(() -> {
if (traceOutput.get() != null) {
span.setAttribute("langfuse.trace.output", traceOutput.get());
}
});
});
}
补充的属性:
| 属性 | 来源 | 作用 |
|---|---|---|
langfuse.session.id | ctx.getSessionId() | Langfuse 按会话分组 |
langfuse.user.id | ctx.getUserId() | Langfuse 按用户统计 |
langfuse.trace.input | input.msgs() 序列化 | Trace 输入(用户提问) |
langfuse.trace.output | AgentResultEvent.getResult() | Trace 输出(最终回复) |
langfuse.trace.name | agentName + sessionId | Trace 显示名 |
onModelCall(Generation)
span.setAttribute("langfuse.observation.type", "generation");
span.setAttribute("langfuse.observation.model.name", input.model().getModelName());
span.setAttribute("langfuse.observation.input", serializeMessages(input.messages()));
span.setAttribute("langfuse.observation.tools", serializeToolSchemas(input.tools()));
// 通过 doOnNext 捕获 TextBlockDeltaEvent 拼接模型输出
span.setAttribute("langfuse.observation.output", modelOutput.toString());
type=generation 让 Langfuse 把这个 Observation 当成「模型调用」渲染,显示模型名、prompt、completion、token 用量。额外补的 langfuse.observation.tools 把本次提交给模型的所有工具定义(含 MCP 工具 schema)序列化进去,便于排查「模型为什么没调用某工具」。
onActing(Span)
span.setAttribute("langfuse.observation.type", "span");
span.setAttribute("langfuse.observation.input", serializeToolCalls(input.toolCalls()));
// 通过 doOnNext 捕获 ToolResultTextDeltaEvent 拼接工具结果
span.setAttribute("langfuse.observation.output", toolOutput.toString());
type=span 标记为工具执行,input 是工具调用参数,output 是工具返回结果。
6.2 input/output 的异步捕获
注意上面三个 hook 都用了同一个模式:input 在进入 hook 时同步设置,output 通过 doOnNext/doOnComplete 异步捕获。
这是因为 input 在调用前就能拿到,而 output 要等下游(LLM 返回 / 工具执行完)才有。Reactor 的 doOnNext 会在每个事件流过时回调,doOnComplete 在流结束时回调——正好用来收集最终结果。
AtomicReference 用来在回调里累积结果,避免并发问题。
6.3 注册顺序很重要
在 [AgentConfig.java](file:///c:/me/kaiyuan/agenttrace/agentscope2.0demo/src/main/java/com/conca/chat/config/AgentConfig.java#L82-L83) 中,两个中间件按顺序注册:
return ReActAgent.builder()
.name("chat-agent")
// ...其他配置...
.middleware(new OtelTracingMiddleware()) // ① 先创建 Span
.middleware(new LangfuseEnrichmentMiddleware()) // ② 再补充属性
.build();
顺序不能反。Middleware 是洋葱模型,先注册的包在外层。OtelTracingMiddleware 在外层创建 Span 并推入 OTel Context,LangfuseEnrichmentMiddleware 在内层才能从 Context 里取到这个 Span 往上补属性。如果反了,增强中间件拿不到 Span,所有 setAttribute 都是 no-op。
七、Reactor Context 与 Span 的异步传播
这一章是整个集成里最容易踩坑的部分。
7.1 问题:Span.current() 在异步线程失效
AgentScope 的推理循环是基于 Reactor 的(Flux<AgentEvent>),中间会有线程切换(比如 LLM 调用、工具执行在别的线程)。OpenTelemetry 的 Span.current() 依赖 ThreadLocal 存储当前 Span——线程一切换,ThreadLocal 就丢了。
最初的设计稿(见 [design.md](file:///c:/me/kaiyuan/agenttrace/agentscope2.0demo/openspec/changes/integrate-langfuse-tracing/design.md) 第 159 行)写的是 Span span = Span.current(),实际跑起来发现增强中间件拿到的全是 invalid span(spanId 全 0),属性全设空。
7.2 解决:从 Reactor Context 取 Span
Reactor 提供了 ContextView,它能在异步线程切换间传播上下文。OpenTelemetry 的 reactor instrumentation 提供了 ContextPropagationOperator,会把 OTel Context 注入 Reactor Context。
所以正确做法是:
private Span resolveSpan(reactor.util.context.ContextView ctxView) {
Context otelCtx = ContextPropagationOperator
.getOpenTelemetryContextFromContextView(ctxView, Context.current());
return Span.fromContext(otelCtx);
}
并且在每个 hook 里用 Flux.deferContextual 包裹,确保能拿到 ctxView:
public Flux<AgentEvent> onAgent(Agent agent, RuntimeContext ctx,
AgentInput input, Function<AgentInput, Flux<AgentEvent>> next) {
return Flux.deferContextual(ctxView -> {
Span span = resolveSpan(ctxView); // ← 从 Reactor Context 取,而非 Span.current()
// ...后续操作...
return next.apply(input).doOnNext(...).doOnComplete(...);
});
}
7.3 为什么 OtelTracingMiddleware 不需要这么写
因为 OtelTracingMiddleware 是创建 Span 的一方,它用 tracer.spanBuilder(name).startSpan() 创建后,通过 Context.current().with(span) 把 Span 推入 Context,再交给 reactor-netty 的 instrumentation 传播。而增强中间件是读取已存在的 Span,必须用同样的传播机制取回来。
这也是为什么 [LangfuseEnrichmentMiddleware.java](file:///c:/me/kaiyuan/agenttrace/agentscope2.0demo/src/main/java/com/conca/chat/config/LangfuseEnrichmentMiddleware.java) 顶部注释特意强调:「使用 ContextPropagationOperator 而非 Span.current()」。
八、消息序列化与内容提取
Langfuse 的 input/output 字段原生支持 JSON,所以要把 AgentScope 的 Msg、ToolUseBlock、ToolResultBlock 等对象序列化成 JSON 字符串。
8.1 简化 DTO
直接序列化原始对象会包含大量无关字段(甚至循环引用),所以定义了三个轻量 record:
private record SimpleMessage(String role, String content) {}
private record SimpleToolCall(String name, Object input) {}
private record SimpleToolResult(String name, String output) {}
序列化后形如:
[
{"role":"USER","content":"现在几点了?"}
]
8.2 ASSISTANT 消息的内容拼接
这是个隐蔽的坑。ASSISTANT 消息可能同时包含文本和工具调用(模型一边说话一边调工具),但 msg.getTextContent() 只返回文本,ToolUseBlock 要单独取。如果只取文本,Langfuse 里就看不到模型调了什么工具。
extractContent 方法做了分支处理:
private String extractContent(Msg msg) {
String text = msg.getTextContent();
List<ToolUseBlock> toolUses = msg.getContentBlocks(ToolUseBlock.class);
// 文本 + tool_calls 同时存在:拼接
if (text != null && !text.isEmpty() && !toolUses.isEmpty()) {
return text + "\n" + serializeToolUses(toolUses);
}
// 仅文本
if (text != null && !text.isEmpty()) {
return text;
}
// 仅 tool_calls(ASSISTANT 纯工具调用)
if (!toolUses.isEmpty()) {
return serializeToolUses(toolUses);
}
// TOOL 结果消息:取 ToolResultBlock
List<ToolResultBlock> toolResults = msg.getContentBlocks(ToolResultBlock.class);
if (!toolResults.isEmpty()) {
return "tool_results: " + objectMapper.writeValueAsString(...);
}
return null;
}
serializeToolUses 输出形如:
现在为您查询时间
tool_calls: [{"name":"get_current_datetime","input":{}}]
这样 Langfuse 里一条 ASSISTANT 消息就能同时看到「说了什么」和「调了什么」。
8.3 null 安全
所有序列化方法都包了 try-catch 返回 null,所有 setAttribute 前都判空。因为 Langfuse 对 null 属性会直接忽略,不会报错——但序列化异常会让整个推理流中断,这是绝对要避免的。
九、装配与启动
9.1 Middleware 注入
回到 [AgentConfig.java](file:///c:/me/kaiyuan/agenttrace/agentscope2.0demo/src/main/java/com/conca/chat/config/AgentConfig.java),业务代码的改动只有 builder 链里两行:
.middleware(new OtelTracingMiddleware()) // 新增
.middleware(new LangfuseEnrichmentMiddleware()) // 新增
其余业务代码(Controller、SessionManager、CustomTools)零修改。这符合「最小侵入」约束。
9.2 sessionId / userId 的来源
LangfuseEnrichmentMiddleware 从 RuntimeContext 取 sessionId/userId。那 RuntimeContext 是怎么来的?看 [ChatController.java](file:///c:/me/kaiyuan/agenttrace/agentscope2.0demo/src/main/java/com/conca/chat/controller/ChatController.java#L50):
RuntimeContext context = sessionManager.createContext(sessionId);
Msg response = agent.call(List.of(userMsg), context).block();
[SessionManager.createContext](file:///c:/me/kaiyuan/agenttrace/agentscope2.0demo/src/main/java/com/conca/chat/session/SessionManager.java#L27-L31):
public RuntimeContext createContext(String sessionId) {
return RuntimeContext.builder()
.sessionId(sessionId)
.build();
}
sessionId 由 HTTP 请求带入(没传就 UUID.randomUUID() 生成),userId 当前 demo 没设置(为 null,增强中间件会跳过)。要接入用户体系,只需在 createContext 时加 .userId(...)。
9.3 启动流程
Langfuse 实例需提前跑起来(默认 http://localhost:3000),并在项目设置页拿到 public-key / secret-key。然后设置环境变量启动应用:
# 设置鉴权(key 从 Langfuse 项目设置页复制)
$env:LANGFUSE_PUBLIC_KEY="pk-lf-xxx"
$env:LANGFUSE_SECRET_KEY="sk-lf-xxx"
# 设置 OpenRouter Key(LLM 调用必需)
$env:OPENROUTER_API_KEY="sk-or-v1-xxx"
# 临时启用 Langfuse(不改 application.yaml)
cd c:\me\kaiyuan\agenttrace\agentscope2.0demo
mvn spring-boot:run "-Dspring-boot.run.arguments=--langfuse.enabled=true"
启动日志里会看到:
Initializing OpenTelemetry SDK with Langfuse endpoint: http://localhost:3000/api/public/otel
OpenTelemetry SDK initialized successfully
十、Trace 结构与端到端验证
10.1 发起一次调用(MCP 工具实战)
下面是一次真实抓取的端到端测试:让 Agent 通过 MCP 工具 list_files 列出当前目录下的文件。
PowerShell 请求:
PS C:\Users\conca> Invoke-RestMethod -Uri http://localhost:8080/api/chat `
>> -Method Post `
>> -Headers @{"Content-Type"="application/json; charset=utf-8"} `
>> -Body ([System.Text.Encoding]::UTF8.GetBytes('{"message":"请列出当前目录下的文件"}'))
sessionId reply
--------- -----
ea72d28a-7bc5-41fa-a354-9a6cd92c6f5e 当前系统为 Windows 环境,我列出了 `C:\` 根目录的内容...
注意:PowerShell 5.x 终端里
reply可能显示成å½åç³»ç»ä¸º Windows...之类的乱码,这是控制台按 GBK 解析 UTF-8 响应体导致的显示问题,不是数据问题——在 Langfuse 里能看到正确的中文。彻底解决需切换到 PowerShell 7 或按 [上一篇](file:///c:/me/kaiyuan/agenttrace/agentscope2.0demo/docs/博客-AgentScope工具加载机制.md) 8.3 节强制 UTF-8。
返回的 sessionId(ea72d28a-7bc5-41fa-a354-9a6cd92c6f5e)就是 Langfuse Trace 的会话标识,可以直接拿去 Langfuse 搜索。
10.2 在 Langfuse 看到什么
打开 http://localhost:3000,在 Traces 页面搜索上面的 sessionId,定位到 trace:
chat-agent - ea72d28a-7bc5-41fa-a354-9a6cd92c6f5e 总耗时 29.68s
│
└── invoke_agent chat-agent 29.68s [Trace 根]
│
├── chat qwen/qwen3.7-max 15.05s [Generation]
│ tokens: 898 → 526 (∑ 1,424)
│ ① 第一轮:模型决定调用工具
│
├── list_files 4.97s [Span]
│ ② MCP 工具执行(file-list-server)
│
└── chat qwen/qwen3.7-max 9.29s [Generation]
tokens: 2,006 → 351 (∑ 2,357)
③ 第二轮:基于工具结果生成最终回复
这条 trace 完整呈现了 ReAct 的「思考 → 行动 → 观察 → 再思考」三段式:
| 步骤 | Span | 耗时 | Token(in→out) | 作用 |
|---|---|---|---|---|
| ① 思考 | chat qwen3.7-max | 15.05s | 898 → 526 | 模型读 system prompt + 用户问题,决定调用 list_files |
| ② 行动 | list_files | 4.97s | — | 通过 MCP 协议调用 file-list-server 列目录 |
| ③ 再思考 | chat qwen3.7-max | 9.29s | 2,006 → 351 | 把工具结果拼进上下文,生成格式化的 Markdown 回复 |
注意第二轮的 input token(2,006)远大于第一轮(898)——因为第二轮把完整的工具结果(C:\ 下 52 项的列表)也塞进了 prompt。这正是 ReAct 循环的上下文累积特征,在 Langfuse 里一目了然。
10.3 实战解析:第二轮 Generation 的细节
点开第二轮 chat qwen/qwen3.7-max,能看到 Langfuse 把这次模型调用的全貌都记录了下来。
Input(4 条消息,完整对话历史):
[
{
"role": "SYSTEM",
"content": "你是一个有用的 AI 助手。你可以使用工具来帮助用户解决问题。请用中文回复用户。\n## Available Skills\n<usage> Skills provide specialized capabilities..."
},
{
"role": "USER",
"content": "请列出当前目录下的文件"
},
{
"role": "ASSISTANT",
"content": "我来为您列出当前目录下的文件。由于工具需要绝对路径,让我先尝试获取当前目录的信息。\n\ntool_calls: [{\"name\":\"list_files\",\"input\":{\"path\":\"/\"}}]"
},
{
"role": "TOOL",
"content": "tool_results: [{\"name\":\"list_files\",\"output\":\"目录: C:\\\\ 共 52 项(目录 47,文件 5):\\n [目录] $Recycle.Bin/\\n [目录] Program Files/\\n ...\"}]"
}
]
- ASSISTANT 消息同时包含文本和 tool_calls:第 2 条 ASSISTANT 既有「我来为您列出…」的文本,又有
tool_calls: [...]的工具调用——这正是extractContent()做拼接的效果,没有它 Langfuse 里就看不到模型调了list_files; - TOOL 消息保留了工具结果:第 3 条 TOOL 的 content 是
tool_results: [{...}],来自ToolResultBlock的序列化,能看到list_files返回的完整目录列表。
Output(模型最终回复):
当前系统为 Windows 环境,我列出了 `C:\` 根目录的内容,共 **52 项**(47 个目录,5 个文件):
### 📁 目录(47 个)
包括 `$Recycle.Bin`、`Program Files`、`Program Files (x86)`、`Users`、`Windows` 等系统目录...
### 📄 文件(5 个)
| 文件名 | 大小 | 类型 |
|--------|------|------|
| DumpStack.log | 8.0 KB | .log |
| pagefile.sys | 16.87 GB | .sys |
...
模型把原始的工具输出(纯文本目录列表)重新组织成了带 emoji、表格的 Markdown——这种「格式化加工」正是第二轮 generation 耗时 9.29s、输出 351 token 的原因。
Metadata(Span 属性全集):
{
"attributes": {
"gen_ai.request.tools.count": "3",
"langfuse.observation.type": "generation",
"gen_ai.operation.name": "chat",
"gen_ai.usage.input_tokens": "2006",
"gen_ai.usage.output_tokens": "351",
"gen_ai.request.messages.count": "4",
"langfuse.observation.tools": [
{ "name": "reset_equipped_tools", "description": "Reset your equipped tools..." },
{ "name": "list_files", "description": "列出给定路径下的文件与目录..." },
{ "name": "get_current_datetime", "description": "获取当前日期和时间" }
],
"langfuse.observation.model.name": "qwen/qwen3.7-max",
"gen_ai.request.model": "qwen/qwen3.7-max"
},
"resourceAttributes": {
"service.name": "unknown_service:java",
"telemetry.sdk.language": "java",
"telemetry.sdk.name": "opentelemetry",
"telemetry.sdk.version": "1.61.0"
},
"scope": { "name": "io.agentscope" }
}
属性里有两类来源,正好对应两个中间件的分工:
| 属性前缀 | 来源中间件 | 含义 |
|---|---|---|
gen_ai.* | OtelTracingMiddleware(内置) | GenAI 语义约定:模型名、token 用量、消息数、工具数 |
langfuse.* | LangfuseEnrichmentMiddleware(自写) | Langfuse 专有:observation 类型、工具定义、模型名映射 |
特别值得关注 langfuse.observation.tools:它把本次提交给模型的 3 个工具定义(reset_equipped_tools、list_files、get_current_datetime)全部序列化进去了。排查「模型为什么没调用某工具」时,这里是第一现场——能看到工具的 description、parameters schema 是否合理。
resourceAttributes 里的 telemetry.sdk.version: 1.61.0 对应 [pom.xml](file:///c:/me/kaiyuan/agenttrace/agentscope2.0demo/pom.xml) 里的 OpenTelemetry 版本,scope.name: io.agentscope 说明 Span 由 AgentScope 框架生成。
10.4 验证矩阵
通过调整请求 message,可以验证不同场景的 trace 是否完整:
| 测试 message | 触发链路 | 期望 Trace 结构 |
|---|---|---|
现在几点了? | get_current_datetime(@Tool) | 2 轮 generation + 1 个 execute_tool |
请列出当前目录下的文件 | file-list-server(MCP) | 2 轮 generation + 1 个 execute_tool(MCP 工具) |
加载 demo-skill 技能 | load_skill_through_path(内置工具) | 2 轮 generation + 1 个 execute_tool |
| 多轮对话(带 sessionId) | 同 sessionId 串联 | Langfuse Session 视图聚合多条 trace |
重点验证:MCP 工具调用是否被 onActing 覆盖。答案是肯定的——onActing 包裹所有工具执行(本地 @Tool、MCP 远程工具、内置 meta tool),因为它们都走 Toolkit.callTools() 统一通道。
十一、设计要点小结
回顾整个集成,有几个关键设计决策值得品味:
- 标准协议优先:用 OTLP 而非 Langfuse SDK,换后端零改动;
- 两个 Middleware 职责分离:内置的管 Span 骨架 + GenAI 标准属性,自写的管 Langfuse 专有属性,互不污染;
- 配置驱动 + 默认关闭:
@ConditionalOnProperty让 Langfuse 未启用时零开销降级,业务代码无需 if-else; - Reactor Context 而非 ThreadLocal:异步场景下
Span.current()会失效,必须用ContextPropagationOperator从 Reactor Context 取 Span; - input/output 的异步捕获:用
doOnNext/doOnComplete+AtomicReference在事件流结束时分批回填属性; - 消息内容的完整提取:ASSISTANT 消息要同时取 text + tool_calls,TOOL 消息要取 ToolResultBlock,否则 Langfuse 里会丢内容;
- 最小侵入:业务代码只加两行
.middleware(...),Controller/SessionManager/Tools 零改动,为将来抽成独立 JAR 铺路。
11.1 已知局限与未来方向
当前实现还有几个可改进点:
SimpleSpanProcessor同步导出:高并发下会阻塞推理线程,生产环境应换BatchSpanProcessor;- 未做 Trace Context 跨服务传播:如果 Agent 调用的下游服务也接了 OTel,目前 trace 不会自动串联(需要注入 W3C Trace Context Header);
- userId 未接入:demo 没有用户体系,
RuntimeContext.userId恒为 null; - 未抽成独立 JAR:
TracingConfig+LangfuseEnrichmentMiddleware目前还在业务项目里,将来可封装成langfuse-tracing-spring-boot-starter,配合AutoConfiguration.imports实现加依赖即接入。
结语
Agent 的可观测性,本质上是把「黑盒的 ReAct 循环」拆成「可追溯的步骤序列」。本文展示的方案用三层抽象实现了这件事:
- AgentScope
OtelTracingMiddleware负责打点骨架; LangfuseEnrichmentMiddleware负责补齐 Langfuse 需要的血肉(input/output/session/user);- OpenTelemetry SDK + OTLP 负责把数据送出去。
三者各司其职,业务代码几乎无感。理解了这条链路,后续无论是给其他 Agent 项目接入 Langfuse,还是迁移到 Jaeger/Tempo 等其他 OTel 后端,都能有的放矢。
本系列的上一篇是 [《AgentScope 2.0 工具加载机制》](file:///c:/me/kaiyuan/agenttrace/agentscope2.0demo/docs/博客-AgentScope工具加载机制.md),聚焦「能跑起来」;本文聚焦「看得见」。两者合起来,就是一个可调试、可监控的 Agent demo 雏形。
更多推荐



所有评论(0)