跳到主要内容

L5.6 OpenTelemetry 链路追踪(LLM 管道)

三维坐标 layer: L5(MLOps/LLMOps)level: Seniorpillar: 训推框架

L5.4 以 Prometheus 指标与 SLO 为主;本章专讲 Tracing——用 OTel 把 LLM 管道的每一跳延迟钉在 Span 树上,定位「worker 正常但端到端 P99 爆炸」类长尾(L5.4 思考题 2 的完整落地)。

学习目标

  • 前置知识:读过 L5.4;会 Python。无需已有 Jaeger/Tempo 集群(Console Exporter 即可)。
  • 学完产出:① 能解释 Trace / Span / Context 传播三概念;② 能为 RAG 管道设计 retrieve / rerank / llm_call / tool_call 子 Span;③ 能配置 gen_ai.* 语义属性(model、input/output tokens);④ 能说明 traceparent HTTP 头为何必须全链路透传,并能定位异步队列(Celery/Kafka)导致的 trace 断链;⑤ 能算清 head vs tail sampling 的「捕获率-存储成本」账,以及 Span 属性基数爆炸的代价;⑥ 亲手跑通 ConsoleSpanExporter 并打印完整 Span 树。
  • 阅读姿势:Metrics 告诉你「P99 坏了」;Tracing 告诉你「哪一跳坏了」——二者互补,不可替代。

背景与现状

LLM 应用调用链典型深度 5–15 层

网关 → 路由 → RAG 检索 → 重排 → LLM API → Tool/MCP → 二次 LLM → 流式返回

单机 worker P99 正常、端到端 P99 异常时,瓶颈常在:

  • 网关排队(未暴露给 GPU metrics)
  • 跨 AZ RPC
  • 向量库冷启动
  • KV Cache 抢占等待

OpenTelemetry 成为 CNCF 标准;GenAI 语义约定(OpenLLMetry / OTel Semantic Conventions)统一 gen_ai.request.model 等属性,便于跨厂商比对(截至 2026-07 该约定仍处 experimental/development 阶段,字段命名以官方 semconv 为准)。

📅 时效提示(2026):本节生态判断以 2026 年为口径。其一,OTel 的 GenAI 语义约定仍标记为 experimentalgen_ai.* 属性名在历次版本中发生过更名(如早期 gen_ai.prompt 系列被事件/属性重组取代),生产埋点建议用常量集中管理属性名,升级 semconv 版本时只改一处;其二,LLM 可观测生态(OpenLLMetry、Langfuse、Arize Phoenix、各云厂商 LLM Observability 产品)已事实收敛到 OTLP 作为导出协议——自研埋点只要说 OTLP,后端从 Jaeger 换到商业平台通常无需改应用代码。具体字段与产品能力请以各官方文档当期版本为准。

原理与架构

2.1 Trace 是一棵树

  • TraceID:整条请求唯一标识,跨服务不变。
  • SpanID:树中节点;ParentSpanID 链接父子。
  • Baggage / Context:在 async/线程/HTTP 间传播。

2.2 三大信号与 OTLP

信号用途LLM 典型
Traces延迟分解每 hop 耗时
Metrics聚合 SLOQPS、token/s
Logs离散事件prompt hash、error

OTLP(OpenTelemetry Protocol)统一导出到 Collector → Jaeger / Tempo / Datadog。

分工提醒:Tracing 不能替代 Prometheus 做 SLO 告警——原始 Span 是高基数高成本数据,不适合直接算 burn rate。正确姿势:Prometheus 管「告警触发」,Tracing 管「触发后的归因」;中间可用 Collector 的 span metrics connector 从 trace 派生 RED 指标桥接二者。

2.3 GenAI 语义属性(推荐)

属性示例
gen_ai.systemopenai, vllm
gen_ai.request.modelgpt-4o-mini
gen_ai.usage.input_tokens512
gen_ai.usage.output_tokens128
gen_ai.response.finish_reasons["stop"]

2.4 Context 透传

HTTP 请求必须携带 W3C traceparent(及可选 tracestate):

traceparent: 00-{trace-id}-{parent-span-id}-01

网关生成 → 下游 LLM 服务继续 child span → 否则 trace 断链,只剩孤岛 Span。

HTTP 之外的断链高发区traceparent 是 HTTP header,但 LLM 管道里大量调用不走 HTTP——Celery/RQ 任务队列、Kafka/RabbitMQ 消息总线、asyncio.create_task 后台协程、gRPC 自定义 metadata。这些通道不会自动携带 context,必须手动注入/提取(OTel propagate.inject() 把 context 写进消息头,消费者侧 propagate.extract() 恢复为父 context),或使用对应的 instrumentation 包(如 opentelemetry-instrumentation-celery)。经验法则:凡是「跨进程 + 非 HTTP」的边界,默认假设 context 会丢,逐个验证

