Agent 慢在哪、钱花在哪?用 OTel GenAI 语义约定打通 LLM 全链路追踪

AIE 0 次阅读
Agent 慢在哪、钱花在哪?用 OTel GenAI 语义约定打通 LLM 全链路追踪

你的 Agent 一次回答要 45 秒,日志里只有一行 request finished;账单里 token 涨了 3 倍,却说不清是哪个模型、哪一步、哪个工具吃掉的。本文用 OpenTelemetry GenAI 语义约定(GenAI Semantic Conventions,下称 GenAI 语义约定)把这层黑盒拆开——从 span 模型、属性分层,到 Collector 脱敏与尾采样,给你一套能直接上生产的实现。

开篇:从一个 10 秒的「咖啡机选购」请求说起

Elastic Observability Labs 公开过一个几乎每个做 Agent 的团队都会遇到的案例:一个 LangChain 购物助手,用户问「100 美元以内、宽度不超过 20cm、至少能煮 4 杯的咖啡机」。这个请求耗时 10.10 秒。

传统 APM(应用性能监控)能告诉你什么?只有一条出向 HTTPS 请求,200 OK,10.10s。你不知道请求打给了哪个模型、用了多少 token、为什么慢。

换成 GenAI trace 展开之后,同一段时间被拆成了能动手的事实:

  • 路由调用(决定调用哪个工具)1.91s
  • 产品比对工具 4.93s,其中 Sol 模型的比对调用 4.20s、Elasticsearch 检索 690ms
  • 最终回答生成 3.15s
  • 三次模型调用合计约占整个请求的 92%

再点开那次 4.20s 的 chat span,gen_ai.usage.input_tokens = 680、gen_ai.usage.output_tokens = 268、gen_ai.response.model = openai/gpt-5.6-sol。到这里,优化方向才第一次变得具体:把比对模型换成便宜一档的,单次提问成本下降 76%–83%——但 5 个问题里有 4 个反而变慢了。这个反直觉的结果,恰恰只有「成本 + 时延同时可测」的系统才能发现。

本文的目标很单一:让你自己的 Agent 也输出上面这样一棵 span 树。不谈空泛的「可观测性方法论」,只讲 GenAI 语义约定怎么落、代码怎么写、生产怎么配、哪里最容易翻车。

技术背景与核心概念扫盲

为什么 http.* 和 db.* 不够用

传统语义约定是为「同一个输入永远得到同一个输出」的服务设计的。LLM 应用在三条硬约束上都不符合:

  1. 计费单位是 token,不是请求数。一次慢请求可能因为上下文塞满而烧掉 10 倍预算,看 QPS 完全看不出来。
  2. 请求参数会改变语义,不只是改变性能。temperature、top_p、max_tokens 决定模型「说什么」,而不是「多快说」,传统约定里没有对应位置。
  3. 调试对象就是内容本身。调试一条慢 SQL 不需要读表里的行数据;调试一个离谱回答,恰恰必须读 prompt 和 completion——而这正是传统约定刻意不采集的敏感数据。

所以 2024 年 4 月 OpenTelemetry 在 Semantic Conventions SIG 下成立了 GenAI SIG,起点只是「给 LLM 客户端调用定几个属性」(模型名、token 数、时延),随后一路扩到 Agent 编排、MCP 工具调用、内容捕获与质量评估。

2026 年必须知道的两件变化

