跳到主要内容

创建日期:2026-09-20 | 最近更新:2026-09-20 证据分级:【实测】=本机真跑(Node v24.14.1,用假服务端抓真实请求,无需任何 API Key);【文档】=官方文档/包元数据。脚本在 source/langfuse-lab/。 本篇由一段常用的 getTracingCallbacks() 引出。结论先行:那段代码能跑,但有两个坑——它用的是已进入维护模式的 legacy 包,而且它在短生命周期进程里会静默丢光所有 trace。

LangChain 可观测性:Langfuse 是什么、你那片段的问题在哪

先说「Langfuse 是啥」:

Langfuse 是一个开源(MIT 核心)的 LLM 应用可观测性平台。 它把你应用的每一次调用记成 trace(一次用户请求)→ 内部嵌套 span(每个步骤)→ 其中模型调用是 generation(带 prompt、completion、token、成本、延迟)。在此之上还有评分/人工标注、提示词管理、数据集与评估。

关键差别:它可以自托管(Docker 一套),数据留在自己机房;也可以直接用它的云服务。这一点让它常被拿来替代/补充 LangSmith——后者是 LangChain 官方的托管平台,闭源、数据要出网。

一、那段片段在干什么​

逐层拆开,它其实只做一件事:按环境变量决定「把 LangChain 的埋点回调挂给谁」。

export const getTracingCallbacks = (): BaseCallbackHandler[] => {
const callbacks: BaseCallbackHandler[] = [];
// 有 Langfuse 的 key 就挂 Langfuse
if (process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY) {
callbacks.push(new CallbackHandler({ publicKey, secretKey, baseUrl }));
}
// 开了 LangSmith 开关就挂 LangSmith
if (process.env.LANGCHAIN_TRACING_V2 === 'true') {
callbacks.push(new LangChainTracer());
}
return callbacks;
};

用的时候挂到链上(LangChain 的 callback 机制就是「把 handleChainStart / handleLLMEnd 这类钩子插进执行流程」):

await chain.invoke(input, { callbacks: getTracingCallbacks() });

设计上是对的:用环境变量开关观测,不侵入业务代码;两家可以同时挂。但有两个问题。

二、坑一:langfuse-langchain 已经是 legacy 包​

npm 上这个包的 description 直接写了(【实测】读包元数据):

langfuse-langchain@3.39.2
"LEGACY Langfuse v3 integration for LangChain (maintenance mode).
For new projects use the current CallbackHandler from @langfuse/langchain:
npm install @langfuse/langchain @langfuse/otel"
peerDependencies: { langchain: ">=0.0.157 <0.4.0" }

两条都值得注意:

  1. 维护模式:官方让你新项目用 @langfuse/langchain;
  2. peer 范围极窄:langchain >=0.0.157 <0.4.0——而 LangChain 早已是 1.x(本站 LangChain 系列 用的是 langchain@1.5.11)。这条 peer 声明和现代 LangChain 是错开的,装的时候要靠 --legacy-peer-deps 硬压(我就是这么装上的)。

顺带一提,核心包 langfuse@3.39.2 的 description 同样标注自己 不是最新的 JS/TS SDK:

"NOT the latest Langfuse JS/TS SDK. Deprecated v3 client(new Langfuse(), trace(), api.traceList).
For new work: npm install @langfuse/tracing @langfuse/otel @opentelemetry/sdk-node"

一句话:Langfuse 的 JS SDK 从「自有客户端 + callback」整体迁到了 OpenTelemetry。 这是本篇最该带走的信息。

两代差异(实测对比)​

legacy:langfuse-langchain@3.39.2当前:@langfuse/langchain@5.11.1
依赖自有客户端(langfuse、langfuse-core)OTel:peer @opentelemetry/api,配 @langfuse/otel
peer 要求langchain <0.4.0(已错开)@langchain/core >=0.3.8
CallbackHandler 构造参数要传 key / baseUrl不传任何凭据(靠全局 OTel provider)
上报端点【实测】POST /api/public/ingestionPOST /api/public/otel/v1/traces
上报格式【实测】自定义事件批次(10 个事件/3 节点链)OTLP/HTTP JSON(4 个 span,scope=langfuse-sdk)
认证【实测】HTTP Basic(publicKey:secretKey)由 processor 持有
需要额外搭 OTel 管线❌✅ 必须(见坑三)

三、实测:legacy 路径到底发了什么​

把 baseUrl 指向本地假服务端,跑一条三节点链(ChatPromptTemplate → FakeListChatModel → StringOutputParser),抓到的请求:

