Skip to main content

04 - 语义约定与内容治理

前置02 篇的埋点路线。

本篇回答:span 上的字段该叫什么名字(约定),以及 prompt 和模型输出这些字段值到底记不记(内容治理)。这两件事看着是两个话题,但决定它们的开关经常在同一个配置对象里。

本篇会用到的词

意思
语义约定规定 span 叫什么名、带哪些属性的标准。有它,不同厂商的工具才读得懂同一份数据
命名空间属性名的前缀。gen_ai.*llm.* 是两个不同命名空间,表达的却常是同一件事
OTTLOpenTelemetry Transformation Language,在 Collector 里改写遥测数据的表达式语言
归一化把不同来源、不同命名的数据改写成同一套字段名
索引属性形如 llm.input_messages.0.message.role 的属性 —— 数组下标编在属性名里,条数越多属性越多

一、两套约定的定位

OpenTelemetry GenAIOpenInference
仓库open-telemetry/semantic-conventions-genaiArize-ai/openinference
2671,159
协议Apache-2.0Apache-2.0
属性前缀gen_ai.*llm.* document.* embedding.*
出身OpenTelemetry 官方Arize(Phoenix 的开发方)
定位通用可观测标准的 GenAI 扩展面向 LLM 应用调试的实用约定

不要用 star 数比较这两个仓库。 使用者 star 的是 SDK 和平台,不是规范文本 —— semantic-conventions-genai 只有 267 星,不代表它边缘。判断采纳度看的是哪些平台和基础设施项目在生产路径上实现了它,第三节给证据。

二、覆盖范围不同

两套的差异不在命名风格,而在建模粒度

差异不在命名风格,在建模粒度 —— 各自把哪一块做细了OTel GenAI模型调用:chat · embeddings · token 用量 · 延迟Agent 生命周期:invoke_agent · plan · execute_tool记忆:七个 memory 操作 + 五个 memory 属性检索:只有一个 retrieval span,粒度较粗OpenInference模型调用:llm.* 命名空间检索链路:document.* 逐文档记 id · score · 内容向量化:embedding.* 独立建模MCP:没有对应约定深色格子是各自的强项。做 Agent 编排与 MCP 调试,左边的 span 类型更全;做 RAG 效果排查,右边能看到每篇召回文档的得分。
差异来自出身:OTel GenAI 走通用可观测性路线,OpenInference 出自做 ML 可观测性的 Arize,评测和检索质量在他们的世界里本来就是一等公民。

最后一行的 MCP 空缺不是我推断的。Envoy AI Gateway 的源码注释里写着:

// OpenInference defines no MCP conventions, so MCP spans keep the
// gateway-specific vocabulary they have always emitted.
mcp: mcpVocabularyLegacy(),

选了 OpenInference,MCP 那部分 span 就得各家自己发明词汇。 而 OTel GenAI 那边有一份 1,332 行的 docs/gen-ai/mcp.md

三、生态站队:一个产品同时实现两套

判断采纳度最可靠的信号,是看基础设施项目在生产路径上用了谁。Envoy AI Gateway(★1,943,Apache-2.0)给了一个完整的样本 —— 它的 internal/tracing/两套都实现了,由环境变量选:

// internal/tracing/semconv.go
const EnvTracingSemConv = "AI_GATEWAY_TRACING_SEMCONV"

// 列表第一项是默认值 —— 也就是说不配的话走 OpenInference
var semConvs = []semConv{
{name: "openinference", newRecorders: newOpenInferenceRecorders},
{name: "gen_ai", newRecorders: newOTelGenAIRecorders},
}

// 值写错了直接启动失败,不做静默兜底。注释解释了为什么:
// 「一个网关连续几个月发着没人在看的约定,比拒绝启动更糟」
func newRecordersFromEnv() (recorderSet, error) {
name := os.Getenv(EnvTracingSemConv)
if name == "" { return semConvs[0].newRecorders(), nil }
for _, sc := range semConvs {
if sc.name == name { return sc.newRecorders(), nil }
}
return recorderSet{}, fmt.Errorf("invalid %s %q: must be one of %s", ...)
}

从这段代码能读出三件事:

  1. 两套都得支持 —— 一个 CNCF 生态的网关项目,没法只押一边。
  2. 默认是 OpenInference —— 至少在追踪这条路径上,它比"官方"更实用。
  3. 而它的 Prometheus 指标那一侧走的是 OTel GenAI 约定。同一个产品,指标一套、追踪另一套。

