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",
duration, self.total_tokens)
def on_llm_error(self, error, **kwargs):
self.error_count += 1
logger.error("LLM error: %s", error)
def _mask_prompt(self, text: str) -> str:
# 简单脱敏:手机号、邮箱替换
import re
text = re.sub(r"1[3-9]\d{9}", "[手机号]", text)
text = re.sub(r"\w+@\w+\.\w+", "[邮箱]", text)
return text
使用时,有两种挂载方式。如果是单个模型调用:
python复制llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
llm = llm.with_config({"callbacks": [LLMMetricsCallback()]})
如果是跑在链或Agent里,建议在请求级别统一挂载,保证整个链上所有模型调用都能被追踪到:
python复制callbacks = [LLMMetricsCallback()]
result = chain.invoke({"question": "你好"}, config={"callbacks": callbacks})
这里有个细节很多人会忽略:回调Handler放在模型级和放在请求级,作用范围完全不同。放在模型级,只有这个模型自己的调用会被捕获;放在请求级,该请求下所有环节的模型调用、工具调用都会被捕获。生产环境建议放在请求级,这样数据和Trace才能一一对应。
2.4 回调里的两个常见坑:同步阻塞和异常吞噬
回调代码虽然看起来只是"顺手记录一下",但它运行在主业务线程上。如果你在回调里做了网络请求、磁盘I/O、甚至调用了另一个大模型,会直接拖慢主链路。之前有个同事在on_llm_end里同步往Elasticsearch写日志,结果ES抖动,整个问答接口跟着超时。回调内部一定只做轻量操作,重活丢进消息队列或异步任务。
另一个坑是回调异常导致主流程失败。有些框架对回调异常处理不完善,Handler里一个NullPointerException会让整个模型调用直接报错。稳妥做法是在回调方法体内自行兜底:
python复制def on_llm_end(self, response, **kwargs):
try:
# 业务逻辑
pass
except Exception as e:
logger.exception("callback handler failed: %s", e)
我自己现在所有回调代码都套这一层保护,宁可监控数据丢一条,也不能让监控把业务拖死。
3. Trace:给每一次RAG问答做一次全链路"CT扫描"
3.1 从一次"答非所问"说起:为什么日志拼不出真相
Callback拿到了事件,但单个事件是离散的。一个完整的AI应用请求往往要经历:接收用户输入 → 判断意图 → 检索向量库 → 召回候选文档 → 构造提示词 → 调用大模型 → 解析结果 → 后处理校验 → 返回用户。这个链条里任何一步慢、乱、错,最终表现都可能一样——用户觉得回答变差了。
日志能告诉你"检索模块第3次调用耗时2秒",但不能告诉你这次检索属于哪个用户请求、用的什么查询词、召回了哪些文档、提示词最终拼成了什么样。要回答这些问题,就需要把一次请求经过的所有节点串成一条完整的链路,这就是Trace。
3.2 Trace的核心概念:Span、Trace ID、上下文传播
Trace体系里有几个概念需要先搞明白:
- Span(跨度):一次操作的最小追踪单位,比如"检索向量库"是一个Span,"调用大模型"是另一个Span。
- Trace:由多个Span组成的有向无环树,代表一个请求从入口到出口的完整路径。
- Trace ID:贯穿整条链路的唯一ID,生成在请求入口,随上下文传播到所有下游节点。
- Span Context:承载Trace ID、Span ID等信息的上下文对象,负责在进程内和跨进程之间传递追踪信息。
不同语言和框架的API虽然长得不一样,但核心就一个思路:入口生成Trace ID,每进入一个子操作就创建一个子Span,挂在父Span下面,退出时记录状态和耗时。 最终把这些结构化的Span数据上报到后端,就能还原整棵调用树。
3.3 动手埋点:OpenTelemetry手动插桩其实很简单
现在做链路追踪,我默认首选OpenTelemetry(OTel),因为它开源、无厂商锁定、生态覆盖最广。下面是一段给RAG检索和模型调用手动埋点的示例:
python复制from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
# 初始化TracerProvider,生产环境会把collector地址配成环境变量
provider = TracerProvider()
provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317"))
)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("customer-service")
def rag_answer(question: str) -> str:
# 入口:一次问答对应一个根Span
with tracer.start_as_current_span("rag.answer") as root_span:
root_span.set_attribute("question", question)
# 子操作1:向量检索
with tracer.start_as_current_span("rag.retrieve", context=root_span) as ret_span:
docs = vector_store.similarity_search(question, k=4)
ret_span.set_attribute("retrieved.docs", len(docs))
ret_span.set_attribute("retrieved.top_score", docs[0].metadata.get("score", 0))
# 子操作2:拼提示词并调用模型
with tracer.start_as_current_span("llm.generate") as llm_span:
prompt = build_prompt(question, docs)
llm_span.set_attribute("llm.model", "gpt-4o-mini")
llm_span.set_attribute("llm.prompt_tokens", estimate_tokens(prompt))
resp = llm.invoke(prompt)
llm_span.set_attribute("llm.completion_tokens", resp.usage.completion_tokens)
return resp.content
这段代码里没有任何一行传统日志,但后端拿到的信息比十行日志都丰富:调用栈结构、每个阶段耗时、检索结果数量、模型和Token消耗,全都有了。
3.4 Trace建好了之后,日常排查应该看什么
Trace不是为了"好看",而是为了能快速回答这几类问题:
- 慢请求慢在哪:看瀑布图里哪个Span宽度最大。是向量库检索慢,还是模型生成慢,还是前置的意图识别慢?一目了然。
- 报错的完整上下文:有了Trace ID,用户反馈"刚才回答错了"时,你能直接定位到那一次请求的完整链路,看模型收到了什么提示词、召回的是什么文档。
- 重试和超时:看Span数量判断同一阶段是否发生了重试;看Span状态码判断哪些下游调用失败了。
- 上下文长度变化:在LLM Span上记录
prompt_tokens属性,按时间聚合,就能看出用户的上下文长度是否有持续膨胀的趋势,提前发现成本隐患。
4. 生产级可观测性怎么搭:指标、日志、追踪之外,AI还要多算一笔账
4.1 三支柱在AI场景下的分工
传统的生产可观测性有所谓"三支柱":指标(Metrics)、日志(Logs)、追踪(Traces)。在AI应用里,它们的分工是:
- 指标:回答"系统现在整体健康吗"——QPS、P95延迟、错误率、Token消耗速率,按分钟聚合,适合做告警。
- 日志:回答"这个请求具体发生了什么"——记录提示词、响应、错误堆栈、脱敏后的用户输入。
- 追踪:回答"一次请求内部每个环节花了多少时间、状态如何"——跨模块、跨服务还原调用链。
一个生产级AI应用,三者缺一不可。只做日志没有追踪,遇到慢请求只能在日志海洋里捞针;只做追踪没有指标,无法回答"平台整体稳定性趋势如何";只做指标没有日志,出了问题不知道根因。
4.2 AI应用特有的"第四笔账":Token、成本、质量和安全
传统业务监控关注的是服务器健康,AI应用除此之外还必须监控模型调用本身。我把它叫做"模型经济账",包括五类指标:
| 指标类别 | 具体指标 | 为什么重要 |
|---|---|---|
| Token消耗 | 每请求Prompt Token、Completion Token、总Token | 直接决定成本,也影响延迟 |
| 成本 | 单请求成本、日成本、模型维度成本 | 算清楚钱花在哪,是老板最关心的 |
| 质量 | 召回率、上下文命中数、用户点赞/点踩率 | 回答好不好,传统监控反映不了 |
| 安全 | 提示词注入拦截数、敏感信息泄漏数、内容合规拦截数 | 护栏是否生效,必须有数 |
| 模型行为 | 温度参数分布、随机种子、模型版本 | 模型版本升级导致行为漂移时,可快速定位 |
前两类是最容易漏的。很多团队上线大模型应用后只看服务器QPS和延迟,结果月底账单出来才发现Prompt Token占比异常高,因为提示词模板里塞进了大量历史会话记录。
4.3 工具选型:别一开始就自研,先用成熟方案跑通
市面上的可观测性工具已经很多,核心选型逻辑是:先跑通,再评估,最后再谈定制。我整理了一个横向对比:
| 工具 | 核心定位 | 部署方式 | 开源 | 适合阶段 |
|---|---|---|---|---|
| Langfuse | LLM调用追踪、成本分析、数据集评测 | 云服务/自托管 | 是 | 中小团队,快速起步 |
| LangSmith | LangChain生态深度集成、调试与评测 | 云服务 | 否 | 深度使用LangChain的团队 |
| Arize Phoenix | 开放标准、可自托管、偏实验评估 | 自托管 | 是 | 对数据隐私要求高的团队 |
| OpenTelemetry + 自建Grafana | 全栈可观测性统一入口 | 自托管 | 是 | 已有监控体系的大团队 |
| 云厂商APM(阿里云ARMS、腾讯云等) | 全栈监控、告警、链路一体化 | 云服务 | 否 | 希望省去运维成本 |
我的建议是:如果还处于产品验证阶段,直接用Langfuse或云厂商APM,一天就能接入;如果已经是几十个微服务的大系统,建议以OpenTelemetry为统一标准,把模型调用纳入已有的技术栈,避免再造一套监控孤岛。
4.4 把三者串起来:Callback是耳朵,Trace是骨头,指标是仪表盘
真正的生产级可观测性,不是三个工具各干各的,而是一套架构:
- Callback负责采集:在模型调用的各个生命周期事件上挂载Handler,把Token用量、耗时、错误信息等捞出来。
- Trace负责串联:把所有事件通过Trace ID关联起来,形成一棵完整的调用树,同时把Token、成本、模型版本等作为Span属性挂上去。
- 指标负责聚合:从Trace和Callback数据里按时间窗口聚合出Token消耗速率、P95延迟、错误率、预估成本,灌入Prometheus之类的监控系统,配置告警。
这一层架构跑通之后,你就同时拥有了三个东西:一份能解释"每一个请求发生了什么"的全链路数据,一堆能反映"系统现在是否健康"的指标,以及一套能主动通知你"出了问题"的告警规则。
5. 实战落地:给一个RAG问答服务装上Callback和Trace
5.1 场景设定:最小可复刻的RAG客服问答
为了把上面这些概念串起来,我以一个典型的RAG客服问答服务为例:用户提问 → 向量库检索产品文档 → 拼提示词 → 调用大模型生成回答。整体用FastAPI暴露接口,LangChain做编排。这是目前最常见的大模型落地形态,我直接用这个场景走一遍完整落地过程。
5.2 第一步:定义统一的可观测性初始化模块
python复制# observability.py
import os
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from langfuse import Langfuse # 可选:用于LLM调用可视化
def init_observability(service_name: str = "rag-service"):
resource = Resource.create({"service.name": service_name})
provider = TracerProvider(resource=resource)
exporter = OTLPSpanExporter(
endpoint=os.getenv("OTEL_EXPORTER_ENDPOINT", "http://localhost:4317"),
insecure=True
)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
return trace.get_tracer(service_name)
有两个配置点我特别说明一下。BatchSpanProcessor是异步批量导出,不会阻塞业务线程,但要注意控制批量大小和导出间隔,生产环境一般设置max_export_batch_size=512、schedule_delay_millis=5000。insecure=True仅用于本地调试,正式环境一定要走TLS。
5.3 第二步:在业务链路埋点,让每个阶段都有据可查
python复制# main.py
from fastapi import FastAPI, Request
from observability import init_observability
from callback import LLMMetricsCallback
app = FastAPI()
tracer = init_observability()
@app.post("/chat")
async def chat(req: Request):
body = await req.json()
question = body.get("question", "")
callbacks = [LLMMetricsCallback()]
with tracer.start_as_current_span("chat.endpoint") as root:
root.set_attribute("app.user_id", body.get("user_id", "anonymous"))
root.set_attribute("app.question_length", len(question))
# 1. 检索
with tracer.start_as_current_span("rag.retrieve") as ret:
docs = vector_store.similarity_search(question, k=4)
ret.set_attribute("rag.docs_returned", len(docs))
# 2. 生成
with tracer.start_as_current_span("llm.generate") as gen:
prompt = build_prompt(question, docs)
response = llm.invoke(prompt, config={"callbacks": callbacks})
gen.set_attribute("llm.model", response.response_metadata.get("model_name", ""))
usage = response.response_metadata.get("token_usage", {})
gen.set_attribute("llm.prompt_tokens", usage.get("prompt_tokens", 0))
gen.set_attribute("llm.completion_tokens", usage.get("completion_tokens", 0))
gen.set_attribute("llm.total_tokens", usage.get("total_tokens", 0))
return {"answer": response.content}
这段代码的埋点策略是:根Span覆盖整个接口生命周期,子Span覆盖检索和模型生成两个关键阶段。每个Span上挂的属性,就是之后做聚合分析的数据源。
5.4 第三步:配置看板和告警,用数字盯住每个版本
数据上报之后,需要在Grafana或Langfuse里建看板。我建议第一版只盯五个数字:
- P95端到端延迟和P95模型生成延迟
- 每请求平均Token消耗和单日预估成本
- 检索模块失败率
- 模型调用错误率(按模型版本分组)
- 平均召回文档数和Top1文档分数(反映检索质量)
告警阈值第一次可以放宽一点,先拿一周基线数据再收紧。比如模型生成P95延迟先按5秒告警,跑一周发现正常在3秒左右,再调到4秒。告警不是越灵敏越好,告警疲劳会让团队麻木。
5.5 上线后最容易忽略的两件事:版本维度和评测回流
可观测性数据不只用于故障排查,还有两个容易被忽略的用法。第一,模型版本维度的对比:同一套提示词,从gpt-4o切到gpt-4o-mini,Token成本立刻下降,但P95延迟和用户点踩率有没有变化?把指标按模型版本分组,这个决策就有了数据支撑。第二,评测回流:线上日志里积累的真实用户问题,定期抽一部分回流到离线评测集,防止模型或提示词调整后出现"线上看着正常、离线指标崩了"的回归。这一步做与不做,决定了你的可观测性体系是"被动救火"还是"主动体检"。
6. 排障实录:回调不触发、Trace窗口打不开,问题出在哪
6.1 回调不触发的三种典型原因
我在社区里看到很多朋友反馈"我明明传了Callback,为什么不执行",结合我自己踩过的坑,原因基本逃不出这三类:
类型一:同步异步混用。 LangChain里同步调用用的是invoke,异步调用用的是ainvoke。如果你定义的是异步Handler(方法带async def),却在同步调用里传入,部分框架版本不会自动适配,事件就悄悄丢了。反过来的情况更常见:定义同步Handler,链里内部用了异步执行,回调也不可靠。统一做法是:主流程用同步就全同步,用异步就全异步,不要混。LangChain 0.2之后提供了AsyncCallbackHandler,异步场景一定继承它。
类型二:回调挂错了层级。 前面提到过,回调挂在模型级只能捕获该模型的事件,挂在链级才能捕获整条链的事件,挂在请求级才能捕获跨模块完整链路。如果你的回调目标是统计整次调用的Token消耗,却挂在了某个子模型上,那统计结果必然偏少。
类型三:请求被缓存命中。 不少团队会在大模型前面加一层语义缓存,同样的问法直接返回上次结果,根本不会走到模型调用这一步。这时候on_llm_start不触发是正常的,但很多人会误以为回调坏了。排查时先在on_chain_start里打点,确认"走到哪一步才断的"。
6.2 Trace窗口不显示、筛选条件突然消失的排查链路
后台Trace页面打开是空白,或者原本能看到的Trace突然看不见了,这个问题我在不同工具上都遇到过。排查顺序我整理成了一条链路:
- 确认数据是否真的上报到了后端:在Trace页面按时间范围搜索,重点看是不是选的时区不对。很多后端默认UTC,你按北京时间查最近一小时,实际查的是凌晨的数据。
- 检查采样率配置:如果设置了
sampling_ratio=0.1,那本来90%的请求就不会产生Trace。用户反馈的"某些请求查不到Trace",很可能就是采样被丢弃了。生产环境建议全量采集请求元数据,只在详情Span上做采样。 - 确认Collector链路是否有断点:SDK → OTLP Collector → 后端,中间任何一个环节挂了,前端自然看不到新数据。看两个地方:SDK侧的
console导出器是否能看到Span,Collector的debug模式是否打印了接收日志。 - 前端资源和展示层的坑:Trace窗口不显示,还有一个很容易踩的原因是浏览器缓存了旧版前端资源,而后端接口已经升级,数据格式不兼容。排查方式是强制刷新、无痕模式打开,或者看浏览器Network面板里Trace查询接口是否返回了非200状态码。
- 筛选条件消失:这类问题多半是后端版本升级改变了查询参数格式,老链接里的字段名失效。不要只在UI上点,直接看API请求参数和后端日志,多半是服务端抛了字段不存在的异常。
6.3 接入可观测性之后性能反而下降了?
如果接入Callback和Trace之后接口变慢,优先查这几处:
- 同步导出器:早期图省事用了
SimpleSpanProcessor,它是同步的,每条Span都立即发送,网络抖动直接拖慢主线程。换成BatchSpanProcessor就好。 - 回调里做了重操作:前面强调过,回调里做网络请求和磁盘I/O是大忌,会让生成链路的延迟雪上加霜。把落库操作改成异步写入消息队列。
- Span属性打得太密:每条Span挂了几十上百个属性,序列化开销不小。建议每个Span只挂真正要用于聚合分析的属性,详情数据放到日志里。
6.4 敏感数据脱敏:Trace和日志不能裸奔
大模型应用的可观测性有一个天然矛盾:要排查问题,就得记录提示词和响应;但提示词里往往带着用户手机号、身份证、企业机密。我的做法是三层防线:
- 在Callback采集端就地脱敏,日志和Span里只落脱敏后的文本(代码见2.3节)。
- 在OTel SDK侧配置属性过滤器,对内网敏感字段(如
http.request.body)直接丢弃,不让它进后端。 - 在后端配置数据保留策略,Trace详情保留7天,聚合指标保留30天,过期自动清理。
这套三层方案落地之后,既能满足排查需求,又能过内部安全和合规评审。
结尾:我的个人体会
这几轮折腾下来,最深的感受是:大模型应用的可观测性,本质上是把你对系统的安全感从"猜"变成"看"。再聪明的模型,再不透明的推理过程,只要Callback事件能抓到、Trace链路能串起来、指标能聚合出来,问题就一定有迹可循。认知层的东西讲再多都不如动手跑一遍。我建议你从最小的场景开始:先给一个最简单的LLM调用挂一个Callback Handler,把Token和耗时打出来;再接入一个Trace后端,跑通一次完整的RAG链路。这两个小实验做完,你对这篇文章里每个概念的理解会比读十篇文档都深。等这套基础打牢了,再去考虑成本核算、质量评估、模型版本治理这些进阶能力,你会发现所有数据底座都已经就位了。
