跳到主要内容

GenAI 语义约定:span 上到底记了什么、记在哪

30 秒导读: 上一章讲了 OpenLLMetry 怎么"拦住"每一次 LLM 调用。拦住之后,它得把这次调用 翻译成一条 span——但"用什么 key 记 token 数""厂商名叫什么""prompt 塞哪个字段"必须全项目 统一,否则后端看到的就是一堆各说各话的属性。这一章讲的就是这套统一词汇表(gen_ai.* 一族常量),以及一个容易踩的分叉:prompt/completion 到底是记成 span 属性,还是记成独立的日志事件。

本章不讲装饰器怎么设置 workflow/agent 这些上下文值(那是第 4 章); 本章只讲词汇表本身,以及遥测数据两种承载方式


1. 这是什么(零基础也能懂)

先建立一个直觉

把一次 LLM 调用想象成一张报销单。你可以在纸上随便写"花了多少钱、买了啥",但财务系统 只认固定的表格字段:金额填这格、日期填那格、类别填第三格。字段名一乱,系统就读不懂。

语义约定(semantic conventions)就是这张表格的字段定义。 它规定:一次模型调用产生的 遥测数据里,"输入 token 数"这一格必须叫 gen_ai.usage.prompt_tokens,"厂商"这一格必须叫 gen_ai.system 且值是 openai/anthropic 这种固定串——不是 provider、也不是 vendor

它解决什么问题

一个可观测后端(Traceloop 平台、Grafana、Jaeger……)要能对任意被埋点的库画出统一的 "token 花费""延迟"仪表盘。前提是:不管这条 span 来自 OpenAI 埋点还是 Anthropic 埋点, 同一个含义的数据用同一个 key。语义约定就是这份"全项目通用的 key 清单"。

在 OpenLLMetry 里,它是一个独立的包

这套词汇表被单独抽成一个 PyPI 包 opentelemetry-semantic-conventions-ai,几乎只有一个文件:

packages/opentelemetry-semantic-conventions-ai/
opentelemetry/semconv_ai/__init__.py ← 全部常量都在这

里面没有逻辑,全是"常量类"——一堆把人类可读的名字映射到线上真实字符串 key的定义。 所有埋点包(OpenAI、Anthropic、Pinecone……)都 from opentelemetry.semconv_ai import SpanAttributes, 这样大家写出来的 span 用的是同一套 key

用起来什么样

埋点代码里,设置一个属性长这样(示意):

# 示意,非源码:埋点内部如何用这套词汇表
from opentelemetry.semconv_ai import SpanAttributes

span.set_attribute(SpanAttributes.LLM_REQUEST_MODEL, "gpt-4o") # 实际 key = "gen_ai.request.model"
span.set_attribute(SpanAttributes.GEN_AI_USAGE_TOTAL_TOKENS, 1287) # 实际 key = "gen_ai.usage.total_tokens"

重点看:代码里写的是符号名(LLM_REQUEST_MODEL),落到 span 上的是字符串 (gen_ai.request.model)。词汇表的全部价值,就是保证这个字符串在整个项目里只有一个写法。


2. 顶层全景(这套词汇表怎么组织)

一张图:三类常量 + 两条内容路径

这张图从左到右读:左边是"词汇表定义了哪些名字",右边是"这些名字最终落在哪种遥测信号上"。

opentelemetry/semconv_ai/__init__.py(词汇表定义)
┌───────────────────────────────────────────────┐
│ GenAISystem 各厂商 system 名(枚举) │
│ SpanAttributes span 上的属性 key(最大的一坨) │
│ Meters 指标的名字(token 用量/延迟…) │
│ Events/EventAttrs 向量库查询事件的 key │
└───────────────────────────────────────────────┘
│ 被各埋点包 import 使用

┌──────────────────────── 落到遥测信号上 ────────────────────────┐
│ │
▼ Trace(span) ▼ Metrics(meter) ▼ Logs(event)
一条 span 的属性: 计数器/直方图: 日志事件:
gen_ai.request.model gen_ai.client.token.usage gen_ai.user.message
gen_ai.usage.total_tokens gen_ai.client.operation.… gen_ai.choice
gen_ai.input.messages ◄──┐ (见第 1 章的 meter 管道) ▲
gen_ai.output.messages │ │
└── prompt/completion 走这里(use_attributes=True,默认)
或走这里(use_attributes=False)──┘

