创建日期: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" }
两条都值得注意:
- 维护模式:官方让你新项目用
@langfuse/langchain; - 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/ingestion | POST /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
两个可迁移的点:
- 是 OTLP/HTTP + JSON(不是 protobuf)——意味着你公司已有的 OTel collector 可以统一收,不必只为 Langfuse 开一条专用链路。这是迁到 OTel 的最大好处;
- 埋点粒度一致(还是 4 个 span 对应 4 个 Runnable),换的只是传输协议与 SDK 形态,所以迁移主要是「换包 + 搭 OTel 管线」,不是重写埋点。
五、★ 坑二:不 flush 就退出,trace 全丢(两代都一样)
这是你那段 getTracingCallbacks() 真正缺少的一步。埋点上报是异步批量的,默认不阻塞业务;进程提前退出 = 缓冲区里的数据丢掉,而且不报错。
实测(同一台机器、同一条链,唯一变量是「退出前有没有 flush」):
| legacy 路径 | 当前 OTel 路径 | |
|---|---|---|
不 flush,直接 process.exit | 0 批(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?
| Langfuse | LangSmith | |
|---|---|---|
| 开源 | ✅ 核心 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 侧只验证了「回调存在」,没有实测其上报(缺可控的端点开关,故未展开)。
关联
- 前置:LangChain + LangGraph 系列——消息/LCEL/状态图,本系列第 5 篇
- 自带可观测性的一体化框架对照:LangChain Deep Agents 入门
- 「跑完就退出会丢数据」的同类问题:Agent 流式传输选型(流式响应被缓冲吞掉,同样是静默失败)
- 另一种「以落盘为准」的思路:InkOS 架构拆解(产物真值而非仅靠日志)
自测
- Langfuse 记录的三层结构是什么(trace / span / generation 各是什么)?它相对 LangSmith 最关键的区别是什么?
langfuse-langchain现在处于什么状态?为什么它和现代 LangChain 的 peer 范围是错开的?- 当前
@langfuse/langchain的CallbackHandler为什么不需要传 key?少配了什么会导致静默不上报? - 两代路径分别发到哪个端点、用什么格式?
- 为什么「不 flush 就退出」会丢 trace?哪些部署形态最容易中招?
- 两代各自的 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