2.5 Tail-based Sampling

随机 1% 采样会丢掉 正好那条 800ms 慢请求。生产应用 tail-based sampling:Collector 暂存全量 trace,仅导出 latency > 阈值的完整树。

两种采样的本质区别在于决策时机

  • head-based:请求入口掷骰子,决策时对「这条请求慢不慢/会不会出错」一无所知,只能均匀丢弃。优点是实现零成本(SDK 内完成)、下游流量恒定。
  • tail-based:trace 结束后再决策,可按「慢、报错、命中特定 tenant」等条件全保留,另抽小比例正常 trace 做基线。代价是 Collector 需要缓冲决策窗口内的全量 Span,且多副本部署时必须保证同一 trace 的所有 Span 路由到同一个 Collector 实例(loadbalancing exporter 按 trace_id 分片),否则决策各看半棵树。

两者的捕获率与存储成本定量对比,见思考题 1。

2.6 两个真实业务症状(场景化)

症状 A:「trace 到 rerank 就断了」。RAG 服务凌晨 P99 突增到 2.2s,值班同学打开 Jaeger 按 latency 排序找慢 trace,发现所有慢请求的 Span 树都只有 rag.request → retrieve 两层、总时长 55ms 左右——后面的 rerank、llm.call 全部消失,剩下一堆和任何 trace 都对不上的孤岛 rerank Span。真因:两周前 rerank 从同步调用改成了 Celery 异步队列(削峰),任务消息里没带 traceparent,worker 侧 OTel SDK 找不到父 context,只能新起孤儿 trace。那 1.2s 的队列排队时间在任何一棵树上都不可见——这正是端到端 P99 爆炸却「处处正常」的元凶(完整定位与修复见思考题 3)。

症状 B:「接了 LLM 可观测平台,账单先爆了」。某团队把 user_id、session_id 和 prompt 全文都塞进 Span 属性方便排查,两个月后:trace 后端存储费用翻了近 3 倍,且从 trace 派生指标的 spanmetrics 流水线把 Prometheus 内存打爆——每个 user_id 取值都变成一条独立时序。这是 属性基数爆炸(cardinality explosion)的典型剧本:Span 属性对「trace 存储」是线性成本,但一旦流入「指标维度」就是乘法成本(代价量化见思考题 2)。

动手实践:RAG 管道 Console Trace

pip install opentelemetry-api opentelemetry-sdk
# otel_rag_demo.py
import time
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter

provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("rag.pipeline", "1.0.0")

def fake_vector_search(q, k=5):
time.sleep(0.02)
return [f"doc{i}" for i in range(k)]

def fake_llm(q, docs):
time.sleep(0.05)
class Usage:
prompt = 120
completion = 40
class Resp:
usage = Usage()
text = "answer"
return Resp()

def rag_query(q: str):
with tracer.start_as_current_span("rag.request") as root:
root.set_attribute("user.query.length", len(q))
with tracer.start_as_current_span("retrieve") as s:
docs = fake_vector_search(q, k=5)
s.set_attribute("retrieve.k", 5)
s.set_attribute("retrieve.hits", len(docs))
with tracer.start_as_current_span("llm.call") as s:
s.set_attribute("gen_ai.system", "vllm")
s.set_attribute("gen_ai.request.model", "TinyLlama-1.1B")
resp = fake_llm(q, docs)
s.set_attribute("gen_ai.usage.input_tokens", resp.usage.prompt)
s.set_attribute("gen_ai.usage.output_tokens", resp.usage.completion)
return resp.text

for i in range(3):
print(rag_query(f"question {i}"))
python otel_rag_demo.py
# 预期:stdout 打印 JSON Span,含 rag.request → retrieve → llm.call 父子关系

导出到 OTLP Collector(可选)

from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace.export import BatchSpanProcessor

provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True))
)
docker run -p 4317:4317 -p 16686:16686 jaegertracing/all-in-one:latest
# UI: http://localhost:16686

与 FastAPI 集成要点

from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
FastAPIInstrumentor.instrument_app(app)
# 出站 httpx 调用需 opentelemetry-instrumentation-httpx 以传播 traceparent

踩坑预警

  1. async 丢 contextasyncio.create_taskcontext.attach 或使用已 instrument 的框架。
  2. 日志与 trace 未关联:日志行应打印 trace_id,便于 Loki 跳转 Jaeger。
  3. Span 属性 PIIuser.query 勿存原文,存 hash 或 length。
  4. 高基数 attribute:不要把 user_id 不设限写入 Span——存储成本爆炸。

配套代码ai-infra-labs otel_rag_demo.py

深入思考

