1. 前面手动搭了一整套 Tracing,LangChain 里还要再来一遍吗
在第一章的可观测性中,我们从头搭了一套完整的可观测体系:
- TraceContext:每次请求创建一个实例,用
trace.span()包装每个管线节点,自动记录输入、输出、耗时、状态 - 采样策略:错误和降级全量保留,正常请求按比例采样
- 分层存储:D1 存完整现场,KV 存聚合摘要,waitUntil 不阻塞响应
那套方案很好,但接入成本不算低。每个管线节点都要手动包一层 trace.span(),每次新增节点都要记得埋点。
1// 前面的做法:手动包装每个节点2const memories = await trace.span(3'memory_retrieval',4{ query: userMessage, sessionId },5() => retrieveMemories(env, userMessage, sessionId)6)
现在进入 LangChain 体系,事情变了。
LangChain 的每个核心组件——ChatModel、Tool、Chain、Retriever——在运行时都会自动在关键节点发出事件通知。你不需要手动写 trace.span(),框架已经帮你埋好了。
这套事件通知机制就是 Callbacks。
一句话概括它和前面 TraceContext 的关系:
TraceContext 是你自己搭的事件采集框架,Callbacks 是 LangChain 内建的事件采集框架。 概念一样,只是谁来做的区别。
2. Callbacks 的事件模型:和 Span 一一对应
前面定义 Span 时,每个 Span 记录三件事:什么时候开始(startTime)、什么时候结束(duration)、结果是什么(status + output)。
LangChain 的 callbacks 把这三件事拆成了成对的事件。以工具调用为例:
| Span 阶段 | 对应 Callback 事件 | 触发时机 |
|---|---|---|
| 开始 | handleToolStart | 工具开始执行,携带工具名和输入参数 |
| 成功结束 | handleToolEnd | 工具执行完成,携带输出结果 |
| 失败结束 | handleToolError | 工具执行报错,携带错误信息 |
每一对 start/end 事件,就等价于前面 TraceContext 里的一个 Span。
LangChain 为所有核心组件都定义了这样的事件对:
模型调用
| 事件 | 触发时机 |
|---|---|
handleChatModelStart | ChatModel 开始推理,携带完整的消息列表 |
handleLLMEnd | 模型推理完成,携带生成结果和 token 用量 |
handleLLMError | 模型调用失败 |
handleLLMNewToken | 流式输出时,每产生一个 token 触发一次 |
工具调用
| 事件 | 触发时机 |
|---|---|
handleToolStart | 工具开始执行 |
handleToolEnd | 工具执行完成 |
handleToolError | 工具执行报错 |
Chain(链)
| 事件 | 触发时机 |
|---|---|
handleChainStart | Chain 开始运行 |
handleChainEnd | Chain 运行完成 |
handleChainError | Chain 运行报错 |
Retriever(检索器)
| 事件 | 触发时机 |
|---|---|
handleRetrieverStart | 检索器开始查询 |
handleRetrieverEnd | 检索器返回结果 |
handleRetrieverError | 检索器报错 |
Agent(代理)
| 事件 | 触发时机 |
|---|---|
handleAgentAction | Agent 决定执行一个动作(选择了哪个工具、传什么参数) |
handleAgentEnd | Agent 本轮运行结束 |
你会发现这些事件和前面 TraceContext 的 span() 方法做的事是一样的——记录每个操作的输入、输出和状态。区别在于:TraceContext 需要你手动调用,Callbacks 由框架自动触发。
3. 用 Callbacks 重新实现 TraceContext 的能力
理解了事件模型以后,我们来做一件直觉上很自然的事:用 LangChain 的 Callbacks 接口,重新实现一个和前面 TraceContext 等价的追踪器。
这个练习的目的不是为了实际使用(LangSmith 和 LangFuse 已经帮你做好了),而是让你真正理解 Callbacks 和 TraceContext 的映射关系。
3.1 BaseCallbackHandler:所有 handler 的基类
LangChain 提供了 BaseCallbackHandler 基类。你只需要继承它,覆盖你关心的事件方法。没有覆盖的事件会被默认忽略。
001import { BaseCallbackHandler } from '@langchain/core/callbacks/base'002import type { Serialized } from '@langchain/core/load/serializable'003004// 复用前面定义的 Span 结构005interface Span {006spanId: string007name: string008startTime: number009duration?: number010status: 'ok' | 'error' | 'degraded'011input: Record<string, any>012output: Record<string, any>013}014015class TraceCallbackHandler extends BaseCallbackHandler {016name = 'TraceCallbackHandler'017018// 和前面的 TraceContext 一样,收集所有 Span019private spans: Span[] = []020// runId → Span 的映射,用于在 end 事件里找到对应的 start021private activeSpans = new Map<string, Span>()022023// --- 模型事件 ---024025handleChatModelStart(026llm: Serialized,027messages: any[][],028runId: string029) {030// 模型开始推理 → 创建一个新 Span031const span: Span = {032spanId: runId,033name: `llm:${llm.id?.at(-1) ?? 'unknown'}`,034startTime: Date.now(),035status: 'ok',036input: { messages },037output: {},038}039this.activeSpans.set(runId, span)040}041042handleLLMEnd(output: any, runId: string) {043// 模型推理完成 → 填充 duration 和 output,关闭 Span044const span = this.activeSpans.get(runId)045if (span) {046span.duration = Date.now() - span.startTime047span.output = {048text: output.generations?.[0]?.[0]?.text?.slice(0, 200),049tokenUsage: output.llmOutput?.tokenUsage,050}051this.spans.push(span)052this.activeSpans.delete(runId)053}054}055056handleLLMError(error: Error, runId: string) {057const span = this.activeSpans.get(runId)058if (span) {059span.duration = Date.now() - span.startTime060span.status = 'error'061span.output = { error: error.message }062this.spans.push(span)063this.activeSpans.delete(runId)064}065}066067// --- 工具事件 ---068069handleToolStart(tool: Serialized, input: string, runId: string) {070const span: Span = {071spanId: runId,072name: `tool:${tool.id?.at(-1) ?? 'unknown'}`,073startTime: Date.now(),074status: 'ok',075input: { toolInput: input },076output: {},077}078this.activeSpans.set(runId, span)079}080081handleToolEnd(output: string, runId: string) {082const span = this.activeSpans.get(runId)083if (span) {084span.duration = Date.now() - span.startTime085span.output = { result: output.slice(0, 200) }086this.spans.push(span)087this.activeSpans.delete(runId)088}089}090091handleToolError(error: Error, runId: string) {092const span = this.activeSpans.get(runId)093if (span) {094span.duration = Date.now() - span.startTime095span.status = 'error'096span.output = { error: error.message }097this.spans.push(span)098this.activeSpans.delete(runId)099}100}101102// --- 检索器事件 ---103104handleRetrieverStart(105retriever: Serialized,106query: string,107runId: string108) {109const span: Span = {110spanId: runId,111name: `retriever:${retriever.id?.at(-1) ?? 'unknown'}`,112startTime: Date.now(),113status: 'ok',114input: { query },115output: {},116}117this.activeSpans.set(runId, span)118}119120handleRetrieverEnd(documents: any[], runId: string) {121const span = this.activeSpans.get(runId)122if (span) {123span.duration = Date.now() - span.startTime124span.output = {125documentCount: documents.length,126documents: documents.map(d => d.pageContent?.slice(0, 100)),127}128// 检索到了但结果为空 → 标记为 degraded129if (documents.length === 0) {130span.status = 'degraded'131}132this.spans.push(span)133this.activeSpans.delete(runId)134}135}136137handleRetrieverError(error: Error, runId: string) {138const span = this.activeSpans.get(runId)139if (span) {140span.duration = Date.now() - span.startTime141span.status = 'error'142span.output = { error: error.message }143this.spans.push(span)144this.activeSpans.delete(runId)145}146}147148// --- 对外暴露的查询方法 ---149150getSpans(): Span[] {151return this.spans152}153154hasError(): boolean {155return this.spans.some(s => s.status === 'error')156}157158hasDegraded(): boolean {159return this.spans.some(s => s.status === 'degraded')160}161162toJSON() {163return {164spanCount: this.spans.length,165spans: this.spans,166}167}168}
对比一下前面的 TraceContext:
| 能力 | TraceContext(手动) | TraceCallbackHandler(LangChain) |
|---|---|---|
| 创建 Span | 手动调用 trace.span(name, input, fn) | 框架自动触发 handleXxxStart / handleXxxEnd |
| 记录输入输出 | 手动传入 input,从返回值取 output | 事件参数自动携带 |
| 计算耗时 | span() 方法内部计时 | start 事件记录开始时间,end 事件计算差值 |
| 标记 degraded | 手动调用 trace.markDegraded() | 在 end 事件里根据业务逻辑判断 |
| 错误处理 | span() 的 try/catch | 框架自动触发 handleXxxError |
核心区别只有一个:谁来触发。 手动方案需要你在每个节点的调用处包一层,Callbacks 由 LangChain 框架在组件内部自动触发。
3.2 怎么把 handler 传给组件
LangChain 支持两个传入位置,作用域不同:
构造时传入 → 全局生效
1import { ChatOpenAI } from '@langchain/openai'23const handler = new TraceCallbackHandler()45// handler 会收到这个 model 实例以及它内部所有子组件的事件6const model = new ChatOpenAI({7model: 'gpt-4.1-mini',8callbacks: [handler],9})
构造时传入的 handler 会向下传播。如果一个 Agent 在构造时带了 handler,那么这个 Agent 内部调用的每个工具、每次模型推理,handler 都会收到事件。这就像前面 TraceContext 被传入整个管线一样。
调用时传入 → 单次生效
01const handler = new TraceCallbackHandler()0203const result = await agent.invoke(04{05messages: [{ role: 'user', content: '明天上海天气怎么样?' }],06},07{08callbacks: [handler],09},10)1112// 这次调用结束后,handler 里就有了完整的 Span 列表13console.log(handler.toJSON())
调用时传入的 handler 只对这一次 invoke 生效。适合你只想追踪特定请求的场景——这和前面"每次请求创建一个新的 TraceContext 实例"是一样的思路。
两种方式可以同时使用。 LangChain 会把构造时和调用时的 handler 合并,所有 handler 都会收到事件。
3.3 完整示例:追踪一轮 Agent 运行
01import { createAgent, tool } from 'langchain'02import * as z from 'zod'0304const getWeather = tool(05async ({ city }) => `${city}:明天小雨,17-22 度`,06{07name: 'get_weather',08description: '查询某个城市未来的天气情况',09schema: z.object({10city: z.string().describe('要查询天气的城市名'),11}),12},13)1415const createReminder = tool(16async ({ content, time }) => `提醒已创建:${time} - ${content}`,17{18name: 'create_reminder',19description: '帮用户创建一个提醒事项',20schema: z.object({21content: z.string().describe('提醒内容'),22time: z.string().describe('提醒时间'),23}),24},25)2627const sendMessage = tool(28async ({ text }) => `已发送给用户:${text}`,29{30name: 'send_message',31description: '把一段结果发送给用户',32schema: z.object({33text: z.string().describe('要发送的内容'),34}),35},36)3738const agent = createAgent({39model: 'openai:gpt-4.1-mini',40tools: [getWeather, createReminder, sendMessage],41systemPrompt: '你是用户的生活助理,会在需要时连续调用工具。',42})4344// 每次请求创建一个新的 handler 实例(等价于每次请求创建一个新的 TraceContext)45const handler = new TraceCallbackHandler()4647const result = await agent.invoke(48{49messages: [50{51role: 'user',52content: '帮我看看明天上海天气。如果下雨就提醒我带伞,再把结果顺手发给我。',53},54],55},56{57callbacks: [handler],58},59)6061// 调用结束后,handler 里已经自动收集了所有 Span62const trace = handler.toJSON()63console.log(`共 ${trace.spanCount} 个 Span:`)64trace.spans.forEach(span => {65console.log(66` [${span.status}] ${span.name} - ${span.duration}ms`67)68})
输出大概会是这样:
1共 6 个 Span:2[ok] llm:gpt-4.1-mini - 820ms3[ok] tool:get_weather - 3ms4[ok] llm:gpt-4.1-mini - 650ms5[ok] tool:create_reminder - 2ms6[ok] tool:send_message - 1ms7[ok] llm:gpt-4.1-mini - 430ms
和前面手动 trace.span() 包每个节点相比,这里的代码量少了很多。你不需要在每个工具定义里加埋点,不需要在 Agent 内部改逻辑,只要在调用时传一个 handler 进去,框架就帮你记录了所有过程。
4. 接入 LangSmith 和 LangFuse:Callbacks 是统一的对接口
前面工具选型那篇已经详细对比过 LangSmith、LangFuse 和自建方案的适用场景。这里不重复介绍它们的功能差异,只聚焦一个问题:在 LangChain 体系里,它们是怎么接进来的。
答案就一句话:它们都是 BaseCallbackHandler 的实现。
4.1 LangSmith:零代码接入
LangSmith 的接入不需要你写任何代码。LangChain 检测到环境变量后,会自动在内部注册一个 LangSmith 的 CallbackHandler。
1LANGSMITH_TRACING=true2LANGSMITH_API_KEY=lsv2_pt_xxxxxxxxxxxxxxxx3LANGSMITH_PROJECT=aicompanion-single-agent
设好以后,你的 Agent 代码完全不用改。每次 invoke 时,LangChain 都会自动把事件发送给 LangSmith 的 handler,后者负责把数据上报到 LangSmith 平台。
这和前面"把 TraceContext 传入管线"的思路是一样的——只是 LangChain 帮你自动做了。
如果你想对个别请求加上 tags 和 metadata(方便在 LangSmith 里筛选),在调用时传入即可:
01const result = await agent.invoke(02{03messages: [{ role: 'user', content: '明天上海天气怎么样?' }],04},05{06tags: ['single-agent', 'weather-flow', 'staging'],07metadata: {08user_id: 'user_2048',09session_id: 'session_20260331_01',10},11},12)
但如果你有自定义函数(不是 LangChain 组件),LangChain 自然无法自动追踪它们。这时候需要用 traceable 手动包装——这在工具选型那篇已经讲过:
01import { traceable } from 'langsmith/traceable'0203// 非 LangChain 组件,需要手动包装才能纳入 Trace 链路04const customBusinessLogic = traceable(05async (input: string) => {06// 你自己的业务逻辑...07return result08},09{ name: 'custom_business_logic', run_type: 'chain' }10)
4.2 LangFuse:通过 CallbackHandler 对接
LangFuse 的接入方式和我们前面自己实现的 TraceCallbackHandler 一模一样——它也是一个 BaseCallbackHandler 的子类。
1yarn add @langfuse/langchain
1LANGFUSE_SECRET_KEY=sk-lf-xxxxxxxx2LANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxx3LANGFUSE_BASE_URL=https://cloud.langfuse.com
01import { CallbackHandler } from '@langfuse/langchain'0203const langfuseHandler = new CallbackHandler({04sessionId: 'session_20260331_01',05userId: 'user_2048',06tags: ['single-agent', 'weather-flow'],07})0809const result = await agent.invoke(10{11messages: [{ role: 'user', content: '明天上海天气怎么样?' }],12},13{14callbacks: [langfuseHandler],15},16)1718// 确保 trace 数据发送完毕(LangFuse 默认异步批量发送)19await langfuseHandler.flushAsync()
注意这和前面工具选型篇里的 LangFuse 用法不一样。前面那篇用的是 LangFuse 的原生 SDK:
1// 前面的用法:直接用 Langfuse 原生 SDK,手动创建 trace 和 span2const langfuse = new Langfuse({ publicKey: '...', secretKey: '...' })3const trace = langfuse.trace({ name: 'ai-companion-chat' })4const span = trace.span({ name: 'memory_retrieval', input: { query } })5// ... 手动结束 span ...6span.end({ output: { memories } })
而在 LangChain 体系里,你用的是 @langfuse/langchain 包提供的 CallbackHandler:
1// LangChain 里的用法:传入 CallbackHandler,框架自动调用2const handler = new CallbackHandler({ userId: '...' })3await agent.invoke(input, { callbacks: [handler] })
两种方式最终都是把数据发到 LangFuse 平台,区别在于谁来负责埋点。 原生 SDK 是你自己手动埋,CallbackHandler 是 LangChain 框架自动埋。如果你的管线完全基于 LangChain 组件,用 CallbackHandler 更省事;如果管线里有大量非 LangChain 的自定义逻辑,混用两种方式更灵活。
4.3 同时挂多个 handler
Callbacks 是一个数组,你可以同时挂多个 handler。它们都会收到同样的事件,互不干扰。
01import { CallbackHandler as LangFuseHandler } from '@langfuse/langchain'0203const traceHandler = new TraceCallbackHandler() // 自己的追踪器04const langfuseHandler = new LangFuseHandler() // 发到 LangFuse0506const result = await agent.invoke(07{ messages: [{ role: 'user', content: '明天上海天气怎么样?' }] },08{09callbacks: [10traceHandler, // 本地记录,用于采样决策11langfuseHandler, // 发到 LangFuse 平台12// LangSmith 不用手动加,环境变量开了就自动生效13],14},15)1617// 用本地 handler 做采样决策18if (traceHandler.hasError() || traceHandler.hasDegraded()) {19// 完整写入 D1...20}
这个模式特别适合前面讲过的分层策略:用自己的 handler 做采样决策和自建存储,同时用 LangFuse 或 LangSmith 做可视化排查。
4.4 Serverless 环境的注意事项
在 Cloudflare Workers、AWS Lambda 这类 Serverless 环境中,函数执行完就销毁了。LangChain 默认在后台异步执行 callbacks,可能来不及完成。
解决方法是关掉后台回调:
1LANGCHAIN_CALLBACKS_BACKGROUND=false
设为 false 后,callbacks 会同步执行(阻塞主流程)。如果你希望不阻塞响应,结合前面讲过的 waitUntil 模式:先同步收集事件到内存中的 handler,然后在 waitUntil 里异步刷新到远端平台。
5. 在 Callbacks 里实现采样和 degraded 标记
前面采样策略那篇讲了"异常全量保留,正常按比例采样"的思路。在 LangChain 体系里,这个逻辑可以完全用 Callbacks 实现。
5.1 用 handler 收集信息,在调用结束后做采样决策
01const handler = new TraceCallbackHandler()0203const result = await agent.invoke(04{ messages: [{ role: 'user', content: userMessage }] },05{ callbacks: [handler] },06)0708// 调用结束后,handler 里已经有了所有 Span09// 用和前面一样的采样逻辑做决策10const totalDuration = handler.getSpans().reduce(11(sum, s) => sum + (s.duration ?? 0), 012)1314if (handler.hasError()) {15// 有错误 → 完整写入 D116await storeFullTrace(env, handler.toJSON(), userId, sessionId)17} else if (handler.hasDegraded()) {18// 有降级 → 完整写入 D119await storeFullTrace(env, handler.toJSON(), userId, sessionId)20} else if (totalDuration > 5000) {21// 异常慢 → 完整写入 D122await storeFullTrace(env, handler.toJSON(), userId, sessionId)23} else if (Math.random() < 0.05) {24// 正常请求 5% 采样25await storeFullTrace(env, handler.toJSON(), userId, sessionId)26} else {27// 其余只存摘要28await storeSummary(env, handler.toJSON())29}
5.2 在 handler 里自动标记 degraded
前面 TraceContext 有一个 markDegraded() 方法需要手动调用。在 Callbacks 体系里,你可以把 degraded 的判断逻辑直接写进 handler 的事件方法里,实现自动标记。
上面 TraceCallbackHandler 的 handleRetrieverEnd 已经展示了这种做法:
01handleRetrieverEnd(documents: any[], runId: string) {02const span = this.activeSpans.get(runId)03if (span) {04span.duration = Date.now() - span.startTime05span.output = { documentCount: documents.length }0607// 检索结果为空 → 自动标记为 degraded08if (documents.length === 0) {09span.status = 'degraded'10}1112this.spans.push(span)13this.activeSpans.delete(runId)14}15}
你还可以在 handleLLMEnd 里检查 token 用量是否异常高(可能意味着 Prompt 没有被合理裁剪),或者在 handleToolEnd 里检查工具返回的数据格式是否符合预期。这些判断逻辑集中在 handler 里,不会侵入业务代码。
6. 排错思路:先对比预期步骤和实际 Span
这个思路在前面可观测性那篇讲过,这里把它映射到 LangChain 的 Callbacks 语境里再走一遍。
还是前面那个例子,用户说了三件事。你预期 Agent 应该产生这些 Span:
llm:gpt-4.1-mini→ 模型推理,决定先查天气tool:get_weather→ 查天气llm:gpt-4.1-mini→ 模型推理,决定建提醒和发消息tool:create_reminder→ 建提醒tool:send_message→ 发结果llm:gpt-4.1-mini→ 模型推理,生成最终回复
打开 handler 收集到的 Span 列表,按顺序检查:
如果只有 1 个 llm Span,没有任何 tool Span:
模型根本没有产生工具调用意图。问题在 system prompt 或工具描述不够明确。
如果有 tool:get_weather 但没有 tool:create_reminder:
看 get_weather 的 output。如果天气结果里没有明确出现"雨"字样,模型可能判断不需要提醒。也可能是 create_reminder 的工具描述没有清楚表达它的触发条件。
如果前面都对但没有 tool:send_message:
看第二次 llm Span 的 output。模型可能认为它已经通过自然语言告知了用户,不需要再调 send_message。这时候要调整的是 system prompt 或工具描述。
如果某个 tool Span 的 status 是 error:
直接看该 Span 的 output.error 字段,问题原因一般一目了然。
如果某个 retriever Span 的 status 是 degraded:
说明检索到了但结果为空,可能是向量索引没有更新,或者查询理解偏了。
这套排查方式和前面完全一致。唯一的区别是:前面你要手动 trace.span() 才能看到这些信息,现在 LangChain 的 Callbacks 自动帮你收集了。
7. 总结
如果你的管线完全基于 LangChain 组件,Callbacks 就足够了,不需要再手动写 TraceContext
如果管线里有大量非 LangChain 的自定义逻辑,两种方式混用是最务实的选择