[控场AI]
· 20 分钟阅读· 10,102 字

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

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网关已成为标准组件。它夹在应用代码与多个模型提供商之间,负责路由、缓存、限流与成本控制。但如果没有深度可观测性,团队就是在盲飞。当用户抱怨响应"慢"或"奇怪"时,你能分辨这是网络超时、模型幻觉,还是缓存未命中触发的重复昂贵推理吗?

AI网关OpenTelemetry追踪

传统的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_tokensprompt的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强制保留,兼顾成本与可观测性。

#OpenTelemetry#AI网关#LLM可观测性#分布式追踪#GenAI语义约定#OpenTelemetry Python#AI网关埋点#RAG追踪
分享:

相关推荐