怎么读这张图的关键: 左边定义的名字,大部分落在 span 属性上;token 用量/延迟这类可聚合的 数值落在 metrics 上;而最占空间、最敏感的 prompt/completion 内容有一个特殊分叉——默认 记成 span 属性(gen_ai.input.messages),也可以改成走日志事件。这个分叉是本章第 5 节的重点。

词汇表里的几个"常量类"各管什么

__init__.py 就是一组类,每个类是一个命名空间:

常量类管什么位置
GenAISystem各厂商的 system 名(openai/anthropic/…)opentelemetry/semconv_ai/__init__.py:6
Meters指标名字(token 用量、操作延迟、向量库计量)opentelemetry/semconv_ai/__init__.py:38
SpanAttributesspan 上的属性 key——最大的一坨opentelemetry/semconv_ai/__init__.py:66
Events / EventAttributes向量库查询/检索结果作为 span 事件的 keyopentelemetry/semconv_ai/__init__.py:336
LLMRequestTypeValues请求类型枚举(chat/completion/embedding/rerank)opentelemetry/semconv_ai/__init__.py:365
TraceloopSpanKindValues手动埋点的 span 种类(workflow/task/agent/tool)opentelemetry/semconv_ai/__init__.py:373
GenAICustomOperationName官方 spec 之外自定义的 operation 名opentelemetry/semconv_ai/__init__.py:381

3. GenAISystem:厂商名,以及"哪些对齐了官方 spec"

它要解决的小问题

每条 LLM span 都要标"这是哪家的模型"。问题是:这个字符串不能随便起。如果 OpenLLMetry 写 openai 而官方 OTel 生态约定写别的,后端就没法把 OpenLLMetry 的数据和别人的数据放一起看。

关键设计:两种取值来源,靠注释区分

GenAISystem 是个枚举,但它的有两个来源,__init__.py:6 的类 docstring 说得很直白:

有官方 OTel GenAI 约定对应项的,用 spec 定义的小写串;没有对应项的,用 小写加下划线作为项目自有约定。

对照代码看这个区别:

枚举成员是否对齐官方 spec
OPENAI"openai"对齐(spec 小写串)
ANTHROPIC"anthropic"对齐
MISTRALAI"mistral_ai"项目自有(下划线约定)
TOGETHER_AI"together_ai"项目自有
WATSONX"ibm.watsonx.ai"对齐(点号命名空间,spec 风格)
AZURE"az.ai.openai"对齐
AWS"aws.bedrock"对齐
GOOGLE"gcp.gen_ai"对齐

真源码:GenAISystem 定义在 opentelemetry/semconv_ai/__init__.py:6-35。判断"对齐/自有"的 唯一线索就是命名风格——点号分层(aws.bedrock)通常是 spec 风格,裸下划线 (together_ai)通常是项目占位,直到官方补上对应项 (inferred,依据类 docstring 的说明规则)。


4. SpanAttributes:属性 key 大全,以及和上游 spec 的关系

这是整个词汇表最庞大的一块

SpanAttributes(opentelemetry/semconv_ai/__init__.py:66)是一个巨大的常量类,按领域分块。 挑几块看看它覆盖什么:

分块例子 key干什么
LLM 用量/请求/响应gen_ai.usage.total_tokensgen_ai.response.finish_reason记 token、finish reason、是否流式
OpenAI 专属gen_ai.openai.api_basegen_ai.openai.api_typeOpenAI/Azure 特有的端点信息
Vector DB 通用db.systemdb.vector.query.top_kdb.vector.query.top_score向量检索的通用维度
Pinecone 专属pinecone.usage.read_unitspinecone.query.top_kPinecone 特有的计量/查询参数
Traceloop 工作流traceloop.workflow.nametraceloop.entity.pathtraceloop.association.properties手动埋点的上下文(见第 4 章)
Prompt 管理traceloop.prompt.keytraceloop.prompt.versiontraceloop.prompt.template托管 prompt 的版本追踪