第三条最能说明问题:这不是"哪套会赢"的竞争,是两套在不同信号上各自扎根。

3.1 一条藏在同一个文件里的生产坑

同一段代码里还有个字段,注释值得逐字读:

// unboundedAttributeCount reports whether this convention emits indexed
// per-message attributes. Those scale with conversation length and exceed
// OTEL's default cap of 128, silently truncating spans.
unboundedAttributeCount: cfg.CapturesMessages(),

OpenInference 记消息的方式是 llm.input_messages.0.message.rolellm.input_messages.0.message.contentllm.input_messages.1.message.role……下标编在属性名里,一轮对话两三个属性。

而 OpenTelemetry SDK 的 OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT 默认值是 128

对话轮数属性数量结果
十轮以内几十个正常
四十轮以上超过 128超出的属性被静默丢弃
两套都会在长对话时静默截断,只是撞的上限不是同一个OpenInference · 属性「个数」会涨128 · 默认上限超出的属性被丢掉0 轮204060 轮OTel GenAI · 属性「值」会变长值长度上限10 轮40 轮60 轮被截掉属性个数恒定 ≈ 15 个,永远撞不到 128左边撞的是 OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT(默认 128),右边撞的是 OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT。两者都不报错,都是查询时才发现数据少了一截 —— 而长对话恰恰是最需要排查的那一批。
横轴取对话轮数是因为它是唯一会无限增长的量。一轮对话在 OpenInference 下约产生两到三个索引属性,四十轮上下就会越过默认上限。

现象是:短对话的 trace 完整,长对话的 trace 缺尾部消息 —— 而长对话恰恰是最需要排查的那些。Envoy AI Gateway 的做法是只在该约定确实要记消息时才抬高上限;自己搭埋点的话,这个上限要手动调:

# 记消息内容时必调。注意抬高上限意味着单个 span 变大,
# 这笔账要和 05 篇的存储成本一起算
export OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT=1024

OTel GenAI 那边把整段消息放进 gen_ai.input.messages 一个属性里(序列化成 JSON 字符串),不吃这个限制 —— 但换来的是这个属性的可能超过 OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT,被从中间截断。两套各有各的截断方式,都得管。

四、收敛:三个位置可以做归一

既然两套都要活着,问题就变成"在哪一层把它们统一"。有三个位置,代价依次降低:

同一件事,做在越靠后的位置,改起来越便宜① 业务代码里的转换层自己写一个属性出口类所有服务都要引它规范一改 → 改代码→ 全量发版 → 历史数据仍是旧的只在没有现成埋点库时才这么干② 埋点库的双发开关一个环境变量,同时输出两套OPENINFERENCE_ENABLE_GENAI_SEMCONV改配置即可,不改代码但 span 体积翻倍,且只��有部分库支持过渡期两边后端都要喂时用③ Collector 侧归一genainormalizerprocessor内置两张映射表,改 YAML 即可应用侧完全不动改错了改回来就行,无需发版默认选它三者不互斥。实际组合通常是:应用侧用现成埋点库(发什么算什么),Collector 侧统一归一到 gen_ai.*,只在少数需要业务维度的地方补手工属性。②只在「过渡期两边后端都得喂」这一种情况下才需要。注意①的致命伤:它只影响归一之后产生的数据,已经落库的历史数据永远是旧字段名 —— ③没有这个问题。
把这张图和 02 篇那条侵入性谱系放在一起看:埋点和归一是两个独立的决定,前者决定「谁产数据」,后者决定「数据落库前长什么样」。

4.1 位置②:埋点库的双发开关

OpenInference 的配置里有这么一项:

# openinference/instrumentation/config.py
OPENINFERENCE_ENABLE_GENAI_SEMCONV = "OPENINFERENCE_ENABLE_GENAI_SEMCONV"
# Emits OTel GenAI semantic conventions alongside OpenInference attributes
DEFAULT_ENABLE_GENAI_SEMCONV = False

打开之后,同一个 span 上同时带 llm.*gen_ai.*。适用场景很窄:新旧后端并行的迁移期,两边都得喂数据。稳定之后应该关掉 —— 每个 span 体积接近翻倍,这笔账在 05 篇会具体算。

