作为一只常年在大模型应用开发一线摸爬滚马的团队,我们最近几乎每天都在跟 Callback、Trace 和 可观测性 这三个词死磕。前阵子线上一个AI问答功能突然开始“胡说八道”,排查了大半天,最后发现是LLM调用超时触发了降级逻辑,而日志里只记录了一个“Unknown error”——那一刻我就意识到,大模型应用的可观测性,绝对不是装个监控面板、看几个指标那么简单。
这篇文章就把我们这段时间的实践心得整理出来,力求把 Callback、Trace 和生产级可观测性这三件事讲透。不管你是刚接触大模型开发的新手,还是正在被线上AI应用“玄学问题”折磨的架构师,这篇都能给你一些可以落地参考的东西。
1. 大模型应用的可观测性,为什么和传统后端完全不一样
我们做传统后端时,可观测性三板斧是日志、指标、链路追踪,围绕的是“请求-响应”这个确定性的循环。但到了大模型应用这里,情况发生了两个根本性变化:一是 不确定性的输入与输出,同样的Prompt,模型返回的内容可能每次都有细微差异,甚至偶尔“抽风”输出格式错误;二是 成本与性能的敏感性,一次请求可能要消耗几千个Token,一个循环调用链可能串起多个模型与工具,出了问题如果不能在分钟级定位,用户流失和资源浪费都是实打实的损失。
1.1 传统监控体系在大模型场景下的“失灵”
传统监控依赖的是固定的状态码和预定义的错误类型。可大模型应用的错误往往不是“500 Internal Server Error”这样直白的信号,而是隐藏在文字中的“幻觉”、格式错误、超时重试、上下文截断。我见过太多团队,明明模型已经因为上下文窗口超限而报错,监控面板上的系统指标却一切正常,因为 CPU、内存、QPS 都没爆,问题出在“Token用量”和“Prompt长度”这种传统监控根本不会关注的维度上。
更麻烦的是,大模型应用的调用链比传统后端要长得多。一次用户请求,可能经历“意图识别 -> Prompt模板渲染 -> 调用LLM -> 解析结果 -> 调用数据库/外部API -> 二次调用LLM做校验”这么多个环节。任何一个环节出问题,都会导致最终结果不可用。这种情况下,Trace 不再是可选项,而是必备项。
1.2 认清大模型可观测性的四个核心维度
基于实际踩坑经验,我给大模型应用的可观测性总结了四个核心维度,缺一不可:
- 质量维度:模型返回的内容是否正确、是否包含幻觉、是否满足格式要求。这通常需要额外的评估机制,比如人工反馈、自动规则检查,或者用另一个模型来做裁判。
- 成本维度:单次请求的Token消耗、多轮对话的累计Token消耗、不同模型的成本对比。大模型应用的成本大头就是Token,不盯紧这里,月底账单会让你怀疑人生。
- 性能维度:LLM调用的延迟、首Token响应时间、完整响应时间、重试次数。大模型调用动辄几秒,性能瓶颈和网络抖动、模型负载都有关,需要细粒度追踪。
- 行为维度:AI应用是否按照预设的逻辑执行了正确的工具调用、是否触发了不该触发的分支、Agent的每一步决策是否合理。这个维度通常被忽视,但排查“AI为什么不按套路出牌”时,是最关键的。
这四个维度,正好对应了我们接下来要讲的 Callback(行为与质量) 和 Trace(全链路关联)。理解了大模型可观测性的特殊性,我们再来看具体的技术手段。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Callback是什么:大模型生命周期的“监控探头”
我第一次接触 Callback 机制是在用 LangChain 的时候,当时只觉得它不过是“函数回调”的Python语法糖。直到有一次排查线上问题,我发现一个Agent应用在自己定义的工具里偷偷改了全局状态,导致后续所有请求的行为都变得诡异,才意识到 Callback 不仅仅是调试工具,更是把握大模型应用行为脉搏的关键入口。
2.1 从LangChain开始理解Callback的挂载点
LangChain 把大模型的一次调用抽象成了比较清晰的生命周期,而 Callback 就是在这些生命周期节点上挂载的“监听器”。用代码来展示最直观:
python复制from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.messages import HumanMessage
from langchain_openai import ChatOpenAI
class MyCallbackHandler(BaseCallbackHandler):
def on_llm_start(self, serialized, prompts, **kwargs):
print(f"LLM 调用开始,输入 prompts: {prompts}")
# 记录开始时间,后续计算耗时
self.start_time = time.time()
def on_llm_end(self, response, **kwargs):
print(f"LLM 调用结束,输出内容: {response}")
generated_text = response.generations[0][0].text
print(f"生成的文本长度: {len(generated_text)}")
# 记录结束时间,计算延迟
elapsed_time = time.time() - self.start_time
print(f"LLM 调用耗时: {elapsed_time:.2f} 秒")
def on_llm_error(self, error, **kwargs):
print(f"LLM 调用出错: {error}")
# 错误信息记录到日志,并关联 trace_id
callback_handler = MyCallbackHandler()
llm = ChatOpenAI(
model="gpt-4o-mini",
temperature=0.7,
callbacks=[callback_handler] # 在调用时直接挂载
)
response = llm.invoke("你好,请介绍一下你自己")
这段代码里的 on_llm_start、on_llm_end、on_llm_error 就是三个最基本的挂载点。但 LangChain 不只是这几个钩子,更完整的还包含 on_chain_start、on_chain_end、on_agent_action、on_tool_start、on_tool_end 等等。我们团队在后来的实践中发现,孤立的 LLM 调用监控意义有限,真正有价值的是把这些钩子串联成完整的“行为录像”,然后在录像上做分析。
2.2 生产级Callback:不能只print,要结构化采集
很多人写到上面那一步就停了,以为“能看到输出就行了”。但在生产环境,print 到大盘日志里,对排查问题几乎没有帮助。真正的生成级 Callback 设计,应该考虑以下几点:
- 结构化事件输出:不要 print 字符串,而是输出 JSON 结构化的事件对象,包含
type(事件类型)、timestamp、trace_id、span_id、model_name、prompt_tokens、completion_tokens、latency_ms、content等字段。 - 采样策略:生产环境流量大,不可能每条事件都完整落库。我常用的策略是,正常请求按 10% 采样,报错或延迟超过阈值的请求 100% 采集。
- 上下文关联:Callback 事件必须能和当前请求的 Trace ID 关联上。如果你的应用已经接入了 OpenTelemetry,可以通过
LangChain Instrumentation实现自动关联,这个后面细讲。 - 避免阻塞主流程:Callback 里的处理逻辑(写日志、上报存储)不能阻塞主链路,否则用户请求会变慢。我的做法是把事件写入一个内存队列,由后台异步任务消费并上报。
2.3 实战:一个巡检Agent的Callback配置
今年年初我们做过一个代码巡检Agent,它的核心流程是“读取代码 → 识别潜在问题 → 生成修复建议”。当时线上出现了一个诡异现象:同一个仓库、同一条规则,有时候巡检结果正常,有时候完全漏检。我们就是在 Callback 里加了 on_agent_action 和 on_tool_end 两个钩子,把Agent每一步选择的工具、工具返回的内容摘要都记录下来,回放了一遍才定位到问题:Agent在某些情况下选择了“跳过”,而 Context 里给它温度设置过高,导致决策不稳定。
这里的核心经验是:对于Agent类应用,Callback 采集的重点绝不只是“最终结果”,而是中间的每一步决策路径。建议从 LangChain 的 on_agent_action 钩子里,把 tool_name、tool_input、thought 这些字段结构化保存下来。这类数据的价值在事后复盘时会让你惊喜。
3. Trace与OpenTelemetry:让每一次AI调用都有“全链路GPS”
Callback 能解决单机单进程内的事件采集,但大模型应用往往是复杂微服务架构里的一环。用户在页面点了个按钮,请求可能经过网关、业务服务、Agent服务、模型网关,最后才到达 OpenAI 或国产大模型。如果只看单个服务的日志,A 服务认为成功,B 服务认为超时,谁来背锅?这时候 Trace(分布式链路追踪) 就该登场了。
3.1 为什么是OpenTelemetry而不是自己造轮子
现实中,很多团队最初会自己设计一套简单的 Trace 方案:在 HTTP 请求头里塞一个 request_id,然后把日志都打到同一个 ID 下。这种做法在流量不大、链路不深时还算能用,但一旦你要分析跨服务的耗时分布、定位某一个慢节点,自己造的轮子往往会卡在“没有标准采样规则”“没有统一的 Span 语义”“排查工具链缺失”这三大难题上。
OpenTelemetry(OTel) 现在基本算是可观测性领域的事实标准。它把“埋点”、“传输”、“分析”三层拆开,支持多种语言 SDK,有成熟的 Collector 可以统一接收数据,还能导出到 Jaeger、Zipkin、SkyWalking 或各类商业可观测性平台。我们团队的决定是:不重复建轮子,直接用 OTel 语义约定定义模型调用 Span,再用 Jaeger 做链路可视化。这能让我们把精力集中在 AI 业务的数据价值上。
3.2 大模型调用的 Span 设计:不只是“加一个跨度”
我做了一个最小可运行的 FastAPI + OpenTelemetry + LangChain 例子,实际项目里可以直接参考:
python复制from fastapi import FastAPI, Request
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
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
# 1. 初始化 TracerProvider,并配置导出器到本地 OTel Collector
provider = TracerProvider()
processor = BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True))
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
# 2. 给 FastAPI 和 requests 库加自动埋点
app = FastAPI()
FastAPIInstrumentor.instrument_app(app)
RequestsInstrumentor()
# 3. 获取一个 Tracer 实例
tracer = trace.get_tracer(__name__)
@app.post("/chat")
async def chat(request: Request):
payload = await request.json()
prompt = payload.get("prompt", "")
# 4. 创建一个自定义 Span 表示 LLM 调用
with tracer.start_as_current_span("llm.chat") as span:
span.set_attribute("llm.prompt", prompt)
span.set_attribute("llm.model", "gpt-4o-mini")
# 这里实际调用 LangChain 或原生 SDK
response_text = call_llm(prompt)
span.set_attribute("llm.response", response_text)
# 注意:OpenTelemetry 不建议存大量文本,生产环境应存摘要或哈希
# span.set_attribute 的 value 也不建议过长,否则对存储压力大
return {"reply": response_text}
这段代码的关键在于:
- 把 LLM 调用包在一个自定义的
llm.chatSpan 里; - 通过 OTel 自动埋点,HTTP 请求(从网关到本服务)的 Trace Context 会自动传递;
- Span 上挂的关键属性(Prompt、模型名、响应)便于后续在 Jaeger 里按属性和内容检索。
3.3 结合LangChain CallbackHandler自动接OTel
如果你用的是 LangChain,其实不用自己手写 Span 埋点。langchain-core 从 0.1.x 开始支持通过 OpenAI 的 OpenInference 或第三方库自动把 LLM 调用转换成 OTel 格式的 Span。更常见的做法是安装 traceloop-sdk 或 openinference-instrumentation-langchain 这类库:
bash复制pip install traceloop-sdk openinference-instrumentation-langchain
python复制from traceloop.sdk import Traceloop
Traceloop.init(disable_batch=True, exporter_endpoint="http://localhost:4317")
# 或者使用 OpenInference 的自动埋点
from openinference.instrumentation.langchain import LangChainInstrumentor
LangChainInstrumentor().instrument()
这样,LangChain 内部的 Chain、Agent、Tool 都会被自动封装成 Span。我在本地的 Jaeger 里观察到的链路层次大约是:
code复制POST /chat
└── ChatOpenAI (llm span)
├── Prompt template rendering
└── LLM call (OpenAI API)
这个结构已经足够定位“哪一步慢”、“哪一步出错”了。如果你需要更细粒度,还可以在业务代码里手动添加子 Span。
3.4 Trace与Callback怎么分工
很多读者到这里容易混淆:既然 Trace 已经把链路串起来了,还需要 Callback 做什么?我们的实践是 Callback 做“事件级”的采集,Trace 做“链路级”的关联,两者相辅相成:
| 维度 | Callback | Trace |
|---|---|---|
| 作用范围 | 单进程内的生命周期钩子 | 跨服务、跨进程的调用链路 |
| 典型数据 | Prompt、响应文本、Token用量、工具决策 | 请求耗时、Span状态、依赖关系 |
| 核心价值 | 深入理解模型行为与质量 | 快速定位故障点与性能瓶颈 |
| 关联方式 | 在 Span 内触发,携带 trace_id | 通过 Trace Context 传播 |
| 典型工具 | LangChain Callback、自定义Handler | OpenTelemetry、Jaeger、Zipkin |
两者不是二选一,而是在一个生产系统里必须同时存在。用 Trace 解决“哪个环节慢了、断了”,用 Callback 解决“这个环节为什么这样做、输出是否合理”。
4. 生产级可观测性落地:从日志到度量,再到警报与评估
有了 Callback 和 Trace 这些基础观测数据,接下来的一步才是真正拉开团队差距的地方:如何把这些零散的事件和 Span 转化为能指导决策的度量、监控和评估体系。
4.1 需要关注的关键性能与成本指标
我们团队目前维护着一张“大模型应用核心指标表”,每天开发都会盯一眼,出现异常立刻定位。这里直接分享给大家,你可以照着这个思路定义自己的指标:
- 首Token延迟(TTFT):从发起请求到收到第一个Token的时间。这个指标直接影响用户体验,也是排查“模型响应半天没动静”的第一依据。
- 总生成延迟(TPOT/TBT):从发起请求到完整响应的时间。
- Token吞吐量:每秒处理的Token数量,和并发、模型规格强相关。
- 单请求Token消耗:Prompt tokens + Completion tokens,是成本核算的最小单位。
- 模型调用成功率:计算“成功响应数 / 总调用数”,注意这里要区分 HTTP 成功和内容质量成功。
- 重试率:由于超时、限流、备用模型切换等原因触发的重试次数占比。
- 成本增长率:按日/周维度对比Token成本的增幅,防止失控。
我强烈建议把这些指标通过 OTel Metrics API 或者 Prometheus 客户端暴露出来,配合 Grafana 做可视化。大模型应用的容量规划,不能只看 QPS,必须结合 Token 量,否则很容易出现“QPS不高但成本翻倍”的诡异账单。
4.2 端到端可观测性落地步骤(以一个真实线上AI问答系统为例)
下面是我们为一个 AI 问答系统落地可观测性的完整步骤,你可以照着做,基本可以覆盖中小型AI应用的需求:
第一步:梳理调用链路。 先画出完整链路图:客户端 -> 网关 -> 业务服务(含身份鉴权/会话管理) -> Agent服务(Prompt构造、意图识别) -> 模型网关(负载均衡/多模型路由) -> 大模型API。明确每一层的职责与边界,这是后续埋点的基础。
第二步:选型与部署 OTel 基础设施。 我们用的是 Docker Compose 部署 OpenTelemetry Collector + Jaeger + Prometheus + Grafana + Loki。Collector 统一接收 OTLP 格式的 Trace/Metric/Log,然后分发给各个后端。部署参考逻辑:
yaml复制# docker-compose.yml 简化版
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # Jaeger UI
- "4317:4317"
- "4318:4318"
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
第三步:在你的业务服务里接入 SDK 和自动埋点。 对 Python 服务,用 opentelemetry-instrumentation-fastapi;对 Java 服务,用 opentelemetry-javaagent.jar。先不要纠结手动埋点,把自动埋点跑通。
第四步:实现 LLM 调用环节的自定义 Span 和 Callback 采集。 参考前文代码,在 llm.chat Span 上挂模型名、Token用量、耗时等关键属性。用 Callback 把 Prompt 和响应摘要写入日志或专门的存储。
第五步:配置指标暴露与日志采集。 通过 Prometheus 客户端暴露自定义指标如 llm_request_total、llm_token_total、llm_request_duration_seconds。日志建议统一 JSON 格式,方便 Loki 索引与检索。
第六步:设置告警规则。 没有告警的可观测性是不完整的。我们设置的几个首要告警规则:
- 模型调用成功率 < 95% 持续 5 分钟;
- 首Token延迟 P95 > 3 秒;
- 单日 Token 消耗增长率 > 30%;
- LLM 错误中出现特定错误码时立即告警。
这一步做下来,基本上能解决“出了事不知道去哪看”的问题。但要真做到生产级,还有几块硬骨头需要啃。
4.3 生产级可观测性需要克服的三个真实挑战
挑战一:高并发下的成本与存储取舍。 全量采集 Trace 和 Prompt 在低流量时没问题,一旦日请求量到百万级别,存储成本会飞速上涨。我们的解法是三级采样:正常请求只记录 Trace 元数据不记录 Prompt 内容,慢请求和报错请求记录完整 Prompt/Response,另外设置每日采样上限。数据虽然“少”了一点,但有效信息一点没丢。
挑战二:Prompt与响应文本的敏感性。 大模型应用很容易触及用户隐私和商业机密。我们把 Prompt 和响应默认做脱敏处理,只保留文本长度、哈希值、关键词标签,只有在排查特定问题时才通过白名单权限查看原始内容。
挑战三:评估“质量”很难量化。 除了准确率、召回率这些搜索时代的老指标,大模型时代更需要关注“幻觉率”、“上下文相关性”、“指令遵循率”。我们目前的实践是引入一个“评估回调”——在每次 LLM 调用结束后,异步调用一个更便宜的模型,对原输出做自动打分。这个数据以 Metric 形式上报,方便集中观察质量趋势。
4.4 建立模型评估反馈闭环
可观测性最终目的,不只是“发现问题”,而是“改进系统”。我们在收集了大量 Trace 和 Callback 数据后,会定期做“失败样本回放”——从存储中筛选出质量分低于阈值的样本,由业务专家标注原因,再用来增量微调 Prompt 或模型。这个流程已经变成我们每周一次的固定迭代动作。
这个闭环里 Callback 和 Trace 的作用非常清晰:Callback 拿到“这一个 Prompt 生成得不好”,Trace 告诉我们“这个不好的结果经历了哪些服务、耗时多少”,两者结合就能定位是 Prompt 本身的问题、模型选择的问题,还是上下文拼装环节被截断的问题。
5. 实操心得:一次线上故障排查的全程复盘
说了这么多理论和设计,最后分享一个实际的排查案例,让大家看看这些手段是怎么在关键时刻发挥作用的。
5.1 现象:AI助手开始“胡言乱语”
有段时间,我们的智能客服系统频繁出现答非所问,用户问“退款怎么操作”,AI 却回答“你好,我是智能助手,请问有什么可以帮你”。起初以为只是个别模型抽风,但人工投诉量在半天内翻了五倍,我们被迫切流至备用模型,然后开始排查。
5.2 排查路径:从Trace定位范围,从Callback还原真相
Step 1:看 Trace。 打开 Jaeger,按 POST /api/chat 和最近 30 分钟过滤,按错误状态和耗时排序。发现故障请求集中在某个指定的 Node 实例,且耗时明显偏高,均超过 5 秒,而正常实例是 1-2 秒。初步判断:问题不在模型本身,而在某个实例的环境或状态。
Step 2:看 Callback 事件。 从存储里导出故障实例的 Callback 事件,重点看 on_llm_start 和 on_llm_end。发现这些请求里,LangChain 的 on_llm_start 记录的 Prompt 内容被截断了,缺少了系统提示词和部分上下文,只有用户问题本身。
Step 3:看日志与代码。 顺着日志定位到 Prompt 拼装函数,发现有个全局变量在并发场景下会被覆盖——某些请求在从 Session 里读取上下文时,恰好读到另一个线程写入的中间态,导致 Prompt 不完整。
Step 4:修复与验证。 修复方式是把 Session 读取改为请求级别的上下文快照,并在并发测试里复现验证。上线后再看 Jaeger,同实例耗时恢复,答非所问消失,质量评分恢复。
5.3 复盘:如果没上可观测性会怎样
这个 bug 说穿了并不复杂,但如果没有 Trace,我们很难几分钟内把排查范围从“整个系统”缩小到“特定实例”;如果没有 Callback 里保存的 Prompt 快照,我们很难发现自己组装给模型的内容是残缺的。这两类数据缺一个,排查时间可能从半天拉长到一周。
这也是我在给团队内部分享时反复强调的一句话:可观测性不是为了“好看”,不是为了“老板要看大屏”,而是为了在系统出毛病时,把定位问题的平均时间(MTTR)从“小时级”降为“分钟级”。
6. 给不同阶段的团队:落地优先级建议
如果你正处于不同阶段,可以参考下面的落地优先级,避免一上来就摊大饼:
- 刚接触大模型应用开发、还在demo阶段的团队:先使用 LangChain Callback 做本地日志输出,重点观察一次调用的完整生命周期和Token消耗。不用急着上全套 OTel。
- 已经有线上业务、但经常“莫名其妙”出问题的团队:优先打通 OpenTelemetry + Tracing,实现链路可视化。花一到两天把现有服务的自动埋点和链路串联搞定,排查效率提升立竿见影。
- 线上业务稳定、成本压力大、正要优化模型质量的团队:重点投入 Callback 事件采集与模型质量评估闭环,建立失败样本库,每周迭代 Prompt 和模型选择策略。
- 追求行业标杆级可观测性的团队:可以考虑引入 LLMOps 平台(如 LangSmith、Langfuse、Arize Phoenix 等),在 OTel 之上增加模型评测、数据集管理、Prompt 版本管理等功能。这些工具不少都支持自托管,数据敏感性问题也能解决一部分。
我个人不推荐团队一上来就追求“全家桶”,因为可观测性体系的搭建本身也有成本,先解决当前最痛的问题,再逐步完善,是比较务实的路径。
做 AI 应用和做传统应用完全不同,最大的差异是“黑盒性”——你看不到模型内部在怎么思考,只能从输入输出和中间行为去推断。Callback 和 Trace,就是我们在这条黑盒隧道里装上的“声呐”和“探照灯”。投入产出比其实很高,值得每个做 AI 应用的团队认真对待。
