Chapter 09

可观测性:Traces、Spans 与 OpenTelemetry

评估告诉你"好不好",可观测性告诉你"为什么"。LLM 应用一旦在生产环境跑起来,Trace 是排查问题、定位成本、做事故回溯的唯一入口。

为什么 LLM 应用格外需要可观测性

传统 Web 服务的 APM(New Relic / Datadog)关心 CPU、内存、QPS、p95 延迟。这些对 LLM 应用仍然重要,但不够——LLM 独有的失败方式,传统监控看不到:

Trace / Span / Event:三个核心概念

Trace(轨迹)
一次完整用户请求的生命周期。从接到请求到返回响应,由若干个 Span 组成的树状结构。一个 trace_id 贯穿全链路。
Span(时间段)
Trace 中的一个工作单元——一次 LLM 调用、一次 embedding、一次 tool call、一次 DB 查询。有开始时间、结束时间、属性、父子关系。
Event(事件)
Span 内部某一时刻发生的事——如"首个 token 返回"、"触发 guardrail"、"重试发生"。附着在 Span 上的时间戳标注。
Attribute(属性)
Span 上的键值对——model 名、input_tokens、temperature、user_id、prompt_version。查询/过滤/告警的基础。

一个典型 Agent Trace 长什么样

Trace: customer-support (2.4s) ├─ Span: http.request [0-2400ms] │ ├─ Span: llm.planning (claude-sonnet-4-6) [50-800ms] │ │ tokens.in=420 tokens.out=180 cost=$0.003 │ ├─ Span: tool.search_order [820-950ms] │ │ order_id=12345 status=shipped │ ├─ Span: tool.get_refund_policy [960-1100ms] │ ├─ Span: llm.answer (claude-sonnet-4-6) [1120-2300ms] │ │ tokens.in=850 tokens.out=240 cost=$0.006 │ │ ├─ Event: first_token [1250ms] │ │ └─ Event: guardrail_check=pass [2280ms] │ └─ Span: postprocess.format [2310-2400ms] └─ Total: 2 LLM calls · 2 tool calls · $0.009

OpenTelemetry:可观测性的事实标准

OpenTelemetry(OTel)是 CNCF 毕业项目,provides 通用的 trace/metric/log 规范和 SDK。2024-2025 年它发布了专门的 GenAI Semantic Conventions,定义了 LLM 调用应该上报的标准属性。

为什么要遵循 OTel GenAI 规范 属性名统一后,你换后端(Jaeger → Tempo → Langfuse → Datadog)不用改代码。也便于混合使用不同观测工具时数据能打通。

GenAI 核心属性(官方语义约定)

属性名示例值说明
gen_ai.systemopenai / anthropic模型提供商
gen_ai.request.modelgpt-4o-2024-08-06请求模型名
gen_ai.response.modelgpt-4o-2024-08-06实际响应模型名(重要:可能和请求不同)
gen_ai.request.temperature0.0采样温度
gen_ai.request.max_tokens1024最大输出 token
gen_ai.usage.input_tokens512输入 token 数
gen_ai.usage.output_tokens128输出 token 数
gen_ai.response.finish_reasons[stop]停止原因(stop / length / tool_calls / content_filter)
gen_ai.operation.namechat / embeddings操作类型

最小接入:Python + OpenTelemetry

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

trace.set_tracer_provider(TracerProvider())
trace.get_tracer_provider().add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces"))
)
tracer = trace.get_tracer("my-llm-app")

def call_llm(messages, model="gpt-4o-mini"):
    with tracer.start_as_current_span("chat") as span:
        span.set_attribute("gen_ai.system", "openai")
        span.set_attribute("gen_ai.request.model", model)
        span.set_attribute("gen_ai.operation.name", "chat")

        rsp = client.chat.completions.create(model=model, messages=messages)

        span.set_attribute("gen_ai.response.model", rsp.model)
        span.set_attribute("gen_ai.usage.input_tokens", rsp.usage.prompt_tokens)
        span.set_attribute("gen_ai.usage.output_tokens", rsp.usage.completion_tokens)
        span.set_attribute("gen_ai.response.finish_reasons", [rsp.choices[0].finish_reason])
        return rsp

自动插桩(更推荐)

大部分 LLM SDK 已经有自动插桩包,接入两行就完事,不用手写 span:

pip install opentelemetry-instrumentation-openai
# 或 anthropic / langchain / llama-index
from opentelemetry.instrumentation.openai import OpenAIInstrumentor
OpenAIInstrumentor().instrument()

# 之后所有 openai 调用都会自动产出合规的 span
rsp = client.chat.completions.create(...)

三大 LLM 观测后端对比

后端定位强项
Langfuse开源 LLM 观测首选自托管友好,UI 专为 LLM 设计,支持 session/user/score
LangSmithLangChain 官方LangChain 生态零配置,trace 回放与评估一体
Arize Phoenix开源,本地优先基于 OTel,notebook 里一行启动,embedding drift 可视化
Datadog / Honeycomb通用 APM 加 LLM 能力和现有基础设施集成,SRE 团队熟悉
选型建议 新项目起步:Langfuse(自托管) 或 Phoenix(本地);已有 Datadog/Honeycomb:直接加 GenAI 维度;重度 LangChain 用户:LangSmith。这些后端都消费 OTel 协议,上游代码一次接入多端消费。

关键监控维度

① 延迟(Latency)

LLM 延迟比普通 API 更复杂,至少要看 3 个维度:

import time

def stream_with_metrics(messages):
    with tracer.start_as_current_span("chat_stream") as span:
        start = time.time()
        ttft, n_tokens = None, 0

        stream = client.chat.completions.create(
            model="gpt-4o-mini", messages=messages, stream=True,
        )
        for chunk in stream:
            if chunk.choices[0].delta.content:
                if ttft is None:
                    ttft = time.time() - start
                    span.add_event("first_token", attributes={"ttft_ms": int(ttft * 1000)})
                n_tokens += 1
                yield chunk.choices[0].delta.content

        total = time.time() - start
        tpot = (total - ttft) / max(n_tokens - 1, 1)
        span.set_attribute("llm.latency.ttft_ms", int(ttft * 1000))
        span.set_attribute("llm.latency.tpot_ms", int(tpot * 1000))
        span.set_attribute("llm.latency.total_ms", int(total * 1000))

② 成本(Cost)

按模型单价把 token 折算成钱,维度上报。这是 LLM 特有且最关键的 SLO。

PRICING = {  # 美元 / 1K tokens,2026 年报价
    "gpt-4o-mini":       {"in": 0.00015, "out": 0.00060},
    "gpt-4o":            {"in": 0.00250, "out": 0.01000},
    "claude-sonnet-4-6": {"in": 0.00300, "out": 0.01500},
    "claude-opus-4-7":   {"in": 0.01500, "out": 0.07500},
}

def annotate_cost(span, model, in_tokens, out_tokens):
    p = PRICING.get(model)
    if not p: return
    cost = (in_tokens * p["in"] + out_tokens * p["out"]) / 1000
    span.set_attribute("gen_ai.usage.cost_usd", cost)

③ Token 使用

输入/输出 token 分别看。常见异常模式:

④ 质量信号

质量指标也要打到 span 上,和成本/延迟关联看:

告警规则设计

LLM 应用的告警比传统服务多了一批"内容相关"的维度。一份合理的告警基线:

指标阈值窗口严重度
p95 端到端延迟> SLO * 1.35 分钟P2
TTFT p95> 2s5 分钟P2
错误率> 2%5 分钟P1
每小时成本> 预算 * 1.51 小时P1
日成本> 预算P2
refusal 率突增 > 3σ15 分钟P2
平均输出 token突增 > 2x 基线15 分钟P3
finish_reason=length 占比> 5%30 分钟P3
Judge 均分< 基线 - 0.31 小时P2
👎 率> 基线 * 1.530 分钟P2
"3σ 突增"的实现 用 7 天滑动窗口算 μ 和 σ,当前值 > μ + 3σ 触发。比固定阈值稳得多——你不用为业务增长调整阈值,异常检测自适应。

Prompt 版本与实验维度

Trace 上一定要打 prompt_version / experiment / variant,否则你无法做"不同版本在线上的真实表现对比":

span.set_attribute("app.prompt.version", "v7")
span.set_attribute("app.prompt.hash", "sha256:abc123...")
span.set_attribute("app.experiment.name", "tone-tweak")
span.set_attribute("app.variant", "B")
span.set_attribute("app.user.segment", "vip")

这些属性和系统指标组合,能直接在观测后端答出:"VIP 用户在 v7 版本的 p95 延迟和成本如何?"

Session/Trace 关联用户反馈

