前言

上一篇 [《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)):

  1. 最小侵入:业务代码只加一行 middleware 注入;
  2. 配置驱动:开关、端点、鉴权全部走 application.yaml + 环境变量;
  3. 默认关闭:Langfuse 未启用时零开销自动降级;
  4. 可 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提供 SpanContext 等 API编译不过
opentelemetry-sdk提供 SdkTracerProvider 等实现运行时 OtelTracingMiddleware 自动降级为 no-op
opentelemetry-exporter-otlp把 Span 通过 OTLP HTTP 发出去Span 生成但不导出
opentelemetry-semconvGenAI 语义约定常量属性名硬编码,不影响运行

用 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行为适用场景
SimpleSpanProcessorSpan 结束后同步、立即导出开发调试、低吞吐
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 它做了什么

OtelTracingMiddlewareio.agentscope.core.tracing)是 AgentScope 2.0 内置的,实现 MiddlewareBase 接口,在三个生命周期位置自动创建 Span:

Hook触发时机创建的 Span设置的标准属性
onAgentAgent 一次完整调用(call/streamEventsinvoke_agent <name>gen_ai.agent.nameagentscope.agent.reply_id
onModelCall每次 LLM API 调用chat <model>gen_ai.request.modelgen_ai.usage.input_tokensgen_ai.usage.output_tokens
onActing每次工具执行(含 MCP)execute_tool <name>gen_ai.tool.namegen_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.inputlangfuse.trace.input
Trace.outputlangfuse.trace.output
Trace.sessionIdlangfuse.session.id
Trace.userIdlangfuse.user.id
Observation.inputlangfuse.observation.inputgen_ai.prompt✗(只设了 token 数)
Observation.outputlangfuse.observation.outputgen_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.idctx.getSessionId()Langfuse 按会话分组
langfuse.user.idctx.getUserId()Langfuse 按用户统计
langfuse.trace.inputinput.msgs() 序列化Trace 输入(用户提问)
langfuse.trace.outputAgentResultEvent.getResult()Trace 输出(最终回复)
langfuse.trace.nameagentName + sessionIdTrace 显示名

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 的 MsgToolUseBlockToolResultBlock 等对象序列化成 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 的来源

LangfuseEnrichmentMiddlewareRuntimeContext 取 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。

返回的 sessionIdea72d28a-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-max15.05s898 → 526模型读 system prompt + 用户问题,决定调用 list_files
② 行动list_files4.97s通过 MCP 协议调用 file-list-server 列目录
③ 再思考chat qwen3.7-max9.29s2,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_toolslist_filesget_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() 统一通道。


十一、设计要点小结

回顾整个集成,有几个关键设计决策值得品味:

  1. 标准协议优先:用 OTLP 而非 Langfuse SDK,换后端零改动;
  2. 两个 Middleware 职责分离:内置的管 Span 骨架 + GenAI 标准属性,自写的管 Langfuse 专有属性,互不污染;
  3. 配置驱动 + 默认关闭@ConditionalOnProperty 让 Langfuse 未启用时零开销降级,业务代码无需 if-else;
  4. Reactor Context 而非 ThreadLocal:异步场景下 Span.current() 会失效,必须用 ContextPropagationOperator 从 Reactor Context 取 Span;
  5. input/output 的异步捕获:用 doOnNext/doOnComplete + AtomicReference 在事件流结束时分批回填属性;
  6. 消息内容的完整提取:ASSISTANT 消息要同时取 text + tool_calls,TOOL 消息要取 ToolResultBlock,否则 Langfuse 里会丢内容;
  7. 最小侵入:业务代码只加两行 .middleware(...),Controller/SessionManager/Tools 零改动,为将来抽成独立 JAR 铺路。

11.1 已知局限与未来方向

当前实现还有几个可改进点:

  • SimpleSpanProcessor 同步导出:高并发下会阻塞推理线程,生产环境应换 BatchSpanProcessor
  • 未做 Trace Context 跨服务传播:如果 Agent 调用的下游服务也接了 OTel,目前 trace 不会自动串联(需要注入 W3C Trace Context Header);
  • userId 未接入:demo 没有用户体系,RuntimeContext.userId 恒为 null;
  • 未抽成独立 JARTracingConfig + 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 雏形。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