跳到主要内容

数据截至 (上游 commit 36c7a7f6eca6)

数据模型与埋点 API:一次函数调用怎么变成一棵 trace

30 秒导读: 你给一个 Python 函数加上 @mlflow.trace,MLflow 就会为这次调用生成一棵 trace(调用树)。这一章只讲两件事:这棵树由哪些名词构成(Trace / TraceInfo / TraceData / Span / Assessment),以及你手指头能碰到的那层埋点 API 长什么样。span 产生之后怎么被批处理、 导出、落盘,是 02 章 的事。


1. 先看结果:一次调用被记成了什么

假设你有一个最普通的 RAG 函数。加两行装饰器:

# 示意,非源码
import mlflow
from mlflow.entities import SpanType


@mlflow.trace(span_type=SpanType.CHAIN) # 根节点:整个请求
def answer(question: str) -> str:
docs = retrieve(question) # 子节点
return generate(question, docs) # 子节点


@mlflow.trace(span_type=SpanType.RETRIEVER)
def retrieve(q): ...

调用 answer("MLflow 是什么") 之后,你得到的不是一行日志,而是一棵有结构的树:

Trace (trace_id=tr-abc123)
├── TraceInfo ← 这次请求的"档案卡":什么时候、多久、成功没、谁发的、花了多少 token
└── TraceData ← 一个扁平的 span 列表,靠 parent_id 拼成树
├── span "answer" type=CHAIN inputs={"question": "..."} outputs="..."
├── span "retrieve" type=RETRIEVER parent=answer
└── span "generate" type=LLM parent=answer

一句话直觉: trace 就是「一次请求的调用栈快照 + 每层的入参出参」。它和你熟悉的分布式追踪 (Jaeger、Zipkin 里那种火焰图)是同一个东西——事实上底层就是 OpenTelemetry。MLflow 做的加法是: 往这个标准结构里塞进了 LLM 场景需要的东西(完整的 inputs/outputs、token 用量、人工评分)。


2. 顶层全景:三层结构与两条身份线

2.1 三层数据结构

Trace 是一个纯粹的容器 dataclass,只有两个字段(mlflow/entities/trace.py:26-35Trace):

装什么在哪
容器Traceinfo + data,负责整体 JSON/proto 序列化mlflow/entities/trace.py:26
档案卡TraceInfotrace_id、时间、状态、tags、metadata、assessmentsmlflow/entities/trace_info.py:20
内容物TraceData一个 list[Span],没有别的mlflow/entities/trace_data.py:11

这个切分不是审美问题,是成本问题:列表页只需要 TraceInfo(几百字节),点开详情才需要 TraceData(可能几 MB)。search_traces 因此有一个 include_spans 开关 (mlflow/tracing/fluent.py:1002)。

2.2 TraceData 里为什么没有 request / response

TraceData 只有一个 spans 字段。它对外暴露的 request / response算出来的——找到 parent_id is None 的那个根 span,直接读它的 OTel 属性(mlflow/entities/trace_data.py:73-85request / response / _get_root_span):

@property
def request(self) -> str | None:
if span := self._get_root_span():
# Accessing the OTel span directly get serialized value directly.
return span._span.attributes.get(SpanAttributeKey.INPUTS)

这行代码有两个信息量:(1) 整条 trace 的"请求"就是根 span 的输入,没有独立存储; (2) 它绕过 MLflow 的属性封装直接读 _span.attributes,拿到的是已序列化的 JSON 字符串 ——这是下面第 4 节要展开的核心设计。

2.3 TraceInfo 上的两个字典:metadata 与 tags

TraceInfo 上有两个看起来一样的 dict[str, str],区别是可变性契约

字段语义典型内容能否事后改
trace_metadata不可变,写死在这次运行里run ID、token 用量、git commit否(logged 后不再更新)
tags可变,给人打标签用trace 名字、eval 请求 ID是(UI / API 都能改)

依据:mlflow/entities/trace_info.py:39-42 的字段注释,以及 update_current_trace 的参数说明 (mlflow/tracing/fluent.py:1504-1508)。

token_usagecost 不是真字段,是从 trace_metadatajson.loads 出来的 property (mlflow/entities/trace_info.py:219 / :240)——又一次"字符串里套 JSON"。


3. Span 家族:同一份数据,三种身份

3.1 它要解决的小问题

