做AI应用开发的人,可能都有过这种遭遇:用户明明问A,大模型偏偏答了B,你说它该不该背锅?提示词是不是有歧义?检索到的上下文是不是丢了一段?还是模型本身翻车了?没有一套追踪手段,这种问题基本靠猜。尤其是当你的服务里还串着语义缓存、意图识别、向量检索、Rerank、多轮记忆、后置校验这些环节时,问题一旦出现就像在迷宫里找一根断掉的线头。
我自己的项目做到后期就痛定思痛,把可观测性里的Trace追踪老老实实补上了基础。补完之后再遇到线上事故,定位时间从按小时算降到按分钟算。这个系列已经写到第19期,前几期一直在聊怎么调提示词、怎么做RAG、怎么控制Token成本,今天这篇专门把Trace追踪和可观测性在AI应用里的落地经验完整拆一遍,包括核心概念怎么映射到LLM调用链路、OpenTelemetry具体怎么埋点、以及那些只有踩过坑才会写出来的调优细节。适合正在做LLM应用、Agent服务或者RAG系统的朋友,无论你用的是哪家的模型服务,这套追踪思路都可以直接套用。
1. 先搞清楚Trace追踪解决的到底是什么问题
1.1 一次让团队查了一整天的线上事故
先说个让我印象特别深刻的案例。我们一个内部客服机器人,底层结构不算复杂:用户消息进来,先查语义缓存,命中就直接返回;没命中就走RAG流程,先去向量库检索,再把检索结果拼进Prompt,最后调用LLM生成回答。
某天上线的版本被用户疯狂反馈“答非所问”,看监控面板一切正常:API没有报错,Token用量没有暴涨,向量检索耗时也很平稳。负责排查的同事翻日志翻到崩溃,因为每个服务模块都在各自的日志文件里记了一条“看起来正常”的记录,但就是拼不出用户那条请求到底经历了什么。
最后给LLM调用的入参和出参都加了Trace追踪之后,真相很快浮出水面:有一层语义缓存的过期时间设成了24小时,而知识库内容在前一天刚更新过。用户问了一个和旧内容强相关的问题,缓存直接命中旧答案,根本就没走到检索和大模型那一步。这个坑如果只靠Metrics和零散日志,几乎不可能快速定位,因为所有独立环节的“体检指数”都是健康的。
这件事给我最大的触动是:分布式系统里,单个模块的健康不代表整条链路的正确,只有把一次请求从头到尾的路径串起来,才能回答“用户的问题到底是被哪个环节扭曲的”这个核心问题。Trace追踪的价值也就在这。
1.2 可观测性三大支柱不是二选一
很多团队一谈可观测性就想到监控告警,或者想到ELK日志那一套,但真正完整的体系是三块:Metrics(指标)、Logging(日志)、Tracing(链路追踪)。它们解决的问题各有侧重。
| 维度 | Metrics | Logging | Tracing |
|---|---|---|---|
| 关注对象 | 聚合后的数值 | 单次事件记录 | 一次请求的完整路径 |
| 典型问题 | 请求量涨没涨 | 报错的具体堆栈 | 瓶颈在哪个环节 |
| 数据粒度 | 按时间聚合 | 按事件单条 | 按请求ID关联 |
| 适用场景 | 告警、容量评估 | 排错细节挖掘 | 链路梳理、延迟分析 |
打个生活化的比方:Metrics是体检报告里的各项指标,告诉你血糖高不高;Logging是摄像头截图,告诉你某一时刻发生了什么;Tracing则是全程监控录像的回放,能告诉你一个人从进门到出门每个房间待了多久。AI应用排查问题最需要的恰恰是这个“全程回放”的能力。
1.3 为什么AI应用比传统Web服务更需要Trace
传统Web服务里,一次请求的路径相对固定,中间件、缓存、数据库、下游API,链路是串行的,通过日志里打的TraceID也能勉强串。但AI应用,尤其是LLM应用,有三层特殊复杂性决定了追踪体系必须是第一等公民。
第一,链路里出现了“非确定性处理单元”,就是大模型本身。传统服务的输出是确定的,入参相同出参就相同,日志里记个参数就能复现问题。但LLM不同,同样的输入,温度和随机种子的微小变化都可能带来不同输出,你必须在Trace里记录完整的请求上下文,包括Prompt快照、采样参数、Token用量,否则事后完全无法复现。
第二,调用链更长且出现了分支和循环。典型Agent应用里,一个用户问题可能触发模型思考、调用计算器、查知识库、读多个文档、再综合回答,这是一棵执行树,不是一条直线。传统日志的“全局流水号”想法在这种场景里很难支撑清晰的层级关系。
第三,成本和质量的归属问题。Token花了多少,是花在中间推理还是最终回答?某次效果很差,是因为上下文太长挤掉了关键信息,还是因为检索的相似度阈值放水了?这些都必须依赖贯穿全链路的属性记录才能回答。
所以我的结论很简单:传统的Web监控思路搬到AI应用里是远远不够的,Trace不是锦上添花,是LLM应用上线前的基础设施。
2. Trace核心概念与AI链路的映射方案
2.1 从Trace到Span的抽象理解
不扯太深的术语,就用一句话建立基本概念:一次用户请求完整的执行路径叫做一条Trace,而Trace是由多个Span组成的。一个Span代表一个具体的工作单元,比如“调用LLM”是一个Span,“向量检索”是另一个Span。
每个Span有全局唯一的Span ID,Span之间通过Parent-Child关系形成树状结构。最顶层的根Span代表这次请求的宏观操作,下面的子Span代表拆解出来的一个个步骤。把这些Span按时间轴展开,就得到了我们经常在讨论火焰图、瀑布图时看到的那种层叠结构。
这里要特别强调Context Propagation,也就是上下文传播。它解决的是“跨进程调用时怎么把这条Trace的身份信息带下去”的问题。比如Python进程里发一个HTTP请求给向量检索服务,需要把Trace ID和当前Span ID塞进HTTP请求头,下游服务收到之后才能创建自己的子Span并挂接到同一条Trace上。OpenTelemetry定义了标准的W3C Trace Context头,各种语言的SDK内部已经封装好了注入和提取逻辑,你真正要操心的是别在造轮子时把这一步漏掉。
2.2 把AI专用名词翻译成Span结构
很多刚接触追踪的人会问一个问题:我的业务里没有微服务,就一个Python进程,还需要什么Trace?需要,而且特别需要。Trace追踪的粒度不一定要到服务级别,更要到“逻辑单元”级别。
我建议把一条AI应用处理链路设计成下面这样的Span树:
POST /api/chat (根Span:一次完整用户请求) ├── 意图识别与路由 ├── 语义缓存查询 ├── 检索子链路 │ ├── Query改写 │ ├── 向量化 │ ├── 向量库召回 │ └── 结果重排 ├── Prompt组装 │ ├── 读取记忆窗口 │ └── 模板注入与截断 ├── LLM调用 │ ├── 请求发送 │ ├── 流式接收 │ └── 后处理解析 └── 输出安全校验每个Span都有自己的开始时间、结束时间、状态(成功/失败/异常)和属性。比如“LLM调用”这个Span,我们可以把模型名、温度、TopP、Prompt的字符数、输入Token数、输出Token数、首字节延迟、总耗时统统写进去。这些属性看似简单,但攒到一定量之后,价值极大。
2.3 工具选型:放弃自建,拥抱OpenTelemetry
工具链这一块,我强烈建议直接把OpenTelemetry立为标准。它的定位是“可观测性的统一标准框架”,就像日志领域的结构化标准一样,提供了API、SDK、导出器、Collector一套完整组件,而且和具体的后端存储解耦。
后端可视化存储这块,小规模场景和本地调试,我常用Jaeger,部署简单,Docker一条命令就能起一个全功能环境,UI自带的Trace瀑布图非常直观。线上生产环境如果数据量上了规模,Tempo或者用ClickHouse自己存Trace都可以。但第一选择永远是先用OpenTelemetry生成标准数据,后端未来想换就换。
有人热衷自建埋点上报体系,我要泼一盆冷水:自建意味着你要维护链路ID生成、上下文注入、异步上报、采样去重、存储查询一整套东西,成本远超想象。别造轮子,OpenTelemetry的SDK在主流语言里已经足够成熟,留着精力去设计业务Span属性。
3. 实战落地:从零给AI应用埋上Trace
3.1 环境搭建与全局初始化
先看Python环境的依赖安装,这几行命令就能把基础SDK和OTLP导出器装好:
pip install opentelemetry-api pip install opentelemetry-sdk pip install opentelemetry-exporter-otlp本地调试想快速看到效果,用Docker起一个Jaeger后端:
docker run -d --name jaeger \ -e COLLECTOR_OTLP_ENABLED=true \ -p 16686:16686 \ -p 4317:4317 \ -p 4318:4318 \ jaegertracing/jaeger:latestJaeger的Web界面跑在16686端口,4317是gRPC协议,4318是HTTP协议。应用侧初始化代码这样写:
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.sdk.resources import Resource, SERVICE_NAME resource = Resource.create(attributes={SERVICE_NAME: "ai-chat-service"}) provider = TracerProvider(resource=resource) # 使用批量处理器,异步导出,避免阻塞业务线程 exporter = OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True) processor = BatchSpanProcessor(exporter) provider.add_span_processor(processor) trace.set_tracer_provider(provider)这段代码里有几个细节值得说。SERVICE_NAME这个属性务必设置,它决定了Jaeger里服务列表是怎么分组的,你在公司里服务一多,没有服务名等于在一堆数据里捞针。BatchSpanProcessor是异步批量导出,攒够一批再统一发,千万不要用SimpleSpanProcessor,后者每条Span直接同步发送,线上高并发时会把业务延迟直接拉高。
初始化之后,获取一个Tracer对象就可以在业务代码里埋点了:
tracer = trace.get_tracer("ai.application", version="1.0.0")3.2 给LLM调用创建核心追踪Span
现在进入正题,给LLM调用加追踪。最直接的思路是:每次调用大模型,就开启一个名为“llm_call”的子Span,然后把这次调用的关键参数和结果指标都挂上去。
import time from opentelemetry import trace tracer = trace.get_tracer("ai.application") def call_llm_with_trace(messages, model="demo-model", temperature=0.7): start = time.time() tracer = trace.get_tracer("ai.application") with tracer.start_as_current_span("llm_call") as span: # 记录请求侧属性 span.set_attribute("llm.model", model) span.set_attribute("llm.temperature", temperature) span.set_attribute("llm.messages.count", len(messages)) span.set_attribute("llm.prompt.chars", sum(len(m["content"]) for m in messages)) span.set_attribute("llm.prompt.snapshot", messages[-1]["content"][:500]) # 这里替换为你真实的调用逻辑 response = your_llm_api_call(messages, model=model, temperature=temperature) # 记录结果侧属性 span.set_attribute("llm.completion.text", response.content[:200]) span.set_attribute("llm.usage.prompt_tokens", response.prompt_tokens) span.set_attribute("llm.usage.completion_tokens", response.completion_tokens) span.set_attribute("llm.usage.total_tokens", response.total_tokens) span.set_attribute("llm.response.latency_ms", (time.time() - start) * 1000) if not response.success: span.set_status(trace.Status(trace.StatusCode.ERROR, "llm request failed")) span.record_exception(response.error) return response这里有个容易忽略的点:如果用了with语法,Span会自动在代码块结束时结束;但像上面这种需要手动记录耗时和状态的逻辑,我更建议显式使用span = tracer.start_span("llm_call"),配合finally块里执行span.end()。尤其是你的调用里还涉及流式解析、重试逻辑时,手动控制结束时机更精准,避免子逻辑异步处理导致的Span时间轴错位。
另外要聊一个非常实用的点:Prompt快照到底该不该全量记录?我的实践经验是坚决不能全量存,不然你家存储很快就会炸。Prompt本身可能是几百个Token,如果还带长上下文可能是几千个Token,Trace系统接收的Span数量和这个量级相乘,一年下来存储和查询成本非常吓人。我建议只记录最后一条用户消息的前500字符,再加一个prompt_hash字段存全文的哈希值,用于定位真实内容。既能复现问题大方向,又不会泄露全量数据。
3.3 把完整RAG链路拆成子Span
LLM返回结果前后的那一大堆逻辑,才是AI应用追踪的主战场。以RAG链路举例,推荐这样组织Span:
with tracer.start_as_current_span("rag_query") as root_span: root_span.set_attribute("app.session_id", session_id) with tracer.start_as_current_span("query_rewrite") as span: rewritten = rewrite_query(user_query) span.set_attribute("rag.query.original", user_query[:200]) span.set_attribute("rag.query.rewritten", rewritten[:200]) with tracer.start_as_current_span("vector_search") as span: vector = embed_query(rewritten) span.set_attribute("rag.vector.dim", len(vector)) span.set_attribute("rag.search.top_k", top_k) results = vector_search(vector, top_k=5) span.set_attribute("rag.search.hits_count", len(results)) span.set_attribute("rag.search.hit_ids", ",".join([r["id"] for r in results][:10])) # 记录相似度分数分布 scores = [round(r["score"], 4) for r in results] span.set_attribute("rag.search.scores", str(scores)) with tracer.start_as_current_span("prompt_build") as span: context = truncated_context(results) span.set_attribute("rag.context.length", len(context)) span.set_attribute("rag.context.window_over_limit", len(context) > 4000)我特别建议大家把每次检索召回的hit_ids和相似度分数列表记下来。原因很简单,线上大量“回答质量差”的问题,排查下来都是“相似度阈值太高把正确内容过滤了”或者“召回顺序不对导致关键内容排在上下文最后被截断了”。如果不在Trace里留证据,光靠用户描述很难定位是算法问题还是参数问题。
rag_search这个环节特别适合用add_event记录中间事件,比如“本次检索发现相似度低于0.7的结果占一半”,这类事件在调试时比一个简单属性更有直觉价值。
另外,请求里的session_id必须作为根Span的公共属性设置,这样后续在Jaeger里就能按Session搜索一条多轮对话的全部Trace,排查上下文污染问题时极其好用。
3.4 追踪Agent的复杂分支与工具调用
如果你的应用已经发展到了Agent那种带工具调用和多步推理的形态,Span结构要再加一层:每次模型决定调用哪个工具,就应该创建一个独立的Span记录决策过程。
with tracer.start_as_current_span("agent_tool_call") as span: span.set_attribute("agent.tool.name", tool_name) span.set_attribute("agent.tool.input", json.dumps(tool_input, ensure_ascii=False)[:300]) start = time.time() tool_result = execute_tool(tool_name, tool_input) span.set_attribute("agent.tool.result_length", len(str(tool_result))) span.set_attribute("agent.tool.duration_ms", (time.time() - start) * 1000) if isinstance(tool_result, Exception): span.set_status(trace.Status(trace.StatusCode.ERROR, str(tool_result)))这里的核心思想是:Agent的“思考过程”也要变成可视化的一等公民。包括每次模型输出的thought片段、选择工具的原因、对工具结果的观察,全部放进Span的Event或者Attribute里。这样做的好处是,调试Agent时你可以直接回放它的每一步决策,而不是把它当黑盒输出一顿盲猜。
3.5 跨进程传递Trace上下文
实际微服务架构里,AI应用的前端网关和后端推理服务往往是两个进程。OpenTelemetry的HTTP集成会自动把Trace上下文塞进请求头,你只需要在发请求时确保Tracer已初始化。以常见的requests库为例:
from opentelemetry.instrumentation.requests import RequestsInstrumentor RequestsInstrumentor().instrument()此时你的Python服务往向量服务发HTTP请求时,请求头会带上traceparent字段,下游只要也接入了OpenTelemetry,就会自动把新Span挂到同一条Trace下面。对于FastAPI这类服务框架,也可以直接调用FastAPIInstrumentor().instrument()完成服务侧的自动埋点。这些自动埋点虽然不能完全替代业务手写Span,但它们能把最底层的HTTP延迟、状态码、协议行为都记录下来,作为兜底非常香。
4. 高频坑位实录:排查与调优
4.1 常见问题排查速查表
下面这张表是我自己使用Trace追踪过程中遇到的典型问题、现象和解决思路,直接可以当排查手册用。
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| Trace列表能看到,但服务间串联不起来 | 上下文传播没做好,HTTP头没带上 | 检查下游是否注入OpenTelemetry请求库,确认同一Trace ID在两端一致 |
| Span节点错乱,父子关系异常 | 多线程里Context在切换时丢失 | 用contextlib.Context传递,避免线程池里手动复制上下文 |
| Jaeger界面数据量巨大,查询很卡 | 存储膨胀,未做采样 | 配置采样策略,长期保留率降到10%以下,冷数据缩短保留期 |
| Span的耗时和真实请求耗时差太多 | 使用了同步导出或在Span结束后又记录了异步内容 | 换BatchSpanProcessor,确认Span只覆盖同步调用区间 |
| 找不到突发低质量请求的Trace | 采样策略一刀切,普通请求都丢了 | 使用尾部采样,对错误和延迟超过阈值的请求强制全采 |
| LLM调用失败但没有错误详情 | 只设置了Status,没记录异常堆栈 | 用span.record_exception(e)把异常对象挂上去 |
| 检索到什么内容完全看不到 | 没有给检索Span设计属性 | 给hit_ids、scores、query字段设计标准属性名 |
| 成本和效果对不上账 | Trace里没有Token用量和响应质量字段 | 在LLM Span里记录prompt_tokens、completion_tokens、满意度评价 |
4.2 采样策略:别把所有Trace都当宝
不采样的Trace系统必死,这是铁律。线上流量稍微起来,每秒几百个请求,每个请求又会产生十个甚至二十个Span,一天下来轻松破亿条数据,任何存储后端都扛不住。
最常用的方案是头采样(Head-based Sampling),也就是请求进来时直接按比例决定这条Trace存不存。OpenTelemetry SDK里配置非常简单:
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased sampler = ParentBased(root=TraceIdRatioBased(0.1)) provider = TracerProvider(resource=resource, sampler=sampler)上面的配置意味着默认只保存10%的Trace。头采样的缺陷是,你很可能把真正想看的异常Trace扔掉了。所以生产中我建议做“分级采样”:
- 普通健康请求:采样率压到1%-5%,只要能看到大盘趋势就行
- 错误请求:100%全采,通过
span.set_status标记的错误会自动触发 - 慢请求:P99延迟以上的请求全采,这需要你在业务层主动标记,比如给响应时间超过2秒的Span强制打一个高优先级的采样属性
至于尾部采样(Tail-based Sampling),需要引入OpenTelemetry Collector的Tail Sampling Processor,把决策从业务进程里抽离出来,统一在Collector层按“这条链路是否包含错误、延迟多大”来做保留判断。架构复杂一些,但数据质量明显更好,线上高流量服务强烈建议尽早规划。
4.3 用瀑布图做延迟分解:一眼看出瓶颈在哪
Trace里最有价值的东西,我始终觉得是Span瀑布图。它把一次请求的总延迟按时间段拆开,哪个Span耗时长,哪个环节有并发和串行关系,一目了然。
举个例子:用户体感要3秒才回。瀑布图打开后,你会发现llm_call本身其实只花了900毫秒,但整个请求却持续了3秒。原因是vector_search前面还有一个同步的user_memory_load阻塞了800毫秒,而它在代码里被放在了vector_search的前面,没有并行化。如果没有Trace,你大概率会冲着LLM的延迟去优化,结果白折腾。
还有一种常见情况是流式响应视角。LLM首次返回字节的时间,在很多场景下对用户体验的影响远大于总耗时。建议在LLM Span里单独记录llm.time_to_first_byte_ms,这个指标能真实反映你的流式链路管道是否通畅。
4.4 隐私与数据安全的红线
最后必须聊一下数据安全。Trace里记录Prompt全文、检索结果原文、用户Session的完整输入,看起来方便复现,但它同时也是一个大的隐私泄露面。我的处理原则是这样:
- 用户输入不做全量快照,只记录截断后的指纹和哈希
- 检索记录不落原文,落文档ID,需要时从知识库反查
- 会话ID使用内部映射,不用真实外部标识
- 对存储持久化的Trace数据设置定期清理,满足合规要求
- 内部文档库和测试环境里才放开全量快照,便于分析
这几个红线一定要在架构评审时写进设计,不要等事发后再补,数据一旦泄露出去是没法撤回的。
5. 进阶玩法:把成本和质量也变成可观测指标
把Span属性玩出花来,Trace追踪还能承载比“排错”更高级的价值。我自己后来给LLM调用加了几类非技术属性,效果出奇地好。
第一类是成本属性。直接给LLM调用Span加上cost.usd字段,由调用层根据Token用量和单价算好写进去。Jaeger的搜索功能支持按属性过滤,所以我可以随时查“最近一天哪个Prompt模板消耗了最多的预算”,这种视角在业务决策里价值极高。
第二类是质量评估属性。每次用户对回答点踩或点赞,或者你在离线评估时给某条回复打了分,把这个分数作为Span的eval.score属性回填。这样你就能把响应质量和具体的参数配置、检索策略直接关联起来,回答“为什么某版本调整之后满意度掉了”这类问题会非常顺。
第三类是业务语义属性。比如把对话主题分类、是否命中FAQ、是否触发安全拒答这些信息都写进Span,之后用数据分析团队顺手就能跑出很多结论。Trace不再是纯技术人的工具,产品经理都能用了。
6. 最后说几句大实话
从最初觉得Trace是分布式微服务的专利,到现在把AI应用的全链路追踪当作默认选项,我的最大转变其实在于一个认识:大模型的非确定性让传统排错思路失效了。过去定位Bug靠日志和推理,现在根本行不通,因为你没法在真机上复现一次推理过程的具体状态。只有把请求路径、参数快照、上下文片段、每个中间环节的输入输出全部串成一条可回放的流,你才真正拥有了“理解AI应用在干什么”的能力。
真正把这个体系落地之后,我最大的体感是:排查问题的过程从“仰望星空式猜原因”变成了“按图索骥查Span”。另一个很有价值的变化是,产品和运营在汇报效果不佳时,能说清楚是检索问题还是生成问题,而不是笼统说一句“模型不行”。
有一个小经验值得再强调一遍:Trace的属性设计不用一上来就全面铺开,先做最小闭环。把一次请求的根Span、LLM调用Span、向量检索Span先串起来,跑通链路后再慢慢补充业务属性,比如记忆窗口大小、Rerank阈值、流式首字延迟。数据正规之后,你会发现自己对AI应用的理解会上一个台阶。