Agent 越跑越贵?OTel GenAI 语义约定全链路追踪与成本归因实战

AIE 0 次阅读
Agent 越跑越贵?OTel GenAI 语义约定全链路追踪与成本归因实战

一次 LLM 调用花多少钱、慢在哪一步、是不是在死循环——这些问题传统 APM 一个都答不上来。给 Agent 装黑匣子,本质是给「模型调用」这层抽象补一套标准词汇表。

开篇:从一个真实业务场景说起

去年给一家电商客服团队做诊断,现象很典型:一个售后 Agent 上线三个月,GMV 没涨多少,API 账单从每月 1.2 万涨到 4.8 万。SRE 打开监控看板,全是绿的——P99 延迟 3.2 秒、错误率 0.4%、CPU 健康。但用户投诉在涨:「转人工之前的机器人像失忆」「有时候一个问题要等 45 秒」。

于是我们开始查。发现三件事,一件比一件扎心:

第一,账单和监控对不上。监控里没有任何一个指标知道「Token」这件事。它只知道 HTTP 200,不知道这次 200 背后烧了 68k 输入 token。

第二,长尾延迟完全不可归因。那 45 秒的请求,到底是模型慢、向量检索慢、还是某个工具接口超时,trace 里没有答案——因为 RAG 检索、工具调用这些步骤根本没有埋点,整条链路只有一次 LLM SDK 调用是可见的。

第三,也是最贵的:约 3.7% 的会话吃掉了 41% 的 Token。原因不是 prompt 太长,而是工具调用失败后 Agent 会在原地重试,每一轮都把完整历史重新塞进上下文。这个循环在日志里只表现为「同一个 trace id 下 17 次 chat 调用」,而传统监控压根不认为这是异常——它每次都成功了。

后来我们做了一件事:把 LLM 应用按 OpenTelemetry 的 GenAI 语义约定(Semantic Conventions for Generative AI) 重新埋点,一个模型调用一个 span,一次 Agent 运行一个父 span,工具执行、检索各占一层,Token 作为一等公民写进属性与直方图。两周后,那 3.7% 的会话被一条 SQL 拉出来,加了最大轮次限制和上下文裁剪,Token 支出降了 46%,P99 从 45 秒回落到 4 秒以内。

这篇文章只讲这一件事:怎么用一套厂商中立的语义约定,把 LLM 应用的调用、成本、延迟、质量变成可查询的结构化数据。 无论你后端是 Jaeger、Grafana Tempo、Datadog、Langfuse 还是自建 ClickHouse,属性名都是同一套 gen_ai.*。


技术背景与核心概念扫盲

LLM 可观测性为什么不能复用 APM

传统链路追踪的语义约定解决的是「服务间调用」问题:http.request.method、db.system.name、rpc.grpc.status_code。这些名字对一次 chat.completions.create 毫无意义,原因有三:

  1. 计费口径是 token,不是请求数。 一个 HTTP span 记录状态码和耗时就够了;LLM span 必须记录输入/输出 token,因为账单按它结算。
  2. 请求参数影响的是输出语义,不只是性能。 temperature、top_p、max_tokens 决定模型返回什么,而不只是返回多快。HTTP 的 query 参数没有这种语义权重。
  3. 内容本身就是排查对象,同时也是最敏感的数据。 查一个慢 SQL 不需要读行数据;查一个「胡说八道」的回答,你需要读 prompt 和 completion。

前两点要求新增字段,第三点要求一套「默认不采集、按需采集、可脱敏」的机制。这就是 GenAI 语义约定存在的理由。

这套约定从哪来、现在到哪一步了

2024 年 4 月,OpenTelemetry 在 Semantic Conventions SIG 下成立了 GenAI 特别兴趣组(GenAI SIG)。从最初的「给模型调用定几个属性」,扩展到现在覆盖六个层面的标准词汇表。关键里程碑(凭记忆和官方文档核对过):

版本 GenAI 相关变更
v1.37 重构对话历史记录方式(per-message events → gen_ai.input.messages / gen_ai.output.messages / gen_ai.system_instructions);gen_ai.system 改为 gen_ai.provider.name
v1.38 评估事件 gen_ai.evaluation.result;工具定义与调用详情;invoke_agent 的 span kind 指南;embedding 维度属性
v1.39 MCP 语义约定(解决 Agent 端与 MCP Server 端 trace 断裂)
v1.40 Retrieval span;缓存 token 属性;Anthropic 输入 token 计算指南
v1.41 execute_tool span 名必须带工具名;reasoning token;invoke_workflow;流式指标;invoke_agent 拆分 CLIENT / INTERNAL
v1.42(2026-06-12) GenAI 与 MCP 约定从核心仓库迁出,独立成 semantic-conventions-genai 仓库,旧的 opentelemetry.io/docs/specs/semconv/gen-ai/ 变成重定向存根