一个 span 在生命周期里有两种截然不同的状态:

  • 运行中:需要不停地被写——设 inputs、追加 event、改 status、最后 end()
  • 已结束:需要被反复读——UI 渲染、评估器读取、序列化落盘。读的时候每次都反序列化 JSON 太浪费。

再加上第三种尴尬情况:tracing 被关掉了,或者 span 被采样器丢了——此时用户代码里那句 span.set_inputs(...) 不能崩。

MLflow 用三个类分别对应(mlflow/entities/span.py):

身份属性注册表写操作
Span (:99)已结束、不可变_CachedSpanAttributesRegistryset 直接抛异常
LiveSpan (:643)运行中、可变_SpanAttributesRegistry正常写入底层 OTel span
NoOpSpan (:1274)假的、什么都不做一个普通 dict全部 pass

3.2 三者的分工图

用户代码调用 start_span / @mlflow.trace


create_mlflow_span() span.py:74
┌─────────┼──────────┐
▼ ▼ ▼
NoOpSpan LiveSpan Span
(被丢弃) (运行中) (从存储读回)

span.end()

to_immutable_span() ← 唯一的正向转换

Span

工厂函数 create_mlflow_spanmlflow/entities/span.py:74-96)靠 OTel 对象的类型分派: NonRecordingSpanNoOpSpan,可写的 OTelSpanLiveSpan,只读的 OTelReadableSpanSpan。注释里明确要求"创建 span 一律走这个工厂"。

3.3 转换是"零拷贝"的

LiveSpan.to_immutable_span()mlflow/entities/span.py:1175-1182)非常短,因为它几乎不用干活:

# All state of the live span is already persisted in the OpenTelemetry span object.
span = Span(self._span)
span._attachments = dict(self._attachments)

关键点: LiveSpanSpan 共用同一个底层 OTel span 对象,所有状态本来就写在里面了。 "降级"只是换一层壳、换一个属性注册表(从可写换成带缓存的只读)。只有 MLflow 自己额外挂的东西 (attachments、links)需要浅拷贝一份,好让不可变副本不受后续改动影响。

_CachedSpanAttributesRegistry:1412-1433)在 set 上直接抛 MlflowException——注释说得很白: 缓存不处理值变更,所以这个类只准用在已持久化的 span 上。这是用"运行时报错"来兑现"不可变"承诺。

3.4 NoOpSpan 的沉默哲学,以及它为什么要抱住 OTel context

NoOpSpan 的每个 setter 都是 pass:1338-1372),trace_id 返回一个常量哨兵 MLFLOW_NO_OP_SPAN_TRACE_ID:1271)。目的很直接:埋点失败绝不能让业务代码挂掉。

一个不显然的细节:NoOpSpan.__init__ 可以接收一个 otel_span:1294-1297)。为什么假 span 还要 留着真 OTel 对象?因为采样器丢弃一个根 span 之后,它的子 span 必须被一致地丢弃——保留 OTel context 才能让下游 span 继承同一个 trace ID 和同一个采样决定,而不是各自开一条新 trace (mlflow/tracing/fluent.py:759-763:765-769 的注释)。

3.5 SpanType:一个故意不做成 enum 的枚举

SpanTypemlflow/entities/span.py:52-71)是个纯常量类,第 51 行的注释解释了原因: "Not using enum as we want to allow custom span type string." —— 用户可以写任意字符串当 span 类型, 预定义的 15 个(LLM / CHAIN / AGENT / TOOL / RETRIEVER / GUARDRAIL …)只是约定。

span_type 不只是标签,它有副作用:default_log_level_for_span_typemlflow/tracing/utils/default_log_level.py:24-33)用它决定日志级别默认值——LLMTOOLRETRIEVERAGENTCHAT_MODELEMBEDDING 默认 INFO(UI 默认可见),其余默认 DEBUG (默认折叠)。也就是说,填对 span_type 直接影响你的 trace 在 UI 里默不默认展开


4. 核心设计:MLflow 往 OTel span 里塞的私货

4.1 问题:OTel 属性只认基础类型

OpenTelemetry 的 span attribute 只允许 str / int / float / bool 以及它们的列表。而 LLM 应用 要记的是 messages=[{"role": ..., "content": [...]}] 这种嵌套结构。

MLflow 的解法粗暴而有效:全部 json.dumps 成字符串再塞进去mlflow/entities/span.py:1466-1474_SpanAttributesRegistry.set):