对应源码位置:LLM 用量块 :68 起、OpenAI 块 :86、Vector DB 块 :95、Pinecone 块 :104、 Traceloop 工作流块 :117、Prompt 块 :127

关键关系:项目自有属性 vs 上游 spec 属性

这是本章最容易糊涂、也最值得讲清的一点。OpenLLMetry 的属性其实分两半:

  • 一半是项目自己定义的——就在 SpanAttributes 类里,比如 GEN_AI_USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens"(:68)。这些是官方 spec 还没有、或 OpenLLMetry 有自己需求的。
  • 另一半在官方上游包里——opentelemetry.semconv._incubating.attributes.gen_ai_attributes (社区维护的"孵化中"GenAI 属性)。埋点代码直接从那里 import,不再走 SpanAttributes

怎么看出这个分工?看 SpanAttributes:145-172 那段注释掉标记的历史:

# 摘自 opentelemetry/semconv_ai/__init__.py:152-164(节选)
# Removed from SpanAttributes in v0.5.0 (now in upstream OTel gen_ai_attributes)
LLM_SYSTEM = "gen_ai.system"
LLM_REQUEST_MODEL = "gen_ai.request.model"
LLM_REQUEST_TEMPERATURE = "gen_ai.request.temperature"
LLM_PROMPTS = "gen_ai.prompt"
LLM_COMPLETIONS = "gen_ai.completion"

这段注释直说:这些在 v0.5.0 被"移出"了本类,因为上游 OTel 的 gen_ai_attributes 已经收了 它们;但为了让还没迁移的埋点包不崩,这里保留成兼容别名(名字还在,值不变),并挂着 # TODO: migrate to GenAIAttributes.GEN_AI_* counterpart

一句话总结这个关系:

SpanAttributes = OpenLLMetry 项目自有 + 尚未上游化 + 历史兼容别名; 官方 gen_ai_attributes(_incubating)= 已被社区标准收编的属性。 新代码优先用上游的,旧的别名还留着只是为了不破坏没迁移的包。

真实用法两边都能见到:埋点里既有 from opentelemetry.semconv._incubating.attributes import gen_ai_attributes as GenAIAttributes(如 event_emitter.py:14-16),也有 from opentelemetry.semconv_ai import SpanAttributes(如 tracing/tracing.py:31)。同一个文件 可能同时导这两套——一套取"标准属性",一套取"项目属性"。

一个已迁移属性的活样本

event_emitter.py:31-33 就在用上游那套:

# 摘自 .../openai/shared/event_emitter.py:31-33
EVENT_ATTRIBUTES = {
GenAIAttributes.GEN_AI_PROVIDER_NAME: GenAIAttributes.GenAiSystemValues.OPENAI.value
}

这里 GEN_AI_PROVIDER_NAMEGenAiSystemValues.OPENAI 全来自上游 gen_ai_attributes, 不再走本地 SpanAttributes / GenAISystem——正是"新代码优先用上游"的体现。


5. Meters:可聚合的数值信号

它和 SpanAttributes 有什么不同

span 属性是"一次调用的一次性标签";Meters 定义的是可跨调用聚合的指标名字——token 用了 多少、操作花了多久。这些进的是 metrics 管道(第 1 章的 meter),不是 span 属性。

Meters 类在 opentelemetry/semconv_ai/__init__.py:38。核心几个:

符号指标名(线上 key)量什么
LLM_TOKEN_USAGEgen_ai.client.token.usagetoken 用量直方图:40
LLM_OPERATION_DURATIONgen_ai.client.operation.duration一次操作耗时:41
LLM_GENERATION_CHOICESgen_ai.client.generation.choices生成的 choice 数:39
PINECONE_DB_QUERY_DURATIONdb.pinecone.query.durationPinecone 查询耗时:49
DB_QUERY_DURATIONdb.client.query.duration通用向量库查询耗时:54

注意 gen_ai.client.* 前缀:这是 OTel 对客户端侧指标的命名风格。埋点里创建这些指标时直接 引这些常量,比如第 1 章提到的 OpenAI v1 埋点在 v1/__init__.pymeter.create_histogram(name=Meters.LLM_TOKEN_USAGE, ...)