两个必须知道的现实:

  • 全部 gen_ai.* 属性目前都是 Development 稳定性。 一个 GenAI span 上唯一的 Stable 属性是继承来的 error.type、server.address、server.port。这意味着属性名可以在小版本间被改名,没有弃用窗口。
  • 新仓库目前没有打 tag 的 release。 它从 main 发布,对应 schema 版本 gen-ai/1.42.0,而 README 里 schema URL 还写着 TODO。所以"固定规范版本"这件事你只能在 instrumentation 层做——锁库版本、记录它声明的 schema 版本,并把原始属性字符串收敛到自己的映射层里。

听起来很危险?其实概念层已经稳定了。正确姿势是:基于规范构建,但把所有 gen_ai.* 字面量包在自己的常量模块里,并接受"某次升级要改看板"。

六层词汇表全貌

OpenTelemetry GenAI 语义约定的分层结构:从 Client Span 到提供商专属约定

图 1:GenAI 语义约定从底向上分六层:Provider 专属约定 → Metrics → Events 内容捕获 → MCP → Agent/Workflow → Client Span。每一层解决一类不同的可归因问题。

层 解决的问题 典型产物
L1 Client Span 单次模型调用 chat gpt-4o span + token 属性
L2 Agent/Workflow Span 一次 Agent 运行 / 固定流程编排 invoke_agent、execute_tool、plan
L3 MCP 跨进程工具调用的 trace 断裂 tools/call span + mcp.session.id
L4 Events 内容该不该看、看了怎么脱敏 gen_ai.client.inference.operation.details
L5 Metrics 成本/延迟的时序聚合与告警 gen_ai.client.token.usage 直方图
L6 Provider 专属 各家计费差异 gen_ai.usage.cache_read.input_tokens

下面逐层拆。原理讲透了,后面的代码你才知道每一行在干什么。


底层原理深度拆解

三层「要求等级」,决定了你的看板会不会空

规范对属性用三档标记,这不是文档装饰,直接决定线上看板是否生效:

  • Required:每个 inference span 必须有 gen_ai.operation.name 和 gen_ai.provider.name。
  • Conditionally required:值可得时必填。这里最容易漏——gen_ai.request.model、失败时的 error.type、gen_ai.conversation.id、gen_ai.output.type 都属于这一档。
  • Recommended:gen_ai.usage.input_tokens / output_tokens、temperature、top_p、max_tokens、gen_ai.response.finish_reasons、gen_ai.response.model。

真实的踩坑案例:某团队的 Dynatrace 成本面板一直是 $0,没有任何报错。原因就是 gen_ai.client.token.usage 这个 metric 没被 instrumentation 发出(他们用的是老版包装库),而面板在没有 metric 时会静默回退到 span 属性;span 属性里又缺 gen_ai.response.model,于是连回退都失败了。面板空白不等于没问题,这句话在 AI 可观测性里尤其成立。

Span 命名:让瀑布图自己会说话

命名规则不统一,trace 瀑布图就是一串 POST / call_llm / invoke。规范给的规则是按操作类型替换标识符:

操作类型 span 名格式 例子
推理 {operation} {request.model} chat gpt-4o
Agent {operation} {agent.name} invoke_agent support-router
工具 execute_tool {tool.name} execute_tool web_search
检索 {operation} {data_source.id} retrieval kb-v3
记忆 {operation} search_memory

gen_ai.operation.name 是整张表的枢轴,它的取值集合已经超出「调模型」的范畴:chat、text_completion、generate_content、embeddings、create_agent、invoke_agent、invoke_workflow、plan、execute_tool、retrieval、create_memory / search_memory / upsert_memory。

一次 Agent 运行就是一个 trace 树

GenAI trace 的 span 树:顶层 invoke_agent,下挂 chat 与 execute_tool

图 2:一次 Agent 交互的 span 树。父 span 是 invoke_agent,每个模型调用是一个 chat 子 span,每次工具执行是一个 execute_tool 子 span——结构和一次下单流程调用三个微服务没有任何区别。

invoke_agent research-assistant (INTERNAL, trace=t1)
├── chat gpt-4o (CLIENT)          ← 模型决定要搜索
├── execute_tool web_search (INTERNAL)
│   └── (HTTP client span → 搜索 API)
├── chat gpt-4o (CLIENT)          ← 读结果,决定下一步
├── execute_tool summarize_doc (INTERNAL)
└── chat gpt-4o (CLIENT)          ← 产出最终回答

这个结构的价值是可归因性:以前「Agent 答得不好」只是一行日志,现在你能分清是检索返回了垃圾、还是模型在好上下文上推理错了、还是工具压根没被调用。"模型坏了"和"我们的内部 API 坏了"终于变成了两种不同的故障。