4.2 位置③:Collector 侧的官方归一组件

opentelemetry-collector-contrib 里有一个 alpha 阶段的处理器,作用是"把非 OTel 埋点库产生的 span 属性改写成 OTel GenAI 约定":

processors:
gen_ai_normalizer:
sources:
# openinference 和 openllmetry 是内置源,映射表写死在组件里,
# 不用自己维护对照关系
- name: openinference
remove_originals: true # 改完把原属性删掉,否则一个 span 上两套都在
overwrite: false # 目标属性已存在时跳过而不是覆盖
- name: openllmetry
remove_originals: true
# 也可以自定义源,给自研埋点用
- name: my-legacy-sdk
mappings:
"myapp.llm.prompt_tokens": "gen_ai.usage.input_tokens"
value_mappings:
"gen_ai.operation.name":
"completion": "chat" # 值也能折叠,不只是改键名

service:
pipelines:
traces:
# 必须排在依赖上下文的处理器之后,比如 k8sattributes
processors: [k8sattributes, gen_ai_normalizer, batch]

这件事以前要各家自己写映射表,现在是 OTel 官方组件。 这直接改变了"两套约定怎么办"的答案:不需要在业务代码里选边,也不需要写转换层。

改名只是第一步,处理器还会按 semconv 的类型定义校正 —— 校正不了的会消失输入 · OpenInference输出 · OTel GenAI这一条的结局llm.token_count.prompt = "1024"gen_ai.usage.input_tokens = 1024string → int,解析成功llm.model_name = "gpt-4o"gen_ai.request.model = "gpt-4o"类型本来就对,只改名llm.input_messages.0.…(嵌套)gen_ai.input.messages(原形状)规范定义成 any,不动它llm.token_count.prompt = "N/A"这条属性不存在了转不动就整条丢弃,不报错第三行还有个后续:形状没被统一,所以后端要能查它的话,得再串一个 transformprocessor 用 OTTL 把形状拉齐。第四行是上线这个处理器时唯一需要主动验证的事 —— 比对开启前后每个属性的覆盖率,凭空掉下去的那些就是被丢的。
四行对应处理器里四条不同的代码路径。前三行都是预期行为,只有第四行会让你在几周后对着一张缺了一列的报表发愣。

三个实现细节值得记住:

细节行为
类型强制按 semconv 里的类型构造器校正。"123"123(int)能转就转,转不了(比如非数字字符串)直接丢掉这条映射而不是写个错值
结构化字段不动规范里定义成 any 的字段(gen_ai.input.messagesgen_ai.tool.definitions)保留源格式,需要统一形状的话得再串一个 transformprocessor 用 OTTL 处理
schema_url归一写入属性时会把 ScopeSpans.schema_url 设成它对标的版本(https://opentelemetry.io/schemas/1.40.0),已有值默认保留

第一条的"转不了就丢"是个静默行为:归一之后某个属性凭空消失,不会有告警。 上线这个处理器之后要专门比对一次归一前后的属性覆盖率。

五、内容治理:prompt 到底记不记

约定解决了"字段叫什么",还剩"字段值填不填"。这里指的是 gen_ai.system_instructionsgen_ai.input.messagesgen_ai.output.messages 这三个属性 —— 也就是完整的提示词、用户输入和模型输出。

规范对这件事的立场很明确:

OpenTelemetry instrumentations SHOULD NOT capture them by default, but SHOULD provide an option for users to opt in.

默认不记,要记得自己打开。 规范给了三档用法:

档位做法适用
1(默认)完全不记只需要耗时、token、错误率的场景
2记在 span 属性上数据量可控、且合规上没问题的环境 —— 规范原文点名了"预生产环境"
3存到外部存储,span 上只留引用生产环境的推荐做法,规范原文的理由是数据量和"敏感数据需要独立的访问控制"
三档的区别不只是「记多少」,更是「内容最后躺在哪个权限域里」档位 1应用只发指标trace 库 · 0.6 KB/span排查时看不到模型到底说了什么档位 2应用带上全文trace 库 · 8 KB/spantrace 库同时变成了对话内容库档位 3应用trace 库 · 只存一个 URI对象存储 · 独立 ACL体积回到 0.6 KB/span内容上传不受采样决策影响想读内容要另外申请授权规范原文把档位 2 的适用范围写成「预生产环境」,档位 3 才是它给生产环境的推荐做法 —— 理由正是最右边那两列。
注意第三行那两个箭头是并行的,不是先后:span 走追踪管道、内容走上传钩子,两条路互不等待,所以内容上传慢不会拖住请求。

第三档那句"独立的访问控制"是关键。可观测平台的权限模型通常远松于业务数据库 —— 全公司工程师都能查 trace,而能查用户订单表的人可能只有十几个。把完整对话内容原样塞进 trace,等于把业务数据搬到了一个权限更松的地方。这与 Agent 安全 · 记忆与上下文外泄是同一类问题。

5.1 开关矩阵

三套体系的开关长得不一样,混合部署时要三处都设:

同样是「别记 prompt」,在四个位置设开关的效果完全不同① 应用进程内② LLM 网关③ Collector④ 后端存储埋点库的开关CAPTURE_MESSAGE_CONTENT(四档)OPENINFERENCE_HIDE_*(十余个)内容根本不产生,最彻底网关的开关turn_off_message_loggingredact_user_api_key_info单请求头可逐条覆盖管道的开关redactionprocessor白名单模式 + 值正则兜底与审计,不能当主防线后端只剩两个旋钮保留期访问控制数据已经落盘,晚了越靠左关得越彻底:①关掉则内容压根没进过网络;③只能删掉「已经跑过一段网络的数据」,副本可能已经存在于别处。混合部署时三处都要设 —— 只设①,绕过埋点库直连模型的服务照样把 prompt 发出去了。
四个位置不是备选项而是四道闸门。合规评审时被问到的通常是「数据在哪一刻不再存在」,这张图回答的就是那个问题。

OTel 官方(opentelemetry-util-genai —— 一个四档枚举:

# 默认 NO_CONTENT。四个取值分别是:
# NO_CONTENT 不记(默认)
# SPAN_ONLY 只记在 span 属性上
# EVENT_ONLY 只记在事件(日志)里 —— 事件支持结构化值,不用序列化成 JSON 字符串
# SPAN_AND_EVENT 两边都记
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=EVENT_ONLY

# 还要显式开实验特性,否则 gen_ai 的新属性不生效
export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental

EVENT_ONLY 这一档值得注意:内容走日志管道而不是追踪管道。 好处是日志管道的存储和保留期通常可以独立配置,正好对上"分开做访问控制"那个诉求。

OpenInference —— 一组细粒度布尔开关,被隐藏的值替换成 __REDACTED__

OPENINFERENCE_HIDE_INPUTS=true               # 输入整体
OPENINFERENCE_HIDE_OUTPUTS=true # 输出整体
OPENINFERENCE_HIDE_INPUT_MESSAGES=true # 只藏输入消息,保留其他输入元信息
OPENINFERENCE_HIDE_OUTPUT_MESSAGES=true
OPENINFERENCE_HIDE_INPUT_TEXT=true # 只藏文本,保留图片等其他模态的元信息
OPENINFERENCE_HIDE_OUTPUT_TEXT=true
OPENINFERENCE_HIDE_INPUT_IMAGES=true
OPENINFERENCE_HIDE_LLM_INVOCATION_PARAMETERS=true # temperature / top_p 这些
OPENINFERENCE_HIDE_LLM_TOOLS=true # 藏掉告诉模型的工具定义
OPENINFERENCE_HIDE_EMBEDDINGS_VECTORS=true # 向量本身
OPENINFERENCE_HIDE_EMBEDDINGS_TEXT=true
OPENINFERENCE_BASE64_IMAGE_MAX_LENGTH=32000 # base64 图片超过这个长度就截断

HIDE_LLM_TOOLS 那一项容易被忽略:工具定义里常常带内部 API 的字段名、枚举值和描述,它泄露的是系统结构而不是用户数据,但一样敏感。

网关侧(LiteLLM Proxy) —— 全局与单请求两级:

litellm_settings:
turn_off_message_logging: True # 全局关掉消息内容
redact_user_api_key_info: true # 连密钥别名这类信息也不往下游发
# 单请求粒度:客户端自己声明这次调用不要记内容
curl ... -H "x-litellm-enable-message-redaction: true"

5.2 第三档怎么落地:外部存储 + 引用

规范定义了一个钩子机制:

If such a hook is supported and configured, instrumentations SHOULD invoke it regardless of the span sampling decision.

加粗那句是重点 —— 内容上传不受采样决策影响。 也就是说 span 可以被采样丢掉,内容仍然完整存进对象存储。这个设计对得起 Agent 的实际需求:出问题时要查的是那一次具体执行,而采样很可能恰好把它丢了。

两个已经落地的实现:

# OTel 官方:opentelemetry-util-genai 的 CompletionHook,内置 fsspec 支持,
# 因此 s3:// gs:// 这类路径可以直接写
export OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH="s3://my-bucket/genai/"
# OpenInference:注册在 openinference_blob_uploader 这个 entry point 组下,
# 超过 BASE64_IMAGE_MAX_LENGTH 的图片交给它,属性里换成返回的 URI
export OPENINFERENCE_BLOB_UPLOADER=my-uploader

规范自己承认这一档还没写完 —— gen-ai-spans.md 里"记录外部存储引用"那一节的正文是 TODO: document a common approach to record references to externally stored content。也就是说 span 上那个引用字段该叫什么,两家实现各叫各的。

自建时的注意点:

# ❌ 在 hook 里同步上传
# 钩子跑在业务请求的调用栈上,一次 S3 往返几十到几百毫秒,
# 直接加在用户等待时间上 —— 观测把被观测的系统拖慢了
def hook(messages, span):
s3.put_object(Bucket="...", Key=key, Body=json.dumps(messages))
span.set_attribute("app.content_ref", f"s3://.../{key}")

# ✅ 只算引用,实际上传丢给后台队列
# key 用确定性算法从 trace_id + span_id 算出来,这样引用可以立刻写进 span,
# 不必等上传完成。上传失败时的后果是「引用指向一个还不存在的对象」,
# 比「拖慢线上请求」好接受得多,但必须有重试和失败告警
def hook(messages, span):
ctx = span.get_span_context()
key = f"genai/{ctx.trace_id:032x}/{ctx.span_id:016x}.json"
span.set_attribute("app.content_ref", f"s3://my-bucket/{key}")
upload_queue.put((key, messages)) # 非阻塞

5.3 Collector 侧兜底:redactionprocessor

前面几层都是"产生端不产生"。总有漏网的 —— 某个团队没设环境变量、某个自研 SDK 不认识这些开关。管道里还有一道:

processors:
redaction:
# 白名单模式:只有列出的属性能通过,其余全部删除。
# 比黑名单可靠 —— 新增的属性默认是被拦下的,而不是默认放行
allow_all_keys: false
allowed_keys: [gen_ai.operation.name, gen_ai.request.model,
gen_ai.usage.input_tokens, gen_ai.usage.output_tokens,
service.name, tenant.id]
# 对允许通过的属性再做值级别的正则遮蔽
blocked_values: ["\\b\\d{17}[\\dXx]\\b", # 身份证号
"(sk|xoxb)-[A-Za-z0-9]{20,}"] # 常见密钥前缀

这道防线不能替代产生端的开关,因为数据已经离开了业务进程、在网络上走过一段。它的价值是兜底和审计:白名单模式下,任何新出现的属性都会被拦下并计数,你能知道有人开始往 trace 里塞新东西了。

六、两个常见误解

6.1 以为选了后端就等于选了约定

后端和约定是两个独立的选择,中间隔着通用的 OTLP:

后端对约定的态度
Langfuse自有数据模型,两套都映射进来
Phoenix原生 OpenInference,另一套走翻译层
自建 OTel 后端收什么存什么

先定约定,再定后端 —— 反过来会被后端锁死。埋点是最难改的部分,后端是最容易换的部分。

但也别理解成"后端能吃两套所以随便混"。后端能同时接收,不代表两套数据在后端里长得一样 —— 混用的系统最后必然出现"某些请求成本统计为 0"这类现象,因为报表按 gen_ai.usage.input_tokens 求和,而另一半数据的字段叫 llm.token_count.prompt要么团队内统一,要么在 Collector 里归一,二选一。

6.2 以为规范稳定了就不用管

01 篇 5 节已经数过:118 个属性全是 developmentchangelog.d/ 里带 .breaking.md 的条目有六个。

应对方式就是第四节的位置③ —— 把变更收敛到 Collector 配置,那是唯一能对历史数据也生效、且改错了不用发版的位置。

下一篇05 - 采样与数据管道

← 回到 专题索引  ·  Agent Infra 板块总览