L5.6 OpenTelemetry 链路追踪(LLM 管道)
三维坐标
layer: L5(MLOps/LLMOps)|level: Senior|pillar: 训推框架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);④ 能说明traceparentHTTP 头为何必须全链路透传,并能定位异步队列(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 语义约定仍标记为 experimental,
gen_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 | 聚合 SLO | QPS、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.system | openai, vllm |
gen_ai.request.model | gpt-4o-mini |
gen_ai.usage.input_tokens | 512 |
gen_ai.usage.output_tokens | 128 |
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
踩坑预警
- async 丢 context:
asyncio.create_task需context.attach或使用已 instrument 的框架。 - 日志与 trace 未关联:日志行应打印
trace_id,便于 Loki 跳转 Jaeger。 - Span 属性 PII:
user.query勿存原文,存 hash 或 length。 - 高基数 attribute:不要把
user_id不设限写入 Span——存储成本爆炸。
深入思考
下面三题每题先给题干,再用
<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):
- 每日 trace 量:200 × 86400 = 1728 万条/天;全量存储 = 1728 万 × 12 × 2KB ≈ 395.5 GB/天——这就是没人敢全量存 trace 的原因。
- head 1% 的捕获率:某条特定慢请求被采到的概率就是 1%。一天 50 条慢请求,至少抓到 1 条的概率 = 1 − 0.99^50 ≈ 39.5%——也就是说六成的排障日你手里一条 慢 trace 都没有。想以 99% 置信度抓到至少 1 条,需要慢请求数达到约 458 条——等你攒够样本,事故早升级了。
- 三方案存储对比:
| 方案 | 每日存储 | 慢请求捕获率 | 额外代价 |
|---|---|---|---|
| 全量存储 | ≈ 395.5 GB | 100% | 存储成本不可持续 |
| head 1% | ≈ 3.96 GB | 1%(碰运气) | 排障时大概率两手空空 |
| tail-based(慢/错全留 + 1% 基线) | ≈ 7.9 GB | 慢请求 100% | Collector 需暂存决策窗口内全量 Span |
- 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/天):
- prompt 全文的存储账:每条 trace 根 Span 多挂 2KB 原文 → 1728 万 × 2KB ≈ 33 GB/天的纯增量——对比思考题 1 里 tail-based 方案全部才 7.9 GB/天,一个属性字段就把存储翻了好几倍;这还没算 PII 合规风险(prompt 里常含用户隐私,落进 trace 后端就出了数据边界)。
- user_id 变成指标 label 的时序账:100 万 user_id × 10 endpoint × 3 status = 3000 万条活跃时序;按每条时序约 3KB 内存估算,仅索引就需要 ≈ 86 GB 内存——单机 Prometheus 通常几百万时序就到极限,3000 万直接 OOM。这就是「trace 属性线性、指标 label 乘法」的量级差。
- 对比表——信息该放哪:
| 信息 | 放 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。
③ 算一遍——修复前后「可见延迟」对比:
| 阶段 | 真实耗时 | 修复前树上可见 | 修复后树上可见 |
|---|---|---|---|
| 网关 + retrieve | 20 + 35 = 55ms | ✅ 55ms | ✅ 55ms |
| Celery 队列排队 | 1200ms | ❌ 两边都看不见 | ✅ queue.wait Span 1200ms |
| rerank | 80ms | ⚠️ 孤岛 Span,对不上号 | ✅ 挂回父树 |
| llm.call | 900ms | ❌ 跟着孤岛树丢失 | ✅ 挂回父树 |
| 端到端 | 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.4 可观测性与 SRE —— Metrics/SLO 侧的姊妹篇,本章思考题 1 的采样账与其思考题 2 的长尾归因互为表里。
- OpenTelemetry:Semantic Conventions for GenAI ——
gen_ai.*属性权威定义(experimental,升级前先读 changelog)。 - OpenTelemetry:Tail Sampling Processor —— 思考题 1 方案的官方实现,重点看
decision_wait与num_traces参数。 - L5.7 事故响应与复盘 —— 归因之后的组织动作:值班、升级与 postmortem。
下一篇 → L5.7 事故响应与复盘:trace 帮你找到「哪一跳坏了」,事故响应流程决定「接下来 30 分钟谁做什么」。