下面三题每题先给题干,再用 <details> 折叠一份图文并茂的参考答案。建议先合上答案自己想 3 分钟,再展开对照。

思考题 1:1% 采样能抓住 P99 慢请求吗?——head vs tail sampling 算一笔账

你的 RAG 服务 200 QPS,每条 trace 约 12 个 Span、每个 Span 约 2KB。为了省存储,团队上了 head-based 1% 随机采样。某天用户投诉「偶尔要等 3 秒」,你去 Jaeger 里却怎么也找不到对应的慢 trace。结合 2.5 节,回答:① 1% head sampling 下,某一条特定慢请求被采到的概率是多少?一天有 50 条慢请求时,至少抓到 1 条的概率又是多少?② 全量存储、head 1%、tail-based sampling 三种方案的每日存储量各是多少?tail-based 的额外代价(Collector 内存)大概多大?

展开参考答案(含 head vs tail 决策路径图 + 算一遍)

结论:head sampling 在请求入口就掷骰子,决策时还不知道这条请求会不会慢,所以慢请求和快请求被一视同仁地丢弃——1% 采样下任何一条特定慢请求有 99% 概率永远消失。tail-based sampling 把「留不留」的决策推迟到 trace 结束之后,按「慢/出错」条件保留,用一份可控的 Collector 缓冲内存换来「慢请求 100% 在库」,是排查长尾的正解。

算一遍(200 QPS、12 Span/trace、2KB/Span):

  1. 每日 trace 量:200 × 86400 = 1728 万条/天;全量存储 = 1728 万 × 12 × 2KB ≈ 395.5 GB/天——这就是没人敢全量存 trace 的原因。
  2. head 1% 的捕获率:某条特定慢请求被采到的概率就是 1%。一天 50 条慢请求,至少抓到 1 条的概率 = 1 − 0.99^50 ≈ 39.5%——也就是说六成的排障日你手里一条慢 trace 都没有。想以 99% 置信度抓到至少 1 条,需要慢请求数达到约 458 条——等你攒够样本,事故早升级了。
  3. 三方案存储对比
方案每日存储慢请求捕获率额外代价
全量存储≈ 395.5 GB100%存储成本不可持续
head 1%≈ 3.96 GB1%(碰运气)排障时大概率两手空空
tail-based(慢/错全留 + 1% 基线)≈ 7.9 GB慢请求 100%Collector 需暂存决策窗口内全量 Span
  1. tail-based 的内存账:设决策窗口 30s,需缓冲 200 × 30 × 12 × 2KB ≈ 141 MB——用一百多 MB 内存换「慢请求必在库」,几乎总是划算的;真正要防的是窗口设太长(如 5 分钟)或 QPS 突增时缓冲打爆 Collector,生产上要配 num_traces 上限与超限降级策略。

回链:本题是 2.5 节 tail-based sampling 的定量版;「找不到那条 800ms 慢请求」的现象也呼应 2.6 节症状 A 的排障场景。

思考题 2:把 user_id 和 prompt 全文塞进 Span 属性,代价是什么?

2.6 节症状 B:团队为了「排查方便」,在每条 trace 的根 Span 上加了 user.id(100 万活跃用户)、user.prompt(平均 2KB 原文)。服务 200 QPS。请量化:① 这两个属性分别给 trace 存储增加多少成本?② 若 spanmetrics connector 把 Span 属性原样变成指标 label(user_id × 10 个 endpoint × 3 种 status),Prometheus 会发生什么?③ 正确的替代方案是什么——哪些信息该进属性、哪些该进日志、哪些该直接丢弃?

展开参考答案(含基数爆炸链路图 + 算一遍)

结论:高基数/大体积属性对 trace 后端是「线性变贵」(每条 Span 变大),但一旦流入指标系统就是「乘法爆炸」(每个取值组合各占一条时序)——prompt 全文该以 hash/长度入属性、原文进日志采样;user_id 只允许进 trace 属性用于检索,绝不允许作为指标 label。属性设计的铁律:trace 属性回答「这条请求怎么了」,指标 label 回答「这类请求怎么了」,二者基数要求差三个数量级。

算一遍(200 QPS、1728 万 trace/天):

  1. prompt 全文的存储账:每条 trace 根 Span 多挂 2KB 原文 → 1728 万 × 2KB ≈ 33 GB/天的纯增量——对比思考题 1 里 tail-based 方案全部才 7.9 GB/天,一个属性字段就把存储翻了好几倍;这还没算 PII 合规风险(prompt 里常含用户隐私,落进 trace 后端就出了数据边界)。
  2. user_id 变成指标 label 的时序账:100 万 user_id × 10 endpoint × 3 status = 3000 万条活跃时序;按每条时序约 3KB 内存估算,仅索引就需要 ≈ 86 GB 内存——单机 Prometheus 通常几百万时序就到极限,3000 万直接 OOM。这就是「trace 属性线性、指标 label 乘法」的量级差。
  3. 对比表——信息该放哪