内容捕获的三种模式(这是最容易出事的地方)

prompt 和 completion 不是 span 属性。规范刻意把它们放进独立事件 gen_ai.client.inference.operation.details,通过 trace_id / span_id 关联回 span。理由是:span 属性会被索引、有大小限制、且所有能看 trace 的人都能看内容。

规范给了三种记录模式:

  1. 不记录(默认)。 内容捕获默认关闭。
  2. 记在 span 属性上。 gen_ai.input.messages / gen_ai.output.messages,方便,但有大小限制、权限粗放。
  3. 外部存储 + span 记引用。 完整内容进 S3 / 数据库,span 上只留引用地址,可以单独设 IAM 和 retention。规范推荐在数据量大或敏感场景用这种。

开关本身规范没定死。OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT 只是一个示例写法,实际行为因库而异:有的库认 true,有的在配了 OTEL_SEMCONV_STABILITY_OPT_IN 之后认 span_only / event_only / span_and_event。别信任何博客(包括本文)的语法,去读你实际装的那个库的 README。

GenAI 对话视图:把结构化属性渲染成聊天样式

图 3:支持 GenAI 语义约定的后端会把 gen_ai.system_instructions / input.messages / output.messages 渲染成对话视图,而不是一坨原始 JSON。这是「结构化记录」而非「字符串拼接」的直接收益。

Metrics:两个直方图撑起 90% 的运营问题

规范定义的 client 侧指标里,最先该接的是两个:

  • gen_ai.client.token.usage:单位 {token},维度 gen_ai.operation.name、gen_ai.provider.name、gen_ai.request.model、gen_ai.token.type(input / output)。
  • gen_ai.client.operation.duration:单位秒,失败时带 error.type。

token 直方图推荐的 bucket 边界是指数增长序列:

[1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864]

从 1 token 到 67M token。必须用这套边界,否则你在聚合多进程数据时算不出可比的 p95。

两条计量铁律(很多人栽在这里):

  1. 提供商同时报告 used tokens 和 billable tokens 时,上报 billable tokens。否则你的成本看板会系统性偏低。
  2. 如果 instrumentation 无法低成本拿到 token 数(比如需要离线跑 tokenizer),不要猜,直接不上报。缺数据比错数据好——错数据会让成本归因彻底失效。

流式场景还有 gen_ai.client.operation.time_to_first_chunk 和 time_per_output_chunk;Agent 场景有 gen_ai.invoke_agent.duration、gen_ai.invoke_agent.tool_calls、gen_ai.invoke_agent.inference_calls、gen_ai.execute_tool.duration;自建推理服务还有 gen_ai.server.* 家族。

其中 invoke_agent.tool_calls 和 inference_calls 这一对最被低估:单次运行的模型调用数中位数持续上升,是 Agent 开始原地打转的最早信号,比成本告警早得多。两者比值更能区分病因——模型调用涨而工具调用不涨,说明它在"空想";两个都涨,说明它在"重试同一个工具"。

从应用到后端的完整数据流

┌──────────────────┐   OTLP/gRPC:4317
│ Agent 应用        │   OTLP/HTTP:4318
│  ├ SDK 自动埋点   │──────────────┐
│  └ 手工 span      │              │
└──────────────────┘              ▼
                        ┌──────────────────────────────────┐
                        │ OTel Collector (gateway)          │
                        │  1 memory_limiter                 │
                        │  2 gen_ai_normalizer  ← 方言归一   │
                        │  3 transform          ← 成本中心   │
                        │  4 redaction          ← PII 脱敏   │
                        │  5 filter             ← 去噪       │
                        │  6 (不采样 GenAI 链路)             │
                        └───────┬───────────────┬──────────┘
                                │ traces        │ metrics
                                ▼               ▼
                        Trace 后端        Metrics 后端
                        (Tempo/Jaeger/    (Prometheus/
                         Langfuse)         ClickHouse)

图 4:生产环境的推荐拓扑。Collector 是唯一的数据出境闸门——脱敏策略写在一份可评审的 YAML 里,而不是散落在 20 个服务里。

下一节开始动手。


手把手实战落地

环境与依赖

本文代码基于 Python 3.12 / opentelemetry-sdk 1.4x 系列,官方 OpenAI 埋点包已更名为 opentelemetry-instrumentation-openai-v2。

pip install "opentelemetry-sdk>=1.40" \
            "opentelemetry-exporter-otlp-proto-grpc>=1.40" \
            "opentelemetry-instrumentation-openai-v2>=2.0" \
            openai
# 本地看数据:用 Aspire Dashboard(免费、单容器、自带 trace/metrics 视图)
docker run --rm -p 18888:18888 -p 4317:18889 -p 4318:18890 -d --name aspire-dashboard \
  -e ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true \
  mcr.microsoft.com/dotnet/aspire-dashboard:latest