# NB: OpenTelemetry attribute can store not only string but also a few primitives like
# int, float, bool, and list of them. However, we serialize all into JSON string here
# for the simplicity in deserialization process.
self._span.set_attribute(key, dump_span_attribute_value(value))

注意它连 intbool 也一并 JSON 化了——理由写在注释里:统一格式让反序列化侧不用做类型判断。 读的时候一律 json.loads,失败就原样返回(:1392-1399)。

dump_span_attribute_valuemlflow/tracing/utils/__init__.py:125-142)用自定义的 TraceJSONEncoder:67)兜底:Pydantic 模型走 model_dump(),dataclass 走浅层字段提取(注释解释为什么不用 asdict()——它内部的 deepcopy 会把某些 httpx 客户端对象拷坏,进而在 GC 时崩溃),循环引用则 退化成 repr。这一串 fallback 的共同目的只有一个:序列化失败绝不能冒泡到用户代码

4.2 私货清单

于是一个标准 OTel span 上多出了一堆 mlflow.* 前缀的属性。三张 key 表都在 mlflow/tracing/constant.py

span 级(SpanAttributeKeymlflow/tracing/constant.py:98 —— 挂在每个 span 上:

常量实际 key装什么
REQUEST_IDmlflow.traceRequestId该 span 属于哪条 trace(MLflow 自己的 ID,非 OTel trace ID)
INPUTS / OUTPUTSmlflow.spanInputs / mlflow.spanOutputs完整入参出参(JSON 字符串)
SPAN_TYPEmlflow.spanTypeSpanType
CHAT_USAGEmlflow.chat.tokenUsage{input_tokens, output_tokens, ...}
LLM_COSTmlflow.llm.cost{input_cost, output_cost, total_cost}(美元)
TRACE_TAG_PREFIXmlflow.traceTag.<key>把 trace 级 tag 复制到 span 上,好让它挺过 OTLP 导出

trace 级(TraceMetadataKey:5TraceTagKey:40 —— 挂在 TraceInfo 上:

常量实际 key用途
TraceMetadataKey.TOKEN_USAGEmlflow.trace.tokenUsage整条 trace 的 token 汇总
TraceMetadataKey.TRACE_SESSIONmlflow.trace.session会话 ID,多轮对话靠它串起来
TraceMetadataKey.TRACE_USERmlflow.trace.user发起请求的终端用户(注释特意强调:别和 mlflow.user 混,那个是"谁创建的 trace")
TraceTagKey.TRACE_NAMEmlflow.traceNameUI 列表显示名
TraceTagKey.SOURCE_SCORER_NAMEmlflow.trace.sourceScorer标记"这条 trace 是评分器自己产生的",UI 里过滤掉

SOURCE_SCORER_NAME 那条注释很坦白:它语义上应该是 metadata(不可变),但只能用 tag,因为 scorer 执行完才知道这个值,而 metadata 写不进去了。这是数据模型被时序逼出来的妥协,会在 05 章 再出现。

4.3 这样做的收益:白嫖整个 OTel 生态

因为一切都寄生在标准 span 上,MLflow 可以直接用 OTLP 协议收发 trace:

  • Span.to_otel_proto()mlflow/entities/span.py:564-612)把 MLflow span 铺回标准 protobuf;
  • Span.from_otel_proto():458-562)反向解析,服务端接收外部 OTLP 上报时用。

from_otel_proto 里有一个值得学的安全默认值:466-473 的 docstring + :536-543):默认 不信任客户端送来的 mlflow.traceRequestId,而是从 OTLP trace ID 重新推导一个规范 ID;只有内部 可信的往返流程(比如归档 trace 反序列化)才用 preserve_request_id=True 保留原值。防的是伪造 trace ID 覆写别人数据。


5. 评价数据也挂在 trace 上:Assessment 三兄弟

5.1 为什么评分要和 trace 长在一起

LLM 应用的"正确性"不像准确率那样能当场算出来。它可能来自事后的人工标注、可能来自 LLM judge、 可能来自离线跑的启发式脚本。MLflow 的选择是:把这些评价直接挂在被评价的 trace(甚至具体某个 span)上,而不是另开一张表。所以 TraceInfo.assessments 是 trace 档案卡的一部分 (mlflow/entities/trace_info.py:56)。

