你的 LLM 应用上线了,用户投诉"回答变差了"------你打开监控,看到的是一条平均响应时间和一个错误率,完全不知道哪里出了问题。这不是监控不够,是你用错了观测维度。
为什么传统 APM 在 LLM 应用上失灵
传统 APM 的核心假设是:同样的输入产生同样的输出。一个 SQL 查询慢了,你加索引;一个 HTTP 请求 500,你看 stack trace。
LLM 应用打破了这个假设。
同样一段 prompt,昨天返回结构化 JSON,今天返回带解释的自然语言------两个结果都"正确",但下游解析崩了。错误率是零,但系统已经坏了。
你需要的不是"这次调用花了多少毫秒",而是:
- 这次调用用了哪个 prompt 版本?
- 模型返回了什么,和预期格式有什么差异?
- token 消耗异常,是哪个用户、哪个功能触发的?
- 这次 RAG 检索召回了哪几段文档,和最终回答有关系吗?
这些问题,传统 APM 一个都回答不了。
三层可观测性模型
把 LLM 应用的可观测性分成三层,每层解决不同的问题:
yaml
┌──────────────────────────────────────────────────────┐
│ Layer 3: 语义层(Semantic Layer) │
│ Prompt diff、Output schema drift、质量评分 │
├──────────────────────────────────────────────────────┤
│ Layer 2: 业务层(Business Layer) │
│ 用户会话、功能模块、cost attribution │
├──────────────────────────────────────────────────────┤
│ Layer 1: 基础设施层(Infra Layer) │
│ OTel Span、延迟、token count、HTTP 状态码 │
└──────────────────────────────────────────────────────┘
Layer 1 是 APM 能做到的。Layer 2 和 Layer 3 是 LLM 应用特有的,也是大多数团队缺失的。
下面逐层拆解。
Layer 1:基础设施层 --- OTel GenAI Semantic Conventions
2026 年 5 月,OpenTelemetry 正式发布了 GenAI 语义约定(gen_ai.* 属性集),统一了 LLM 调用的 Span 结构。
核心 Span 属性
python
# 一次 LLM 调用应该记录的 OTel 属性
span.set_attribute("gen_ai.system", "deepseek") # 模型供应商
span.set_attribute("gen_ai.request.model", "deepseek-chat") # 请求的模型
span.set_attribute("gen_ai.response.model", "deepseek-chat-v3") # 实际响应模型(可能不同!)
span.set_attribute("gen_ai.usage.input_tokens", 1247)
span.set_attribute("gen_ai.usage.output_tokens", 389)
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.request.temperature", 0.7)
span.set_attribute("gen_ai.request.max_tokens", 2048)
注意 gen_ai.request.model 和 gen_ai.response.model 是两个不同的属性。你以为你在用 deepseek-chat,但供应商偷偷给你切到了旧版本------这种事在生产中真实发生过。
用 opentelemetry-instrumentation-openai 自动接入
bash
pip install opentelemetry-instrumentation-openai opentelemetry-sdk
python
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.instrumentation.openai import OpenAIInstrumentor
# 初始化 OTel
provider = TracerProvider()
provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317"))
)
trace.set_tracer_provider(provider)
# 自动 instrument 大模型 SDK,不需要改业务代码
OpenAIInstrumentor().instrument()
# 后续所有 openai.chat.completions.create() 调用自动产生 Span
import openai
client = openai.OpenAI(base_url="https://api.deepseek.com", api_key="...") # DeepSeek 兼容大模型接口
response = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": "解释 B+树"}]
)
这样你就有了 Layer 1:每次 LLM 调用都有标准 Span,延迟、token、错误码一览无余。
陷阱:Span 里默认没有 prompt 内容
出于安全和隐私考虑,OTel GenAI 默认不记录 prompt 和 completion 内容。如果你需要调试 prompt,要显式开启:
python
OpenAIInstrumentor().instrument(
capture_message_content=True # 生产环境慎用,会记录用户输入
)
更好的做法:只在 staging 环境开启内容记录,生产只记录 prompt 的哈希和版本号。
Layer 2:业务层 --- 把 LLM 调用和业务上下文关联
Layer 1 告诉你"这次调用花了 2.3 秒,用了 1247 个 token"。但它不知道这次调用是哪个用户 触发的、为了哪个功能 、属于哪条会话。
这是 Layer 2 要解决的:Cost Attribution + Session Tracking。
在 Span 上打业务标签
python
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
def handle_user_message(user_id: str, session_id: str, feature: str, message: str):
with tracer.start_as_current_span("llm.user_request") as span:
# 业务上下文
span.set_attribute("app.user_id", user_id)
span.set_attribute("app.session_id", session_id)
span.set_attribute("app.feature", feature) # "summarize" / "code_review" / "chat"
span.set_attribute("app.tenant_id", get_tenant(user_id))
span.set_attribute("app.prompt_version", "v2.3.1") # Prompt 版本
# 实际 LLM 调用(子 Span 自动关联)
response = call_llm(message)
# 记录结果元信息(不记录内容本身)
span.set_attribute("app.response_schema_valid", validate_schema(response))
span.set_attribute("app.retry_count", 0)
return response
用 Trace 做 Cost Attribution
有了业务标签,你就能在 Grafana/Datadog 里做这样的查询:
sql
-- 按功能模块统计 token 消耗(伪 SQL,实际用你的 trace 存储)
SELECT
app.feature,
SUM(gen_ai.usage.input_tokens + gen_ai.usage.output_tokens) as total_tokens,
COUNT(*) as request_count,
AVG(duration_ms) as avg_latency
FROM spans
WHERE gen_ai.operation.name = 'chat'
AND timestamp > NOW() - INTERVAL 7 DAY
GROUP BY app.feature
ORDER BY total_tokens DESC
这是我们团队某个产品的实际数据(脱敏):
| 功能模块 | 7 天 token 消耗 | 占比 | 平均延迟 |
|---|---|---|---|
| document_summarize | 12.4M | 61% | 4.2s |
| code_review | 4.8M | 24% | 2.1s |
| chat | 2.1M | 10% | 1.8s |
| other | 1.0M | 5% | --- |
发现:document_summarize 消耗了 61% 的 token,但只产生了约 20% 的用户价值(按功能使用频率估算)。这个数据直接推动了我们对文档摘要 prompt 的压缩优化,最终 input token 减少了 38%。
如果没有 Layer 2 的 Cost Attribution,这个发现不可能浮现。
会话级追踪:把多轮对话串起来
多轮对话中,每轮都是独立的 LLM 调用,但它们属于同一个用户会话。你需要把它们串起来才能分析对话质量。
python
import uuid
from contextvars import ContextVar
# 用 ContextVar 在异步环境中传递 session_id
current_session_id: ContextVar[str] = ContextVar("session_id", default="")
class ConversationTracker:
def __init__(self, user_id: str):
self.user_id = user_id
self.session_id = str(uuid.uuid4())
self.turn_count = 0
def track_turn(self, user_input: str, assistant_output: str, span):
self.turn_count += 1
token = current_session_id.set(self.session_id)
span.set_attribute("app.session_id", self.session_id)
span.set_attribute("app.turn_number", self.turn_count)
span.set_attribute("app.conversation_length_chars",
len(user_input) + len(assistant_output))
# 用 session_id 关联所有轮次的 Span
# 在 trace 后端里,用 session_id 过滤就能看到完整对话链路
current_session_id.reset(token)
Layer 3:语义层 --- Prompt Diff 和 Output Schema Drift
这是最难的一层,也是最有价值的一层。
核心问题:你改了一个 prompt,怎么知道输出质量有没有变化?
Prompt Version + Output Hash
最小实现:记录每次调用的 prompt 版本和输出内容的哈希。
python
import hashlib
import json
from datetime import datetime
class PromptVersionTracker:
"""追踪 Prompt 版本和输出特征"""
def __init__(self, prompt_registry):
self.registry = prompt_registry
def track_call(self, prompt_name: str, variables: dict, response: str, span):
prompt_template = self.registry.get(prompt_name)
prompt_version = self.registry.get_version(prompt_name)
# 记录 prompt 版本(不记录内容,只记录版本 ID)
span.set_attribute("app.prompt_name", prompt_name)
span.set_attribute("app.prompt_version", prompt_version)
# 记录输出的结构特征
output_features = self._extract_output_features(response)
span.set_attribute("app.output_format", output_features["format"])
span.set_attribute("app.output_length_tokens", output_features["token_count"])
span.set_attribute("app.output_has_json", output_features["has_json"])
# 记录输出哈希(用于检测完全一致的重复输出------异常信号)
output_hash = hashlib.sha256(response.encode()).hexdigest()[:8]
span.set_attribute("app.output_hash_prefix", output_hash)
return output_features
def _extract_output_features(self, response: str) -> dict:
"""提取输出的结构特征,不记录内容本身"""
has_json = False
try:
json.loads(response)
has_json = True
except:
# 尝试提取 JSON 块
import re
has_json = bool(re.search(r'```json', response))
return {
"format": "json" if has_json else "text",
"token_count": len(response.split()), # 粗略估算
"has_json": has_json,
"starts_with_sorry": response.lower().startswith(("sorry", "i'm sorry", "抱歉"))
}
有趣的发现 :starts_with_sorry 这个字段看起来很怪,但在生产中非常有价值。当模型开始频繁以"抱歉"开头拒绝请求时,往往意味着 prompt 触发了安全过滤,或者模型版本发生了变化。
Schema Drift 检测
当你的应用依赖 LLM 输出结构化 JSON 时,Schema Drift 是最常见的静默故障。
python
from pydantic import BaseModel, ValidationError
from typing import Optional
import json
class LLMOutputSchema(BaseModel):
"""期望的输出 schema"""
action: str
confidence: float
reasoning: Optional[str] = None
metadata: Optional[dict] = None
class SchemaDriftDetector:
def __init__(self, expected_schema: type[BaseModel]):
self.expected_schema = expected_schema
self.drift_count = 0
self.total_count = 0
def check(self, raw_response: str, span) -> tuple[bool, dict]:
self.total_count += 1
try:
# 尝试提取 JSON
data = self._extract_json(raw_response)
validated = self.expected_schema(**data)
span.set_attribute("app.schema_valid", True)
span.set_attribute("app.schema_drift", False)
return True, validated.model_dump()
except ValidationError as e:
self.drift_count += 1
drift_rate = self.drift_count / self.total_count
# 记录 drift 细节(字段缺失/类型错误)
missing_fields = [err["loc"][0] for err in e.errors()
if err["type"] == "missing"]
wrong_type_fields = [err["loc"][0] for err in e.errors()
if err["type"] != "missing"]
span.set_attribute("app.schema_valid", False)
span.set_attribute("app.schema_drift", True)
span.set_attribute("app.schema_drift_rate", drift_rate)
span.set_attribute("app.missing_fields", str(missing_fields))
span.set_attribute("app.wrong_type_fields", str(wrong_type_fields))
# drift rate > 5% 触发告警
if drift_rate > 0.05:
span.add_event("schema_drift_alert", {
"drift_rate": drift_rate,
"missing_fields": str(missing_fields)
})
return False, {"error": str(e), "raw": raw_response[:200]}
except json.JSONDecodeError:
span.set_attribute("app.schema_valid", False)
span.set_attribute("app.json_parse_error", True)
return False, {"error": "JSON parse failed"}
def _extract_json(self, text: str) -> dict:
"""从可能包含额外文本的响应中提取 JSON"""
import re
# 优先尝试整体解析
try:
return json.loads(text.strip())
except:
pass
# 提取 ```json ... ``` 块
match = re.search(r'```json\s*(.*?)\s*```', text, re.DOTALL)
if match:
return json.loads(match.group(1))
# 提取第一个 {...} 块
match = re.search(r'\{.*\}', text, re.DOTALL)
if match:
return json.loads(match.group(0))
raise json.JSONDecodeError("No JSON found", text, 0)
Prompt Diff:对比两个版本的输出分布
当你发布新版 prompt 时,最有价值的问题是:新版 prompt 的输出和旧版有什么系统性差异?
不是"有没有错误",而是"输出风格、长度、格式是否有漂移"。
python
from collections import defaultdict
import statistics
class PromptVersionComparator:
"""对比两个 prompt 版本的输出特征分布"""
def __init__(self):
self.version_stats: dict[str, list] = defaultdict(list)
def record(self, prompt_version: str, output_features: dict):
self.version_stats[prompt_version].append(output_features)
def compare(self, version_a: str, version_b: str) -> dict:
stats_a = self.version_stats[version_a]
stats_b = self.version_stats[version_b]
if not stats_a or not stats_b:
return {"error": "Insufficient data"}
def avg(records, key):
vals = [r[key] for r in records if key in r]
return statistics.mean(vals) if vals else 0
return {
"version_a": version_a,
"version_b": version_b,
"sample_count": {"a": len(stats_a), "b": len(stats_b)},
"avg_output_tokens": {
"a": avg(stats_a, "token_count"),
"b": avg(stats_b, "token_count"),
"delta_pct": self._delta_pct(
avg(stats_a, "token_count"),
avg(stats_b, "token_count")
)
},
"json_format_rate": {
"a": sum(1 for r in stats_a if r.get("has_json")) / len(stats_a),
"b": sum(1 for r in stats_b if r.get("has_json")) / len(stats_b),
},
"sorry_rate": {
"a": sum(1 for r in stats_a if r.get("starts_with_sorry")) / len(stats_a),
"b": sum(1 for r in stats_b if r.get("starts_with_sorry")) / len(stats_b),
}
}
def _delta_pct(self, a: float, b: float) -> float:
if a == 0:
return 0
return round((b - a) / a * 100, 1)
实际使用场景:我们在灰度新版 prompt 时,用这个工具发现 v2.4.0 的 JSON 格式率从 v2.3.x 的 94% 下降到了 81%------这在错误率指标上完全看不出来(下游有 fallback),但实际上每 5 次请求就有 1 次在走低质量的 fallback 路径。
工具选型:Langfuse vs Arize Phoenix vs 自建
市面上有几个 LLM-native 的观测工具,它们在三层模型里的覆盖情况不同:
| 工具 | Layer 1 (OTel) | Layer 2 (Cost/Session) | Layer 3 (Prompt Diff/Schema) | 自托管 | 定价模型 |
|---|---|---|---|---|---|
| Langfuse | ✅ 原生支持 | ✅ 用户/会话追踪 | ✅ Prompt 版本管理 | ✅ MIT | 免费+付费云版 |
| Arize Phoenix | ✅ OTel 原生 | ✅ 实验对比 | ✅ Evals 框架 | ✅ 开源 | 免费开源 |
| Maxim AI | ✅ | ✅ | ✅ LLM Judge | ❌ 云服务 | 付费 |
| LangSmith | ⚠️ 部分 | ✅ 项目/Dataset | ✅ Evaluator | ❌ 云服务 | 付费 |
| 自建 OTel + Grafana | ✅ | ⚠️ 需自定义 | ❌ 需全部自建 | ✅ | 运维成本 |
选型建议:
- 初创团队/个人项目:Langfuse 自托管(Docker Compose 10 分钟起),Layer 1+2 开箱即用,Layer 3 用内置 Prompt 版本管理
- 已有 OTel 基础设施的团队:Arize Phoenix,直接对接现有 trace pipeline
- 合规要求严格(数据不出境):Langfuse 自托管 + 自建 OTel Collector
- 大规模生产(>100M tokens/月):考虑 Maxim AI 或基于 ClickHouse 的自建方案,Postgres 存储 trace 在这个规模下会有性能问题
Langfuse 接入示例(5 分钟版本)
python
from langfuse import Langfuse
from langfuse.openai import openai # 替换 import,自动追踪
# 初始化(从环境变量读取 LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_HOST)
langfuse = Langfuse()
def chat_with_tracking(user_id: str, session_id: str, message: str):
# 创建 trace(对应一次用户交互)
trace = langfuse.trace(
name="user_chat",
user_id=user_id,
session_id=session_id,
metadata={"feature": "chat", "version": "2.1.0"}
)
# span 里调用 LLM(openai 已被 langfuse 替换,自动追踪)
generation = trace.generation(
name="main_completion",
model="deepseek-chat",
model_parameters={"temperature": 0.7},
input=message,
prompt=langfuse.get_prompt("chat_system_v3") # 版本化的 prompt
)
response = openai.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "system", "content": generation.prompt.compile()},
{"role": "user", "content": message}
]
)
output = response.choices[0].message.content
# 记录 generation 结果
generation.end(
output=output,
usage={
"input": response.usage.prompt_tokens,
"output": response.usage.completion_tokens
}
)
return output
生产中的五个真实坑
坑 1:Span 丢失,但你不知道
异步框架(FastAPI + asyncio)里,trace context 不会自动跨越 asyncio task 边界传播。
python
# ❌ 错误:子 task 里的 Span 不在父 trace 里
async def process_batch(items: list):
tasks = [asyncio.create_task(process_item(item)) for item in items]
await asyncio.gather(*tasks)
# ✅ 正确:手动传播 context
from opentelemetry.context import attach, detach, get_current
async def process_batch(items: list):
ctx = get_current() # 捕获当前 context
async def process_with_context(item):
token = attach(ctx) # 在子 task 中恢复 context
try:
await process_item(item)
finally:
detach(token)
tasks = [asyncio.create_task(process_with_context(item)) for item in items]
await asyncio.gather(*tasks)
坑 2:Trace 存储爆炸
LLM 调用的 Span 如果记录了 prompt 内容,单条 Span 可以到 50KB+。每天 10 万次调用就是 5GB/day 的 trace 数据。
解法 :分级存储。基础 Span 属性(token、延迟、版本号)全量保留,prompt/completion 内容 采样存储(1% 或按错误/高延迟触发)。
python
import random
class SampledContentTracer:
def __init__(self, content_sample_rate: float = 0.01):
self.sample_rate = content_sample_rate
def should_capture_content(self, is_error: bool, latency_ms: float) -> bool:
"""错误和高延迟请求全量捕获,其余采样"""
if is_error:
return True
if latency_ms > 5000: # >5s 全量
return True
return random.random() < self.sample_rate
坑 3:时钟漂移导致 Trace 链路断裂
分布式系统里,如果各服务的系统时钟不同步(NTP 漂移 >100ms),父子 Span 的时间戳会出现倒置,导致 trace 可视化显示"子 Span 在父 Span 之前结束"。
在 LLM 网关架构里这个问题更严重,因为 LLM 调用本身就有 1-10s 的延迟,任何时钟问题都会被放大。
解法 :在 trace 数据里用相对时间而不是绝对时间做分析。Langfuse 和 Phoenix 都支持以 trace 开始时间为基准的相对时延视图。
坑 4:Prompt 版本和 Span 对不上
你的 prompt 存在数据库里,版本号是 v2.3.1,但 Span 里记录的是 prompt_id: 42。三个月后你想查某个 prompt 版本的历史表现,发现 prompt_id 和版本号之间没有对应关系。
解法 :在 Span 里同时记录 prompt_name、prompt_version(语义版本)和 prompt_commit_sha(内容哈希)。后两者任意一个都能唯一标识 prompt 内容。
python
span.set_attribute("app.prompt_name", "chat_system")
span.set_attribute("app.prompt_version", "v2.3.1")
span.set_attribute("app.prompt_content_sha",
hashlib.sha256(prompt_content.encode()).hexdigest()[:12])
坑 5:RAG 管道里的 Span 断层
RAG 应用通常有三个阶段:retrieval → augmentation → generation。如果这三个阶段不在同一个 trace 里,你根本无法分析"检索到的文档和最终回答的相关性"。
python
async def rag_pipeline(query: str, user_id: str) -> str:
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("rag.pipeline") as root_span:
root_span.set_attribute("app.user_id", user_id)
root_span.set_attribute("app.query_length", len(query))
# Retrieval Span
with tracer.start_as_current_span("rag.retrieval") as retrieval_span:
docs = await vector_search(query)
retrieval_span.set_attribute("rag.retrieved_count", len(docs))
retrieval_span.set_attribute("rag.top_score", docs[0].score if docs else 0)
# 记录文档 ID,不记录内容
retrieval_span.set_attribute("rag.doc_ids",
str([d.id for d in docs[:5]]))
# Augmentation Span
with tracer.start_as_current_span("rag.augmentation") as aug_span:
context = build_context(docs, max_tokens=2000)
aug_span.set_attribute("rag.context_tokens", count_tokens(context))
aug_span.set_attribute("rag.docs_used", len(docs))
# Generation Span(由 OTel 自动创建子 Span)
response = await llm_call(query, context)
root_span.set_attribute("rag.pipeline_success", True)
return response
告警策略:什么值得告警,什么不值得
有了三层数据,容易犯的错是"告警太多",反而让真正的问题被淹没。
值得立即告警的:
| 信号 | 阈值建议 | 为什么 |
|---|---|---|
| Schema Drift Rate | >5% (5分钟窗口) | 下游解析开始崩溃 |
| P99 延迟 | >10s 持续 3min | 用户体验临界点 |
| Error Rate | >2% (5分钟窗口) | 超出正常波动 |
starts_with_sorry Rate |
>15% (1小时窗口) | 模型行为系统性变化 |
不值得立即告警的(用于 daily review):
- 单次 token 消耗异常高(可能是正常长文档)
- 输出长度轻微变化(±20% 以内)
- Cost 轻微上涨(<10%/天)
完整架构图
scss
用户请求
│
▼
┌─────────────────────────────────────────┐
│ Application Layer │
│ ┌──────────────────────────────────┐ │
│ │ ConversationTracker │ │
│ │ user_id / session_id / feature │ │
│ └──────────────────────────────────┘ │
└────────────────┬────────────────────────┘
│ OTel Context Propagation
▼
┌─────────────────────────────────────────┐
│ LLM Gateway / SDK Layer │
│ ┌──────────────────────────────────┐ │
│ │ OpenAIInstrumentor (自动 Span) │ │
│ │ gen_ai.* 属性自动填充 │ │
│ └──────────────────────────────────┘ │
└────────────────┬────────────────────────┘
│ OTLP gRPC
▼
┌─────────────────────────────────────────┐
│ OTel Collector │
│ ┌──────────┐ ┌──────────────────┐ │
│ │ Sampling │ │ Content Scrubber │ │
│ │ 1% / err │ │ PII 过滤 │ │
│ └──────────┘ └──────────────────┘ │
└────────────────┬────────────────────────┘
│
┌───────┴────────┐
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ Langfuse │ │ Grafana/Tempo │
│ (L2+L3) │ │ (L1 基础设施) │
│ Prompt Diff │ │ 延迟/错误率 │
│ Cost Attr. │ │ Dashboard │
└──────────────┘ └──────────────────┘
小结:三层可观测性的实施顺序
不要一口气实现所有层,按影响由大到小推进:
Week 1 :接入 OTel + opentelemetry-instrumentation-openai,获得 Layer 1 基础指标。在 Grafana 上建 LLM 专属 Dashboard:延迟分位、token 消耗、error rate。
Week 2:在关键调用路径上打业务标签(user_id, feature, prompt_version)。建立 Cost Attribution 视图,找到 token 消耗最多的功能模块。
Week 3-4:接入 Langfuse(或 Phoenix),实现 Prompt 版本化管理。在关键 LLM 调用上加 Schema Drift 检测,设置 >5% drift rate 告警。
持续迭代 :每次发布新 prompt 前,用 Prompt Version Comparator 对比历史分布。把 starts_with_sorry 和 schema_drift_rate 加入发布健康检查。
LLM 应用的可观测性不是"加点监控",是重新定义你能观测到的东西。当你第一次用 Prompt Diff 发现一个新版本把 JSON 格式率从 94% 砸到 81% 的时候,你会明白为什么 Layer 3 值得投入。
代码示例均为经过生产验证的真实模式,token 数据来自真实项目(已脱敏)。OTel GenAI 语义约定参考 2026 年 5 月 OpenTelemetry 官方博客。