AI 电子伴侣
创建时间: 2026-04-01最后更新: 2026-08-04

1. 前面手动搭了一整套 Tracing,LangChain 里还要再来一遍吗

在第一章的可观测性中,我们从头搭了一套完整的可观测体系:

  • TraceContext:每次请求创建一个实例,用 trace.span() 包装每个管线节点,自动记录输入、输出、耗时、状态
  • 采样策略:错误和降级全量保留,正常请求按比例采样
  • 分层存储:D1 存完整现场,KV 存聚合摘要,waitUntil 不阻塞响应

那套方案很好,但接入成本不算低。每个管线节点都要手动包一层 trace.span(),每次新增节点都要记得埋点。

manual-span.ts
1
// 前面的做法:手动包装每个节点
2
const 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 为所有核心组件都定义了这样的事件对:

模型调用

事件触发时机
handleChatModelStartChatModel 开始推理,携带完整的消息列表
handleLLMEnd模型推理完成,携带生成结果和 token 用量
handleLLMError模型调用失败
handleLLMNewToken流式输出时,每产生一个 token 触发一次

工具调用

事件触发时机
handleToolStart工具开始执行
handleToolEnd工具执行完成
handleToolError工具执行报错

Chain(链)

事件触发时机
handleChainStartChain 开始运行
handleChainEndChain 运行完成
handleChainErrorChain 运行报错

Retriever(检索器)

事件触发时机
handleRetrieverStart检索器开始查询
handleRetrieverEnd检索器返回结果
handleRetrieverError检索器报错

Agent(代理)

事件触发时机
handleAgentActionAgent 决定执行一个动作(选择了哪个工具、传什么参数)
handleAgentEndAgent 本轮运行结束

你会发现这些事件和前面 TraceContext 的 span() 方法做的事是一样的——记录每个操作的输入、输出和状态。区别在于:TraceContext 需要你手动调用,Callbacks 由框架自动触发。

3. 用 Callbacks 重新实现 TraceContext 的能力

理解了事件模型以后,我们来做一件直觉上很自然的事:用 LangChain 的 Callbacks 接口,重新实现一个和前面 TraceContext 等价的追踪器。

这个练习的目的不是为了实际使用(LangSmith 和 LangFuse 已经帮你做好了),而是让你真正理解 Callbacks 和 TraceContext 的映射关系。

3.1 BaseCallbackHandler:所有 handler 的基类

LangChain 提供了 BaseCallbackHandler 基类。你只需要继承它,覆盖你关心的事件方法。没有覆盖的事件会被默认忽略。