[mock] 收到一批:10 个事件
类型: trace-create, span-create, span-create, generation-create,
span-update, generation-update, span-create, span-update, span-update, trace-create
自动埋点层级: RunnableSequence, ChatPromptTemplate, FakeListChatModel, StrOutputParser

注意那个层级——你没写任何埋点代码,Langfuse 就按 Runnable 的嵌套结构把整条链自动铺成了 trace + span + generation。这就是「挂 callback 就能看链路」的原理。

四、实测:当前(OTel)路径发了什么​

当前路径代码长这样(注意构造参数的区别):

import { trace as otelTrace } from '@opentelemetry/api';
import { LangfuseSpanProcessor } from '@langfuse/otel';
import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
import { CallbackHandler } from '@langfuse/langchain';

const processor = new LangfuseSpanProcessor({ publicKey, secretKey, baseUrl });
const provider = new BasicTracerProvider({ spanProcessors: [processor] });
otelTrace.setGlobalTracerProvider(provider); // ★ 见坑三

const handler = new CallbackHandler(); // 同名,但不需要凭据了
await chain.invoke(input, { callbacks: [handler] });

抓到的导出:

[mock] ← POST path=/api/public/otel/v1/traces | content-type=application/json | 4061 字节
[mock] scope=langfuse-sdk spans=4
[mock] span: ChatPromptTemplate | kind=1 | attrs=4
[mock] span: FakeListChatModel | kind=1 | attrs=10
[mock] span: StrOutputParser | kind=1 | attrs=4
[mock] span: RunnableSequence | kind=1 | attrs=4

两个可迁移的点:

  1. 是 OTLP/HTTP + JSON(不是 protobuf)——意味着你公司已有的 OTel collector 可以统一收,不必只为 Langfuse 开一条专用链路。这是迁到 OTel 的最大好处;
  2. 埋点粒度一致(还是 4 个 span 对应 4 个 Runnable),换的只是传输协议与 SDK 形态,所以迁移主要是「换包 + 搭 OTel 管线」,不是重写埋点。

五、★ 坑二:不 flush 就退出,trace 全丢(两代都一样)​

这是你那段 getTracingCallbacks() 真正缺少的一步。埋点上报是异步批量的,默认不阻塞业务;进程提前退出 = 缓冲区里的数据丢掉,而且不报错。

实测(同一台机器、同一条链,唯一变量是「退出前有没有 flush」):

legacy 路径当前 OTel 路径
不 flush,直接 process.exit0 批(trace 全丢)0 次导出
flush 后退出1 批(10 个事件)1 次导出(4 个 span)
flush 方法await handler.flushAsync()await provider.forceFlush()

在哪些场景会中招:Serverless / 边缘函数、CLI 工具、测试用例、批处理脚本——任何「跑完就退出」的进程。服务器常驻进程一般不会(后台会定时上报),所以这个坑常常「本地没事、上线就少数据」。

修法:把 flush 纳入生命周期收尾。

const callbacks = getTracingCallbacks();

try {
await chain.invoke(input, { callbacks });
} finally {
// 短生命周期进程务必收尾:把缓冲区里的 trace 推出去
await Promise.all(
callbacks.map((c) => (typeof c.flushAsync === 'function' ? c.flushAsync() : undefined)),
);
}

当前 OTel 路径对应的是 await provider.forceFlush()(在 provider 上,不在 callback 上)。两者位置不同,迁移时容易漏。

六、★ 坑三:OTel 路径少一行配置 = 静默 0 导出​

这个是我实测时真的踩了:一开始我只建了 provider 和 processor,忘了注册全局 tracer,结果——

[noflush] 本次实际发出 0 次 OTLP 导出
[flush] 已 await provider.forceFlush()
[flush] 本次实际发出 0 次 OTLP 导出 ← flush 了也是 0,但没有任何报错

原因:当前版的 CallbackHandler 不接收凭据、也不接收 tracer,它走的是全局 OTel tracer。没 setGlobalTracerProvider() 时拿到的是no-op tracer——span 全部被丢弃,不抛错、不打日志。

otelTrace.setGlobalTracerProvider(provider); // 少这一行,整套埋点静默失效

教训:这两代 SDK 的失败模式都是「静默」。所以接完可观测性一定要做一次端到端验证(发一条链,去平台上看有没有数据),别只看「代码没报错」。

七、改好的版本​