信息放 Span 属性?放指标 label?正确做法
user_id可以(用于按用户检索 trace)绝不指标侧最多留 tenant/tier 等低基数分组
prompt 原文绝不属性存 prompt.hash + prompt.length;原文进日志且采样脱敏
model 名可以可以低基数(几十个取值),两边都安全
trace_id天然自带需要时用 exemplar 机制从指标跳转 trace

回链:本题把 2.3 节 GenAI 属性设计与踩坑预警第 3/4 条(PII、高基数)量化成账;「账单先爆」的业务剧本见 2.6 节症状 B

思考题 3:trace 到 rerank 就断了——异步队列断链的定位与修复

沿用 2.6 节症状 A:P99 = 2.2s 的慢请求,Jaeger 里 Span 树只剩 rag.request → retrieve(55ms),rerank 之后全断,另有一堆孤岛 rerank Span。已知真实耗时分布:网关 20ms、retrieve 35ms、Celery 队列排队 1200ms、rerank 80ms、llm.call 900ms。请回答:① 为什么 Celery 会吃掉 context?② 给出修复方案(producer/consumer 两侧各做什么);③ 修复后,那 1200ms 的队列排队时间应该以什么形式出现在 Span 树上?

展开参考答案(含断链 vs 修复后 Span 树对比图 + 算一遍)

结论:OTel 的 context 默认只在「同进程内」和「已 instrument 的 HTTP/gRPC 边界」自动传播;Celery 任务经由 broker(Redis/RabbitMQ)投递,消息体里没有 traceparent,worker 进程拿到任务时 context 为空,SDK 只能新起一条孤儿 trace——于是父 trace 在入队处「断崖」,队列排队时间在两边都不可见。修复靠显式注入/提取:producer 侧把 context 序列化进任务 headers,consumer 侧提取后作为父 context 继续建 Span;排队时间则显式建模为一个 queue.wait Span(或用消息入队时间戳算出的属性),让最大的那段延迟第一次「上树」。

① 为什么断:W3C traceparent 的自动传播依赖「载体已被 instrument」。HTTP/gRPC 有现成 instrumentation 帮你在请求头里带上它;而 Celery 的任务消息只是 broker 里的一段序列化 payload,producer 进程结束调用后 context 留在了自己的线程本地存储里,worker 是另一个进程、另一个时刻启动的执行流——没有任何机制自动把两者接上(呼应 2.4 节「凡是跨进程 + 非 HTTP 的边界,默认假设 context 会丢」)。

② 修复方案(两侧各一步):

# producer 侧:把当前 context 注入任务 headers
from opentelemetry import propagate
carrier = {}
propagate.inject(carrier) # 写入 traceparent/tracestate
rerank_task.apply_async(args=[...], headers={"otel": carrier})

# consumer 侧:提取并作为父 context 建 Span
ctx = propagate.extract(task.request.headers.get("otel", {}))
with tracer.start_as_current_span("rerank", context=ctx) as s:
...

生产上更省事的做法是直接用 opentelemetry-instrumentation-celery(它在 before_task_publish / task_prerun 信号里替你做了上述注入/提取);Kafka 等消息总线同理,把 carrier 塞进 message headers。

③ 算一遍——修复前后「可见延迟」对比

阶段真实耗时修复前树上可见修复后树上可见
网关 + retrieve20 + 35 = 55ms✅ 55ms✅ 55ms
Celery 队列排队1200ms❌ 两边都看不见✅ queue.wait Span 1200ms
rerank80ms⚠️ 孤岛 Span,对不上号✅ 挂回父树
llm.call900ms❌ 跟着孤岛树丢失✅ 挂回父树
端到端2235ms树上仅见 55ms(2.5%)全量 2235ms 可归因

修复前你面对的是「P99 = 2.2s 但 trace 只解释了 55ms」的悬案;修复后一眼看出 1200ms 排队才是主犯(占 54%),下一步行动也清晰了:给 rerank 队列扩 worker 或降级为同步小批量。注意 queue.wait 的 1200ms 不是任何进程「计算」出来的,而是用消息的入队/出队时间戳差建模的——队列时间必须显式建模才会出现在树上,这是异步系统 tracing 与同步调用链最大的思维差异。

回链:断链机制见 2.4 节 Context 透传;业务现场见 2.6 节症状 A;「慢 trace 采没采到」的前置问题见思考题 1。

延伸阅读


下一篇L5.7 事故响应与复盘:trace 帮你找到「哪一跳坏了」,事故响应流程决定「接下来 30 分钟谁做什么」。