Langfuse JavaScript SDK 架构设计与实现原理

本文以 @langfuse/openai@langfuse/tracing@langfuse/otel 三个包为主线,解释 Langfuse JavaScript SDK 如何采集 LLM 调用日志、如何组织 trace/span 父子关系,以及最终如何上报到 Langfuse 服务端。

架构总览图

flowchart TD User["用户在 Chat 页面发起多轮对话"] Session["Session
sessionId = conversationId
表示一整个聊天会话"] User --> Session Session --> Turn1["Trace: turn-1
一次用户请求"] Session --> Turn2["Trace: turn-2
一次用户请求"] Session --> Turn3["Trace: turn-3
一次用户请求"] Turn3 --> Agent["Span: agent-run
单轮 Agent 执行父节点"] Agent --> Tool["Tool observation
search-docs / call-api"] Agent --> LLM["Generation observation
OpenAI chat.completions.create"] LLM --> Proxy["@langfuse/openai
Proxy 包装 OpenAI client"] Proxy --> Tracing["@langfuse/tracing
startObservation / update / end"] Tool --> Tracing Agent --> Tracing Tracing --> OTel["OpenTelemetry Span
traceId / spanId / parentSpanId
attributes"] OTel --> Processor["@langfuse/otel
LangfuseSpanProcessor"] Processor --> Langfuse["Langfuse Server
查询 sessionId 聚合多轮
查询 traceId 查看单轮细节"]

1. 核心目标

Langfuse SDK 不是只记录一条普通日志。它的目标是把一次 LLM/Agent 执行过程结构化成可分析的观测数据:

text 复制代码
Session conv-123
  Trace turn-1
    Span agent-run
      Tool search-docs
      Generation openai-call

  Trace turn-2
    Span agent-run
      Tool search-docs
      Tool call-api
      Generation openai-call

这里有几个层级:

  • sessionId:一整个用户会话,例如用户打开 chat 页面后持续聊天,直到新建会话或关闭。
  • traceId:一次完整执行链路。对聊天产品来说,通常是一轮用户请求。
  • span:trace 中的一个业务步骤,例如 agent-run、检索、数据库查询。
  • generation:特殊 span,表示一次 LLM 调用,例如 OpenAI Chat Completion。
  • tool:特殊 span,表示一次工具调用或外部 API 调用。

2. 包职责划分

@langfuse/openai

负责接入 OpenAI SDK。

它不直接拦截 HTTP 请求,而是用 Proxy 包住 OpenAI client。当用户调用:

ts 复制代码
openai.chat.completions.create(...)

实际调用的是 Langfuse 包装后的函数。包装函数会:

  1. 解析 OpenAI 请求参数。
  2. 创建 Langfuse generation。
  3. 调用原始 OpenAI SDK 方法。
  4. 解析 OpenAI 返回结果和 token usage。
  5. 更新并结束 generation。

核心入口是:

ts 复制代码
observeOpenAI(new OpenAI(), config)

@langfuse/tracing

负责创建 observation,也就是 Langfuse 语义下的 span/generation/tool/chain 等。

主要 API:

ts 复制代码
startObservation(...)
startActiveObservation(...)
propagateAttributes(...)

它基于 OpenTelemetry 创建 span,并在 span 上写入 Langfuse 需要的 attributes。

@langfuse/otel

负责把结束后的 OpenTelemetry span 转换、处理并上报到 Langfuse。

核心类:

ts 复制代码
LangfuseSpanProcessor

它会处理:

  • 批量上报
  • flush/shutdown
  • 数据脱敏 mask
  • span 过滤 shouldExportSpan
  • media 处理
  • environment/release 标记

3. OpenAI 监控实现原理

@langfuse/openai 的核心是 observeOpenAI

简化版逻辑:

ts 复制代码
function observeOpenAI(sdk, config) {
  return new Proxy(sdk, {
    get(target, prop) {
      const original = target[prop];

      if (typeof original === "function") {
        return withTracing(original.bind(target), config);
      }

      if (typeof original === "object" && original !== null) {
        return observeOpenAI(original, config);
      }

      return original;
    },
  });
}

所以:

ts 复制代码
openai.chat.completions.create(...)

会逐层触发:

text 复制代码
get chat
get completions
get create

chatcompletions 是对象,继续递归代理。create 是函数,于是替换为 withTracing(...) 包装函数。

4. 一次 OpenAI 调用的生命周期

一轮 OpenAI 调用大致经历:

text 复制代码
用户调用 openai.chat.completions.create
  ↓
withTracing 包装函数执行
  ↓
parseInputArgs 解析请求参数
  ↓
startObservation 创建 generation
  ↓
调用原始 OpenAI SDK 方法
  ↓
等待返回结果或消费 stream
  ↓
parseCompletionOutput / parseUsageDetails 解析响应
  ↓
generation.update(...)
  ↓
generation.end()
  ↓
LangfuseSpanProcessor 上报

创建 generation 时记录请求侧数据:

ts 复制代码
const generation = startObservation(
  "OpenAI-completion",
  {
    model,
    input,
    modelParameters,
    prompt,
    metadata,
  },
  { asType: "generation" },
);

OpenAI 返回后补充响应侧数据:

ts 复制代码
generation
  .update({
    output,
    usageDetails,
    model: modelFromResponse,
    modelParameters: modelParametersFromResponse,
    metadata: metadataFromResponse,
  })
  .end();

因此一条 generation 的信息是分两次写入的:

  • 开始时:记录 input/model/modelParameters。
  • 结束时:记录 output/usageDetails/metadata。

5. Streaming 调用如何处理

普通调用可以等 Promise resolve 后直接记录 output。

但 streaming 返回的是 async iterable:

ts 复制代码
for await (const chunk of stream) {
  ...
}

Langfuse 不能立刻结束 generation,因为完整输出还没有生成完。

所以 SDK 会返回一个新的 async generator:

ts 复制代码
async function* tracedOutputGenerator() {
  for await (const rawChunk of originalStream) {
    collect(rawChunk);
    yield rawChunk;
  }

  generation.update({
    output: collectedText,
    usageDetails,
    completionStartTime,
  }).end();
}

这保证了两件事:

  • 用户仍然可以正常消费原始 stream。
  • Langfuse 能在 stream 完成后记录完整输出。

6. startObservation 是什么

startObservation 是创建一条 Langfuse observation 的入口。

简化理解:

ts 复制代码
function startObservation(name, attributes, options) {
  const otelSpan = tracer.startSpan(name, parentContext);

  if (options.asType === "generation") {
    return new LangfuseGeneration({ otelSpan, attributes });
  }

  return new LangfuseSpan({ otelSpan, attributes });
}

它做三件事:

  1. 创建 OpenTelemetry span。
  2. 根据 asType 包装成 Langfuse 类型,例如 span/generation/tool。
  3. 把初始 attributes 写到 span 上。

例如:

ts 复制代码
const generation = startObservation(
  "llm-call",
  {
    model: "gpt-4o-mini",
    input: { messages },
  },
  { asType: "generation" },
);

之后可以继续补数据:

ts 复制代码
generation.update({
  output,
  usageDetails,
});

generation.end();

end() 之后,这条 span 才会进入上报流程。

7. startActiveObservation 与父子关系

startActiveObservation 的作用是创建一个 span,并把它设置为当前 async context 中的 active span。

ts 复制代码
await startActiveObservation("agent-run", async () => {
  await openai.chat.completions.create(...);
});

在 callback 内部创建的新 span/generation 会自动挂到 agent-run 下面:

text 复制代码
Trace turn-1
  Span agent-run
    Generation OpenAI.chat

这依赖 OpenTelemetry 的 context propagation。

它解决的问题是:不需要在每个函数里手动传 parent id。

不用这样:

ts 复制代码
await callOpenAI({ parentId: agentRun.id });
await callTool({ parentId: agentRun.id });

而是这样:

ts 复制代码
await startActiveObservation("agent-run", async () => {
  await callTool();
  await callOpenAI();
});

只要 callTool()callOpenAI() 内部使用同一个 OpenTelemetry context,它们就能自动找到父级。

8. sessionIdtraceId、父级 span 的关系

推荐建模:

text 复制代码
sessionId = 整个聊天会话
traceId   = 单轮用户请求
span      = 单轮请求里的业务步骤
generation = 一次 LLM 调用
tool       = 一次工具调用

例如:

text 复制代码
Session conv-123
  Trace turn-1
    Generation llm-call

  Trace turn-2
    Tool search-docs
    Generation llm-call

  Trace turn-3
    Span agent-run
      Tool search-docs
      Tool call-api
      Generation llm-call

是否需要 agent-run 父级 span,取决于单轮逻辑复杂度。

简单场景可以直接:

text 复制代码
Trace turn-1
  Tool search-docs
  Generation llm-call

复杂 Agent 场景建议:

text 复制代码
Trace turn-1
  Span agent-run
    Tool search-docs
    Tool call-api
    Generation llm-call

agent-run 的价值是把一次 Agent 执行内部的多个步骤组织成一棵树,并可以在父级记录总输入、总输出、总耗时等信息。

9. 多轮对话如何展示

多轮聊天里,每一轮 LLM 调用都会真实产生 token 成本。

即使第 4 轮请求的 messages 已经包含了第 1、2、3 轮历史,也不能只保留第 4 轮。因为前 3 轮的请求已经发生,token/cost/latency 都是真实存在的。

推荐日志平台展示:

text 复制代码
Session conv-123
  Overview
    totalTokens = sum(turn1, turn2, turn3, turn4)
    totalCost = sum(turn1, turn2, turn3, turn4)
    turns = 4

  Conversation
    展示最后一轮 input.messages + output

  LLM Calls
    turn-1 tokens/cost/latency
    turn-2 tokens/cost/latency
    turn-3 tokens/cost/latency
    turn-4 tokens/cost/latency

也就是说:

  • 看完整上下文:看最后一轮 generation,或父级 conversation 的 finalHistory
  • 算成本:累加每一轮 generation。
  • 排查问题:点进具体某一轮 trace。

平台侧通常按 sessionId 聚合:

text 复制代码
WHERE sessionId = 'conv-123'
ORDER BY startTime ASC

10. 为什么使用 OpenTelemetry

Langfuse 理论上可以直接 fetch 上报:

ts 复制代码
await fetch("/ingestion", {
  method: "POST",
  body: JSON.stringify({ input, output, usage }),
});

但 OpenTelemetry 已经标准化了以下能力:

  • traceIdspanIdparentSpanId
  • span start/end 生命周期
  • async context 中的父子关系传播
  • processor/exporter 上报管线
  • 与 HTTP、DB、Redis、队列等其他监控系统集成

因此 Langfuse 选择把 LLM 日志建模为 OpenTelemetry span,再由 LangfuseSpanProcessor 负责转换并上报。

这让 LLM 调用可以和普通后端链路放在同一棵 trace 树里:

text 复制代码
POST /chat
  auth
  load-history
  agent-run
    search-docs
    openai-call
  save-message

11. 端到端示例

ts 复制代码
await propagateAttributes(
  {
    traceName: "chat-turn",
    sessionId: conversationId,
    userId,
  },
  async () => {
    await startActiveObservation("agent-run", async (agentSpan) => {
      const tool = agentSpan.startObservation(
        "tool:search-docs",
        { input: { query: userMessage } },
        { asType: "tool" },
      );

      const docs = await searchDocs(userMessage);

      tool.update({ output: docs }).end();

      const openai = observeOpenAI(rawOpenAI, {
        generationName: "llm-call",
        generationMetadata: {
          conversationId,
          turnIndex,
        },
      });

      const completion = await openai.chat.completions.create({
        model: "gpt-4o-mini",
        messages,
      });

      agentSpan.update({
        output: {
          answer: completion.choices[0]?.message.content,
          messages,
        },
      });
    });
  },
);

最终结构:

text 复制代码
Session conversationId
  Trace chat-turn
    Span agent-run
      Tool tool:search-docs
      Generation llm-call

12. 总结

Langfuse JavaScript SDK 的设计可以概括为:

text 复制代码
@langfuse/openai
  用 Proxy 包装 OpenAI SDK,自动采集 LLM input/output/usage

@langfuse/tracing
  基于 OpenTelemetry 创建 span/generation/tool,并管理父子关系

@langfuse/otel
  在 span.end() 后收集、处理、批量上报到 Langfuse

最重要的设计思想是:

text 复制代码
sessionId 管整场会话
traceId 管一次请求
span 管一次请求中的步骤
generation 管一次 LLM 调用
tool 管一次工具调用
OpenTelemetry 管父子关系和上报生命周期
相关推荐
阿里云云原生1 小时前
Agent 开发范式演进:从环境工程出发,“简化”多源实时上下文
agent
AIminminHu1 小时前
(让 C++ 程序长出大脑:从“语音遥控器”到具身智能 Agent 的进化之路)------OpenGL渲染与几何内核那点事------(二-1-(15))
开发语言·c++·agent·具身智能
AI精钢2 小时前
把 Markdown 笔记变成可问答的知识图谱:本地 Graph RAG 工具 Kwipu 实测
人工智能·笔记·python·aigc·知识图谱
阿里云云原生3 小时前
企业级多 Agent 规模化落地怎么做?群虾智能 AI 沙龙 PPT 限时领取
agent
阿里云云原生3 小时前
AgentRun CLI v0.1.0 正式开源:一行命令运行您的托管 Agent
agent
IT大白鼠3 小时前
AIGC+教育:个性化学习、AI助教、内容生产,教育行业的变革路径
人工智能·学习·aigc
YuTaoShao4 小时前
Cursor 的上下文工程新思路:把一切变成文件
ai·agent·cursor·上下文工程
IvanCodes4 小时前
Skills 热潮过去后,我重新理解了 AI Agent 的方向
人工智能·agent
uccs4 小时前
系统认知 Agent 六大支柱
agent·ai编程·claude