5.2 一个基类,三种子类

基类 Assessmentmlflow/entities/assessment.py:34)持有三个互斥的值字段——expectationfeedbackissue——__post_init__ 强制"恰好有一个非空"(:83-88):

子类回答什么问题默认来源值容器
Expectation (:367)「标准答案应该是什么」HUMANExpectationValue (:624)
Feedback (:193)「这次输出实际好不好」CODEFeedbackValue (:680)
IssueReference (:489)「这条 trace 命中了哪个已知问题」LLM_JUDGEIssueReferenceValue (:603)

三个默认 source 各不相同,恰好画出了各自的典型生产者:期望值是人写的,反馈通常是代码/评分器 产出的,问题引用则是 judge 挖出来的(依据::259:416:520)。

5.3 两个为后续章节埋的伏笔

伏笔一:Feedback 可以携带失败。 它的 error 参数接受 Exception,构造时自动拆成 AssessmentError(error_message, error_code, stack_trace):261-266)。这意味着**"judge 自己崩了" 也是一种可记录的评分结果**,而不是丢失的数据点——04 章 的 Scorer 靠 这条保证部分失败不污染整体。

伏笔二:assessment 可以被覆盖而非删除。 基类有 overrides(指向被它覆盖的那条 assessment ID) 和 valid(是否仍有效)两个字段(:74-78)。Trace.search_assessments 默认只返回 valid 的, 传 all=True 才看得到历史(mlflow/entities/trace.py:288-296)。评分是 append-only 的审计链, 人工复核推翻 LLM judge 的结论时,原结论仍然留着——这是 05 章 「对齐」的数据基础。

5.4 一个小坑:Expectation 的值怎么存

期望值可以是任意 JSON 结构,但 protobuf 的 Value 只装得下标量。ExpectationValue.to_proto:629-645)因此分叉:标量直接进 value 字段,列表/字典/Noneserialized_value (JSON 字符串 + serialization_format="JSON_FORMAT" 标记)。判断函数就一行(:674-676):

return self.value is not None and not isinstance(self.value, (int, float, bool, str))

又一次"塞不进去就 JSON 化"——和第 4 节 span attribute 的思路完全一致。


6. 埋点 API:你手指头碰得到的那一层

6.1 @mlflow.trace 的三段式分派

trace 函数(mlflow/tracing/fluent.py:123-296)前面有两个 @overload:90-115),纯粹为了让 类型检查器认得两种用法:@mlflow.trace 直接贴(func 非空)和 @mlflow.trace(name=...) 带参调用 (funcNone)。真正的开关是最后一行(:291):

return decorator(func) if func else decorator

decorator 内部做三件事(:246-289):

被装饰的对象

① 剥壳:classmethod / staticmethod → 取 __func__

② 分派:是生成器函数吗?
├── 是 ──→ _wrap_generator (:369)
└── 否 ──→ _wrap_function (:294)

③ 复原:原来是什么描述符,再包回去

第 ① 和第 ③ 步是 3.0.0 才加的(docstring 的支持表格,:189-192)——因为 classmethod / staticmethod 是描述符对象,不先拆开就拿不到真正的函数。

6.2 精华:用一个协程同时服务 sync 和 async

它要解决的小问题。 装饰一个同步函数和一个 async 函数,埋点逻辑一模一样(开 span → 记 inputs → 调用 → 记 outputs → 关 span),唯一的差别是中间那一句是 fn(...) 还是 await fn(...)。朴素写法 是把整段逻辑复制两遍,两份代码从此开始各自腐烂。

思路。 把"函数调用"这一步从埋点逻辑里挖掉,挖出来的洞用 yield 表示。埋点逻辑写成一个 生成器:跑到洞口就暂停,把控制权还给外面;外面(同步或异步各自实现)算出结果,再 send() 回来。

原理演示:

# 示意,非源码
def wrapping_logic(fn, args):
with start_span(name=fn.__name__) as span:
span.set_inputs(args)
result = yield # ← 洞:暂停,等外面把结果送进来
span.set_outputs(result)
yield result # ← 把结果原样交回去


# 同步版和异步版的差别只剩一个 await
def sync_wrapper(*args):
with _WrappingContext(fn, args) as coro:
return coro.send(fn(*args))


async def async_wrapper(*args):
with _WrappingContext(fn, args) as coro:
return coro.send(await fn(*args))