访问 http://localhost:18888 即可。它接受 OTLP 直连,自带 GenAI 可视化视图,本地调试足够。

第 1 步:初始化 SDK(Traces + Metrics 一起配)

# otel_setup.py
import os
from opentelemetry import trace, metrics
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter


def init_otel(service_name: str = "support-agent") -> None:
    endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")

    # 资源属性 = 成本归因的维度基础,一定要在入口处打全
    resource = Resource.create({
        "service.name": service_name,
        "deployment.environment.name": os.getenv("DEPLOY_ENV", "dev"),
        "service.version": os.getenv("GIT_SHA", "dev"),
        # 自定义维度:让你能按团队 / 租户切 Token 用量
        "cost.center": os.getenv("COST_CENTER", "unknown"),
        "app.tenant.tier": os.getenv("TENANT_TIER", "free"),
    })

    # ---- Traces ----
    tp = TracerProvider(resource=resource)
    tp.add_span_processor(
        BatchSpanProcessor(
            OTLPSpanExporter(endpoint=endpoint, insecure=True),
            max_queue_size=8192,
            max_export_batch_size=512,
            schedule_delay_millis=5000,   # 5s 一批,摊薄网络开销
        )
    )
    trace.set_tracer_provider(tp)

    # ---- Metrics ----
    reader = PeriodicExportingMetricReader(
        OTLPMetricExporter(endpoint=endpoint, insecure=True),
        export_interval_millis=15000,
    )
    metrics.set_meter_provider(MeterProvider(resource=resource, metric_readers=[reader]))

第 2 步:零代码自动埋点,先拿到合规的 span

# main.py
from otel_setup import init_otel
init_otel("support-agent")

# 必须在任何 LLM 调用之前 instrument
from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor
OpenAIInstrumentor().instrument()

from openai import OpenAI
client = OpenAI()

resp = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "订单 #12345 到哪了?"}],
)
print(resp.choices[0].message.content)

此刻你应该能在 Aspire 的 Traces 页面看到名为 chat gpt-4o-mini 的 span,属性里有 gen_ai.operation.name=chat、gen_ai.provider.name=openai、gen_ai.usage.input_tokens、gen_ai.usage.output_tokens、gen_ai.response.finish_reasons。内容不会出现——这是设计使然。

第 3 步:手工埋 Agent 层(自动埋点够不着的地方)

自动埋点只能看到 SDK 调用,看不到「谁在调用」。Agent 的运行边界、工具执行、会话归属,必须手工补。下面这段是一个可直接复用的最小实现:

# agent_trace.py
import time
from contextlib import contextmanager
from opentelemetry import trace

tracer = trace.get_tracer("support-agent")

# 把 gen_ai.* 字面量收敛到一处,规范改名时只改这里
OP_NAME = "gen_ai.operation.name"
PROVIDER = "gen_ai.provider.name"
REQ_MODEL = "gen_ai.request.model"
RESP_MODEL = "gen_ai.response.model"
IN_TOK = "gen_ai.usage.input_tokens"
OUT_TOK = "gen_ai.usage.output_tokens"
CONV_ID = "gen_ai.conversation.id"
AGENT_NAME = "gen_ai.agent.name"
AGENT_VER = "gen_ai.agent.version"
TOOL_NAME = "gen_ai.tool.name"
TOOL_CALL_ID = "gen_ai.tool.call.id"
FINISH = "gen_ai.response.finish_reasons"


@contextmanager
def invoke_agent(name: str, conversation_id: str, version: str = "v3"):
    """一次 Agent 运行的父 span:INTERNAL kind,因为在本进程内编排"""
    with tracer.start_as_current_span(
        f"invoke_agent {name}",
        kind=trace.SpanKind.INTERNAL,
    ) as span:
        span.set_attribute(OP_NAME, "invoke_agent")
        span.set_attribute(AGENT_NAME, name)
        span.set_attribute(AGENT_VER, version)
        # 关键:没有它,多轮对话和循环会被拆成一堆孤立的 span
        span.set_attribute(CONV_ID, conversation_id)
        try:
            yield span
        except Exception as exc:
            span.set_attribute("error.type", type(exc).__name__)
            span.record_exception(exc)
            raise


@contextmanager
def execute_tool(name: str, call_id: str):
    """工具执行 span:名字必须带工具名(v1.41 起的硬要求)"""
    with tracer.start_as_current_span(
        f"execute_tool {name}",
        kind=trace.SpanKind.INTERNAL,
    ) as span:
        span.set_attribute(OP_NAME, "execute_tool")
        span.set_attribute(TOOL_NAME, name)
        span.set_attribute(TOOL_CALL_ID, call_id)
        try:
            yield span
        except TimeoutError as exc:
            # error.type 用稳定的枚举值,别塞自由文本,否则告警没法分组
            span.set_attribute("error.type", "timeout")
            span.record_exception(exc)
            raise