6. 两种承载方式:prompt/completion 记成"属性"还是"事件"

这是本章的压轴——遥测数据的两条承载路径。前面讲的多数属性都是"元数据"(模型名、token 数), 但真正占空间、真正敏感的是 prompt 和 completion 文本本身。OpenLLMetry 给了两条路来放它。

6.1 直觉:同一份内容,两个去处

一次调用的输入消息(prompt)和输出消息(completion),可以:

  • (A)当作 span 属性 塞进这条 span,key 是 gen_ai.input.messages / gen_ai.output.messages;
  • (B)当作独立的 OTel 日志事件 发出去,事件名 gen_ai.user.message / gen_ai.choice

选哪条,由 Traceloop.init(use_attributes=...) 决定,默认走 (A)

6.2 开关在哪、默认是什么

入口在 SDK 的 init docstring 里说得很清楚,packages/traceloop-sdk/traceloop/sdk/__init__.py:77 起(init 方法),参数说明在 :80-91:

use_attributes:若为 True(默认),prompt/completion 设为 span 属性 (gen_ai.input.messages / gen_ai.output.messages),符合当前 OTel GenAI spec; 若为 False,改为发成 OTel 日志事件——这条路要求应用已配置 EventLoggerProvider (通过 opentelemetry._events.set_event_logger_provider),否则事件无处可去,prompt/completion 数据不会被记录。

这个值从 init 一路传到各埋点。以 OpenAI 为例,init_openai_instrumentoruse_attributes 交给 OpenAIInstrumentor(use_attributes=...)(tracing/tracing.py:644), 埋点内部又落到 Config.use_legacy_attributes = use_attributes (.../openai/__init__.py:52)。

6.3 一个必须点破的命名陷阱

use_attributes=True 被赋给了名字叫 use_legacy_attributes 的 Config 字段。 别被 "legacy"这个词误导——这里的判定逻辑是反的。看 should_emit_events (.../openai/utils.py:191):

# 摘自 .../openai/utils.py:191-198
def should_emit_events() -> bool:
return not Config.use_legacy_attributes and isinstance(
Config.event_logger, Logger
)

推一遍这个布尔:

use_attributes(用户传)Config.use_legacy_attributesshould_emit_events()结果
True(默认)Truenot True = False属性路(A),不发事件
FalseFalsenot False 且有 event_logger = True事件路(B)
False 但没配 EventLoggerProviderFalseevent_logger is NoneFalse两头落空,内容丢失

最后一行就是 docstring 警告的坑:选了事件路却没给 EventLoggerProvider,Config.event_loggerNone(默认见 .../openai/shared/config.py:15),should_emit_events() 返回 False, 而属性路又被关掉了——prompt/completion 谁都不记

6.4 路 A(默认):内容写进 span 属性

埋点处理一次请求时,_handle_request(.../openai/shared/chat_wrappers.py:272)先判断走哪条:

# 摘自 .../openai/shared/chat_wrappers.py:275-287(结构示意)
if should_emit_events():
emit_event( ... ) # 路 B:发事件
else:
await _set_prompts(span, kwargs.get("messages")) # 路 A:写属性

路 A 的 _set_prompts_set_input_messages(chat_wrappers.py:508)把每条消息按角色 (user/system/assistant/tool)转成 OTel 规定的 parts 结构,最后一行落地:

# 摘自 .../openai/shared/chat_wrappers.py:552
_set_span_attribute(span, GenAIAttributes.GEN_AI_INPUT_MESSAGES, json.dumps(attr_messages))

输出侧对称:_set_output_messages(chat_wrappers.py:562)在 :612GenAIAttributes.GEN_AI_OUTPUT_MESSAGES。注意这两个 key 都来自上游 gen_ai_attributes, 是当前 spec 的写法(区别于历史别名 LLM_PROMPTS = "gen_ai.prompt",见第 4 节)。

6.5 路 B(opt-in):内容发成日志事件

use_attributes=False 时,内容改由 emit_event(.../openai/shared/event_emitter.py:37) 发成 OTel 日志。事件的"形状"由两个 dataclass 定义,在 .../openai/shared/event_models.py:

事件模型代表事件名位置
MessageEvent一条输入消息(prompt)gen_ai.{role}.message(如 gen_ai.user.message)event_models.py:27
ChoiceEvent一条输出选择(completion)gen_ai.choiceevent_models.py:36

_emit_message_event(event_emitter.py:55)按 spec 规则拼事件名并裁剪 body——例如角色和事件名 同名时删掉冗余的 role 字段(:58-63),非 assistant 角色删掉 tool_calls(:68-71)。 拼好后 Config.event_logger.emit(log_record)(:84)发出去。这个 event_logger 只有在 use_legacy_attributes 为假时才会被创建(.../openai/v1/__init__.py:80-84,内部 get_logger(..., logger_provider=...))——所以没有 provider 就没有 logger,呼应 6.3 的坑。

6.6 use_legacy_attributes 参数的废弃告警路径

init 还留着一个同名的旧参数 use_legacy_attributes(注意:这是 init参数, 和上面 Config 字段同名但不同层)。它已废弃,init 里有一条专门的告警/归一逻辑 (packages/traceloop-sdk/traceloop/sdk/__init__.py:92-110):

# 摘自 traceloop/sdk/__init__.py:92-108(结构示意)
if use_attributes is not None and use_legacy_attributes is not None:
raise TypeError("Cannot pass both ...") # 两个都传 → 直接报错
if use_legacy_attributes is not None:
warnings.warn("`use_legacy_attributes` is deprecated ...", DeprecationWarning)
use_attributes = use_legacy_attributes # 旧参数的值搬到新参数
if use_attributes is None:
use_attributes = True # 都没传 → 默认 True(走属性)

三条规则:两个一起传TypeError;只传旧的DeprecationWarning 并把值搬给 use_attributes;都不传默认 True。之后全流程只认 use_attributes


7. span 富化的另一处:default_span_processor_on_start

前面 6 节讲的都是"埋点自己往 span 上写 LLM 属性"。但还有第二处会给 span 加料——span 处理器 在 span 一创建时就把当前的工作流上下文盖进去。这处发生在 tracing/tracing.py

7.1 它在什么时机跑

OTel 的 span 处理器有个 on_start 钩子:span 一开始就被调用。OpenLLMetry 把 default_span_processor_on_start(tracing/tracing.py:369)挂上去 (见 get_default_span_processor:468processor.on_start = default_span_processor_on_start)。 任何 span 一诞生,它就有机会往上盖属性——不只是 LLM span。

7.2 它盖了哪些上下文

它从 OTel 的 context(get_value(...))里读出当前的工作流/agent/会话上下文,写进这条 span。分两批:

第一批——所有 span 都盖(tracing.py:373-391):

context 里读的值写成的 span 属性
workflow_nametraceloop.workflow.name(SpanAttributes.TRACELOOP_WORKFLOW_NAME):373
agent_namegen_ai.agent.name(上游 GEN_AI_AGENT_NAME):377
conversation_idgen_ai.conversation.id(上游 GEN_AI_CONVERSATION_ID):381
entity_pathtraceloop.entity.path(TRACELOOP_ENTITY_PATH):385
association_properties(dict)traceloop.association.properties.<key> 逐项展开:389

第二批——只有 LLM span 才盖:prompt 管理上下文(managed/key/version/template/variables), 写成 traceloop.prompt.* 一族(tracing.py:393-436)。

7.3 "是不是 LLM span"怎么判定

第二批前面有个 if is_llm_span(span): 门槛。is_llm_span(tracing/tracing.py:320)很简洁:

# 摘自 traceloop/sdk/tracing/tracing.py:320-324
def is_llm_span(span) -> bool:
return (
span.attributes.get(SpanAttributes.LLM_REQUEST_TYPE) is not None
or span.attributes.get(GEN_AI_OPERATION_NAME) is not None
)

判据是"这条 span 有没有 llm.request.type 上游的 gen_ai.operation.name 属性"——有,就当它 是一次真正的 LLM 操作,才值得盖 prompt 版本信息;否则(比如一条普通 workflow/tool span)只盖第一批。 这又是一次"项目属性(LLM_REQUEST_TYPE)+ 上游属性(GEN_AI_OPERATION_NAME)并用"的例子。