重点看: 所有 span 逻辑只写了一遍,sync/async 分支各自只有一行。

真实实现mlflow/tracing/fluent.py:309-375。源码里 _WrappingContext 把这个协程包成上下文 管理器,第 305-306 行的注释直说了动机:"define the wrapping logic as a coroutine to avoid code duplication between sync and async cases"

三个必须做对的细节:

细节代码为什么必须
__enter__next(coro):333-335把协程推进到第一个 yield,即"span 已开、inputs 已记"
__exit__coro.throw() 把异常扔回协程:337-345函数调用发生在协程外面,异常必须扔回去,start_span 和 OTel use_span__exit__ 才有机会执行
yield result 外包 except GeneratorExit:325-328coro.close() 会在挂起点抛 GeneratorExit,吞掉它才算正常收尾

第二条是最容易漏的:如果不 throw,被装饰函数抛异常时 span 不会被标成 ERROR,甚至可能泄漏 context,污染后续所有 trace。

6.3 流式函数:span 只在生成器体内活着

生成器函数不能用上面那套,_wrap_generator 的 docstring(mlflow/tracing/fluent.py:391-416)画了 执行顺序来说明原因:

stream = generate_stream() # A ← 函数体还没跑
for chunk in stream: # D ← 控制权在用户代码里
...
# 实际顺序: A → B → C → D → C → D → ... → E → F
# ↑─────────↑
# span 只应该在 B/C/E(生成器体内)是 active 的

如果按普通方式把整个 for 循环圈进 span,用户循环体里产生的 span 会被错误地认成子 span—— docstring 原话是会 "leak span context and pollute subsequent traces"

解法是手动开合:454-479):

  1. start_span_no_context 起一个游离的 span(:409-418),它不进 OTel 全局上下文;
  2. 每次 next(generator) 时才用 safe_set_span_in_context(span) 临时把它设为 active(:467);
  3. 每个 chunk 记成一个 span event,名字是 mlflow.chunk.item.{index}:443-452,配合 mlflow/tracing/constant.py:196-197);
  4. StopIteration 时才 end,异常则记 exception event 并置 ERROR:423-441)。

output_reducer 就是给第 4 步用的:流式函数的"输出"天然是一串碎片,直接存 100 个 chunk 既 难看也难用。传一个 reducer,MLflow 会把 chunk 列表折叠成一个值再设为 span outputs(:435-441):

# 示意,非源码
@mlflow.trace(output_reducer=lambda chunks: "".join(chunks))
def stream_answer(q):
for token in llm.stream(q):
yield token

reducer 抛异常也只是 _logger.debug 一句然后退回原始列表(:437-439)——同样的"埋点不许崩业务"。 另外 output_reducer 传给非生成器函数会直接报参数错误(:268-271)。

6.4 start_spanstart_span_no_context:什么时候用哪个

start_span (:523)start_span_no_context (:678)
形态上下文管理器(with普通函数,返回 LiveSpan
底层provider.start_span_in_contextprovider.start_detached_span
父子关系从 OTel 当前上下文自动推断手动传 parent_span=
结束方式退出 with 时自动 end()必须自己调 .end()
能否被 get_current_active_span() 找到不能:1300-1305 明确警告)
适用场景手写埋点的默认选择流式、跨线程、异步回调等生命周期不跟栈走的场景

start_span 的 docstring 自己给了建议(:563-569):能用上下文管理器就用,no_context 版本 "更底层、更容易出错"。

两者还有一个共同的隐含前提值得点破:它们都不负责创建 MLflow span 对象,而是从 InMemoryTraceManager:627-631:776-780)——因为 OTel span 一创建,SpanProcessor 的 on_start 就已经跑过并把它注册进去了。这条注册链路是 02 章 的主题。

6.5 update_current_trace:给整条 trace 而不是当前 span 打标

前面的 API 都作用于 span。update_current_tracemlflow/tracing/fluent.py:1489-1666)作用于 整条 trace:改 tags、metadata、client_request_id、preview、state、session、user。

三个设计点:

  • 只写内存。 它拿到 active span 的 trace_id,然后进 InMemoryTraceManagertrace.info:1581-1602)。注释解释得很清楚(:1566-1568):trace 结束时会整体导出,所以 每次改 tag 都发一次服务端请求是浪费。
  • 语法糖归位。 session_id / user / model_id 三个参数其实都是往 metadata 里写固定 key (:1556-1561),省得用户去记 mlflow.trace.session 这种字符串。
  • 失败即静默。 没有 active trace 时只 warningreturn:1538-1543),不抛异常。