def run_agent(conversation_id: str, user_input: str) -> str:
    with invoke_agent("support-router", conversation_id) as agent_span:
        # 这里调用真实 LLM(自动埋点会生成 chat 子 span)
        plan = call_llm(user_input)

        with execute_tool("order_lookup", call_id="call_001") as tool_span:
            order = lookup_order(user_input)
            # 结果大小是很有价值的诊断维度
            tool_span.set_attribute("tool.result.bytes", len(str(order)))

        answer = call_llm(f"{plan}\n订单信息:{order}")
        agent_span.set_attribute("agent.steps", 2)
        return answer

运行一次,你会得到图 2 那样的树:invoke_agent support-router → chat gpt-4o-mini → execute_tool order_lookup → chat gpt-4o-mini,而且全部挂在同一个 gen_ai.conversation.id 下。

第 4 步:自己发 token 直方图与成本指标

如果 instrumentation 没发 metrics(老库常见),或者你需要按业务线补算成本,就自己发。bucket 必须用规范那套:

# cost_metrics.py
from opentelemetry import metrics

meter = metrics.get_meter("support-agent.cost")

# 规范指定的指数 bucket 边界:1 → 67M token
TOKEN_BUCKETS = [1, 4, 16, 64, 256, 1024, 4096, 16384, 65536,
                 262144, 1048576, 4194304, 16777216, 67108864]

token_hist = meter.create_histogram(
    name="gen_ai.client.token.usage",
    description="Number of input and output tokens used",
    unit="{token}",
    explicit_bucket_boundaries_advisory=TOKEN_BUCKETS,
)

cost_counter = meter.create_counter(
    name="llm.cost.usd",          # 自定义指标,不要占用 gen_ai.* 命名空间
    description="Estimated LLM cost in USD",
    unit="USD",
)

# 单位:美元 / 1M token。价格请以官方价目表为准,做成配置而非硬编码
PRICING = {
    "gpt-4o":      {"input": 2.50, "output": 10.00},
    "gpt-4o-mini": {"input": 0.15, "output": 0.60},
}


def record_usage(model: str, in_tok: int, out_tok: int,
                 cache_read_tok: int = 0, attrs: dict | None = None) -> None:
    base = {"gen_ai.request.model": model, "gen_ai.operation.name": "chat"}
    if attrs:
        base.update(attrs)

    # 按 token_type 分别打点,成本看板才能自动分成两条泳道
    token_hist.record(in_tok,  {**base, "gen_ai.token.type": "input"})
    token_hist.record(out_tok, {**base, "gen_ai.token.type": "output"})

    p = PRICING.get(model)
    if not p:            # 未知模型不猜价,只记 token
        return
    # 缓存命中的输入 token 通常有折扣,按 10% 估算(比例以官方为准)
    billable_in = max(in_tok - cache_read_tok, 0) + cache_read_tok * 0.1
    usd = billable_in / 1_000_000 * p["input"] + out_tok / 1_000_000 * p["output"]
    cost_counter.add(usd, base)

第 5 步:Collector 配置——脱敏、归一、去噪一次做完

这是全文最该抄走的一段。数据出境前的一切策略都在这里:

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }
      http: { endpoint: 0.0.0.0:4318 }

processors:
  # 1) 永远第一个:让 Collector 先自保,再干活
  memory_limiter:
    check_interval: 1s
    limit_percentage: 80
    spike_limit_percentage: 20

  # 2) 方言归一:把 OpenInference / OpenLLMetry / 自研 schema 映射到 gen_ai.*
  #    注意:contrib 发行版才有,且为 alpha
  gen_ai_normalizer:
    sources:
      - name: openinference
        remove_originals: true      # 删掉旧名,看板只看到一套名字

  # 3) 环境与成本中心富化
  transform:
    trace_statements:
      - context: resource
        statements:
          - set(resource.attributes["deployment.environment.name"], "${env:DEPLOY_ENV}")
          - set(resource.attributes["cost.center"], "${env:COST_CENTER}")

  # 4) 脱敏:在你自己网络边界内完成,出境前
  redaction:
    allow_all_keys: true
    blocked_values:                 # 只遮盖命中的片段,对话仍可读
      - "sk-[A-Za-z0-9]{20,}"       # API Key
      - "[0-9]{3}-[0-9]{2}-[0-9]{4}"  # 证件号样式
      - "4[0-9]{12}(?:[0-9]{3})?"   # 卡号样式
    # 最严格的做法:整段消息内容不出境(会牺牲对话视图)
    # blocked_key_patterns:
    #   - gen_ai\.input\.messages
    #   - gen_ai\.output\.messages

  # 5) 去噪:健康检查、零 token 的 span 没有分析价值
  filter:
    traces:
      span:
        - 'attributes["http.route"] == "/health"'
        - 'attributes["gen_ai.usage.input_tokens"] == 0 and attributes["gen_ai.usage.output_tokens"] == 0'

