本文以 @langfuse/openai、@langfuse/tracing、@langfuse/otel 三个包为主线,解释 Langfuse JavaScript SDK 如何采集 LLM 调用日志、如何组织 trace/span 父子关系,以及最终如何上报到 Langfuse 服务端。
架构总览图
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 包装后的函数。包装函数会:
- 解析 OpenAI 请求参数。
- 创建 Langfuse generation。
- 调用原始 OpenAI SDK 方法。
- 解析 OpenAI 返回结果和 token usage。
- 更新并结束 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
chat 和 completions 是对象,继续递归代理。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 });
}
它做三件事:
- 创建 OpenTelemetry span。
- 根据
asType包装成 Langfuse 类型,例如 span/generation/tool。 - 把初始 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. sessionId、traceId、父级 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 已经标准化了以下能力:
traceId、spanId、parentSpanId- 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 管父子关系和上报生命周期