第一,规范搬家了。2026 年 5 月新建了独立仓库 open-telemetry/semantic-conventions-genai,自 v1.42.0(2026-06-12)起,GenAI 与 MCP 两套约定正式迁出核心仓库。这意味着你收藏的 opentelemetry.io/docs/specs/semconv/gen-ai/ 现在是重定向占位页,属性注册表里 gen_ai.* 的「稳定性」一列也改成了「已迁移」。真身是仓库里的 docs/gen-ai/*.md 与 model/*.yaml,很多插桩库 README 仍链到旧地址,照着点进去会掉进坑里。

第二,它仍然是 Development 状态。规范里写得很直白:Development 意味着「接口与生成的遥测可能变化,且不保证向后兼容」。在 GenAI span 上,唯一 Stable 的属性是 error.type、server.address、server.port——它们借自核心约定,不是 GenAI 组写的。所有带 gen_ai. 前缀的属性都是 Development。

这不等于「别用」。翻译成工程语言是:词表会变,你要按「它会变」来建设。实践上就是:在应用侧包一层薄薄的属性映射层、在 Collector 侧用 transform 归一、把库版本钉死并记录它对齐的 schema 版本。业界也确实在用:VS Code Copilot、OpenAI Codex、Claude Code 都已按这套约定导出遥测。

三类信号与四种属性等级

GenAI 约定同时用到 traces、metrics、events 三类信号,并沿用(Stable 的)属性等级定义:

等级 默认是否采集 含义 GenAI 里的典型例子
Required 是 插桩必须填,消费方可据此判断「这是 GenAI span」 gen_ai.operation.name、gen_ai.provider.name
Conditionally Required 条件成立时是 条件不成立就不出现 gen_ai.request.model、error.type、gen_ai.conversation.id
Recommended 是 除非基数/成本/隐私有理由,否则都填 gen_ai.usage.input_tokens、gen_ai.request.temperature、gen_ai.response.finish_reasons
Opt-In 否 操作员显式开启才采;所有可能含 prompt/输出内容的属性都在这一档 gen_ai.input.messages、gen_ai.output.messages、gen_ai.tool.call.arguments

最后一行就是这套规范的整个隐私设计:内容默认不存在。

底层原理深度拆解

三层 span 模型:从「一次模型调用」到「一个 Agent 的一生」

GenAI 约定把 span 分成三层,这是它和传统分布式追踪最大的差异点。

第一层:客户端调用 span。应用调模型产生的 span,gen_ai.operation.name 为 chat / text_completion / generate_content(多模态),span kind 通常是 CLIENT(模型在远端)。嵌入是 embeddings,RAG 检索是 retrieval,Agent 记忆是 create_memory / search_memory / upsert_memory。

第二层:Agent 与工作流 span。传统追踪里有 HTTP span、RPC span、DB span,但没有「调用一个 Agent」这种东西。GenAI 约定新增了:create_agent(创建/注册 Agent,多为远端服务,CLIENT)、invoke_agent(一次 Agent 运行;v1.41 明确拆成 CLIENT 与 INTERNAL 两种,前者调远端 Agent 服务,后者是 LangGraph 这类进程内框架)、invoke_workflow(v1.41 新增,预定义流程而非自主推理)、plan(规划/任务分解步骤)、execute_tool(一次工具执行,INTERNAL)。

第三层:MCP 约定。MCP(Model Context Protocol)在 2025 年扩散得极快,但带来一个具体故障:Agent 侧产生的 Trace A 与 MCP Server 侧产生的 Trace B 是断开的,没有任何上下文传播。v1.39 引入的 MCP 约定就是来把这条缝焊上的。

把三层拼起来,一个「研究助手」Agent 的一轮对话长这样:

invoke_agent research-assistant           (INTERNAL)
├── chat gpt-4o                           (CLIENT)   ← 模型决定要搜什么
├── execute_tool web_search               (INTERNAL)
│   └── GET /v1/search                    (CLIENT)   ← 普通 HTTP span,自然嵌套
├── retrieval kb-main                     (INTERNAL) ← 向量库检索
├── execute_tool summarize_doc            (INTERNAL)
└── chat gpt-4o                           (CLIENT)   ← 读结果、出最终答案

图 1:Agent 一轮对话的 span 树。和「下单流程调三个微服务」结构完全一样:根 span、子 span、时序、错误。区别只是「服务」变成了模型和工具,用 gen_ai.agent.name、gen_ai.tool.name 而不是 service.name 标识身份。子 Agent 再嵌一层 invoke_agent,多 Agent 协作就是更深的树,而不是另一种数据形态。

这里有个容易忽略的实际收益:retrieval 和 chat 分成两个 span 之后,「回答不对」这个模糊抱怨可以被定位成两类完全不同的故障——检索拿回了垃圾,还是模型在好上下文上推理失误。工具没被调用,也不再是一片迷雾。

操作词汇表与 span 命名

gen_ai.operation.name 是整套标准的支点:它不只描述一次模型调用,还描述发起调用的 Agent。命名规则不是统一格式,而是每种操作替换成对该类工作有意义且低基数的标识——这正是为什么 retrieval span 用数据源命名、tool span 用工具命名。

gen_ai.operation.name 建模什么 关键属性 span 名
chat / generate_content / text_completion 单次推理 gen_ai.request.model、gen_ai.usage.* {operation} {request.model}
embeddings 向量化 gen_ai.request.model、gen_ai.embeddings.dimension.count {operation} {request.model}
create_agent / invoke_agent Agent 生命周期 / 一次运行 gen_ai.agent.name、gen_ai.agent.id、gen_ai.agent.version {operation} {agent.name}
invoke_workflow 编排的多步流程 gen_ai.workflow.name invoke_workflow {workflow.name}
plan 推理与任务分解 gen_ai.agent.name plan {agent.name}
execute_tool 一次工具调用 gen_ai.tool.name、gen_ai.tool.call.id、gen_ai.tool.type execute_tool {tool.name}(v1.41 起必须带工具名)
retrieval 向量库 / RAG 检索 gen_ai.data_source.id、gen_ai.retrieval.top_k {operation} {data_source.id}
create_memory / search_memory / upsert_memory 记忆读写 gen_ai.memory.store.id、gen_ai.memory.record.count {operation}

一个小细节,但它是判断插桩质量的快捷方式:gen_ai.operation.name 是 Required,所以它的存在本身就是「这是 GenAI span」的探测器。你自建告警、自建看板都应该用它做过滤条件,而不是猜属性组合。

内容捕获:为什么必须在属性之外

prompt 和 completion 不是普通属性,规范把它们放在单独的事件 gen_ai.client.inference.operation.details 里,用 gen_ai.input.messages / gen_ai.output.messages 承载。原因很硬:

  • 属性会被索引、有大小上限、按基数计费;一段 8000 token 的上下文塞进去,存储和查询都会被拖垮;
  • 内容里天然有姓名、手机号、账号、内部文档原文;
  • 事件是独立生命周期,可以在 Collector 侧单独丢弃或脱敏,而不影响 trace 结构。

历史包袱也说清楚:v1.37 之前是「一条消息一个事件」(gen_ai.user.message / gen_ai.assistant.message …),多轮对话会产生海量细碎事件,几乎无法查询和关联。v1.37 改成三段聚合属性:gen_ai.system_instructions、gen_ai.input.messages、gen_ai.output.messages,可挂在 span 上,也可挂在 gen_ai.client.inference.operation.details 事件上。

采集开关不是规范定的。规范只把 OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT 举为「一个 opt-in 可以长什么样」的例子,剩下交给插桩作者。所以同样是这个环境变量,不同库行为不同:

取值 内容落在哪 场景
NO_CONTENT(默认) 不采集,只留模型名、token、时延 生产标准姿势
SPAN_ONLY span 属性 gen_ai.input.messages / gen_ai.output.messages 本地/预发调试
EVENT_ONLY 独立 OTel 事件(日志流) 需要长期留存又要隔离权限
SPAN_AND_EVENT 两边都写 短期排障,注意体积翻倍

部分库还要求配合 OTEL_SEMCONV_STABILITY_OPT_IN 才能吃到新属性形态。别照抄任何博客的语法,包括本文——去读你正在跑的插桩库的 README。

指标:成本不用另起一套 FinOps 管道

span 告诉你「这一次发生了什么」,指标告诉你「系统长期是什么形状」。GenAI 约定定义了这组 histogram:

指标 含义
gen_ai.client.token.usage 按 token 类型(gen_ai.token.type = input/output)统计消耗
gen_ai.client.operation.duration 端到端调用时延(带 error.type)
gen_ai.client.operation.time_to_first_chunk 流式场景的首 chunk 时延
gen_ai.invoke_agent.duration / .tool_calls / .inference_calls Agent 运行时长、工具调用数、模型调用数
gen_ai.invoke_workflow.duration 工作流总时长
gen_ai.execute_tool.duration 单工具耗时(带 tool.name、error.type)
gen_ai.server.*(request.duration / time_to_first_token / time_per_output_token) 自建推理服务时用

两个被严重低估的指标是 gen_ai.invoke_agent.inference_calls 和 gen_ai.invoke_agent.tool_calls:单次运行的中位 tool_calls 缓慢上升,通常是 Agent 开始打转的第一个可见症状,比成本告警早得多。而两者比值更有意思——模型调用涨、工具调用不动,说明它在反复「想」而不「做」,这和反复重试同一个工具是完全不同的 bug。

图 2:规范的能力分层与成熟度

flowchart TD
    A["Layer 1 · 客户端 span<br/>chat / embeddings / retrieval<br/>成熟度:相对稳定"] --> B["Layer 2 · Agent 与工作流<br/>invoke_agent / invoke_workflow / execute_tool / plan<br/>成熟度:Development,改名频繁"]
    B --> C["Layer 3 · MCP 约定<br/>tools/call 跨进程续接 + 去重<br/>2026-06 随 GenAI 一并迁移"]
    C --> D["Layer 4 · 事件与内容捕获<br/>inference.operation.details / evaluation.result<br/>默认关闭,Opt-In"]
    D --> E["Layer 5 · 指标<br/>token.usage / operation.duration / agent 计数器"]
    E --> F["Layer 6 · 评估事件<br/>gen_ai.evaluation.score.value / label"]

越往下越新、越不稳定:chat 与 embeddings 可以放心建永久看板,Agent 与工具编排是最容易在版本升级里被改名的部分。

MCP:把断掉的 trace 接上

MCP 跑在 JSON-RPC 上,但规范建议用 MCP 专用约定而非通用 RPC 约定,因为 MCP span 携带会话与工具细节。一个 stdio 传输的客户端 span:

Span: tools/call get-weather        (CLIENT)
gen_ai.operation.name   = execute_tool
mcp.method.name         = tools/call
mcp.session.id          = session-xyz
mcp.protocol.version    = 2025-03-26
gen_ai.tool.name        = get-weather
jsonrpc.request.id      = 42
network.transport       = pipe        # HTTP 传输时用 tcp/quic + network.protocol.name=http

当两侧都传播 W3C Trace Context(traceparent)时,Server 侧 span 会嵌在 Client 侧 span 之下,跨协议边界保持 trace 连续。规范还处理了去重:如果 MCP 插桩发现外层 GenAI 插桩已经在记录同一次工具执行,它应当给已有 span 补 MCP 属性而不是再造一个重复 span。

MCP 也有自己的四个指标:mcp.client.operation.duration / mcp.server.operation.duration(操作时延)、mcp.client.session.duration / mcp.server.session.duration(会话存活时长)。

手把手实战落地

下面这套代码以 Python 3.12 + OpenTelemetry SDK 1.44 系列为准,思路对其他语言一致。

Step 0:依赖与本地靶场

# 插桩与导出器(版本请按需钉死,GenAI 属性会随版本漂移)
pip install "opentelemetry-sdk>=1.44" \
            "opentelemetry-exporter-otlp-proto-http>=1.44" \
            "opentelemetry-instrumentation-openai-v2>=2.0" \
            "openai>=1.60"

# 本地看板二选一:Aspire Dashboard(零账号,端口映射注意)
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/HTTP 收在 http://localhost:4318

Step 1:初始化 SDK(Trace + Metric 双通道)

# telemetry.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.http.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter

OTLP = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318").rstrip("/")

def init_telemetry(service_name: str = "shop-assistant") -> None:
    """初始化全局 Tracer/Meter。必须在创建任何 LLM client 之前调用。"""
    resource = Resource.create({
        "service.name": service_name,
        # 自 v1.37 起,deployment.environment.name 是环境维度的标准口径
        "deployment.environment.name": os.getenv("DEPLOY_ENV", "dev"),
        "service.version": os.getenv("GIT_SHA", "unknown"),
    })

    # BatchSpanProcessor:异步、按批导出,不阻塞请求路径
    tp = TracerProvider(resource=resource)
    tp.add_span_processor(BatchSpanProcessor(
        OTLPSpanExporter(endpoint=f"{OTLP}/v1/traces"),
        max_queue_size=2048,
        schedule_delay_millis=2000,
        max_export_batch_size=512,
    ))
    trace.set_tracer_provider(tp)

    # 指标:token 用量与时延必须进指标,因为 trace 会被采样、指标不会
    mp = MeterProvider(resource=resource, metric_readers=[
        PeriodicExportingMetricReader(
            OTLPMetricExporter(endpoint=f"{OTLP}/v1/metrics"),
            export_interval_millis=15_000,
        )
    ])
    metrics.set_meter_provider(mp)

Step 2:一行式自动埋点(先拿到基本功,再谈手工)

import os
from telemetry import init_telemetry
from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor

os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "NO_CONTENT"  # 生产默认不采内容
init_telemetry()
OpenAIInstrumentor().instrument()   # 自动产出符合语义约定的 chat span + token/时延指标

from openai import OpenAI
client = OpenAI()
resp = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "帮我把这段巡检报告压缩成三条结论"}],
)
# 你无需写任何 span 代码,就能拿到:
#   span 名 = "chat gpt-4o-mini",gen_ai.provider.name=openai
#   gen_ai.usage.input_tokens / output_tokens / gen_ai.response.finish_reasons

opentelemetry-instrumentation-openai-v2 目前是最成熟的 GenAI 插桩;Anthropic、Bedrock 等由社区库覆盖。LangChain/LangGraph 用 OTel 官方 opentelemetry-instrumentation-genai-langchain,注意它与 OpenLLMetry 的 opentelemetry-instrumentation-langchain 注册同一个 langchain 名字,两个都装会冲突。

Step 3:手写 Agent 与工具 span(v1.41+ 命名规范)

自动插桩覆盖不到「我的 Agent 框架怎么分解任务」。这部分要自己动手,但必须严格按约定命名,否则任何后端都渲染不出树。

import json, uuid
from opentelemetry import trace
from opentelemetry.trace import SpanKind, Status, StatusCode

tracer = trace.get_tracer("agent.runtime")

def run_agent(task: str, tools: dict) -> str:
    # 进程内执行 → INTERNAL;调远端 Agent 服务才用 CLIENT
    with tracer.start_as_current_span(f"invoke_agent support-router",
                                      kind=SpanKind.INTERNAL) as span:
        span.set_attribute("gen_ai.operation.name", "invoke_agent")
        span.set_attribute("gen_ai.agent.name", "support-router")
        span.set_attribute("gen_ai.agent.version", "2026.11.1")
        # 注意:v1.41 起 gen_ai.agent.id 不再打在进程内 agent span 上
        span.set_attribute("gen_ai.conversation.id", task.get("conv_id", str(uuid.uuid4())))

        inference_calls = 0
        tool_calls = 0
        try:
            for step in range(6):                       # 硬性上限,防打转
                inference_calls += 1
                decision = call_model(task)             # 内部会产出 chat span
                if decision.type != "tool_call":
                    return decision.content

                tool_calls += 1
                # v1.41 起 span 名必须带工具名,否则工具维度聚合会全糊在一起
                with tracer.start_as_current_span(
                        f"execute_tool {decision.tool}",
                        kind=SpanKind.INTERNAL) as tool_span:
                    tool_span.set_attribute("gen_ai.operation.name", "execute_tool")
                    tool_span.set_attribute("gen_ai.tool.name", decision.tool)
                    tool_span.set_attribute("gen_ai.tool.type", "function")
                    tool_span.set_attribute("gen_ai.tool.call.id", decision.call_id)
                    try:
                        result = tools[decision.tool](**decision.args)
                    except Exception as e:
                        # error.type 必须落,否则按错误类型拆分的看板缺一维
                        tool_span.set_attribute("error.type", type(e).__name__)
                        tool_span.record_exception(e)
                        tool_span.set_status(Status(StatusCode.ERROR, str(e)))
                        raise
                    tool_span.set_attribute(
                        "app.tool.result.bytes", len(json.dumps(result, ensure_ascii=False))
                    )
                task["last_result"] = result
            raise RuntimeError("agent step budget exceeded")
        except Exception:
            span.set_status(Status(StatusCode.ERROR))
            raise
        finally:
            # 这两个计数是「Agent 打转」的最早信号,务必打上
            span.set_attribute("gen_ai.agent.tool_calls", tool_calls)
            span.set_attribute("gen_ai.agent.inference_calls", inference_calls)

两个必须守住的边界:gen_ai.* 命名空间只放规范定义的属性,你自己的业务维度用 app.*(如 app.tenant.id、app.tool.result.bytes),否则将来和官方词表撞车会很难受;gen_ai.tool.call.arguments / .result 属于 Opt-In,内容敏感时不要随手加上。

Step 4:RAG 检索 span + token 指标 + 成本归因

import time
from opentelemetry import trace, metrics

tracer = trace.get_tracer("rag.pipeline")
meter = metrics.get_meter("shop-assistant.cost")
# 单位与命名严格照规范,才能被任何 GenAI 友好的后端自动识别
token_usage = meter.create_histogram("gen_ai.client.token.usage", unit="{token}")
op_duration = meter.create_histogram("gen_ai.client.operation.duration", unit="s")

# 价格表按你自己的合同维护;此处仅为结构演示
PRICING = {"openai/gpt-5.6-sol": {"in": 3.0 / 1e6, "out": 15.0 / 1e6}}

def retrieve(query: str, top_k: int = 5) -> list[dict]:
    with tracer.start_as_current_span("retrieval kb-main", kind=trace.SpanKind.INTERNAL) as span:
        span.set_attribute("gen_ai.operation.name", "retrieval")
        span.set_attribute("gen_ai.data_source.id", "kb-main")
        span.set_attribute("gen_ai.retrieval.top_k", top_k)  # v1.40 起检索用这个,不要复用 request.top_k
        hits = vector_db.search(query, k=top_k)
        span.set_attribute("app.retrieval.hit_count", len(hits))
        return hits

def chat_with_cost(provider_call, model: str, messages: list[dict], conv_id: str) -> str:
    start = time.perf_counter()
    with tracer.start_as_current_span(f"chat {model}", kind=trace.SpanKind.CLIENT) as span:
        span.set_attribute("gen_ai.operation.name", "chat")
        span.set_attribute("gen_ai.provider.name", "openai")
        span.set_attribute("gen_ai.request.model", model)
        span.set_attribute("gen_ai.conversation.id", conv_id)

        resp = provider_call(model=model, messages=messages)
        elapsed = time.perf_counter() - start

        tin, tout = resp.usage.prompt_tokens, resp.usage.completion_tokens
        span.set_attribute("gen_ai.usage.input_tokens", tin)
        span.set_attribute("gen_ai.usage.output_tokens", tout)
        span.set_attribute("gen_ai.response.model", resp.model)
        span.set_attribute("gen_ai.response.finish_reasons", [resp.choices[0].finish_reason])

        # 成本是本团队的关注点 → 用 app.* 自定义属性,别污染 gen_ai.* 词表
        price = PRICING.get(model, {"in": 0.0, "out": 0.0})
        span.set_attribute("app.cost.usd", round(tin * price["in"] + tout * price["out"], 8))

        attrs = {"gen_ai.provider.name": "openai", "gen_ai.request.model": model,
                 "gen_ai.operation.name": "chat"}
        token_usage.record(tin,  {**attrs, "gen_ai.token.type": "input"})
        token_usage.record(tout, {**attrs, "gen_ai.token.type": "output"})
        op_duration.record(elapsed, attrs)
        return resp.choices[0].message.content

为什么不靠 span 算成本?因为 trace 会被采样(尤其尾部采样丢掉的正是「成功且正常」的请求),而指标是未采样的。成本、SLO、告警一律走指标,span 只用来「下钻定位」。

Step 5:内容捕获与异常(预发开、生产关)

import json
from opentelemetry import trace

tracer = trace.get_tracer("shop-assistant")

def debug_chat_span(messages, output, model, conv_id):
    """仅在 DEPLOY_ENV != prod 时调用。内容走独立事件,可与 trace 分权限、分保留期。"""
    with tracer.start_as_current_span(f"chat {model}", kind=trace.SpanKind.CLIENT) as span:
        span.set_attribute("gen_ai.operation.name", "chat")
        span.set_attribute("gen_ai.provider.name", "openai")
        span.set_attribute("gen_ai.request.model", model)
        span.set_attribute("gen_ai.conversation.id", conv_id)

        span.add_event("gen_ai.client.inference.operation.details", {
            "gen_ai.input.messages": json.dumps(messages, ensure_ascii=False)[:8000],
            "gen_ai.output.messages": json.dumps(
                [{"role": "assistant", "content": output}], ensure_ascii=False)[:8000],
        })
        return span

截断([:8000])不是可选项:属性与事件都有大小上限,一条被截断的 trace 比一条超限被整条丢弃的 trace 有用得多。

Step 6:Collector——一套策略,统一收口

应用侧只负责「如实上报」,脱敏、归一、采样、路由全部放在 Collector。这是本文最值得抄的一段配置:

# otel-collector.yaml
extensions:
  health_check:            # 静默死掉的 Collector 是「没有 trace 也没有告警」的经典事故
    endpoint: 0.0.0.0:13133

receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }   # 必须 0.0.0.0,绑 127.0.0.1 是「trace 收不到」第一大原因
      http: { endpoint: 0.0.0.0:4318 }

processors:
  memory_limiter:            # 必须第一个:先保护自己,再干活
    check_interval: 1s
    limit_percentage: 80
    spike_limit_percentage: 20

  # 把 OpenInference / OpenLLMetry 的老词表映射到 gen_ai.*,并删掉原始键
  gen_ai_normalizer:
    sources: [{ name: openinference, remove_originals: true }]

  transform:
    trace_statements:
      - context: resource
        statements:
          - set(resource.attributes["deployment.environment.name"], "${env:DEPLOY_ENV}")
      - context: span
        statements:
          # 兼容旧版字段:prompt_tokens → input_tokens(历史上真实发生过的一次改名)
          - set(attributes["gen_ai.usage.input_tokens"], attributes["gen_ai.usage.prompt_tokens"])
            where attributes["gen_ai.usage.input_tokens"] == nil

  redaction:
    allow_all_keys: true
    blocked_values:                     # 在内容内部做局部掩码,保留可读性
      - "sk-[A-Za-z0-9]{20,}"
      - "[0-9]{3}-[0-9]{2}-[0-9]{4}"
      - "4[0-9]{12}(?:[0-9]{3})?"

  filter:
    traces:
      span:
        - 'attributes["http.route"] == "/health"'

  # 尾采样:等整条 trace 结束再决定。状态 ERROR 与超慢请求全留,正常请求按比例留。
  # 多副本部署时必须先用 loadbalancing exporter 按 traceID 路由,否则一条 trace 的 span
  # 散落在不同副本,每个副本都判不出完整 trace,采样等于失效。
  tail_sampling:
    decision_wait: 10s
    num_traces: 100000
    policies:
      - name: keep-errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: keep-slow
        type: latency
        latency: { threshold_ms: 8000 }
      - name: keep-baseline
        type: probabilistic
        probabilistic: { sampling_percentage: 8 }

exporters:
  otlp_http:
    endpoint: ${env:OTLP_BACKEND}
    headers: { x-api-key: "${env:OTLP_TOKEN}" }
    sending_queue:            # 当前推荐:批与重试交给 exporter 队列,不再单挂 batch processor
      enabled: true
      queue_size: 100000
      sizer: items

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

注意处理器顺序不是随意的:memory_limiter 在最前;gen_ai_normalizer 必须在任何以 gen_ai.* 匹配的处理器之前,保证后续环节只面对一套规范名;redaction 在归一并之后,规则只需写一套而不是每个框架一套。

图 3:生产数据流与策略落点

flowchart LR
    subgraph APP["业务进程内"]
      A1["LLM SDK / 框架"] --> A2["自动插桩<br/>gen_ai.* span"]
      A3["Agent 运行时"] --> A4["手写 span<br/>invoke_agent / execute_tool"]
      A2 --> A5["BatchSpanProcessor<br/>异步批量"]
      A4 --> A5
    end
    A5 -->|OTLP/HTTP 4318| B1["Collector"]
    B1 --> B2["memory_limiter<br/>保护自身"]
    B2 --> B3["gen_ai_normalizer<br/>统一词表"]
    B3 --> B4["transform<br/>环境/成本中心打标"]
    B4 --> B5["redaction<br/>PII 局部掩码"]
    B5 --> B6["tail_sampling<br/>错误+慢请求全留"]
    B6 --> C1["存储/后端<br/>按模型、Agent、工具聚合"]
    B1 --> D1["prometheus 自监控<br/>丢包、队列、内存"]

Step 7:验证——三条硬性检查

采集了不等于可用。上线前至少跑这三条:

-- ES|QL:按「真正应答的模型」聚合,比按请求模型更准(网关可能路由到别的快照)
FROM traces-generic.otel-default
| WHERE resource.attributes.service.name == "shop-assistant"
    AND attributes.gen_ai.operation.name == "chat"
| STATS calls = COUNT(*),
        avg_ms = ROUND(AVG(duration) / 1e6),
        input_tokens = SUM(attributes.gen_ai.usage.input_tokens),
        output_tokens = SUM(attributes.gen_ai.usage.output_tokens)
  BY model = attributes.gen_ai.response.model
| SORT avg_ms DESC
-- PromQL:p95 时延按请求模型拆分(Collector 的 prometheus exporter 会把 . 转成 _)
histogram_quantile(0.95,
  sum by (le, gen_ai_request_model) (
    rate(gen_ai_client_operation_duration_bucket[5m])
  )
)

第三条不是查询而是目视:打开任意一条 Agent trace,确认 ①invoke_agent 是根 span;②每次模型调用是它的子 span;③工具 span 名带工具名;④SUM(gen_ai.usage.input_tokens) 与你账单的量级接近(差 10 倍以上说明插桩漏了某条调用路径,比如网关重试或流式分支)。

关键细节与踩坑指南

坑 1:属性在版本之间被静默改名。 这是这套规范最贵的学费。真实发生过的迁移:

变更 打断了什么
gen_ai.system → gen_ai.provider.name(v1.37) 所有按 provider 分组的看板
gen_ai.usage.prompt_tokens / completion_tokens → input_tokens / output_tokens token 汇总、成本公式
gen_ai.request.top_k 从 double 变 int,且检索改用 gen_ai.retrieval.top_k(v1.40) 把 top_k 当浮点解析的代码;复用 request.top_k 的检索插桩
逐条消息事件 → gen_ai.input.messages / output.messages / system_instructions(v1.37) 所有按 gen_ai.user.message 抓内容的查询
gen_ai.agent.id 从进程内 agent span 上移除 按 agent id 分组的内部分析
execute_tool span 名必须带工具名(v1.41) 按 span 名精确匹配的工具看板

对策只有一条:迁移靠 Collector,不靠改应用。规范理论上支持 Telemetry Schema(遥测自带 schema URL,后端自动改写),但 GenAI 仓库的 schema URL 目前还是 TODO,gen-ai-dev/1.42.0-dev 也不可解析——现在只能手工迁移,就用上面 transform 那段。

坑 2:head sampling 把一次对话切碎。 GenAI 请求的形态是「一个用户请求 → 十几个 span → 部分很慢」。头部采样(进程内按 traceid 百分比)会让同一次 Agent 运行时而完整时而残缺,排障时最想要信息的那些慢请求恰好被丢。正确做法是 tail_sampling:状态 ERROR 全留、latency > 阈值 全留、其余小比例保留。多副本前置 loadbalancing exporter(routing_key: traceid),否则每个副本都只看得到半条 trace。

坑 3:把 prompt 塞进 span 属性。 反例很常见:

# ❌ 错误:内容进属性 → 基数爆炸、索引膨胀、PII 直接落库
span.set_attribute("prompt", json.dumps(messages))
span.set_attribute("response", completion)

# ✅ 正确:内容走事件(Opt-In),属性只留元数据
span.set_attribute("gen_ai.usage.input_tokens", tin)
span.add_event("gen_ai.client.inference.operation.details", {
    "gen_ai.input.messages": truncated_json(messages),
})

坑 4:环境变量语义因库而异。 同一个 OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT,有的库认 true(旧采集路径),有的只认 span_only / event_only / span_and_event(新形态,需配合 OTEL_SEMCONV_STABILITY_OPT_IN)。升级插桩库后「内容突然没了」通常就是这个。验收方式:发一次调用,抓一条 span 出来看属性,而不是看文档。

坑 5:双份 span 与注册冲突。 MCP 与 execute_tool 会重复记录同一次工具执行——规范要求 MCP 插桩检测到外层已有工具 span 时增强而非新建;同时装 OTel 官方 LangChain 插桩和 OpenLLMetry 的 LangChain 插桩(同名 langchain)会互相覆盖,表现是「插桩没生效」。查法是看 InstrumentationScope。

坑 6:流式(streaming)场景 span 结束时机。 流式返回时,如果在第一个 chunk 就结束 span,gen_ai.usage.output_tokens 会缺失或为 0,gen_ai.client.operation.time_to_first_chunk 也没有意义。必须在生成器 finally 里关闭 span,并在收尾时回填 token。

坑 7:Collector 自身无监控。 队列满 → 丢弃 → 采样率看起来正常但数据消失,前端毫无告警。必须暴露 health_check 与 Collector 自身指标(otelcol_exporter_send_failed_spans、otelcol_processor_refused_spans)并告警。

坑 8:gen_ai.request.model 与 gen_ai.response.model 混用。 你请求 gpt-4o,应答可能是 gpt-4o-2024-08-06;网关路由后甚至换个模型。成本核算和模型对比一律用 response.model,request.model 只反映意图。同理,provider.name 与 request.model 必须分开:同一个 gpt-4o 可以来自 OpenAI 直连或 Azure,两者的 provider 专属属性不同。

生产环境最佳实践

一、按环境分档的内容策略。 别做「全局开/全局关」这种二元决策:

环境 内容捕获 采样 保留期 额外措施
local / dev SPAN_ONLY 全量 3 天 无
staging / 预发 EVENT_ONLY 全量 7 天 掩码 API Key、证件号
prod(默认) NO_CONTENT 尾采样 8% + 错误/慢全留 30 天(指标 13 个月) 严格 RBAC
prod(排障窗口) 按租户/会话灰度开 EVENT_ONLY 该 trace 100% 保留 24 小时自动回滚 双人审批

关键在最后一行:排障时不要全量开内容,而是按会话 ID 定向开,让内容窗口只有 24 小时。这在合规评审里是最好过的一种说法。

二、告警只挂指标,不挂 span。 四条起步:

  1. p95(gen_ai.client.operation.duration) 按 (response.model) 分组环比上涨 > 30%;
  2. rate(gen_ai.client.token.usage) 按模型设分模型阈值——GPT-5 级模型单请求 2000 token 正常,轻量模型同值基本是 prompt bug;
  3. avg(gen_ai.invoke_agent.tool_calls) 中位数连续 3 个窗口上升(Agent 打转的第一信号);
  4. error.type 维度上出现新的枚举值(新错误类别),用 absent 之外的「新值探测」告警。

三、成本归因落在资源属性上。 用 transform 在 Collector 侧给所有 span 打 app.cost_center、app.tenant.id,这样按团队分摊 token 就是一条 SUM by (app_cost_center)。不要在应用里逐处手写,否则新增服务必漏。

四、两套迁移纪律。 ①钉版本:应用侧钉插桩库版本,并在 README 记录它对标的 schema 版本(例如「对齐 gen-ai/1.42.0 词表」);②钉查询:看板一律通过中间视图/变量引用属性名,改名时只改一处。

五、不要自研后端,自研「语义层」。 OTLP 是标准协议,接 Jaeger/Tempo/Datadog/Langfuse/Phoenix 都只是换 endpoint。你唯一需要自研的是:业务维度打标(app.*)、成本函数(价格表 + cache token 折扣)、以及 trace 到业务单据的关联 ID(gen_ai.conversation.id 最好复用你的业务会话号)。

横向对比与选型建议

先区分三类东西,很多选型讨论之所以吵不出结论,就是把它们混为一谈:

维度 代表 你得到什么 代价
语义约定(词表) OTel GenAI SemConv(Development)、OpenInference、OpenLLMetry 自带 schema 属性名一致,换后端不用重写查询 词表仍在变;OpenLLMetry 早年自成一套、尚未完全收敛
插桩库 opentelemetry-instrumentation-openai-v2(最成熟)、-genai-langchain(1.1b1)、OpenLLMetry SDK、OpenLIT 自动生成 span/metric 不同库细节属性有差异,混用即混词表
后端/UI Langfuse(可自托管,OTLP 入口 /api/public/otel + Basic Auth)、Arize Phoenix、Aspire Dashboard(本地)、Datadog、Grafana Tempo+Loki、Elastic 会话视图、评估、成本看板 计费单位各异,见下

计费单位不可直接比价,这是最容易被销售话术带偏的地方(以下为 2026 年公开定价页口径):Datadog 免费额度按「LLM span/月」(约 4 万,Pro 起约 $160/月含 10 万),且只对 LLM span 计费,tool/retrieval/agent span 不计费——高度 Agentic 的系统因此可能异常便宜;Braintrust Starter 按 span(约 100 万/月);LangSmith Developer 按 trace(约 5000/月);Helicone 按 request(约 1 万/月)。同一个系统在这四种口径下的账单能差一个数量级,先算清自己的 span/trace/request 三者比例,再比价。

选型决策路径(按顺序回答,第一个「是」即停):

  1. 只要你自己的 trace,本地调试、不想有外部依赖 → Aspire Dashboard / Jaeger,docker run 起;
  2. 要 trace + 会话视图 + Prompt 管理 + 自托管合规 → Langfuse 或 Phoenix,走 OTLP 接入,应用侧代码不变;
  3. 已有 APM 厂商(Datadog/Grafana/Elastic)且要求「一套系统看全部」→ 用它,前提是原生支持 GenAI 约定(如 Datadog v1.37+);
  4. 重点在评估(LLM-as-judge、数据集回归、Prompt 版本对比)→ MLflow / Braintrust 这类偏实验平台的,OTLP 已能接入;
  5. 只想要成本数字、不想改代码 → 网关型(Helicone 这类),代价是丢掉 span 树内部结构,Agent 排障能力约等于 0。

一句话总结:词表选 OTel GenAI,插桩选官方库,后端按合规与预算选,且务必让后端成为可替换的一层。

性能实测与效果验证

效果侧:一次真实请求的分解

回到开篇那个案例,在同一份 trace 上做量化(Elastic 公开数据):

环节 耗时 占比 说明
路由调用(选工具) 1.91s 18.9% 小模型
产品比对工具(含子调用) 4.93s 48.8% 工具 span 包含子 span,不可与子项相加
├─ Sol 比对模型调用 4.20s 41.6% input 680 / output 268 token
└─ Elasticsearch 检索 0.69s 6.8% 固定返回 6 条记录
最终回答生成 3.15s 31.2% 小模型
合计 10.10s 100% 三次模型调用合计 ≈ 92%

一个必须记住的统计纪律:工具 span 的耗时包含其子 span,把 4.20s 与 0.69s 再加到 4.93s 上就是重复计算。看总耗时用根 span,找贵的那一步靠展开子 span。这条如果不讲清楚,团队里一定会有人做出一张加和超过 100% 的报表。

同一次改造的收益与代价:把比对模型换成更便宜的一档后,单次提问成本下降 76%–83%,但 5 个问题里 4 个变慢。这个结论只有「成本 + 时延同时归因到 span」的系统才能得到。

开销侧:埋点本身贵不贵

分两部分看,别混为一谈:

  • 元数据埋点(推荐路径):span 只带模型名、token 数、时延、错误类型,走 BatchSpanProcessor 异步批量导出。相对一次 1–4 秒的模型调用,这部分开销在工程上可忽略;真正的风险不是 CPU,而是忽略背压——队列打满就丢数据,所以 max_queue_size、Collector 的 memory_limiter、sending_queue 三处都要显式配。
  • 内容捕获:这是数量级变化的地方。粗算一个量级(按 OTLP/JSON 编码经验值估算,实际以你自测为准):一条只带元数据的 chat span 约 0.5–1KB;带上 1500 token 的输入 + 300 token 的输出(约 6–7KB 文本)后,单 span 会到 8KB 上下,放大约 10 倍。以 10 万请求/日、每请求 3 次模型调用计:纯元数据约 300k span/日 ≈ 0.2–0.3GB/日;开内容捕获约 2.5–3GB/日。存储费、索引费、查询延迟都会跟着涨,这也是「生产默认 NO_CONTENT」最实在的理由。

图 4:同一系统在三种采集策略下的日增数据量与可排障能力

策略                     日增 span 量    日增数据量(估算)   能回答的问题
元数据 + 8% 尾采样         ~24k            ~0.02 GB         成本趋势、P95 时延、错误分类、慢请求全貌
元数据 + 全量              ~300k           ~0.2-0.3 GB      同上 + 长尾分布、单请求精确归因
元数据 + 全量 + 内容        ~300k           ~2.5-3 GB        同上 + 为什么答错、prompt 质量回归
                          (后两列为本量级估算,用于容量规划,非厂商 Benchmark)

真正省钱的顺序是:先把元数据埋全 → 用尾采样控制量 → 只在排障窗口按会话开内容。反过来做(先开内容再想采样)几乎一定会被合规和账单同时打回。

总结与未来展望

  • GenAI 语义约定把「模型调用、Agent 运行、工具执行、检索」变成可聚合的一等 span,gen_ai.operation.name 是整套词表的支点,也是判断「这是不是 GenAI span」的探测器。
  • 它的价值不在属性表本身,而在契约:应用只上报,脱敏/归一/采样/路由全部收口到 Collector,于是换后端、改词表都不动业务代码。
  • 三条不能妥协的工程纪律:内容默认不采(Opt-In)、成本与时延落指标而非 span(因为 trace 会被采样)、尾采样保留错误与慢请求且前置按 traceID 负载均衡。
  • 现阶段它仍是 Development 状态:钉版本、包一层属性映射、把改名当作常态来设计,而不是当作事故来救火。

往前看,三个方向值得盯:GenAI 约定与 MCP 约定合并到同一独立仓库后迭代明显加速(一个月内就新增了 fetch_response 操作与 gen_ai.response.status 等属性),稳定化路线图(2026 Roadmap)已在收集各子 SIG 提案;gen_ai.evaluation.result 事件让「质量」也开始有共享词表,评估数据与 trace 有望在同一个后端里 join;而 OpenTelemetry Demo 3.0 已内置基于 LangGraph + MCP 的 Agentic 参考栈,是下一个「照抄就会」的模板。

延伸阅读

关键词:OpenTelemetry、GenAI Semantic Conventions、LLM 可观测性、AI Agent 追踪、MCP 遥测、OTel Collector、尾采样、token 成本归因