前段时间团队上线了一个基于 LangChain 的 RAG 客服问答机器人,线上跑了两周,问题开始一个个冒出来。用户反馈凌晨时段回答特别慢,个别请求甚至要 40 秒;监控面板上 CPU、内存、磁盘全部正常;日志里只有零星几条超时异常,完全看不出根因。当时我们最迫切需要弄清楚的,就是用户的一次提问到底经过了哪些环节——向量检索花了多久、大模型调用花了多久、中间有没有重试、是不是误连到了某个慢速外部工具——传统的服务器监控完全回答不了这些问题。这就是我决定把 OpenTelemetry 全面接入 LangChain 应用的原因,目标很直接:让基于 LangChain 的应用链路在观测云上实现全链路可观测。
这篇文章不讲虚的,主要围绕我们落地过程中的真实经验来写。内容包括:为什么 LLM 应用比传统应用更依赖全链路追踪、OpenTelemetry 的 GenAI 语义约定是怎么回事、LangChain 项目中完整接入 OTel 的配置步骤、Trace 数据如何关联成本和回答质量,以及我们踩过的几个埋点陷阱。内容面向正在用 LangChain 做应用开发、并且开始意识到"线上出问题查不动"的开发者,也适合想从零搭建 LLM 应用可观测体系的团队参考。
1. 为什么 LLM 应用尤其需要全链路可观测——从一次线上事故说起
1.1 传统监控在 LLM 应用面前的三层盲区
传统 Web 应用的可观测性,核心是围绕服务器展开的:请求量、错误率、延迟、CPU、内存、磁盘、GC 停顿。这套体系对常规业务系统是够用的,因为应用的瓶颈往往就在资源层或数据库层。但 LangChain 应用不是这种形态,它的调用链拉得非常长:用户输入可能先经过意图识别,再触发检索器去向量库召回文档,召回结果拼装进 Prompt,最后调用大模型生成回答,某些场景还要再调外部工具做后续动作。整条链路的耗时大头可能根本不在我们的服务器上,而在外部 API 的响应时间里。
那一次凌晨的慢请求事故,我们最初怀疑是数据库连接池不够,扩容之后毫无改善;后来又怀疑是部署 Pod 的资源限制,调整 Limits 也没用。最后顺着 Trace 数据才看清真相——检索器在某个特定条件下会去调用一个第三方文档解析接口,该接口的免费额度用完后响应从 200ms 退化到 8 秒,同时触发了内部重试机制,一个请求最多重试 3 次,单次用户问题最坏情况下光这个环节就要 30 多秒。这种情况,传统监控根本无能为力,因为你无从得知"慢"是发生在哪一层、哪一次外部依赖上。
1.2 LLM 应用可观测性的三个特殊维度:模型黑盒、动态上下文、Token 成本
除了链路变长,LLM 应用还带来了三个传统可观测性没有充分覆盖的新维度。
第一个是模型本身是黑盒。传统应用里,数据库是慢是快、有没有报错,我们都有明确的指标和日志。但大模型的推理过程就算对我们这些开发者也是不透明的,我们能观察到的只有输入输出的 token 数、响应延迟、返回内容,以及偶尔出现的限流或审核报错。这些维度必须通过额外的手段采集。
第二个是 Prompt 是动态生成的。传统应用的请求参数虽然千变万化,但至少我们能通过日志还原。LangChain 里,每一次调用的 Prompt 都是由模板、检索到的文档、历史对话记录、甚至另一个模型的输出拼接而成,如果不专门记录,事后根本没法还原"模型到底看到了什么"。而"模型看到了什么"恰恰是排查回答质量问题的关键。
第三个是成本随着调用量线性上升。大模型 API 按 token 计费,同样一个功能,Prompt 设计得啰嗦一点、检索时多塞几段不相关文档,单次成本可能就翻一倍。没有 Trace 数据,你很难定位到是哪一次调用、哪个 Prompt 模板在烧钱。
这三个维度叠加在一起,结论就非常清晰了:我们需要一种不仅能看服务器状态、还能完整还原"一次用户请求从进入到返回全过程中每个内部步骤"的机制。这正是 OpenTelemetry 分布式追踪擅长的领域。
1.3 全链路可观测的核心价值:还原一次请求的完整生命周期
"全链路可观测"这个词听起来很大,落到实际场景里其实就一句话:任何一个用户问题进来,你能按时间线回答出它经过了哪些服务、哪些组件、每一步花了多久、传入了什么、输出了什么、在哪里出错。
我用一个比例来说明它和传统监控的区别。传统监控类似于看城市的交通摄像头,你能看到某条路堵了,但不知道拥堵是不是因为某个路口的红绿灯坏了。全链路追踪则像是给每一辆车装了定位器,你不但知道哪里堵,还能回放每一辆车的完整行驶路线,精确到哪一段路多走了、哪个路口等太久、哪辆车绕了远路。
这个能力在 LangChain 场景下尤其重要,因为一次回答的质量和延迟,往往取决于链路里几个环节的组合效应,而不是单点。比如检索召回率低,模型拿不到关键文档,回答就会敷衍;检索结果太多太长,Prompt 上下文超长,延迟和 token 消耗就一起涨;外部工具偶尔超时,触发重试后反而把整个请求拖垮。全链路可观测的价值,就是把这些问题从"感觉哪里不对"变成"就是这一步出了问题"。
2. 技术底座为什么要选 OpenTelemetry:OTel 本质与 GenAI 语义约定
2.1 OTel 不是一款"监控软件",而是一套标准化的数据采集框架
说到可观测性,很多人的第一反应是 Prometheus、Grafana、SkyWalking 这类具体产品。OpenTelemetry 的思路和它们不一样,它不做完整的监控展示,而是把数据采集、数据传播、数据导出这三个环节做成了标准。
打个比方,OpenTelemetry 相当于物流行业里的标准快递面单。不管你的货物本身长什么样、是哪家快递公司承运、最终送到哪个仓库,面单上的字段是统一的:发件人、收件人、物品名称、重量、体积。这样整个物流链条里的每一方——中转站、干线运输方、末端配送点——都能识别和交接,不需要为每一家快递公司定制一套表单。
放到可观测性领域:OpenTelemetry 定好了 Trace、Metrics、Logs 三类数据模型和采集 API,业务代码只需按 API 埋点,数据通过统一格式导出到任意支持该格式的后端。今天我们用 A 平台做展示,明天换 B 平台,业务代码完全不用动,因为导出协议是标准的。
2.2 为什么 LangChain 应用选 OTel 而不是自己打点写日志
LangChain 是一个快速迭代的框架,今天用 OpenAI,明天可能换 Claude 或国产模型;今天用 JSONLoader 读文档,明天可能接别的检索器。如果可观测性方案是跟某个具体平台深度绑定的,框架一升级、模型一更换,埋点代码就要跟着返工。
OpenTelemetry 的厂商中立特性正好解决了这个痛点。我们只需要在应用里接好 OTel SDK,后续换模型、换向量库、换导出后端,业务侧的埋点代码基本都是稳定的。另外,LangChain 生态里已经有现成的 OpenTelemetry 集成方案,不需要我们手工去包一层调用逻辑,只需要做好参数配置。这一点我后面会详细展开。
2.3 GenAI 语义约定:让 Token 数、模型名、Prompt 内容成为统一的可观测字段
光有通用的 OTel 标准还不够。传统的 HTTP 请求追踪里,Span 的字段主要是 URL、状态码、延迟这些;但 LLM 应用需要记录的信息完全不同——模型名称、请求/响应 token 数、温度参数、Prompt 内容、补全内容、向量检索的文档 ID 和相似度分数。这些信息如果没有一个统一的字段规范,就会出现一个尴尬的局面:每个团队各自埋点,字段名五花八门,数据进了平台没法聚合分析。
OpenTelemetry 社区后来推出了 GenAI 语义约定,为生成式 AI 应用定义了标准化的 Span 属性和事件字段。举个例子:
{ "gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.request.temperature": 0.7, "gen_ai.usage.input_tokens": 1520, "gen_ai.usage.output_tokens": 386, "gen_ai.response.model": "gpt-4o-mini", "gen_ai.usage.prompt_tokens": 1520, "gen_ai.usage.completion_tokens": 386 }这些字段在语义上有一个统一的 schema,只要接入方遵循这个约定,无论你是调 OpenAI、Claude 还是通义千问,采集上来的数据都能在同一个视图里做横向对比。比如我们后来在观测云平台上写了一个聚合视图,按gen_ai.request.model分组统计每日 token 消耗,所有模型的数据整齐排在一起,哪个模型贵、哪个模型调用量大,一眼就能看出来。
2.4 Trace、Metrics、Logs 三大支柱如何串联协同
OTel 体系里有三类数据:Tracing 记录链路、Metrics 做聚合统计、Logs 记录事件详情。它们在 LLM 应用可观测性里各司其职,但真正发挥作用要靠关联。
核心关联机制是 TraceID。一次 LangChain 请求从入口开始生成一个 TraceID,链路里所有 Span、关联的日志、甚至基于这些 Span 聚合出来的指标,都能通过 TraceID 串在一起。我们出了问题时,流程一般是这样:先看指标,发现某个时间段内某个模型错误率飙升;再查 Trace,找到错误率高的那类请求;最后点开某一条 Trace 的日志,看具体的报错内容。三个环节由 TraceID 串联,定位速度比看代码猜根因快得多。
3. 在 LangChain 项目中落地全链路追踪的完整配置
3.1 基础依赖安装与版本配对的坑
LangChain 接入 OpenTelemetry,我们选的是 openllmetry 这个社区方案,它是 Traceloop 维护的开源项目,把 LangChain 内部各种回调事件封装成了符合 GenAI 语义约定的 Span。另一个可选方案是 LangChain 官方文档里提供的langchain.observability模块,但实测下来 openllmetry 的 Span 字段更完整,对国内外主流 LLM 的适配也更好。
依赖安装比较简单:
pip install traceloop-sdk opentelemetry-exporter-otlp但这里有一个版本配对的坑:LangChain 的版本升级很快,openllmetry 的某些版本对 langchain-core 的类型定义有依赖。我们当时先是随意装了一个 langchain 最新版,发现 SDK 初始化的回调处理器直接报类型不匹配。排查到最后,确认是某个小版本的langchain-core改了回调函数的签名,导致 openllmetry 内部注册tracer_provider时拿到的是变更前的旧接口。
规避办法很简单:在 requirements.txt 里锁定已知稳定组合。我们当前线上用的是:
langchain==0.2.x langchain-openai==0.1.x traceloop-sdk==0.xx.x这个组合在目前的生产环境里没有出现过回调类型问题。如果你用的版本组合不同,建议先在一个独立环境里做一次最小化冒烟测试,确认能正确生成 Span 再上主分支合并。
3.2 初始化 OTel 导出的核心配置:Node 与 Collector 对接
初始化时最核心的工作有两件事:设置资源属性,让数据能标识出来源服务;配置 OTLP Exporter 指向采集端点。
下面是我们在项目入口处的初始化代码。这里要注意,service.name这个资源属性很重要。如果你的系统里后续会接入不止一个 LangChain 服务,这个字段就是观测云平台区分数据来源的关键标识,务必起得规范一点。
import os from opentelemetry import trace from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.trace.export import BatchSpanProcessor from traceloop.sdk import Traceloop resource = Resource.create({ "service.name": "langchain-customer-service", "service.namespace": "production", "deployment.environment": os.getenv("ENV", "dev"), }) tracer_provider = TracerProvider(resource=resource) otlp_exporter = OTLPSpanExporter( endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://otel-collector.example.com:4317"), headers={ "Authorization": f"Bearer {os.getenv('OTEL_EXPORTER_OTLP_AUTH_TOKEN', '')}" }, ) tracer_provider.add_span_processor(BatchSpanProcessor(otlp_exporter)) trace.set_tracer_provider(tracer_provider) Traceloop.init( tracer_provider=tracer_provider, disable_batch=True, )这里有几个配置细节值得解释一下。
disable_batch=True是方便本地调试时用的,它会让 Span 立刻导出,不用等批处理攒够数量。生产环境务必去掉这个参数,否则每个 Span 都实时推送会把链路里的 I/O 放大很多,性能损耗明显。我见过有团队直接照抄文档里的调试配置上生产,结果应用吞吐掉了 20% 的。
BatchSpanProcessor是默认推荐的方式,它在内存里攒一批 Span 再批量发送,既能保证数据不丢,又不会对业务线程产生频繁阻塞。当然,代价是观测云上看到的数据会比真实时间点晚几十秒,这个延迟对于定位问题来说完全可接受。
OTLP Exporter我们选的是 gRPC 协议,它比 HTTP/JSON 协议多了一些内建的流控能力,数据压缩率也更好。如果你的采集器只能接收 HTTP 协议的数据,换成OTLPSpanExporter的 HTTP 版本即可,代码改动很小。
3.3 把 LangChain 的自动埋点能力打开:从 Chain 到 Agent 全部纳入
配置完基础 SDK 后,openllmetry 会通过 LangChain 内部的回调机制自动为 LLM 调用、Chain 执行、Agent 动作等关键步骤生成 Span。也就是说,你不需要去改业务代码里的每一个调用,只要在入口处初始化一次,后续 LangChain 在构建 Prompt、调用模型、执行检索的时候都会自动上报。
为了确认自动埋点确实生效,我们在本地测试环境跑了一个最小链路:
from langchain.chat_models import ChatOpenAI from langchain.schema import HumanMessage llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) response = llm.invoke([HumanMessage(content="你好,请用一句话介绍一下你自己")]) print(response.content)跑完之后,我们在观测云平台上看到了langchain.invoke这个根 Span,下面挂着openai.chat子 Span,openai.chat里有完整的 model、usage 等属性,包括 input_tokens 和 output_tokens。那一刻的感觉是:这东西比传统打点省事太多了。
需要特别提醒的是,自动埋点覆盖的是 LangChain 框架内部的核心调用。如果你的业务代码里有自定义逻辑——比如自定义文档解析函数、自定义 Prompt 模板拼接逻辑、自定义检索后处理——这些是自动埋点覆盖不到的,需要手动包一层 Span。这部分我后面会细讲。
3.4 手动埋点自定义业务逻辑:把检索、重排、拼接等阶段纳入链路
虽然自动埋点省事,但真实生产环境里 LangChain 应用的核心逻辑往往不在框架内部,而在我们自己写的那些定制化代码里。比如我们的快递客服机器人里,有一个客服工单查询的函数,它需要把用户表述的时间范围解析成结构化查询条件,再去调用内部工单系统的 API。这个函数独立于 LangChain 的 Chain 和 Agent,自动埋点完全看不到它。
为了让这条链路完整,我们在自定义函数里手动开启了新的 Span。OpenTelemetry 的做法是获取当前上下文中已经存在的 Span,然后以它为父级创建一个新的 Span 作为子节点。
from opentelemetry import trace tracer = trace.get_tracer("langchain.custom") def query_ticket_period(user_statement: str): with tracer.start_as_current_span("custom.ticket_period_parse") as span: span.set_attribute("custom.input_raw", user_statement) query_result = _parse_period(user_statement) span.set_attribute("custom.query_start", query_result.start_date) span.set_attribute("custom.query_end", query_result.end_date) return query_result这段代码看起来简单,但有一个关键点值得注意:trace.get_tracer("langchain.custom")这个 Tracer 必须继承全局的 TracerProvider,才能保证手动 Span 能正确地挂到 LangChain 自动创建的那条链路树里。如果你在自己的模块里重新 new 了一个 TracerProvider,这些手动 Span 会和主线链路彻底断开,所有的指标聚合和 Trace 回放都会失败。
判断手动 Span 有没有正确挂接,最直观的方法是看观测云平台上的 Trace 详情页里,它的父 Span 节点是不是正确指向了langchain.invoke。如果手动 Span 自己变成了一个孤立的根节点,那基本就是 TracerProvider 没有正确共享导致的。
3.5 异步场景下的上下文传播:LangChain + asyncio 的隐藏陷阱
LangChain 0.2 之后的版本对异步支持相当友好,ainvoke、astream这类异步接口可以在高并发场景下大幅提升吞吐。但异步并发也带来了一个 OTel 上下文传播的经典问题:Span 上下文默认是跟随线程的,异步任务在线程之间切换时,如果上下文变量没有被正确传递,子 Span 就会丢失父 Span 的关系,变成一堆没有根节点的孤儿 Span。
我们最开始接入 OTel 时,API 层是 FastAPI 异步接口,LangChain 链路里又混用了同步检索器调用,结果观测云平台上的 Trace 图非常难看:一半链路是完整的,另一半只有零散的模型调用 Span,完全连不起来。
造成这个问题的最核心原因,是同步 HTTP 客户端(比如某些版本的 embeddings 调用库)在异步循环里执行时,把运行在 event loop 线程里的上下文变量给覆盖掉了。解决方式有两种:一是把异步接口里调用的 LangChain 同步操作统一包进asyncio.to_thread里,避免同步阻塞 event loop 的同事也保护了上下文;二是在每个异步入口处显式提取和注入 context。
from opentelemetry import context as otel_context from opentelemetry import trace # 在异步入口处提取当前上下文 current_ctx = otel_context.get_current() trace.get_current_span() # 在进入子任务之前注入 otel_context.attach(current_ctx)老实说,手动注入上下文比较繁琐。更稳妥的做法,是在整个应用层统一用异步客户端,确保每个调用点都走httpx.AsyncClient或aiohttp,让 OpenTelemetry 的异步上下文传播机制有机会自动接管。如果短期内无法统一,那就接受"异步部分链路不完整"的现实,优先保证单次请求内的主链路连续。
4. Trace 链路里的关键数据维度与成本/质量关联分析
4.1 核心 Span 的数据价值拆解:LLM 调用、检索器、工具调用
当整个链路跑通之后,观测云平台上会呈现一棵完整的 Trace 树。LangChain 应用比较典型的结构大致是这样的:
langchain.invoke (根 Span) ├── langchain.retriever │ ├── embedding.openai │ └── vector_store.query ├── langchain.chain │ ├── prompt.template │ └── chat.openai (LLM 调用) └── langchain.tool └── custom.ticket_period_parse这里每一个 Span 都有自己的价值。retriever系列 Span 记录的是检索阶段的工作:embedding 生成耗时、向量库查询耗时、召回文档数量。chat.openaiSpan 记录了模型调用的完整元信息,包括模型名、token 消耗和延迟。tool系列 Span 则记录了我们自定义工具的执行情况。
把这些 Span 串联起来,就能回答一系列业务问题。下面这张表是我平时排查问题时最常用的几个视图:
| 观察维度 | 涉及 Span | 典型问题 | 排查方向 |
|---|---|---|---|
| 响应延迟 | 所有 Span | 用户反馈回答慢 | 比较各 Span 耗时占比,定位大头 |
| Token 成本 | chat.openai | 成本突增 | 查看哪个模型、哪个功能调用消耗 token |
| 回答质量 | retriever + chat.openai | 答非所问 | 看检索文档数量和质量、Prompt 拼接内容 |
| 工具稳定性 | tool.* | 功能不可用 | 看工具调用耗时、重试次数、报错信息 |
4.2 建立 Token 消耗分布视图:谁在烧钱一目了然
在线上的第一周,我们主要是靠 Trace 详情看单条请求,还没有做聚合分析。后来老板问"这个机器人每天到底要烧多少钱",我才意识到,光有 Trace 没有 Metrics,无法回答成本问题。
于是我们在观测云平台里建立了一个简单的聚合视图,按服务名和模型名分组,统计每日调用次数和 token 消耗总和。OTel 体系里,Metrics 数据可以从 Trace 里的 Span 事件中聚合出来,也可以直接通过一个专门的元数据导出流程进行计算。但更简单的做法是,在导出层用平台自带的 Trace 查询能力,写一条类似 SQL 的聚合查询:
SELECT gen_ai.request.model AS model_name, SUM(gen_ai.usage.input_tokens) AS total_input_tokens, SUM(gen_ai.usage.output_tokens) AS total_output_tokens, COUNT(*) AS total_calls FROM trace_spans WHERE span.kind = 'CLIENT' AND gen_ai.system IS NOT NULL GROUP BY gen_ai.request.model ORDER BY total_input_tokens + total_output_tokens DESC这个视图上线后,我们发现每天的成本大头根本不是客服问答主链路,而是内部知识库自动更新任务里的一段 DocumentLoader 文本切分逻辑——它对每一段文档都调了一次 embedding 模型,而且重复调用了同一批文档。这就是典型的"Trace 不集合起来看完全发现不了"的资源浪费。
4.3 回答质量问题的回溯:从一条用户差评出发反向定位
回答质量问题的排查,是全链路可观测最显价值的地方。有一天我们收到一条用户反馈说"问它退货政策,回答完全是错的"。如果是没有 Trace 系统的状态,这个排查几乎无从下手——我们不知道用户的具体输入走了哪个 Prompt 模板、检索器召回了哪些文档、模型最后看到了哪些内容。
现在流程清晰了很多。先在观测云平台上搜索该时段的 Trace,找到对应的那一条(前提是业务接口里把用户 ID 或对话 ID 写进了 Span attribute,这个建议从一开始就做好)。打开 Trace 详情,依次检查langchain.retriever召回的文档列表、最终拼进 Prompt 的上下文内容、模型返回的完整输出,问题就很容易定位。
那次用户差评的根因,最后发现是检索器在语义相似度较低的情况下仍然返回了 top-5 文档,而文档库里又恰巧有几篇描述"退货政策"但内容讲的是"换货流程"的相似文本。模型被这几篇文档误导,给出了错误的回答。通过调整检索的相似度阈值,问题随即改善。
如果没有 Trace 里的检索上下文记录,这种问题需要好几天才能定位。有了全链路数据,整个过程压缩到了半小时以内。
4.4 从 Trace 到 SLI/SLO:把可观测数据变成交付承诺
数据积累到一定程度,我们开始基于这些链路数据定义服务水平指标。传统 SLI 一般是可用性、P99 延迟、错误率,但在 LangChain 应用里,还可以加入专门面向 LLM 场景的指标,比如模型调用错误率、检索召回率、单次请求平均 token 消耗量。
我们把两个指标纳入了 SLO:回答延迟 P95 小于 5 秒,模型调用错误率小于 1%。这两个指标直接从 Trace 数据聚合而来,平台会自动计算每个时间窗口的数据。当了 SLO 之后,告警规则也随之建立:当 P95 延迟连续 10 分钟超过阈值,就触发企业微信推送,值班团队直接在手机上点开 Trace 链接,跳到具体的问题请求上。
这一步是整个可观测体系从"能看"到"能用"的转折点。我们不再只是出了事故才查数据,而是让数据在问题发生之前直接报警,效率提升非常明显。
5. 实测中常见的埋点陷阱与完整排查链路
这一节写几个我们实际踩过的坑。每个坑如果单独看都不复杂,但组合在一起,真的能让你的数据收集工作原地报废。我会按"问题现象 -> 排查链路 -> 最终原因 -> 修复方式"的完整过程来写。
5.1 陷阱一:Span 导出了,但没有 GenAI 语义字段
问题现象:配置完 Traceloop 之后,观测云平台上的 Trace 图标显示正常,链路结构完整,但点开chat.openaiSpan 没有模型名、没有 token 数、没有温度等 GenAI 语义字段,只有一堆看起来像是自动生成的默认 attributes。
排查链路:我们先怀疑是 openllmetry 的版本不支持当前 LangChain 版本,于是把 LangChain 降了一版再测,问题依旧。接着怀疑是 Traceloop 初始化时的某个参数没有把语义增强的开关打开。最后我们对照了 openllmetry 的仓库文档,发现 Traceloop 默认会对某些模型提供商做 Instrumentor 注册,如果之前初始化时传入过tracer_provider,某些版本的 SDK 会跳过自动注册。
最终原因:版本组合产生的兼容性问题导致 Traceloop 的某些回调处理器没有生效。只有 Span 结构和调用耗时被上报,LLM 特有的属性被静默丢弃了。
修复方式:升级 traceloop-sdk 到我们测试过的最新稳定版本,同时把初始化参数里的disable_batch、tracer_provider等显式传参加固了一遍。修完之后重新测试,模型名称和 token 数开始正确出现在 Span 里。
5.2 陷阱二:异步回调里拿不到 Token 用量
问题现象:使用astream流式输出时,openai.chatSpan 的gen_ai.usage.output_tokens始终是 0。但同步调用invoke时同样的字段却是正常值。
排查链路:我们起初以为是对流式输出场景的支持不完善,去翻 openllmetry 的 issue,发现已经有人报过类似问题。根因是流式响应过程中,token 用量信息是在流结束的最后一个数据块里返回的,而某些 SDK 版本的回调处理器在流式场景下注册的结束钩子触发时机太早,读到的是还没补全的中间状态。
最终原因:不是我们的代码写得不对,而是回调处理器对流式事件的生命周期处理有缺陷。
修复方式:在版本未修复之前,我们的临时方案是绕过自动埋点,在业务代码里手动包一层流式调用,等流完成之后,从返回的完整响应对象里读取 usage 数据,再把它写回当前 Span 的 attribute 里。虽然代码略丑,但至少数据是完整的。
from opentelemetry import trace span = trace.get_current_span() # 等 stream 完成 full_response = "" async for chunk in stream: full_response += chunk usage = getattr(full_response, "usage", None) if usage: span.set_attribute("gen_ai.usage.output_tokens", usage.completion_tokens)5.3 陷阱三:手动 Span 变成孤儿节点
问题现象:我们在一部分接口里手动埋点了自定义工具函数,但观测云平台上有大量孤立的 Span,父节点显示为 NULL,时长一到两秒,跟主链路完全断开。
排查链路:我们一开始以为又是异步上下文传播问题,于是把相关函数全部改成同步调用,问题仍然复现。后来我们打印了手动 Span 创建时的当前 Span 上下文,发现上下文里取到的父 Span ID 是空值。当时立刻意识到,问题出在入口函数里没有先进入 LangChain 的invoke模板或者其他会自动创建根 Span 的包装层——我们的某些工具接口直接调用了自定义函数,跳过了 LangChain 的包装,导致整个子链路没有父级。
最终原因:检查代码后发现确实有一段逻辑在 Chain 之外提前调用了自定义函数,在返回结果后才进入 LangChain 流程。这就好比快递包裹直接从分拣点发出,没有经过主枢纽,物流系统没有登记它的发件关系。
修复方式:调整代码顺序,把自定义函数的调用移入 Chain 内部,或者在不调整逻辑的情况下,在自定义函数外层手动创建一个根 Span,保证链路有一个明确的起点。
5.4 陷阱四:重试机制造成 Token 费用爆炸
问题现象:某天成本视图上发现 gpt-4o-mini 的调用量是平时的三倍多,检查 Trace 后发现大量相同的调用请求在短时间内重复出现,每次都带完整的 token 消耗。
排查链路:顺着其中一条 Trace 点进去,发现是一个调用外部工具的函数触发了重试逻辑,每次重试都重新跑了一遍完整的 LangChain 开始到模型调用的流程。也就是说,重试的不是一个小的 HTTP 请求,而是整条 Chain。
最终原因:我们给 Agent 配的外层重试机制设置得太激进,当外部工具返回临时错误时就整体重试,导致模型调用也跟着翻倍。正常情况下重试机制应该只重试失败的工具调用,不应该让已经成型的模型完成调用重复执行。
修复方式:调整业务代码,把重试范围缩小到工具调用这一个环节;同时给 Trace 告警加了一个规则:单条 Trace 中出现模型调用次数超过 5 次就告警。这样既防止了成本失控,也方便发现异常的重试逻辑。
5.5 陷阱五:OTel 导出与 LLM 调用共用出口网络
问题现象:接入 OTel 之后,线上偶发出现部分请求超时,集中在晚间高峰。检查应用日志没有任何报错,但外部依赖的调用成功率有一些波动。
排查链路:我们一开始怀疑是模型 API 端的问题,但对比观测云平台上的延迟曲线,发现超时请求的时间点跟 OTel 批量导出 Span 的时间高度重合。进一步检查网络配置,发现应用出口是一个小带宽的共享代理,OTel 导出线程在高并发时会把网络带宽占满,影响正常 LLM 请求的发出。
最终原因:OTel 的批量导出虽然不阻塞业务线程,但它依然占用网络 I/O。在出口带宽紧张的环境下,它会与 LLM 请求争抢资源。
修复方式:把 OTLP 导出端指向内网部署的 OTel Collector,让业务实例只对内网发送数据,再通过 Collector 统一外发到观测云平台。这样既解决了带宽争抢问题,又让数据在发送前可以做一些脱敏和过滤处理,可谓一举两得。
6. 从能看监控到能定预案:把可观测能力变成团队保障体系
6.1 数据接入之后先别急着追求全量埋点
接入 OTel 的初期,很容易陷入一个误区:想把所有东西都埋上点,每个函数都开一个 Span,每个变量都记成 attribute。这样做的问题有两个:一是代码侵入性太强,到处是埋点逻辑,业务代码的可读性严重下降;二是数据量暴增,存储和查询成本都上去了,而且真正有用的信号被海量无效数据稀释了。
我个人的建议是分三步走。第一步先把 LangChain 自动埋点和自定义关键函数的手动埋点做好,确保主线链路完整;第二步再添加成本聚合视图和告警规则,让数据能指导行动;第三步才根据实际遇到的线上问题,逐步补充细粒度的埋点。不要一开始就追求一步到位,可观测性的价值是逐步显现的。
6.2 建立基于 Trace 的常见故障预案库
数据积累一段时间后,我们开始整理一份"故障特征与处置动作"的对照表。这个表本质上就是排障手册的雏形。举例来说:
| Trace 特征 | 可能根因 | 建议动作 |
|---|---|---|
| 检索 Span 耗时高且文档数多 | 向量库查询慢 / 召回阈值过低 | 检查索引状态,调整 top_k |
| 模型调用 Span 错误率升高 | 限流 / 模型服务异常 / Prompt 触发审核 | 检查响应码,查看提示内容 |
| 工具 Span 多次重试后失败 | 外部服务不稳定 | 确认依赖方状态,考虑熔断 |
| 单条 Trace 模型调用次数异常多 | Agent 循环 / 重试配置过激 | 检查 Agent 的迭代次数限制 |
有了这张表,新同学接手线上问题时不用从零摸索,直接在观测云平台上对照特征就能快速给出一个初步判断。这套东西,才是全链路可观测落地之后最值钱的沉淀。
6.3 告警规则要避免"只报症状不指方向"
最后聊聊告警。我们最早写的告警规则比较简单粗暴——只要某个 Span 的耗时超过阈值就告警。结果上线第一天告警群就炸了,因为高峰期的正常请求也可能有部分超过阈值,信息太多反而没人认真看。
后来我们学乖了,告警规则一定要绑定可操作的信号。比如"模型调用错误率超过 5% 持续 5 分钟"比"单次请求超过 10 秒"更有价值,因为前者意味着需要有人介入,后者可能就是一次正常的慢查询。再比如"单条 Trace 中相同模型调用超过 5 次"这个告警,直接指向重试配置异常,收到告警的人知道该去查什么代码。告警的价值不在于"有问题就通知",而在于"通知了就一定有问题且知道往哪查"。
在整个接入过程中,我最大的体会是:OpenTelemetry 真正解决的不是"能不能看到数据"的问题,而是"数据之间能不能对得上号"的问题。LangChain 应用天然是多组件协作的产物,模型、向量库、外部工具、自定义逻辑混在一起,只有把这些环节的全部数据和统一的 TraceID 绑在一条绳子上,做全链路可观测才有实际意义。如果你们团队也正在用 LangChain 做应用,建议尽早把这套体系搭起来,哪怕最开始只是搭一个最小可用的配置——先把主链路跑通,把数据存下来,后面再慢慢丰满。真等线上出了事再去补课,成本会高出好几倍。