7. 大对象怎么办:截断与外置

一条 trace 里最容易爆炸的是两样东西:很长的对话历史图片/音频的 base64。MLflow 用两套 完全不同的机制处理。

span.set_inputs(value)

┌──────────────┴───────────────┐
│ │
① 二进制外置 ② 文本截断
attachment → URI 引用 算 preview,原文照存
(改写 span 数据) (只影响 TraceInfo 的摘要字段)

7.1 ② 文本截断:只影响列表页的摘要

set_request_response_previewmlflow/tracing/utils/truncation.py:15-24)在 trace 转成 MLflow 对象时被调用(mlflow/tracing/trace_manager.py:35_Trace.to_mlflow_trace),负责填 TraceInfo.request_preview / response_preview

三条行为规则:

  1. 用户优先。 只在 preview 还是 None 时才自动生成——update_current_trace(request_preview=...) 设过的值不会被覆盖(:19-24 的注释与条件)。
  2. 不是傻截。 _try_extract_messages:69-100)认识 OpenAI ChatCompletion 的 messages / choices、Responses API 的 input / output,以及 ResponsesAgent 的 request 嵌套;认出来之后 取最后一条匹配角色的消息(请求取 user,响应取 assistant:107-115),再从中挖文本。 所以列表页看到的是"用户最后说了啥",而不是一坨 JSON 头部。
  3. 长度按后端分。 OSS 1000 字符、Databricks 10000 字符(mlflow/tracing/constant.py:180-181), 超出部分换成 ...:53-56)。

注意 preview 是摘要,不是替代品——span 里的完整 inputs/outputs 原样保留。

7.2 ① 二进制外置:base64 变成一个 URI

图片和音频以 base64 内联在消息里时,一张图就能顶掉整条 trace 的体积预算。MLflow 的做法是把它 抠出来,原地换成一个引用 URI。

Attachmentmlflow/tracing/attachments.py:7-48)就是这个引用的定义:

return f"mlflow-attachment://{self._id}?{query}"

query 里带 content_type / trace_id / size:42-48),所以 UI 光看 URI 就知道该怎么渲染、 去哪取、有多大,不用先下载。

抠取发生在 LiveSpan.set_inputs / set_outputsmlflow/entities/span.py:722-739),分两趟:

方法干什么为什么需要
第一趟_extract_attachments (:756)递归走 dict/list,命中就换 URI处理原生 Python 结构
第二趟_extract_attachments_from_serialized (:741)已序列化的结果再扫一遍Pydantic 模型、LangChain BaseMessage 只有 JSON 化之后才变成普通 dict(:742-747 注释)

命中规则有两类:显式传入的 Attachment 对象,以及六种框架内联格式的白名单——OpenAI input_audio、Anthropic image.source.base64、DALL-E b64_json、OpenAI 音频输出、Bedrock image.source.bytes、Gemini inline_data、OpenAI Responses 的 image_generation_call_try_convert_structured_content:821-962)。Gemini 那支还额外处理了一个恶心情况:SDK 可能 把 bytes 序列化成 Python repr 字符串(b'\x89PNG...'),于是先试 ast.literal_eval 再试 base64 (:925-936)。源码里有一条 TODO 承认这个函数在膨胀,考虑拆成 per-provider 助手(:822-825)。

_store_attachment:781-800)是守门人:span 已结束就只返回引用不存内容;超过 MLFLOW_TRACE_MAX_ATTACHMENT_SIZE 就丢内容、记一条 exception event、返回一句人类可读的占位串 (:789-797)。默认开关 MLFLOW_TRACE_EXTRACT_ATTACHMENTSTrue,大小上限默认为 None (不限制)——依据 mlflow/environment_variables.py:780-789

真正的上传发生在导出时,不在埋点时:exporter 把所有 span 的 _attachments 合并后调 self._client._upload_attachmentsmlflow/tracing/export/mlflow_v3.py:359-367)。那里的注释点明了这个顺序的 用意——附件上传单独包一个 try,失败也不影响 trace 本体落盘。细节见 02 章