exporters:
  otlp_http:
    endpoint: https://api.example-backend.com
    headers:
      x-api-key: ${env:BACKEND_API_KEY}
    sending_queue:                  # 批处理 + 重试交给 exporter,而非 batch processor
      enabled: true
      sizer: items
      queue_size: 100000

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, gen_ai_normalizer, transform, redaction, filter]
      exporters: [otlp_http]

处理器顺序是有讲究的:memory_limiter 必须最先;gen_ai_normalizer 必须早于任何按 gen_ai.* 匹配的处理器,这样后面的脱敏规则只需要针对一套名字写一次。

第 6 步:查询与告警

指标进 Prometheus 后,名字里的点会变成下划线:

# 各模型 P95 调用延迟(秒)
histogram_quantile(
  0.95,
  sum by (le, gen_ai_request_model) (
    rate(gen_ai_client_operation_duration_bucket[5m])
  )
)

# 各模型每小时消耗 token(按输入/输出拆分)
sum by (gen_ai_request_model, gen_ai_token_type) (
  increase(gen_ai_client_token_usage_sum[1h])
)

# 预算告警:单服务单小时成本超阈值
sum by (service_name) (increase(llm_cost_usd_total[1h])) > 50

会话级归因需要从 span 数据里算(trace 后端 / ClickHouse 类引擎):

-- 找出「token 吃最多」的会话,这是抓 Agent 循环的核心查询
SELECT
  attributes['gen_ai.conversation.id']                          AS conversation_id,
  count(*)                                                       AS span_cnt,
  countIf(name LIKE 'chat %')                                    AS model_calls,
  countIf(name LIKE 'execute_tool %')                            AS tool_calls,
  sum(CAST(attributes['gen_ai.usage.input_tokens']  AS UInt64))  AS in_tokens,
  sum(CAST(attributes['gen_ai.usage.output_tokens'] AS UInt64))  AS out_tokens,
  round(sum(CAST(attributes['gen_ai.usage.input_tokens'] AS UInt64))
        / nullIf(countIf(name LIKE 'execute_tool %'), 0), 1)      AS in_tok_per_tool
FROM otel_traces
WHERE timestamp > now() - INTERVAL 24 HOUR
  AND attributes['gen_ai.conversation.id'] != ''
GROUP BY conversation_id
HAVING model_calls > 8
ORDER BY in_tokens DESC
LIMIT 20;

in_tok_per_tool 高 + model_calls 多 + tool_calls 少,基本就是「模型在空转、每轮重复回传全量历史」。这条查询在我们那个项目里 30 秒就捞出了元凶会话。

第 7 步:内容捕获的开关与边界

只在需要排查的环境开,并且明确记录是 span 还是 event:

# 生产:只留元数据(默认即此行为,显式写出来防止被误改)
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=false

# 预发 / 排障窗口:开内容,但仍经 Collector 脱敏后才出境
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
# 部分库需要额外声明写入位置(span_only / event_only / span_and_event)
# export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai

再次强调:变量名和行为随库版本变化,上线前务必读你装的那个 instrumentation 的 README。


关键细节与踩坑指南

坑 1:gen_ai.system 还没改完,你的看板一分为二

gen_ai.system 已被 gen_ai.provider.name 取代,但很多库在过渡期两个都发。结果是同一批数据里既有旧名又有新名,按 provider 分组的图会裂成两半。

错误写法(查询时同时兼容,长期越写越脏):

provider = attrs.get("gen_ai.provider.name") or attrs.get("gen_ai.system") or "unknown"

正确写法(在 ingest 层一次性归一,应用代码只认新名):

transform:
  trace_statements:
    - context: span
      statements:
        - set(attributes["gen_ai.provider.name"], attributes["gen_ai.system"]) where attributes["gen_ai.provider.name"] == nil
        - delete_key(attributes, "gen_ai.system")

同理,gen_ai.usage.prompt_tokens → gen_ai.usage.input_tokens、completion_tokens → output_tokens 也是真实发生过的改名。在 Collector 里做映射,而不是在 20 个服务的代码里做兼容。

坑 2:以为能靠 schema processor 自动迁移——现在还不行

标准做法是用 schema 文件让后端在 ingest 时自动翻译旧属性名。但 GenAI 新仓库目前没发布 schema URL(README 里是 TODO),这条路暂时走不通。过渡期只能用 transform 处理器手写映射表,并把 instrumentation scope 的 schema 版本记在文档里,升级时人工 review。

坑 3:漏了 gen_ai.conversation.id

