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 应用在三条硬约束上都不符合:
- 计费单位是 token,不是请求数。一次慢请求可能因为上下文塞满而烧掉 10 倍预算,看 QPS 完全看不出来。
- 请求参数会改变语义,不只是改变性能。
temperature、top_p、max_tokens决定模型「说什么」,而不是「多快说」,传统约定里没有对应位置。 - 调试对象就是内容本身。调试一条慢 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。 四条起步:
p95(gen_ai.client.operation.duration)按(response.model)分组环比上涨 > 30%;rate(gen_ai.client.token.usage)按模型设分模型阈值——GPT-5 级模型单请求 2000 token 正常,轻量模型同值基本是 prompt bug;avg(gen_ai.invoke_agent.tool_calls)中位数连续 3 个窗口上升(Agent 打转的第一信号);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 三者比例,再比价。
选型决策路径(按顺序回答,第一个「是」即停):
- 只要你自己的 trace,本地调试、不想有外部依赖 → Aspire Dashboard / Jaeger,
docker run起; - 要 trace + 会话视图 + Prompt 管理 + 自托管合规 → Langfuse 或 Phoenix,走 OTLP 接入,应用侧代码不变;
- 已有 APM 厂商(Datadog/Grafana/Elastic)且要求「一套系统看全部」→ 用它,前提是原生支持 GenAI 约定(如 Datadog v1.37+);
- 重点在评估(LLM-as-judge、数据集回归、Prompt 版本对比)→ MLflow / Braintrust 这类偏实验平台的,OTLP 已能接入;
- 只想要成本数字、不想改代码 → 网关型(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 参考栈,是下一个「照抄就会」的模板。
延伸阅读
- Inside the LLM Call: GenAI Observability with OpenTelemetry(OpenTelemetry 官方 2026 实操文):从 VS Code Copilot 导出真实 GenAI 遥测,用 Aspire Dashboard 看 span 树、指标与内容可视化,最快的上手路径。
- How OpenTelemetry Traces LLM Calls, Agent Reasoning, and MCP Tools(六层规范逐层拆解):讲清 v1.37→v1.41 每次演进「为什么这么改」,以及 MCP 与
execute_tool的去重设计。 - OpenTelemetry Collector Configuration for LLM Observability(生产级 Collector 配置):归一化、脱敏、量控、导出的完整 YAML 与验证/排错清单,把本文 Step 6 扩到生产规模。
关键词:OpenTelemetry、GenAI Semantic Conventions、LLM 可观测性、AI Agent 追踪、MCP 遥测、OTel Collector、尾采样、token 成本归因