本节和第 6 节的分工: 第 6 节是埋点在写"这次调用本身"的 LLM 数据(model、token、messages); 本节是处理器在 span 出生时补写"这次调用发生在哪个工作流/会话/prompt 版本下"的上下文。前者 关心"调用",后者关心"调用所处的环境"。这些上下文值从哪来、被谁设置,是第 4 章的内容。


8. 巧妙之处(值得带走的设计)

  • 词汇表零逻辑、纯常量。 把 key 定义抽成一个几乎没有代码的独立包,让每个埋点包对"叫什么名字" 有唯一真相源。改一个 key,全项目一致(opentelemetry/semconv_ai/__init__.py 整个文件)。

  • 迁移用"保留别名 + TODO"而非硬删。 属性上游化后不是直接删,而是留成兼容别名并标 # TODO: migrate(__init__.py:145-212),让没迁移的埋点包继续能跑——用注释把"技术债"显式记账。

  • 内容承载做成开关,而非写死。 敏感的 prompt/completion 既能进 span 属性、也能改走日志事件 (use_attributes),让用户按后端能力和合规要求二选一(sdk/__init__.py:80-91)。

  • 一个布尔同时管三件事。 should_emit_events()not use_legacy_attributes and has event_logger 一次性表达"没选属性路 事件路配置齐全才发事件",顺手把"选了事件路却没配 provider"这个坑 变成静默降级而非崩溃(.../openai/utils.py:191)。


9. 边界与局限(诚实)

  • use_attributes=False 且未配 EventLoggerProvider = 内容静默丢失。 不报错、不告警,只是 should_emit_events() 返回 False,prompt/completion 两头落空(见 6.3)。文档里靠 docstring 提醒,代码不强制。

  • 命名有历史包袱。 use_attributes(新)↔ Config.use_legacy_attributes(旧字段名)↔ inituse_legacy_attributes(废弃参数)三者同名近义,极易读错;"legacy"一词与"默认走属性" 的语义甚至相反。

  • GenAISystem 谁对齐 spec 靠命名风格猜。 代码没有显式标记哪个成员对齐了官方,只有类 docstring 给了"点号=spec 风格、下划线=项目自有"的经验规则(__init__.py:6-14),需要人工比对上游。

  • 本章只覆盖 OpenAI 埋点的内容路径细节。 _set_input_messages 等属于 OpenAI 埋点;其它厂商 埋点走同一套 use_attributes 约定,但转 parts 的具体实现各包不同,未逐一核对。


10. 代码地图(导航索引)

主题文件路径符号名
词汇表总入口(全部常量)packages/opentelemetry-semantic-conventions-ai/opentelemetry/semconv_ai/__init__.py(整个模块)
厂商 system 枚举同上 :6GenAISystem
指标名同上 :38Meters
span 属性 key 大全同上 :66SpanAttributes
历史兼容别名 + 上游化说明同上 :145-212SpanAttributes.LLM_SYSTEM
请求类型 / span 种类枚举同上 :365 / :373LLMRequestTypeValues / TraceloopSpanKindValues
init 与 use_attributes 文档串packages/traceloop-sdk/traceloop/sdk/__init__.py:77Traceloop.init
use_legacy_attributes 废弃告警同上 :92-110init(warn 分支)
span 出生时富化上下文packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py:369default_span_processor_on_start
判定是否 LLM span同上 :320is_llm_span
事件 vs 属性的判定.../opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/utils.py:191should_emit_events
use_attributes → Config 映射.../openai/__init__.py:52OpenAIInstrumentor.__init__
Config 默认值.../openai/shared/config.py:14Config.use_legacy_attributes
内容写进 span 属性.../openai/shared/chat_wrappers.py:508 / :562_set_input_messages / _set_output_messages
内容发成日志事件.../openai/shared/event_emitter.py:37emit_event
事件数据模型.../openai/shared/event_models.py:27 / :36MessageEvent / ChoiceEvent

相邻章节: 词汇表由谁写上 span → 02 自动埋点机制; 本章第 7 节那些上下文值由谁设置 → 04 手动埋点装饰器; 数据最终导到哪 → 01 SDK 启动与导出管道;全景与阅读地图 → index