这是最贵的一个疏漏。缺了它,多轮对话和 Agent 循环会变成一堆互不相干的单个 span,你在 trace 列表里看到的是「今天有 4 万次 chat 调用」,永远发现不了「其中 17 次属于同一个会话」。它是 Conditionally required,值可得就必须填。

坑 4:finish_reasons 是数组,不是字符串

# 错:类型不一致,后端过滤会失效
span.set_attribute("gen_ai.response.finish_reasons", resp.choices[0].finish_reason)

# 对:始终传字符串数组,即使用例只有一个值
span.set_attribute("gen_ai.response.finish_reasons", [resp.choices[0].finish_reason])

同一个坑还有 token 数:某些库会把 token 数写成字符串,导致 SUM() 静默返回 0。gen_ai_normalizer 带类型强制转换,自研映射表要自己做 int()。

坑 5:对 GenAI 流量做头部采样

传统服务降采样没问题,Agent 对话不行。一条对话缺了几轮就失去诊断价值——你看得到「Agent 行为异常」,看不到它到底说了什么。正确策略:GenAI 链路完整保留,用 filter 砍掉零价值 span 和冗长属性来控制体积;非 LLM 的周边服务该采样就采样(用 tail sampling 处理器),两条管线分开跑。

坑 6:把 prompt 直接塞进 span 属性

后果有三个:属性超限被截断、后端索引被打爆、PII 直接落到第三方。正确做法是走 event + 外部引用,或至少经过 Collector 的 redaction。规范提供三种内容模式不是让你选最省事的那个,是让你按数据敏感度选。

坑 7:error.type 写自由文本

# 错:每种错误消息一个值,基数爆炸,告警无法分组
span.set_attribute("error.type", f"OpenAI 429: {msg[:80]}")

# 对:用稳定枚举,细节放 exception event
span.set_attribute("error.type", "429")
span.record_exception(exc)

生产环境最佳实践

Collector 部署形态

形态 适用 注意
Sidecar 单服务强隔离、需要就近脱敏 资源开销 × Pod 数,配置难统一
DaemonSet(节点级) K8s 中等规模,成本低 节点故障会丢未 flush 的数据
Gateway(推荐) 需要统一脱敏/归一/路由 必须配 memory_limiter + HPA

脱敏策略写成一份可评审、可 diff、走 Code Review 的 YAML,比散落在服务代码里安全一个量级。

成本治理闭环(四步,缺一不可)

  1. 可见:gen_ai.client.token.usage 按 service.name × gen_ai.request.model × cost.center 聚合。
  2. 可归因:gen_ai.conversation.id 打通会话,落到「哪个租户/哪个功能/哪类问题最贵」。
  3. 可告警:小时级成本环比 +30% 触发;inference_calls / tool_calls 比值 > 5 触发 Agent 循环告警。
  4. 可止损:入口限流 + 单会话 token 上限 + 最大工具轮次,这三道闸必须在应用侧,不能只靠告警。

SLO 建议定义

SLO 指标 参考目标
首 token 延迟 gen_ai.client.operation.time_to_first_chunk p95 < 1.2s
端到端延迟 gen_ai.client.operation.duration p95(按模型分组) < 8s
可用性 非 error.type 空值占比 > 99%
成本预算 llm.cost.usd 每小时 按业务定
Agent 效率 invoke_agent.inference_calls 中位数 < 6

基数控制三条

  • 不要把 user.id、request.id 放到 metric 维度上(放 span 属性没问题,放 metric 会炸)。
  • gen_ai.request.model 用模型名,不要拼上版本号;版本号放 gen_ai.response.model。
  • error.type 用有限枚举。

升级守护

因为规范是 Development 稳定性,给自己加一道闸:把 gen_ai.* 字面量集中在一个常量模块(如上面的 agent_trace.py),并在 CI 里加一条断言,检查关键属性名是否仍在预期集合内。改看板的那一天会感谢自己。


横向对比与选型建议

方案 接入成本 数据主权 强项 弱项
OTel + 通用后端(Tempo / Jaeger / ClickHouse / Datadog) 中(要自己配看板) 完全自控 与现有 APM 同一套管线,成本最低,厂商中立 没有 LLM 专用 UI,Prompt 管理/数据集要自己做
Langfuse(自建/云) 低 自建可自控 OTel 原生 ingest,Prompt 版本管理、数据集、评估链路最完整 功能面大,运维面也大
Arize Phoenix 极低(pip install 单进程) 自建可自控 起步最轻,RAG 调试视图好,notebook 友好 生产级平台能力弱于 Langfuse
LangSmith 低 云为主 与 LangChain 生态贴合,调试体验好 自建选项有限
商业 APM 原生支持(Datadog / Dynatrace) 极低 云 v1.37+ 起原生识别 gen_ai.*,成本/延迟瓦片开箱可用 按 span 计费,大流量下成本需算账

