AI网关可观测性:用OpenTelemetry追踪LLM调用

用OpenTelemetry为AI网关构建语义化追踪管线,覆盖埋点、传播、脱敏与采样实践。
文章指出传统APM工具无法理解LLM交互的语义,只能看到HTTP请求而抓不住prompt、token数、结束原因等关键信息,导致团队在排查响应慢、模型幻觉等问题时无从下手。为此作者提出为AI网关搭建一套OpenTelemetry追踪管线:先定义以根span加retrieval.search、llm.inference、llm.generate子span组成的层次化架构,再遵循GenAI语义约定(gen_ai.system、gen_ai.usage.prompt_tokens等)丰富属性。文中用FastAPI与httpx给出完整Python埋点示例,并重点讨论异步上下文丢失、traceparent注入、PII脱敏、采样策略以及Jaeger/Grafana可视化等工程细节。
从实验性的LLM游乐场走向生产级应用,AI网关已成为标准组件。它夹在应用代码与多个模型提供商之间,负责路由、缓存、限流与成本控制。但如果没有深度可观测性,团队就是在盲飞。当用户抱怨响应"慢"或"奇怪"时,你能分辨这是网络超时、模型幻觉,还是缓存未命中触发的重复昂贵推理吗?

传统的APM工具在AI负载面前力不从心,因为它们缺乏对LLM交互的语义理解——只能看到HTTP请求,却抓不住真正重要的语义span:实际发送的prompt、消耗的token数、结束原因,以及检索步骤(RAG)与最终生成之间的语义关系。本文带你实现一套健壮的OpenTelemetry(OTel)追踪管线。
为什么标准追踪对AI网关失效
传统分布式追踪关注延迟"在哪里"——数据库查询还是API调用。而在AI系统中,问题往往是"为什么"模型会这样表现。一条标准HTTP trace只会告诉你/api/chat耗时3.2秒,却不会告诉你其中2.5秒花在LLM推理引擎生成token上,或者prompt长达4000 token(因此成本高昂)。
AI工作流通常是多步的。典型的RAG流程包括:嵌入用户查询、搜索向量数据库、重排结果、将上下文与查询一起送入LLM。如果不把这些操作链接到同一条trace中,你就无法诊断"向量库返回了不相关结果,导致LLM产生幻觉"这类问题。OpenTelemetry提供的Span原语让我们可以嵌套这些操作,形成请求生命周期的层次化视图。
OpenTelemetry(OTel) 是CNCF旗下的开源可观测性框架,提供统一的API、SDK和协议(OTLP),用于生成、采集和导出三类遥测信号:Traces(分布式追踪)、Metrics(指标)和Logs(日志)。其核心数据模型是Span——代表一次操作的结构化记录,包含名称、起止时间、状态、属性(attributes)以及与其他span的父子关系。Trace即由一组共享同一trace ID的span构成的有向无环图。OTel的价值在于厂商中立:同一套埋点可导出到Jaeger、Tempo、Datadog等任意后端,避免了早期APM的供应商锁定问题。
GenAI语义约定 是OTel社区为生成式AI场景制定的属性命名规范(如gen_ai.*前缀),旨在让不同语言、不同厂商的LLM埋点具备互操作性。它规定了模型名、token用量、请求参数、结束原因等字段的标准键名,使Jaeger、Grafana等工具无需定制解析即可读懂LLM调用。该约定仍在快速演进中,但对齐它能显著降低未来迁移成本。
AI网关可观测性管线架构
在写代码前先定义架构。AI网关通常充当反向代理或SDK包装器。数据流大致是:网关接收入站HTTP请求,创建代表整个请求的根span;随后为具体操作创建子span——retrieval.search(向量库取上下文)、llm.inference(模型提供商调用)、llm.generate(流式chunk)。每个span都会用OpenTelemetry GenAI语义约定定义的属性进行丰富,比如gen_ai.model.name、gen_ai.usage.prompt_tokens。最后,span批量通过OTLP导出到collector,再路由到Jaeger或Tempo进行存储与可视化。
语义约定:该记录什么
要让trace能被标准工具读懂,应遵循正在成型的OpenTelemetry GenAI语义约定。虽然这些约定仍在演进,但现在就定义一致的属性集,可以避免日后重写埋点。
关键属性包括:
| 属性键 | 说明 | 示例值 |
|---|---|---|
gen_ai.system | 厂商或系统名 | openai、anthropic、local |
gen_ai.request.model | 请求的模型标识 | gpt-4-turbo、claude-3-opus |
gen_ai.request.temperature | 采样温度 | 0.7 |
gen_ai.response.model | 实际响应的模型 | gpt-4-turbo-2024-04-09 |
gen_ai.usage.prompt_tokens | prompt的token数 | 150 |
gen_ai.usage.completion_tokens | 响应的token数 | 1024 |
gen_ai.prompt | 实际prompt内容 | "..."(注意PII) |
gen_ai.completion | 实际补全内容 | "..."(注意PII) |
除非有严格的PII脱敏管线,否则不要在span中记录原始prompt/completion内容。span虽然采样率通常低于指标,但仍然会被记录。
PII(Personally Identifiable Information,个人身份信息) 指可用于识别特定自然人的数据,如姓名、邮箱、电话、身份证号、IP地址等。在LLM场景中,prompt天然可能携带用户上传的文档、对话历史、账号凭据,因此span中记录原文的风险远高于传统API追踪。除了合规压力(GDPR、CCPA、《个人信息保护法》),span后端通常是多团队共享的低权限系统,一旦泄露影响面极大。
脱敏(Redaction) 与假名化(Pseudonymization) 是两种不同策略:前者用占位符替换敏感片段(如[EMAIL_REDACTED]),信息不可逆;后者用hash或随机ID替换,保留可关联性以便调试。实践中不推荐对prompt做字符级正则脱敏后仍记录原文——正则难以覆盖全部敏感模式且会遗漏语义级泄露(如“我的社保号是……”后紧跟数字)。更稳妥的方案是整段不落span,仅在安全日志库中存储并关联trace ID。
用Python为网关实现埋点
我们用FastAPI和httpx搭建一个最小AI网关,并用opentelemetry-sdk和opentelemetry-exporter-otlp进行埋点。先安装依赖:
pip install fastapi uvicorn httpx opentelemetry-sdk opentelemetry-exporter-otlp-proto-http opentelemetry-instrumentation-fastapi opentelemetry-instrumentation-httpx
配置Tracer Provider
初始化OpenTelemetry SDK,设置span生成与导出的环境:
# app/telemetry.py
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
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased
import os
OTEL_ENDPOINT = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
OTEL_HEADERS = os.getenv("OTEL_EXPORTER_OTLP_HEADERS", "")
def get_tracer_provider() -> TracerProvider:
resource = Resource.create({
SERVICE_NAME: "ai-gateway",
"service.version": "1.0.0",
})
provider = TracerProvider(resource=resource)
# 生产环境可用基于父级的采样策略
sampler = TraceIdRatioBased(0.5)
exporter = OTLPSpanExporter(
endpoint=OTEL_ENDPOINT,
headers=OTEL_HEADERS,
insecure=True # 本地开发使用;生产环境请用HTTPS
)
processor = BatchSpanProcessor(exporter)
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
return provider
网关逻辑与埋点
核心网关服务用嵌套span区分"检索"与"生成"两个阶段。注意tracer.start_as_current_span的使用——这个上下文管理器自动处理父子关系,vector_db.search和llm.generate会自动嵌套在gateway.handle_chat内。
# app/gateway.py
import asyncio
from fastapi import FastAPI
from pydantic import BaseModel
from opentelemetry import trace
from app.telemetry import get_tracer_provider
tracer = trace.get_tracer("ai_gateway")
app = FastAPI()
get_tracer_provider()
class ChatRequest(BaseModel):
message: str
user_id: str
async def mock_vector_search(query: str):
async with tracer.start_as_current_span("vector_db.search") as span:
span.set_attribute("db.system", "pinecone")
await asyncio.sleep(0.1)
return ["Context chunk 1", "Context chunk 2"]
async def mock_llm_call(prompt: str, model: str = "gpt-4"):
async with tracer.start_as_current_span("llm.generate") as span:
span.set_attribute("gen_ai.system", "openai")
span.set_attribute("gen_ai.request.model", model)
span.set_attribute("gen_ai.request.temperature", 0.7)
await asyncio.sleep(0.5)
response_text = f"Response based on: {prompt[:20]}..."
span.set_attribute("gen_ai.response.model", f"{model}-2024-01")
span.set_attribute("gen_ai.usage.prompt_tokens", 150)
span.set_attribute("gen_ai.usage.completion_tokens", 1024)
span.set_attribute("gen_ai.finish_reason", "stop")
return response_text
@app.post("/chat")
async def chat(request: ChatRequest):
with tracer.start_as_current_span("gateway.handle_chat") as root_span:
root_span.set_attribute("user.id", request.user_id)
context = await mock_vector_search(request.message)
final_prompt = f"Context: {str(context)}\nQuery: {request.message}"
response_text = await mock_llm_call(final_prompt)
return {"response": response_text, "usage": {"prompt_tokens": 150, "completion_tokens": 1024}}
异步Python中的上下文传播
埋点Python AI网关时常见的坑是异步代码中的上下文丢失。如果你spawn了新的asyncio.Task或线程,OpenTelemetry上下文(保存当前trace ID)不会自动传播。在前面的例子中,我们在同一个task内await所有操作,所以传播是正常的。但如果网关使用了独立的worker进程,就必须显式捕获并注入上下文。
下面是向httpx请求传播上下文的方式——网关常用它调用LLM提供商。opentelemetry-instrumentation-httpx包基本能处理,但你必须确保发送了traceparent header:
import httpx
from opentelemetry.trace import SpanKind
from opentelemetry.propagate import inject
async def call_external_llm_with_trace(url: str, payload: dict):
tracer = trace.get_tracer("http_client")
with tracer.start_as_current_span("http.client.llm_call", kind=SpanKind.CLIENT) as span:
span.set_attribute("http.method", "POST")
span.set_attribute("http.url", url)
headers = {}
inject(headers) # 添加'traceparent' header
async with httpx.AsyncClient() as client:
try:
response = await client.post(url, json=payload, headers=headers)
response.raise_for_status()
span.set_attribute("http.status_code", response.status_code)
return response.json()
except httpx.HTTPError as exc:
span.set_status(trace.StatusCode.ERROR, str(exc))
span.record_exception(exc)
raise
注入header后,即使LLM提供商也支持OpenTelemetry(或你在跨微服务追踪),trace都能保持连续。对于不支持OTel的本地LLM,至少客户端侧的耗时与状态会被记录。
上下文传播(Context Propagation) 是分布式追踪的基石。OpenTelemetry使用contextvars在异步任务中隐式传递当前span,但这一机制依赖Python的协程上下文继承规则:asyncio.create_task会复制当前上下文,而run_in_executor、线程池、多进程worker则会丢失。
当跨进程或跨服务调用时,需要通过传播器(Propagator) 将上下文序列化到传输载体中。默认的W3C Trace Context传播器会写入traceparent头,格式为00-{trace_id}-{span_id}-{flags},接收方解析后即可将远端span挂到同一条trace下。inject(headers)负责写,extract(headers)负责读,二者配对使用。需要注意的是:如果网关对下游服务做了连接池复用或HTTP/2多路复用,仍需为每个逻辑请求单独注入header,不能假设一次注入长期有效。
PII与敏感数据脱敏
这是AI追踪中最关键的安全环节。LLM prompt常含用户PII、API密钥或专有数据。绝不能在没有脱敏层的情况下把原始prompt/completion记录进span。
可以通过工具函数在属性附加到span前做清洗:
import re
def redact_pii(text: str) -> str:
if not text:
return text
text = re.sub(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b', '[EMAIL_REDACTED]', text)
text = re.sub(r'\b\d{3}[-.]?\d{3}[-.]?\d{4}\b', '[PHONE_REDACTED]', text)
text = re.sub(r'sk-[A-Za-z0-9]{20,}', '[API_KEY_REDACTED]', text)
return text
更好的做法是:把完整的prompt/completion存入独立的、有严格访问控制的安全日志库(如CloudWatch或Datadog Logs),在OpenTelemetry span中只存hash或引用ID。这样开发者可通过ID在安全日志中调试,同时保持trace后端干净且合规。
可视化数据:Jaeger与Grafana
要看到成果需要一个后端。本地开发最简单的方式是跑Jaeger的All-In-One模式:
docker run -p 16686:16686 -p 4317:4317 jaegertracing/all-in-one
浏览器访问http://localhost:16686,搜索服务ai-gateway,你会看到类似这样的trace瀑布:
gateway.handle_chat (200ms)
├── vector_db.search (50ms)
└── llm.generate (150ms)
解读瀑布图
延迟分解:如果llm.generate占总时间的80%,说明问题在推理而非检索;如果vector_db.search慢,就需要优化索引。错误传播:如果llm.generate失败(如429 Too Many Requests),由于上下文传播,根spangateway.handle_chat也会被标记为错误,便于在Jaeger中筛选"所有出错请求"。成本关联:通过导出gen_ai.usage.*属性,可以在Grafana中构建将Trace ID与成本关联的看板,识别那些昂贵却质量不佳的查询。
常见问题
应该追踪每一次LLM调用,还是只追踪采样? 在高吞吐系统中,每次调用都追踪成本很高。OpenTelemetry的BatchSpanProcessor和采样策略(如TraceIdRatioBased(0.1))允许只采10%的trace。但应对错误span采用基于头部的更高采样率——这样即使成功调用被丢弃,失败的LLM调用也总能被捕获。
如何处理流式响应? 流式响应(SSE)的挑战在于completion_tokens要等流结束才可知。可以在流结束后再更新span属性——在async for循环完成后调用span.set_attribute。不要在流处理完前结束span,否则trace会不完整。
OpenTelemetry能用于Llama 3这类本地LLM吗? 可以,但需要埋点客户端库(如transformers、ollama、vllm)。本地LLM跑在自己的基础设施上,网络延迟被CPU/GPU计算延迟取代。可以用类似opentelemetry-instrumentation-nvidia-smi的工具在trace span旁捕获GPU利用率指标,形成整体视图:trace告诉你发生了什么,指标告诉你硬件表现如何。
加入这层可观测性后,AI网关就从一个"黑盒"变成了透明可调试的系统,让你能回答关于性能、成本和质量的复杂问题,从而在生产环境中更安全、更可靠地部署AI应用。
BatchSpanProcessor 与 SimpleSpanProcessor 是OTel SDK提供的两种导出器处理器。前者在内存中缓冲span,达到批大小(默认512)或超时(默认5秒)后批量通过OTLP推送,吞吐高、网络开销小,但进程崩溃时可能丢失未刷新的span,适合生产;后者同步逐条导出,调试友好但性能差,仅建议本地开发。
采样策略 分为两类:基于头部(head-based) 在trace创建时一次性决定采样与否,代价低但无法根据后续结果调整;基于尾部(tail-based) 在trace完整结束后依据整体特征(如是否含错误、总延迟是否超阈值)决定保留,能精准捕获异常但需要在Collector侧缓存全部span,资源消耗大。实践中常见组合是:SDK侧用TraceIdRatioBased做低比例头部采样,同时在Collector配置尾部采样策略,对错误或慢trace强制保留,兼顾成本与可观测性。
相关推荐

花旗14000名工程师用Devin,技术外包账单砍掉30%
花旗集团14000名工程师使用AI编程智能体Devin,一年内将技术供应商账单削减30%,产品发布提速30-40%。但30%仅限单一成本项,摩根大通同期表示AI收益尚未体现在财报中,揭示企业AI落地的真实分化。

AI写代码,程序员要失业?Devin自主编程真相
Devin等自主AI编程代理正改变软件开发:从Copilot辅助编码到全自动工程代理,程序员角色从写代码转向系统设计与监督。本文分析AI编程对就业、代码安全、初级开发者培养的影响,以及效率悖论下程序员需求的真实走向。

AGENTS.md 实战指南:让编程 Agent 真正好用
AGENTS.md 和 CLAUDE.md 能显著提升编程 Agent 表现,但研究发现用错反而让成本上升 20%。本文详解该写什么、该删什么、200 行与 32KB 的硬边界、Monorepo 嵌套规则及安全提醒。