1. 一次"查不出原因"的故障,让我重新认识了AI应用的可观测性
上个月我们上线了一个基于大模型的客服问答服务,压测阶段一切正常,上线第二天开始陆续有用户反馈"转人工"。我打开监控面板,CPU、内存、QPS全部健康,应用日志里也没有一条报错,但用户的体感就是越来越差。排查了一整个下午,最后发现是RAG检索组件在特定上下文长度下触发了超时重试,模型白白多等了6秒才开始生成。这个故障最让人后背发凉的在于:它不是崩溃,不是报错,而是"慢一点""答非所问"这种软性劣化。传统监控体系对这类问题几乎没有任何感知能力。
那次之后,我把AI应用的可观测性重新梳理了一遍,核心就落在三件事上:Callback、Trace和生产级可观测性。这三者不是三个独立概念,而是一条完整链路:Callback负责在模型调用关键节点埋下事件探针,Trace负责把这些事件串联成一条完整的请求生命线,可观测性体系则负责把Trace、日志、指标统一收口,变成团队能看懂、能告警、能复盘的数据资产。
这篇文章不打算从"什么是可观测性"这种定义讲起,而是直接讲清楚三件事:回调机制在AI框架里到底怎么用、链路追踪为什么是排查LLM应用问题的唯一可靠手段、以及一个生产级AI应用的可观测性体系应该怎么搭。内容偏落地,适合正在做大模型应用、或者已经上线但总觉得"两眼一抹黑"的团队。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Callback:在模型调用的关键时刻插入"钩子",而不是等出事了再翻日志
2.1 回调机制的本质:让框架在关键时刻主动喊你
Callback说白了就是反向调用。正常程序是你调用别人,回调是别人在某个事件发生时反过来调用你写好的函数。打个比方:你不用每隔一秒钟就去掀开洗衣机盖子看衣服洗好没有,只需要在洗衣机上设置一个"洗好了响铃"的钩子,事件发生时会主动通知你。
在大模型应用开发里,框架(LangChain、LlamaIndex、OpenAI SDK、自研推理服务)都会在生命周期关键节点预留回调入口。你在这些入口上挂自己的处理函数,就能在提示词发送前、模型返回后、出错重试时拿到事件数据,做日志记录、Token统计、限流、缓存、成本核算等操作。
回调的另一个常见形态是HTTP层面的Webhook回调。比如很多内部服务会提供类似 /v1/print/status?callback=printVersion 这样的接口,调用方传入一个回调地址,服务处理完异步任务后主动往这个地址推送状态。也就是说,回调既可以是进程内的事件钩子,也可以是服务间的异步通知机制。在AI应用里,这两种形态都会出现,但日常开发中接触最多的还是框架内置的Callback Handler。
2.2 框架回调事件:这些"关键时刻"分别对应什么
目前主流AI框架的回调事件已经相当标准化,以LangChain为例,核心事件可以整理成一张表:
| 事件 | 触发时机 | 典型用途 |
|---|---|---|
on_llm_start |
模型调用即将发起 | 记录提示词、请求ID、开始时间 |
on_llm_new_token |
流式输出产生新token | 实时监控生成速度、做流式日志 |
on_llm_end |
模型调用成功返回 | 记录Token用量、耗时、完整响应 |
on_llm_error |
模型调用抛出异常 | 记录错误类型、触发告警、重试逻辑 |
on_chain_start |
一个链(Chain)开始执行 | 追踪业务节点边界 |
on_chain_end |
链执行完成 | 统计该节点的整体耗时 |
on_tool_start / on_tool_end |
工具(函数/API)调用前后 | 记录外部依赖调用情况 |
on_retry |
重试机制被触发 | 统计重试次数、分析稳定性 |
理解了这些事件,再看Callback的威力:你不需要改动业务代码里的任何一个核心逻辑,只需要在初始化模型或链的时候把自定义的Handler传进去,就能拿到整套执行过程的细颗粒度数据。
2.3 实战:写一个能记账、能限流、能脱敏的回调Handler
我实际项目里最常用的一个Callback Handler长这样:
python复制import time
import logging
from langchain_core.callbacks import BaseCallbackHandler
logger = logging.getLogger("llm.metrics")
class LLMMetricsCallback(BaseCallbackHandler):
def __init__(self):
self.total_tokens = 0
self.request_count = 0
self.error_count = 0
self.start_time = None
def on_llm_start(self, serialized, prompts, **kwargs):
self.request_count += 1
self.start_time = time.time()
# 注意:prompts里可能包含用户敏感信息,落地日志前必须脱敏
safe_prompt = self._mask_prompt(prompts[0]) if prompts else ""
logger.info("LLM request #%s start: %s", self.request_count, safe_prompt)
def on_llm_end(self, response, **kwargs):
if "llm_output" in response and response.llm_output:
usage = response.llm_output.get("token_usage", {})
self.total_tokens += usage.get("total_tokens", 0)
duration = (time.time() - self.start_time) * 1000
logger.info("LLM success, duration=%.1fms, total_tokens=%s",
