
AI 应用上线后,经常会遇到一种尴尬情况:用户反馈“等了很久”,服务端日志却只有一条请求开始和一条请求结束。中间到底卡在 Prompt 渲染、Chain 编排、模型调用、工具执行,还是流式输出,日志里完全看不出来。
LangChainGo 的 Callback 可以补上这段空白。它不会改变业务逻辑,也不会让模型变聪明,但能把执行过程里的关键节点暴露出来,方便接日志、指标和链路追踪。
LangChainGo 的 callback 代码位于 github.com/tmc/langchaingo/callbacks 包中。核心抽象是 Handler 接口,覆盖了几类常见事件:
Chain 事件包括 HandleChainStart、HandleChainEnd、HandleChainError;LLM 事件包括 HandleLLMStart、HandleLLMGenerateContentStart、HandleLLMGenerateContentEnd、HandleLLMError;此外还有 Tool、Agent、Retriever 和 Streaming 相关事件。
接口方法不少,直接全部实现会让示例很难读。更顺手的做法是嵌入 callbacks.SimpleHandler,只覆盖真正需要的几个方法:
type TraceHandler struct {
callbacks.SimpleHandler
start time.Time
}
SimpleHandler 已经把所有方法都实现成空操作。业务侧覆盖少量方法即可,不需要写一堆空函数。
先从最常见的需求开始:看一次 Chain 调用到底花了多久。
func (h *TraceHandler) HandleChainStart(ctx context.Context, inputs map[string]any) {
h.start = time.Now()
log.Printf("chain start inputs=%d", len(inputs))
}
结束事件里计算耗时:
func (h *TraceHandler) HandleChainEnd(ctx context.Context, outputs map[string]any) {
log.Printf("chain end outputs=%d cost=%s", len(outputs), time.Since(h.start))
}
func (h *TraceHandler) HandleChainError(ctx context.Context, err error) {
log.Printf("chain error: %v", err)
}
错误日志最好带上 trace id、用户请求 id 或任务 id。只打印一条错误文本,排查链路问题时仍然不够用。完整输入也不要直接写入日志,用户问题、文档片段、访问令牌这类数据一旦落盘,后续清理会非常麻烦。
这里有个容易写错的点:Chain 级事件和 LLM 级事件不是同一层。
对于 LLMChain,创建 Chain 时通过 chains.WithCallback 挂载 handler,chains.Call 执行时才会触发 Chain 开始、结束和错误事件。
handler := &TraceHandler{}
chain := chains.NewLLMChain(llm, prompt,
chains.WithCallback(handler))
注意,chains.WithCallback 不会把模型生成开始/结束回调自动注入所有模型调用。它在 chains.GetLLMCallOptions 里主要会把 handler 转成流式输出回调。要观察模型请求开始和结束,通常需要在具体模型构造时挂 callback。以 OpenAI 适配器为例:
llm, err := openai.New(
openai.WithCallback(handler),
)
自定义 Chain 还要多做一步。如果希望 chains.Call 能拿到 Chain 级 handler,需要让结构体实现 callbacks.HandlerHaver:
func (c *MyChain) GetCallbackHandler() callbacks.Handler {
return c.handler
}
如果自定义 Chain 内部直接调用 llms.GenerateFromSinglePrompt,仍然要把 Chain 选项转换成 LLM 选项继续传下去。这样至少能传递模型名、温度、流式输出函数等调用参数。
func (c *MyChain) Call(ctx context.Context, inputs map[string]any,
options ...chains.ChainCallOption) (map[string]any, error) {
opts := chains.GetLLMCallOptions(options...)
out, err := llms.GenerateFromSinglePrompt(ctx, c.llm, prompt, opts...)
return map[string]any{"text": out}, err
}
少了这一步,调用参数不会继续下沉;至于模型生成开始/结束事件是否触发,还取决于具体模型实例有没有配置 callback handler。
GenerateFromSinglePrompt 会把字符串 prompt 包装成 MessageContent,再调用更通用的 GenerateContent。因此在很多模型实现里,更值得关注的是 HandleLLMGenerateContentStart 和 HandleLLMGenerateContentEnd。
开始事件适合记录消息数量、模型名和请求时间,不适合记录完整 prompt。结束事件可以读取响应对象,但落库时也应以摘要信息为主,例如输出长度、stop reason、模型名、Token 用量等。
func (h *TraceHandler) HandleLLMGenerateContentEnd(ctx context.Context,
res *llms.ContentResponse) {
if res == nil {
log.Print("llm end empty response")
return
}
log.Printf("llm end choices=%d", len(res.Choices))
}
需要注意的是,不同模型实现能够提供的 GenerationInfo 不完全一致。接入指标系统时,不要假设所有 provider 都返回同样的字段。
如果业务开启了流式响应,Callback 还可以观察每个输出分片。chains.GetLLMCallOptions 在没有显式设置 streaming func 且存在 callback handler 时,会把 streaming chunk 转发到 HandleStreamingFunc。
func (h *TraceHandler) HandleStreamingFunc(ctx context.Context, chunk []byte) {
log.Printf("stream chunk bytes=%d", len(chunk))
}
这类事件适合统计首包时间、分片数量和输出速度。流式内容通常就是用户能看到的文本,直接写入日志风险很高,更合理的做法是记录字节数、字符数或 hash 摘要。
首先是隐私问题。Prompt 中可能包含用户输入、业务数据、知识库片段甚至访问凭证。默认只记录长度、数量、耗时、错误类型和必要的业务 id,更适合作为生产环境基线。
其次是并发安全。一个 handler 实例如果被多个请求复用,内部字段就可能被并发读写。前文为了简化演示,把 start 放在结构体字段中。生产代码更适合为每次请求创建独立 handler,或者把状态放到 context 里。
最后是指标粒度。Chain 级耗时回答“这次任务慢不慢”,LLM 级耗时回答“模型调用慢不慢”,Tool 和 Retriever 级耗时回答“外部依赖慢不慢”。只记录其中一种,很容易误判瓶颈。
LangChainGo 的 Callback 不负责提升模型效果,它解决的是另一个更工程化的问题:出了问题以后,能不能快速知道问题发生在哪一步。
多模型适配解决模型切换问题,自定义 Chain 解决流程编排问题,Callback 则负责把执行过程暴露出来。当 AI 应用开始接入真实用户、真实文档和真实工具后,这部分能力会直接影响排障效率。