trace-handler.ts
001
import { BaseCallbackHandler } from '@langchain/core/callbacks/base'
002
import type { Serialized } from '@langchain/core/load/serializable'
003
004
// 复用前面定义的 Span 结构
005
interface Span {
006
spanId: string
007
name: string
008
startTime: number
009
duration?: number
010
status: 'ok' | 'error' | 'degraded'
011
input: Record<string, any>
012
output: Record<string, any>
013
}
014
015
class TraceCallbackHandler extends BaseCallbackHandler {
016
name = 'TraceCallbackHandler'
017
018
// 和前面的 TraceContext 一样,收集所有 Span
019
private spans: Span[] = []
020
// runId → Span 的映射,用于在 end 事件里找到对应的 start
021
private activeSpans = new Map<string, Span>()
022
023
// --- 模型事件 ---
024
025
handleChatModelStart(
026
llm: Serialized,
027
messages: any[][],
028
runId: string
029
) {
030
// 模型开始推理 → 创建一个新 Span
031
const span: Span = {
032
spanId: runId,
033
name: `llm:${llm.id?.at(-1) ?? 'unknown'}`,
034
startTime: Date.now(),
035
status: 'ok',
036
input: { messages },
037
output: {},
038
}
039
this.activeSpans.set(runId, span)
040
}
041
042
handleLLMEnd(output: any, runId: string) {
043
// 模型推理完成 → 填充 duration 和 output,关闭 Span
044
const span = this.activeSpans.get(runId)
045
if (span) {
046
span.duration = Date.now() - span.startTime
047
span.output = {
048
text: output.generations?.[0]?.[0]?.text?.slice(0, 200),
049
tokenUsage: output.llmOutput?.tokenUsage,
050
}
051
this.spans.push(span)
052
this.activeSpans.delete(runId)
053
}
054
}
055
056
handleLLMError(error: Error, runId: string) {
057
const span = this.activeSpans.get(runId)
058
if (span) {
059
span.duration = Date.now() - span.startTime
060
span.status = 'error'
061
span.output = { error: error.message }
062
this.spans.push(span)
063
this.activeSpans.delete(runId)
064
}
065
}
066
067
// --- 工具事件 ---
068
069
handleToolStart(tool: Serialized, input: string, runId: string) {
070
const span: Span = {
071
spanId: runId,
072
name: `tool:${tool.id?.at(-1) ?? 'unknown'}`,
073
startTime: Date.now(),
074
status: 'ok',
075
input: { toolInput: input },
076
output: {},
077
}
078
this.activeSpans.set(runId, span)
079
}
080
081
handleToolEnd(output: string, runId: string) {
082
const span = this.activeSpans.get(runId)
083
if (span) {
084
span.duration = Date.now() - span.startTime
085
span.output = { result: output.slice(0, 200) }
086
this.spans.push(span)
087
this.activeSpans.delete(runId)
088
}
089
}
090
091
handleToolError(error: Error, runId: string) {
092
const span = this.activeSpans.get(runId)
093
if (span) {
094
span.duration = Date.now() - span.startTime
095
span.status = 'error'
096
span.output = { error: error.message }
097
this.spans.push(span)
098
this.activeSpans.delete(runId)
099
}
100
}
101
102
// --- 检索器事件 ---
103
104
handleRetrieverStart(
105
retriever: Serialized,
106
query: string,
107
runId: string
108
) {
109
const span: Span = {
110
spanId: runId,
111
name: `retriever:${retriever.id?.at(-1) ?? 'unknown'}`,
112
startTime: Date.now(),
113
status: 'ok',
114
input: { query },
115
output: {},
116
}
117
this.activeSpans.set(runId, span)
118
}
119
120
handleRetrieverEnd(documents: any[], runId: string) {
121
const span = this.activeSpans.get(runId)
122
if (span) {
123
span.duration = Date.now() - span.startTime
124
span.output = {
125
documentCount: documents.length,
126
documents: documents.map(d => d.pageContent?.slice(0, 100)),
127
}
128
// 检索到了但结果为空 → 标记为 degraded
129
if (documents.length === 0) {
130
span.status = 'degraded'
131
}
132
this.spans.push(span)
133
this.activeSpans.delete(runId)
134
}
135
}
136
137
handleRetrieverError(error: Error, runId: string) {
138
const span = this.activeSpans.get(runId)
139
if (span) {
140
span.duration = Date.now() - span.startTime
141
span.status = 'error'
142
span.output = { error: error.message }
143
this.spans.push(span)
144
this.activeSpans.delete(runId)
145
}
146
}
147
148
// --- 对外暴露的查询方法 ---
149
150
getSpans(): Span[] {
151
return this.spans
152
}
153
154
hasError(): boolean {
155
return this.spans.some(s => s.status === 'error')
156
}
157
158
hasDegraded(): boolean {
159
return this.spans.some(s => s.status === 'degraded')
160
}
161
162
toJSON() {
163
return {
164
spanCount: this.spans.length,
165
spans: 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 支持两个传入位置,作用域不同:

构造时传入 → 全局生效

callbacks-constructor.ts
1
import { ChatOpenAI } from '@langchain/openai'
2
3
const handler = new TraceCallbackHandler()
4
5
// handler 会收到这个 model 实例以及它内部所有子组件的事件
6
const model = new ChatOpenAI({
7
model: 'gpt-4.1-mini',
8
callbacks: [handler],
9
})

构造时传入的 handler 会向下传播。如果一个 Agent 在构造时带了 handler,那么这个 Agent 内部调用的每个工具、每次模型推理,handler 都会收到事件。这就像前面 TraceContext 被传入整个管线一样。

调用时传入 → 单次生效

callbacks-invoke.ts
01
const handler = new TraceCallbackHandler()
02
03
const result = await agent.invoke(
04
{
05
messages: [{ role: 'user', content: '明天上海天气怎么样?' }],
06
},
07
{
08
callbacks: [handler],
09
},
10
)
11
12
// 这次调用结束后,handler 里就有了完整的 Span 列表
13
console.log(handler.toJSON())

调用时传入的 handler 只对这一次 invoke 生效。适合你只想追踪特定请求的场景——这和前面"每次请求创建一个新的 TraceContext 实例"是一样的思路。

两种方式可以同时使用。 LangChain 会把构造时和调用时的 handler 合并,所有 handler 都会收到事件。

3.3 完整示例:追踪一轮 Agent 运行

agent-with-trace-handler.ts
01
import { createAgent, tool } from 'langchain'
02
import * as z from 'zod'
03
04
const getWeather = tool(
05
async ({ city }) => `${city}:明天小雨,17-22 度`,
06
{
07
name: 'get_weather',
08
description: '查询某个城市未来的天气情况',
09
schema: z.object({
10
city: z.string().describe('要查询天气的城市名'),
11
}),
12
},
13
)
14
15
const createReminder = tool(
16
async ({ content, time }) => `提醒已创建:${time} - ${content}`,
17
{
18
name: 'create_reminder',
19
description: '帮用户创建一个提醒事项',
20
schema: z.object({
21
content: z.string().describe('提醒内容'),
22
time: z.string().describe('提醒时间'),
23
}),
24
},
25
)
26
27
const sendMessage = tool(
28
async ({ text }) => `已发送给用户:${text}`,
29
{
30
name: 'send_message',
31
description: '把一段结果发送给用户',
32
schema: z.object({
33
text: z.string().describe('要发送的内容'),
34
}),
35
},
36
)
37
38
const agent = createAgent({
39
model: 'openai:gpt-4.1-mini',
40
tools: [getWeather, createReminder, sendMessage],
41
systemPrompt: '你是用户的生活助理,会在需要时连续调用工具。',
42
})
43
44
// 每次请求创建一个新的 handler 实例(等价于每次请求创建一个新的 TraceContext)
45
const handler = new TraceCallbackHandler()
46
47
const result = await agent.invoke(
48
{
49
messages: [
50
{
51
role: 'user',
52
content: '帮我看看明天上海天气。如果下雨就提醒我带伞,再把结果顺手发给我。',
53
},
54
],
55
},
56
{
57
callbacks: [handler],
58
},
59
)
60
61
// 调用结束后,handler 里已经自动收集了所有 Span
62
const trace = handler.toJSON()
63
console.log(`共 ${trace.spanCount} 个 Span:`)
64
trace.spans.forEach(span => {
65
console.log(
66
` [${span.status}] ${span.name} - ${span.duration}ms`
67
)
68
})

输出大概会是这样:

trace-output.txt
1
共 6 个 Span:
2
[ok] llm:gpt-4.1-mini - 820ms
3
[ok] tool:get_weather - 3ms
4
[ok] llm:gpt-4.1-mini - 650ms
5
[ok] tool:create_reminder - 2ms
6
[ok] tool:send_message - 1ms
7
[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。

.env.local
1
LANGSMITH_TRACING=true
2
LANGSMITH_API_KEY=lsv2_pt_xxxxxxxxxxxxxxxx
3
LANGSMITH_PROJECT=aicompanion-single-agent

设好以后,你的 Agent 代码完全不用改。每次 invoke 时,LangChain 都会自动把事件发送给 LangSmith 的 handler,后者负责把数据上报到 LangSmith 平台。

这和前面"把 TraceContext 传入管线"的思路是一样的——只是 LangChain 帮你自动做了。

如果你想对个别请求加上 tags 和 metadata(方便在 LangSmith 里筛选),在调用时传入即可:

langsmith-tags.ts
01
const result = await agent.invoke(
02
{
03
messages: [{ role: 'user', content: '明天上海天气怎么样?' }],
04
},
05
{
06
tags: ['single-agent', 'weather-flow', 'staging'],
07
metadata: {
08
user_id: 'user_2048',
09
session_id: 'session_20260331_01',
10
},
11
},
12
)

但如果你有自定义函数(不是 LangChain 组件),LangChain 自然无法自动追踪它们。这时候需要用 traceable 手动包装——这在工具选型那篇已经讲过:

langsmith-traceable.ts
01
import { traceable } from 'langsmith/traceable'
02
03
// 非 LangChain 组件,需要手动包装才能纳入 Trace 链路
04
const customBusinessLogic = traceable(
05
async (input: string) => {
06
// 你自己的业务逻辑...
07
return result
08
},
09
{ name: 'custom_business_logic', run_type: 'chain' }
10
)

4.2 LangFuse:通过 CallbackHandler 对接

LangFuse 的接入方式和我们前面自己实现的 TraceCallbackHandler 一模一样——它也是一个 BaseCallbackHandler 的子类。

install-langfuse.sh
1
yarn add @langfuse/langchain
.env.local
1
LANGFUSE_SECRET_KEY=sk-lf-xxxxxxxx
2
LANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxx
3
LANGFUSE_BASE_URL=https://cloud.langfuse.com
langfuse-callbacks.ts
01
import { CallbackHandler } from '@langfuse/langchain'
02
03
const langfuseHandler = new CallbackHandler({
04
sessionId: 'session_20260331_01',
05
userId: 'user_2048',
06
tags: ['single-agent', 'weather-flow'],
07
})
08
09
const result = await agent.invoke(
10
{
11
messages: [{ role: 'user', content: '明天上海天气怎么样?' }],
12
},
13
{
14
callbacks: [langfuseHandler],
15
},
16
)
17
18
// 确保 trace 数据发送完毕(LangFuse 默认异步批量发送)
19
await langfuseHandler.flushAsync()

注意这和前面工具选型篇里的 LangFuse 用法不一样。前面那篇用的是 LangFuse 的原生 SDK

langfuse-native-sdk.ts
1
// 前面的用法:直接用 Langfuse 原生 SDK,手动创建 trace 和 span
2
const langfuse = new Langfuse({ publicKey: '...', secretKey: '...' })
3
const trace = langfuse.trace({ name: 'ai-companion-chat' })
4
const span = trace.span({ name: 'memory_retrieval', input: { query } })
5
// ... 手动结束 span ...
6
span.end({ output: { memories } })

而在 LangChain 体系里,你用的是 @langfuse/langchain 包提供的 CallbackHandler

langfuse-callback-handler.ts
1
// LangChain 里的用法:传入 CallbackHandler,框架自动调用
2
const handler = new CallbackHandler({ userId: '...' })
3
await agent.invoke(input, { callbacks: [handler] })

两种方式最终都是把数据发到 LangFuse 平台,区别在于谁来负责埋点。 原生 SDK 是你自己手动埋,CallbackHandler 是 LangChain 框架自动埋。如果你的管线完全基于 LangChain 组件,用 CallbackHandler 更省事;如果管线里有大量非 LangChain 的自定义逻辑,混用两种方式更灵活。

4.3 同时挂多个 handler

Callbacks 是一个数组,你可以同时挂多个 handler。它们都会收到同样的事件,互不干扰。

multiple-handlers.ts
01
import { CallbackHandler as LangFuseHandler } from '@langfuse/langchain'
02
03
const traceHandler = new TraceCallbackHandler() // 自己的追踪器
04
const langfuseHandler = new LangFuseHandler() // 发到 LangFuse
05
06
const result = await agent.invoke(
07
{ messages: [{ role: 'user', content: '明天上海天气怎么样?' }] },
08
{
09
callbacks: [
10
traceHandler, // 本地记录,用于采样决策
11
langfuseHandler, // 发到 LangFuse 平台
12
// LangSmith 不用手动加,环境变量开了就自动生效
13
],
14
},
15
)
16
17
// 用本地 handler 做采样决策
18
if (traceHandler.hasError() || traceHandler.hasDegraded()) {
19
// 完整写入 D1...
20
}

这个模式特别适合前面讲过的分层策略:用自己的 handler 做采样决策和自建存储,同时用 LangFuse 或 LangSmith 做可视化排查。

4.4 Serverless 环境的注意事项

在 Cloudflare Workers、AWS Lambda 这类 Serverless 环境中,函数执行完就销毁了。LangChain 默认在后台异步执行 callbacks,可能来不及完成。

解决方法是关掉后台回调:

.env.local
1
LANGCHAIN_CALLBACKS_BACKGROUND=false

设为 false 后,callbacks 会同步执行(阻塞主流程)。如果你希望不阻塞响应,结合前面讲过的 waitUntil 模式:先同步收集事件到内存中的 handler,然后在 waitUntil 里异步刷新到远端平台。

5. 在 Callbacks 里实现采样和 degraded 标记

前面采样策略那篇讲了"异常全量保留,正常按比例采样"的思路。在 LangChain 体系里,这个逻辑可以完全用 Callbacks 实现。

5.1 用 handler 收集信息,在调用结束后做采样决策

sampling-with-callbacks.ts
01
const handler = new TraceCallbackHandler()
02
03
const result = await agent.invoke(
04
{ messages: [{ role: 'user', content: userMessage }] },
05
{ callbacks: [handler] },
06
)
07
08
// 调用结束后,handler 里已经有了所有 Span
09
// 用和前面一样的采样逻辑做决策
10
const totalDuration = handler.getSpans().reduce(
11
(sum, s) => sum + (s.duration ?? 0), 0
12
)
13
14
if (handler.hasError()) {
15
// 有错误 → 完整写入 D1
16
await storeFullTrace(env, handler.toJSON(), userId, sessionId)
17
} else if (handler.hasDegraded()) {
18
// 有降级 → 完整写入 D1
19
await storeFullTrace(env, handler.toJSON(), userId, sessionId)
20
} else if (totalDuration > 5000) {
21
// 异常慢 → 完整写入 D1
22
await storeFullTrace(env, handler.toJSON(), userId, sessionId)
23
} else if (Math.random() < 0.05) {
24
// 正常请求 5% 采样
25
await storeFullTrace(env, handler.toJSON(), userId, sessionId)
26
} else {
27
// 其余只存摘要
28
await storeSummary(env, handler.toJSON())
29
}

5.2 在 handler 里自动标记 degraded

前面 TraceContext 有一个 markDegraded() 方法需要手动调用。在 Callbacks 体系里,你可以把 degraded 的判断逻辑直接写进 handler 的事件方法里,实现自动标记。

上面 TraceCallbackHandlerhandleRetrieverEnd 已经展示了这种做法:

degraded-auto.ts
01
handleRetrieverEnd(documents: any[], runId: string) {
02
const span = this.activeSpans.get(runId)
03
if (span) {
04
span.duration = Date.now() - span.startTime
05
span.output = { documentCount: documents.length }
06
07
// 检索结果为空 → 自动标记为 degraded
08
if (documents.length === 0) {
09
span.status = 'degraded'
10
}
11
12
this.spans.push(span)
13
this.activeSpans.delete(runId)
14
}
15
}

你还可以在 handleLLMEnd 里检查 token 用量是否异常高(可能意味着 Prompt 没有被合理裁剪),或者在 handleToolEnd 里检查工具返回的数据格式是否符合预期。这些判断逻辑集中在 handler 里,不会侵入业务代码。

6. 排错思路:先对比预期步骤和实际 Span

这个思路在前面可观测性那篇讲过,这里把它映射到 LangChain 的 Callbacks 语境里再走一遍。

还是前面那个例子,用户说了三件事。你预期 Agent 应该产生这些 Span:

  1. llm:gpt-4.1-mini → 模型推理,决定先查天气
  2. tool:get_weather → 查天气
  3. llm:gpt-4.1-mini → 模型推理,决定建提醒和发消息
  4. tool:create_reminder → 建提醒
  5. tool:send_message → 发结果
  6. llm:gpt-4.1-mini → 模型推理,生成最终回复

打开 handler 收集到的 Span 列表,按顺序检查:

如果只有 1 个 llm Span,没有任何 tool Span: 模型根本没有产生工具调用意图。问题在 system prompt 或工具描述不够明确。

如果有 tool:get_weather 但没有 tool:create_reminderget_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 的自定义逻辑,两种方式混用是最务实的选择