决策路径(按顺序问自己四个问题):

  1. 你已经有成熟的 OTel 管线?→ 直接接 Collector,前端用现有 trace 后端 + 自建看板。别引入第二套监控栈。
  2. 你需要 Prompt 版本管理、数据集、在线评估?→ Langfuse(自建)或 LangSmith。
  3. 你只想先在本地把一次 Agent 运行看清楚?→ pip install arize-phoenix 或 Aspire Dashboard,十分钟见效。
  4. 你在强监管行业、数据不能出境?→ Collector 就地脱敏 + 自建后端,内容捕获走「外部存储 + span 记引用」模式。

该用这套约定、而不是继续用厂商私有 SDK 的信号:你有两个以上 LLM 框架/提供商;你换后端不想改代码;你要把 LLM 成本和普通基础设施成本放在同一个时间轴上对齐。反之,如果你只是做一个 demo,直接上 SaaS 试用版更快。


性能实测与效果验证

埋点开销实测(可复现口径)

环境:Python 3.12,opentelemetry-sdk 1.4x,BatchSpanProcessor(max_export_batch_size=512,schedule_delay=5000ms),OTLP/gRPC 发本地 Collector,压测 200 并发持续 5 分钟,LLM 调用用固定 300ms 延迟的 mock 替代(排除上游波动)。

场景 P50 延迟增量 P99 延迟增量 CPU 增量 RSS 增量
无埋点(基线) — — — —
自动埋点,关闭内容捕获 +0.5 ms +2.1 ms +2.4% +38 MB
自动埋点 + 手工 Agent span +0.8 ms +2.9 ms +3.1% +44 MB
自动埋点 + 开启内容捕获 +1.2 ms +4.6 ms +4.7% +61 MB

结论很直接:相对于秒级的 LLM 调用,毫秒级埋点开销可以忽略。真正需要盯的不是 CPU,而是传输体积:

数据类型 单 trace 体积(8 span)
仅元数据 ≈ 6.8 KB
开启内容(输入 1.2k token、输出 300 token、2 次工具调用) ≈ 42 KB(约 6 倍)

按 10 万次会话/天、每会话 8 span 估算,元数据约 0.68 GB/天,开内容约 4.2 GB/天。这就是为什么生产环境默认关内容,并对 retention 分级。

治理效果

同一套埋点上线两周后:

指标 改造前 改造后 变化
单会话平均输入 token 18.4k 9.9k −46%
P99 端到端延迟 45 s(长尾) 3.9 s −91%
月度 LLM 支出 ¥4.8 万 ¥2.6 万 −46%
故障平均定位时间 > 2 小时 8 分钟 靠 trace 树直接定位
Token 成本可见性 无 按会话/模型/租户 从「查不出」到「可归因」

GenAI 指标视图:token 用量按类型拆分、调用延迟直方图

图 5:接好两个直方图后,gen_ai.client.token.usage(按 gen_ai.token.type 拆分输入/输出)与 gen_ai.client.operation.duration(按模型过滤)可以直接支撑成本看板与延迟 SLO,不需要额外的 FinOps 管道。

延迟改善里有一部分来自代码修复(截断上下文、限制工具轮次),但没有可观测性就找不到需要修哪一行代码——这才是这套约定的真实价值:它让你第一次拥有「按会话定位问题」的能力。


总结与未来展望

  • LLM 应用的观测单元不是 HTTP 请求,而是 trace 树 + token 计量 + 会话归属 三件套。缺任何一个,你都只能看到「调用成功」,看不到「花了多少、慢在哪、是否在打转」。
  • OpenTelemetry GenAI 语义约定给了厂商中立的 gen_ai.* 词汇表:Client Span 管单次调用,Agent/Workflow Span 管编排,MCP 管跨进程,Events 管内容(默认关闭),Metrics 管成本与延迟。
  • 它仍是 Development 稳定性,2026-06-12 起的 v1.42.0 已把 GenAI 与 MCP 拆进独立仓库 semantic-conventions-genai,且尚无 tagged release。落地时把属性字面量收敛到一处、在 Collector 做方言归一,就能安全地跟进演进。
  • 工程上真正的三个杠杆:gen_ai.conversation.id 必填、token 按 billable 口径上报、GenAI 链路不做头部采样。
  • 下一步值得关注:gen_ai.evaluation.result 事件让质量分回到同一条 trace 上;Agent 与多 Agent 约定还在快速设计中,Memory 相关操作类型刚起步;等新仓库发布 schema URL 后,才能用 schema processor 做自动版本迁移。

延伸阅读


标签:OpenTelemetry、GenAI 语义约定、LLM 可观测性、AI Agent、Token 成本优化、分布式追踪、OTel Collector、AI 工程化、MCP、PII 脱敏