8. 边界与局限(诚实清单)

  • 属性对标准 OTel 后端是"字符串"。 因为一切都 json.dumps 过(mlflow/entities/span.py:1474), 把 MLflow span 导给 Jaeger/Datadog,看到的 mlflow.spanInputs 是一坨 JSON 文本,没有结构化查询能力。 这是"复用 OTel 传输层"付出的代价。
  • 不可变 span 一写就炸。 _CachedSpanAttributesRegistry.set 无条件抛异常 (:1430-1433);从存储读回的 trace 不能就地修改。
  • Span links 在 Unity Catalog trace 上被直接丢弃(只 warning),Span.__init__LiveSpan.__init__from_otel_proto 三处都做了同样的丢弃(:124-132:679-687:517-524)。
  • TraceData.request/response 只认根 span。 没有根 span(比如 trace 残缺)就返回 Nonemlflow/entities/trace_data.py:67-71)。
  • get_current_active_span() 看不见 start_span_no_context 起的 span,这是设计如此 (mlflow/tracing/fluent.py:1364-1369),但用错了会静默拿到 None
  • 失败一律降级为日志。 LiveSpan.end 整个包在 try 里,出错只 warning:1154-1159);start_span_no_context 失败返回 NoOpSpan:802-807)。好处是不崩业务, 代价是埋点数据可能悄悄丢失,排查得靠 MLFLOW_LOGGING_LEVEL=DEBUG
  • v2/v3/v4 三套 schema 并存。 Trace.__post_init__ 会把 TraceInfoV2 就地升成 v3 (mlflow/entities/trace.py:37-39),Span.from_dict 要同时兼容 base64 编码 ID、3.5.0 的旧格式 和 v2 schema(:339-417)。读这部分代码时先分清你在哪个版本。

9. 横向对比与下一步

想知道什么去哪一章
span 从 end() 到落盘中间经过谁02-tracing-runtime.md
为什么装 OpenAI SDK 后不写装饰器也有 trace03-autolog-instrumentation.md
Feedback 是谁产生的、怎么并发产生04-evaluation-harness.md
judge 怎么读 trace 里的 span 来打分05-judges-and-alignment.md
TraceInfo / TraceData 分别存到哪张表、怎么搜06-storage-serving-monitoring.md

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

主题文件路径符号名
trace 顶层容器、按条件搜 span/评分mlflow/entities/trace.pyTracesearch_spanssearch_assessmentsto_proto
trace 档案卡、token/cost 派生属性mlflow/entities/trace_info.pyTraceInfotoken_usagecost_is_v4
span 列表容器、根 span 推导mlflow/entities/trace_data.pyTraceData_get_root_spanrequestresponse
span 三态与工厂mlflow/entities/span.pycreate_mlflow_spanSpanLiveSpanNoOpSpanSpanType
可变→不可变转换mlflow/entities/span.pyto_immutable_spanfrom_immutable_span
属性 JSON 序列化与只读缓存mlflow/entities/span.py_SpanAttributesRegistry_CachedSpanAttributesRegistry
OTLP 互转mlflow/entities/span.pyto_otel_protofrom_otel_proto
二进制外置逻辑mlflow/entities/span.py_extract_attachments_store_attachment_try_convert_structured_content
所有 mlflow.* key 常量mlflow/tracing/constant.pySpanAttributeKeyTraceMetadataKeyTraceTagKeyAssessmentMetadataKey
评价三型mlflow/entities/assessment.pyAssessmentFeedbackExpectationIssueReferenceExpectationValue
装饰器分派mlflow/tracing/fluent.pytrace_wrap_function_wrap_generator_wrap_function_safe
sync/async 共用协程mlflow/tracing/fluent.py_WrappingContext_wrapping_logic
手动开 span 的两种方式mlflow/tracing/fluent.pystart_spanstart_span_no_contextget_current_active_span
trace 级打标mlflow/tracing/fluent.pyupdate_current_trace
预览截断与消息抽取mlflow/tracing/utils/truncation.pyset_request_response_preview_try_extract_messages
附件引用 URImlflow/tracing/attachments.pyAttachmentrefparse_ref
属性序列化编码器mlflow/tracing/utils/__init__.pydump_span_attribute_valueTraceJSONEncodercapture_function_input_args
span 类型 → 默认日志级别mlflow/tracing/utils/default_log_level.pydefault_log_level_for_span_type_INFO_SPAN_TYPES