反馈回流必须用 session_id/trace_id 关联,否则"thumbs down"是孤立的点,无法映射到具体 prompt 版本:

@app.post("/feedback")
async def record_feedback(payload: FeedbackIn):
    # trace_id 由前端通过 response header 收到并回传
    langfuse.score(
        trace_id=payload.trace_id,
        name="user_feedback",
        value=1 if payload.thumb == "up" else 0,
        comment=payload.comment,
    )
    return {"ok": True}

敏感信息处理

默认记录完整 prompt/response 很危险——用户隐私、PII、公司机密全进了你的观测后端。三种处理策略:

① 不记录正文
只记 metadata(token 数、延迟、模型)。最安全,但排查问题靠猜。适合高敏感行业(医疗/金融)。
② 脱敏后记录
上报前跑一遍 PII redactor(邮箱、手机、身份证、信用卡),用 [REDACTED] 占位。主流方案。
③ 采样 + 加密存储
只记 5%,且存到独立加密表,访问需审批。balance 隐私与可观测。
import re

PII_PATTERNS = [
    (re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b"),  "[EMAIL]"),
    (re.compile(r"\b1[3-9]\d{9}\b"),                "[PHONE]"),
    (re.compile(r"\b\d{17}[\dXx]\b"),              "[IDCARD]"),
    (re.compile(r"\b\d{13,19}\b"),                  "[CARD]"),
]

def redact(text: str) -> str:
    for pat, repl in PII_PATTERNS:
        text = pat.sub(repl, text)
    return text

span.set_attribute("gen_ai.prompt", redact(prompt_text)[:2000])

事故回溯流程(Post-Mortem)

一次线上"回答错误"的投诉,靠 trace 如何 5 分钟定位原因?标准操作:

  1. 拿到 trace_id:用户投诉附带 session_id,从中找到 trace
  2. 查看完整 span 树:看 LLM 调用次数、tool 调用序列
  3. 对比基线:同类 query 的典型 trace 长什么样
  4. 抓 prompt_version 和 model:是不是升了新版本?是不是命中了 A/B
  5. 检查输入:RAG 拿到了什么上下文?有没有污染
  6. 检查 guardrail:有没有被过滤,为什么放行
  7. 记录根因:写进事故库,加进评估集,避免再犯
评估集的自然来源 线上事故的每一个 trace 都是下一版本的黄金评估案例。建立 "incident → regression set" 的固定流程,评估集会自动生长。

Langfuse 最小接入示例

from langfuse import Langfuse
from langfuse.openai import openai  # drop-in 替换

lf = Langfuse(
    public_key="pk-...",
    secret_key="sk-...",
    host="https://cloud.langfuse.com",
)

# 普通 openai 调用,自动 trace
rsp = openai.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "hello"}],
    name="greet-user",                    # span name
    metadata={"prompt_version": "v7"},   # 自定义维度
    user_id="user_42",                     # 用户聚合
    session_id="sess_abc",                 # session 关联
)

# 业务维度打分回流
lf.score(trace_id="...", name="task_success", value=1)

把 Evals 和 Traces 打通

这是闭环的关键:线上 trace → 评估集 → 离线评估 → 新版本 → 再上线。一个推荐架构:

线上请求 │ ├──▶ OTel span (含 cost/latency/tokens/prompt_version) │ │ │ ├──▶ Langfuse / Datadog (实时监控 + 告警) │ │ │ ├──▶ S3 冷存 (parquet,按天分区) │ │ │ │ │ ▼ │ │ 每日 job:按 cluster + 问题类型采样 │ │ │ │ │ ▼ │ │ 人工 review → 评估集入库 │ │ │ └──▶ 异步 2% Judge worker → 写回 span score │ └──▶ 用户 thumbs → POST /feedback → trace_id score

成熟度自检表

阶段你们在哪?
L0 无观测只有应用日志,出问题靠打印。Prompt 改完丢上线,不知道有没有变差。
L1 基础 trace每次 LLM 调用记下 model / input / output / tokens / latency。事后能查。
L2 结构化维度OTel GenAI 规范,prompt_version / experiment / user_segment 都有。可以切片分析。
L3 告警 + 反馈回流成本/延迟/质量全套告警,用户 feedback 关联 trace。异常自动 page oncall。
L4 闭环线上 trace → 评估集 → 离线对比 → A/B → 再部署,全链路自动化。

本章小结