import { CallbackHandler as LangfuseHandler } from '@langfuse/langchain';
import { LangfuseSpanProcessor } from '@langfuse/otel';
import { trace as otelTrace } from '@opentelemetry/api';
import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
import { LangChainTracer } from '@langchain/core/tracers/tracer_langchain';
import type { BaseCallbackHandler } from '@langchain/core/callbacks/base';

let otelReady = false;

/** 挂载监控回调;返回 handlers 与一个「收尾 flush」函数 */
export function setupTracing() {
const callbacks: BaseCallbackHandler[] = [];
const flushes: Array<() => Promise<unknown>> = [];

const pk = process.env.LANGFUSE_PUBLIC_KEY;
const sk = process.env.LANGFUSE_SECRET_KEY;
if (pk && sk) {
// ① 当前包:先把 OTel 管线搭好并注册为全局(缺了这一步 = 静默不上报)
if (!otelReady) {
const processor = new LangfuseSpanProcessor({
publicKey: pk,
secretKey: sk,
baseUrl: process.env.LANGFUSE_HOST || 'https://cloud.langfuse.com',
});
const provider = new BasicTracerProvider({ spanProcessors: [processor] });
otelTrace.setGlobalTracerProvider(provider);
otelReady = true;
flushes.push(() => provider.forceFlush()); // ② 收尾在 provider 上
}
callbacks.push(new LangfuseHandler());
}

if (process.env.LANGCHAIN_TRACING_V2 === 'true') {
callbacks.push(new LangChainTracer()); // LangSmith 路径(见 §八)
}

return { callbacks, flush: async () => { for (const f of flushes) await f(); } };
}

用法:

const { callbacks, flush } = setupTracing();
try { await chain.invoke(input, { callbacks }); } finally { await flush(); }

常驻服务可以把 flush 挂到进程退出钩子(process.on('SIGTERM'))上,但不要在每次请求里 flush(那等于放弃批量上报的收益)。

八、Langfuse 还是 LangSmith?​

LangfuseLangSmith
开源✅ 核心 MIT,可自托管❌ 闭源托管
数据位置自己机房 / 官方云出网到 LangChain 官方
接入方式callback(legacy)→ OTel(当前)LangChain callback(LangChainTracer)
与 LangChain 的耦合松(OTel 是通用标准)紧(官方自家)
适合数据合规要求高、要多框架统一收全押 LangChain、想要最省事

「能不能自托管」通常是决定性因素——尤其在国内或有数据出境限制的场景。而且迁到 OTel 之后,Langfuse 的接入不再绑定 LangChain,同一套 collector 可以收其他框架/服务的 trace,这是它相对 LangSmith 的结构性优势。

九、局限​

  • 【实测】部分均为指向本地假服务端的结果:能真实反映「发了什么、发到哪、什么时候发、不 flush 会怎样」,不能反映真实 Langfuse 服务的鉴权失败、限流、采样与服务端行为;
  • 只覆盖 LangChain 集成这一条路径,未测 Langfuse 的提示词管理 / 评分 / 数据集 / 自托管部署;
  • 未测 Python SDK(Langfuse 的 Python 侧是另一套,OTel 迁移进度与包名不同);
  • 版本迭代快(Langfuse JS 正处在 v3 → v5 迁移期),结论以文首版本为准,升级后请自行复跑;
  • LangSmith 侧只验证了「回调存在」,没有实测其上报(缺可控的端点开关,故未展开)。

关联​

自测​

  1. Langfuse 记录的三层结构是什么(trace / span / generation 各是什么)?它相对 LangSmith 最关键的区别是什么?
  2. langfuse-langchain 现在处于什么状态?为什么它和现代 LangChain 的 peer 范围是错开的?
  3. 当前 @langfuse/langchain 的 CallbackHandler 为什么不需要传 key?少配了什么会导致静默不上报?
  4. 两代路径分别发到哪个端点、用什么格式?
  5. 为什么「不 flush 就退出」会丢 trace?哪些部署形态最容易中招?
  6. 两代各自的 flush 方法是什么(注意调用对象不同)?

参考​

  • 实测脚本(随仓库留档,无需 API Key,用假服务端抓真实请求):source/langfuse-lab/——legacy 与 modern 两套(各含 -tracing.mjs 与假服务端),跑法与局限见其中 README.md
  • 环境:Node v24.14.1 / macOS(Darwin 24.6.0);langfuse-langchain@3.39.2、@langfuse/langchain@5.11.1、@langfuse/otel@5.11.1、@langchain/core@1.2.12
  • Langfuse 官方文档:langfuse.com/docs(观测 SDK 概览与 OTel 迁移)
  • LangChain 回调机制:js.langchain.com/docs/concepts/callbacks