跳到主要内容

Span 数据模型与 LLM 语义抽取

30 秒导读: 一个 LLM 调用被 SDK 记录成一条 OpenTelemetry span,里面是几十个扁平的字符串键值对(gen_ai.request.model = "gpt-4o"gen_ai.prompt.0.content = "..."…)。本章讲 Laminar 怎么把这堆异构、跨厂商、格式各异的属性,判成 LLM/TOOL/DEFAULT,并抽成一条带模型名、token 用量、成本、结构化 input/output 消息和工具调用的 Span。这是整个项目工程含量最高的一块——难点不在"接收 span",而在"把十几家 SDK 各说各话的属性统一成一种内部表示"。

本章聚焦"一次 LLM 调用在 Laminar 里被表示成什么、哪些字段是解析出来的"。不覆盖去重与落库(见 03),也不覆盖 gRPC 接入管线(见 01)。上游全景见 index


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

一句话定义: 把 OpenTelemetry 的原始 span,翻译成 Laminar 内部那条"看得懂 LLM"的结构化记录。

先看"原料"长什么样。一个用 OpenLLMetry 自动埋点的 OpenAI 调用,发过来的 span 属性大致是这样一堆扁平键(键名是字符串,值是字符串/数字/布尔):

gen_ai.system = "OpenAI"
gen_ai.request.model = "gpt-4.1-nano"
gen_ai.response.model = "gpt-4.1-nano-2025-04-14"
gen_ai.usage.input_tokens = 42
gen_ai.usage.output_tokens = 7
gen_ai.prompt.0.role = "user"
gen_ai.prompt.0.content = "旧金山现在天气和时间?"
gen_ai.completion.0.role = "assistant"
gen_ai.completion.0.tool_calls.0.name = "get_weather"
gen_ai.completion.0.tool_calls.0.arguments = "{\"location\":\"SF\"}"

难在哪: 这只是一家埋点库的说法。换成 Vercel AI SDK,同样的消息叫 ai.prompt.messages(一个 JSON 字符串);换成 OTel 官方 GenAI 约定,叫 gen_ai.input.messages({role, parts} 数组);换成 LiteLLM,叫 llm.openai.messages;LangChain 又有自己的 traceloop.entity.input。token、成本、工具调用同理,每家键名和形状都不一样。

Laminar 要做的: 认出这是哪一家、走对应的解析分支,把结果统一塞进同一个 Span.input / Span.output / token / cost 字段。

产出长什么样(目标形态): 上面那坨属性,最终变成一条 Span——span_type = LLM,input 是一个结构化消息数组 [{role:"user", content:"..."}],output 是带 tool_calls 的 assistant 消息,外加 input_tokens=42 / output_tokens=7 / total_cost=...,而那些被解析掉的原始属性键(gen_ai.prompt.*)会从存储里剔除,避免重复。

一句话直觉: 把它想成一个"万能翻译官 + 报关员":先听懂十几种方言(各家 SDK 属性),翻成一种普通话(内部 ChatMessage),再给这批货贴上统一的报关单(span 类型 / token / 成本)。


2. 顶层全景(它大概怎么转)

一条 span 的加工分两相:先做便宜的结构解析(把 protobuf/JSON 变成内部 Span 骨架),再做昂贵的语义抽取(认厂商、抠消息、算 token/成本)。

怎么读下图: 从上到下是一条 span 的加工顺序,左侧是阶段,右侧标出干活的函数(file:符号)。

OTLP 请求 (protobuf 或 browser SDK 的 JSON)

┌────▼─────────────────────────────┐
│ 相 0:解码成 prost 类型 │ opentelemetry_json.rs:
│ JSON→shadow struct→OtelSpan │ decode_export_trace_service_request
└────┬─────────────────────────────┘

┌────▼─────────────────────────────┐
│ 相 1:结构解析(便宜) │ spans.rs:
│ OtelSpan → 内部 Span 骨架 │ Span::from_otel_span
│ 只定 id/时间/attributes/初判类型 │ (→ SpanAttributes::span_type)
└────┬─────────────────────────────┘

┌────▼─────────────────────────────┐
│ 相 2:语义抽取(昂贵,本章重点) │ spans.rs:
│ ① 归一化各家键名 │ parse_and_enrich_attributes
│ ② 认 LLM/TOOL,抠 input/output │
│ ③ 抠 tool calls │
└────┬─────────────────────────────┘

┌────▼─────────────────────────────┐
│ 相 3:provider 特化改写 │ provider/mod.rs:
│ LangChain 消息重排 │ convert_span_to_provider_format
└────┬─────────────────────────────┘

┌────▼─────────────────────────────┐
│ 相 4:算 token/成本 + 收尾 │ utils.rs:
│ 查模型价、写回 usage、定 parent │ get_llm_usage_for_span
└────┬─────────────────────────────┘ prepare_span_for_recording

▼ 一条可落库的结构化 Span(交给去重/存储,见 03/04)

各相的职责一句话:

干什么入口符号文件
0 解码JSON/protobuf → prost OtelSpandecode_export_trace_service_requesttraces/opentelemetry_json.rs
1 结构解析OtelSpan → 内部 Span,初判类型Span::from_otel_spantraces/spans.rs
2 语义抽取归一化 + 认厂商 + 抠消息/工具Span::parse_and_enrich_attributestraces/spans.rs
3 provider 特化LangChain 等重排消息convert_span_to_provider_formattraces/provider/mod.rs
4 usage/收尾算 token/成本、写回、定 parentget_llm_usage_for_span / prepare_span_for_recordingtraces/utils.rs

相 1 vs 相 2 为什么要分开? 相 1 号称"轻量"(from_otel_span 的文档注释,spans.rs:707-710),相 2 号称"重"(parse_and_enrich_attributes,spans.rs:761-762)。历史上相 1 在 gRPC 接收线程跑、相 2 在消费者跑;现在为了减小 MQ 载荷,相 2/3 也会在生产者侧提前跑(producer.rs:64-65preprocess_for_queue),消费者据 pre_processed 标记决定是否重跑(processor.rs:128-136)。谁在哪跑属于接入管线的事;本章只关心抽取逻辑本身

选读顺序建议: 先读 §4 建立"原始形态"的直觉(相 0/1),再读 §5 看"抽取成什么"(相 2,本章核心),最后 §6 看 provider 特化。


3. 核心数据模型:三个类型

抽取的一切都围绕三个类型转。先认识它们,后面的逻辑才好读。

3.1 Span —— 统一的目标形态

所有厂商的属性,最终都要塞进这一个结构体(db/spans.rs:73-95,pub struct Span)。关键字段:

字段含义抽取来源
span_typeLLM / TOOL / DEFAULT / CACHEDSpanAttributes::span_type() 判定
input / output结构化消息(Option<Value>)各家消息属性解析而来
attributes原始属性包(SpanAttributes)OTel 原样 + 归一化后的键
namespan 名(可能被工具名/functionId 改写)见 §5.6
parent_span_id父 spanids_path 推定

SpanType 是个枚举(db/spans.rs:22-33),序列化用 SCREAMING_SNAKE_CASE(LLM/TOOL/DEFAULT/CACHED…),这就是为什么属性里 lmnr.span.type = "EXECUTOR" 能直接反序列化成枚举值。

3.2 SpanAttributes —— 属性包 + 一堆访问器

它就是对原始属性 map 的一层薄封装(spans.rs:100-103):

// 真实源码 spans.rs:100
pub struct SpanAttributes {
pub raw_attributes: HashMap<String, Value>,
}

值钱的是挂在它上面的几十个访问器方法:span_type()input_tokens()request_model()provider_name()tags()metadata()… 每个方法知道"这个语义该去哪些键里找、按什么优先级 fallback"。抽取逻辑的大部分知识,就编码在这些访问器里。

3.3 ChatMessage —— 内部统一消息

各家消息最终翻译成的普通话(language_model/chat_message.rs:170-176):

// 真实源码 chat_message.rs:170
pub struct ChatMessage {
pub role: String,
pub content: ChatMessageContent, // Text(String) 或 ContentPartList(Vec<...>)
pub tool_call_id: Option<String>,
}

content 要么是纯文本,要么是内容片段列表(ChatMessageContentPart,chat_message.rs:149-161)——支持文本、图片(URL/base64)、文档、工具调用、工具结果等多模态片段。各家 SDK 的原始片段先反序列化成 InstrumentationChatMessageContentPart(容忍各家字段别名),再经 from_instrumentation_content_part(chat_message.rs:277)统一成内部 ChatMessageContentPart


4. 第一站:OTel 原始形态与解码(相 0/1)

这一节建立"原料长什么样"的直觉。

4.1 两种传输编码,同一套 prost 类型

SDK 发 span 有两种编码:OTLP/protobuf(绝大多数后端 SDK)和 OTLP/JSON(浏览器 SDK)。protobuf 走 prost 直接解;JSON 这条路稍微麻烦——prost 生成的类型只认 protobuf 线格式,不认 OTLP/JSON 的怪癖。

opentelemetry_json.rs 就是这套 JSON 编码的"影子类型 + 转换"。它先反序列化进一组 ...Json shadow struct,再 Into 成 prost 类型(decode_export_trace_service_request,opentelemetry_json.rs:29)。

为什么值得单开一个模块? 因为浏览器/JS SDK 发的 JSON 有一堆现实中的偏差,规范化时必须全兼容(opentelemetry_json.rs:139-155,AnyValueJson):

字段规范说法现实里还会出现处理
trace/span idhex 字符串base64(标准/URL-safe/带不带 padding)decode_id_field 逐个 fallback(:368)
startTimeUnixNano十进制字符串裸数字deserialize_u64_or_string(:406)
intValue字符串数字deserialize_i64_or_string_field(:422)
kind / status.code整数序号枚举名 "SPAN_KIND_INTERNAL"deserialize_span_kind(:441)
bytesValue标准 base64URL-safe / 不带 paddingdecode_bytes_value_lenient(:340)

一个细节体现了"宽进严出"的取舍:id 长度是严格校验的(trace 16 字节、span 8 字节),因为下游 Uuid::from_slice 遇到错长度会 panic,所以这里宁可返回干净的 400,也不让脏 id 流进去(decode_id_field 的注释,opentelemetry_json.rs:358-367)。而单个 bytesValue 解不出来只会置空 + 打 warn,不因一个坏属性丢掉整批(:324-328)。

4.2 from_otel_span:便宜的结构解析(相 1)

拿到 prost OtelSpan 后,Span::from_otel_span(spans.rs:711)做最轻的活:

  • trace_id/span_id/parent_span_id 的字节转成 Uuid;
  • 把每个 OTel 属性 k.valueconvert_any_value_to_json_value(utils.rs:266)转成 serde_json::Value,收进 raw_attributes;
  • span.attributes.span_type() 初判一次类型(spans.rs:750);
  • 处理一个 hack:lmnr.internal.override_parent_span = true 时把 parent_span_id 清空(用于重写 trace id 的场景,:752-756)。

注意此时 input/output 还是空的——真正的消息抽取在相 2。这就是"便宜"的含义:只搭骨架,不碰语义。


5. 核心机制:属性 → LLM 语义抽取(相 2)

这是本章的心脏。入口是 parse_and_enrich_attributes(spans.rs:763),它像一条流水线,按固定顺序尝试各家格式。下面逐个机制拆。

5.1 归一化:先把各家键名对齐(normalize_aisdk_attributes)

流水线第一步(spans.rs:769)是把"意思相同、键名不同"的属性,复制到一套规范键上,这样后面的抽取代码只认规范键、不用到处 or()

思路: 与其让每个下游读取点都去认 5 种 cache-token 键名,不如在入口处统一改写一次。核心是 normalize_if_absent(源键, 目标键)——目标键不存在才从源键复制(spans.rs:293)。

它归一化的东西包括:

  • 模型/provider: aisdk.model.idgen_ai.request.modelaisdk.model.providerai.model.provider(:189-196);
  • cache token: OTel 点分形式 gen_ai.usage.cache_read.input_tokens、pydantic_ai 的 gen_ai.usage.details.cache_read_tokens、AI SDK 的 ai.usage.cachedInputTokens 全部对齐到遗留键 gen_ai.usage.cache_read_input_tokens(:198-231)。注释点明了动机:前端和 ClickHouse 聚合直接查遗留键,所以这次改写就是让新埋点的 cache token 能在 UI 显示出来(:210-215);
  • Mastra 等操作前缀: 有些库把属性挂在 streamText.usage.inputTokens 这种操作名前缀下,detect_aisdk_operation_prefix(:265)先探出前缀,再把 {prefix}.usage.* / {prefix}.prompt.messages 归一到标准 ai.* 键(:233-262)。

5.2 判类型:span_type() 的五级优先级

一切以类型为纲——是 LLM 才走消息抽取,是 Tool 才走工具抽取。span_type()(spans.rs:435)按优先级从高到低判:

怎么读: 从上往下,命中即停。

① 显式 lmnr.span.type 属性? → 直接反序列化成该类型(EXECUTOR/LLM/...)
② gen_ai.operation.name?
chat|text_completion|embeddings|generate_content → LLM
execute_tool → Tool
invoke_agent → Default(容器,内容在子 span)
③ 有 gen_ai.tool.call.arguments 或 .result? → Tool
④ 有 gen_ai.system / request.model / response.model? → LLM
⑤ 都没有 → Default

第 ③ 级是个专门的补丁:pydantic_ai 的工具 span 不发 gen_ai.operation.name,只有 gen_ai.tool.call.*,所以从这两个键兜底判成 Tool(spans.rs:455-461)。第 ④ 级是自动埋点还没法设类型时的"快速 hack"(:463-471)——只要闻到 LLM 味的键就当 LLM。

还有个 is_llm_span()(spans.rs:1121)比 span_type() 更宽:它额外把"原本是 LLM、后来被标成 CACHED"的 span(lmnr.span.original_type == "LLM")也算作 LLM,用于调试回放缓存场景。

5.3 抠 input/output 消息:多分支尝试

判成 LLM 后(spans.rs:771),按 SDK 家族依次尝试。每个分支只在"轮到它的键存在"时才动手:

分支触发键解析函数说明
OpenLLMetry 索引式gen_ai.prompt.0.role/.contentinput_chat_messages_from_genai_attributes见 §5.4
Vercel AI SDKai.prompt.messages(JSON 串)input_chat_messages_from_json + try_parse_ai_sdk_output:790-807
OTel GenAI 原生gen_ai.input.messages / .output.messagesparse_genai_messages_attribute见 §5.5,保留原生形状
LiteLLM 内层span 名 raw_gen_ai_request直接取 llm.openai.messages:901-929
Vercel 外层包装ai.prompt(含 messages/system)input_chat_messages_from_json + 前插 system:934-977
手动埋点lmnr.span.input / .output直接解析,优先级最高:1039-1062

手动埋点为什么放最后? 因为它是用户显式设置的,应当覆盖任何自动埋点的猜测,所以这个块特意放在 LLM 类型判断之外、流水线末尾(spans.rs:1036-1038 的注释)。

还有几个收尾清理:LangChain 的 traceloop.entity.input/output 作为最后 fallback 填入(:1013-1022);RunnableSequence 这类噪声 span 的 input 被清掉(:1024-1034);lmnr.internal.tracing_level = MetaOnly 时 input/output 全部清空,只留元信息(:1064-1067)。

5.4 演示:索引式属性 → 消息数组

OpenLLMetry 把一段对话拆成 gen_ai.prompt.{i}.role / .content / .tool_calls.{j}.* 一堆扁平键。input_chat_messages_from_genai_attributes(spans.rs:1287)要把它们重新组装成消息数组。

先看它在干嘛(教学示意):

# 示意,非源码:重建索引式消息的核心思路
max_i = max(i for key "gen_ai.prompt.{i}..." ) # 先扫出最大消息下标
messages = []
for i in range(0, max_i + 1):
tool_calls = parse_tool_calls(attrs, f"gen_ai.prompt.{i}") # 抠该消息的工具调用
content = attrs.pop(f"gen_ai.prompt.{i}.content") or attrs.pop(f"...reasoning") or ""
role = attrs.pop(f"gen_ai.prompt.{i}.role") \
or ("assistant" if tool_calls else "user") # 有工具调用默认 assistant
# content 可能本身是一段 JSON(多模态片段数组),尝试解析,失败就当纯文本
messages.append(ChatMessage(role, content_or_parts, tool_calls))

重点看两处真实实现的巧思:

  • 确定消息条数靠正则扫最大下标,而不是假设从 0 连续(spans.rs:1293-1301,用 GEN_AI_PROMPT_ATTRIBUTE_REGEX);
  • content 可能是多模态片段的 JSON 串:先试着反序列化成 Vec<InstrumentationChatMessageContentPart>,成功就是多模态消息,失败才退化为纯文本(spans.rs:1336-1368)。这样一个函数同时吃"纯文本消息"和"图文混合消息"。

output 侧对称:output_from_genai_attributes(spans.rs:1474)扫 gen_ai.completion.{i}.*,每条经 output_message_from_genai_attributes(:1502)组装,content 与 tool_calls 拼进同一条 assistant 消息。

5.5 OTel GenAI 原生格式:保留而非重塑

新的 OTel GenAI 约定用 gen_ai.input.messages(一个 {role, parts:[...]} 的 JSON 数组)。Laminar 对它的处理刻意保守:只解析字符串外壳、不重塑成内部 ChatMessage,原样端到端传给前端渲染(spans.rs:809-841)。

一个语义细节:gen_ai.system_instructions(系统提示)被前插成一条合成的 {role:"system", parts:[...]} 消息,并进同一个消息数组(prepend_system_instructions,spans.rs:1433)。裸字符串数组会包成 text part,保持形状一致。

5.6 工具调用抽取(parse_tool_calls)

工具调用是 LLM span 里最容易各说各话的部分。parse_tool_calls(spans.rs:1555)统一处理两种索引法:

  • OpenAI 式: {prefix}.tool_calls.{i}.{name,id,arguments},i 递增循环;
  • LiteLLM 式: {prefix}.function_call.{name,id,arguments}(单个,无 i)——识别到后处理一次就 break,因为 LiteLLM 把并行工具调用拆成多条 gen_ai.completion.N,再循环会死循环(spans.rs:1595-1600 的注释)。

arguments 若是 JSON 字符串,尝试解析成有序 map(IndexMap,保留键顺序);解析失败保留原字符串(:1577-1587)。

AI SDK 那条路的工具调用形状不同(toolName/toolCallId/args),由 parse_ai_sdk_tool_calls(spans.rs:1647)单独处理,兼容 v4 的 args 和 v5 的 input 别名。

5.7 token 与成本(相 4,get_llm_usage_for_span)

消息抽完,get_llm_usage_for_span(utils.rs:34)算用量和钱。

token 拆分是个要点。input_tokens()(spans.rs:308)返回三段:

// 真实源码 spans.rs:329
let regular_input_tokens =
(total_input_tokens - (cache_write_tokens + cache_read_tokens)).max(0);

即"常规输入 token = 总输入 − 缓存写 − 缓存读",clamp 到非负。总输入优先读 gen_ai.usage.input_tokens,读不到退回遗留的 prompt_tokens 并顺手写回新键(:308-320)。cache 读写 token 之所以能简单读到,正是因为 §5.1 的归一化已经把各家别名对齐了(:322-325 的注释)。

成本分两种情况(utils.rs:60-127):

  1. 埋点已带成本(gen_ai.usage.cost 等 > 0)→ 直接采用,不查价(:60-76);
  2. 只有 token→ 用 (模型名, provider) 查项目自定义或通用价目表(get_model_costs_for_project),再 calculate_span_cost 按细分 token 算钱(:82-101)。细分包括音频 token、推理 token、5m/1h 缓存写、service tier、batch 折扣等,由 build_span_cost_input(utils.rs:130)从属性里凑齐。

模型名取值优先 response.modelrequest.model(utils.rs:51);provider 名由 provider_name()(spans.rs:391)处理,还含一段历史兼容——老版 OpenLLMetry 会把 provider 报成 "Langchain",此时改从 ls_provider 关联属性或 span 名里抠真实 provider(:400-423)。

算完 set_usage(spans.rs:525)把结果写回属性,prepare_span_for_recording(utils.rs:202)做最后收尾:据事件设 error 状态、按 ids_path 定父 span、顶层 span 的 parent 清空、补 prompt hash。

5.8 span 改名

pydantic_ai / AI SDK 会用 execute_tool get_weather 这种把操作前缀塞进 span 名。抽取时按 gen_ai.tool.name / gen_ai.agent.name(spans.rs:866-898)或 AI SDK 的 operation.name(:961-975)改成裸的工具/agent 名,并用 rename_last_span_in_path(:1674)同步改 lmnr.span.path 末段,保持路径一致。


6. Provider 特化:LangChain 消息重排(相 3)

相 2 之后,convert_span_to_provider_format(provider/mod.rs:8)对特定 provider 再改写一遍。它只碰 LLM span,且对 AI SDK span 直接跳过(那套格式前端能直接渲染,mod.rs:12-14)。

目前唯一实现的是 LangChain(provider/langchain.rs)。触发条件是有 lmnr.association.properties.ls_provider 属性(is_langchain_span,langchain.rs:154)。它把内部 ChatMessage 转成 LangChain 自己的消息形状——重点是把工具调用从 content 里挪到独立的 tool_calls 字段(仿 OpenAI 格式,langchain.rs:301)。

一个稳健性原则:input 和 output 必须都转换成功,才更新 span(langchain.rs:190-195)。任一失败就整体保持原样,绝不产出半转半不转的消息。内容片段级别则宽容——单个片段转不了就 log + skip,不拖垮整条消息(convert_to_langchain_content,:280-293)。


7. 巧妙之处(可借鉴的技术)

  • 归一化前置,读取点简单。 把"5 种 cache-token 键名"的知识集中在入口 normalize_aisdk_attributes 一处(spans.rs:188),下游 input_tokens() 只读一个规范键。改一处胜过改十处。
  • "宽进严出"分层。 JSON 解码对格式怪癖极度宽容(base64 四种变体、字符串/数字混用),但对会导致下游 panic 的 id 长度严格拒绝(opentelemetry_json.rs:358-367)。宽容在无害处,严格在危险处。
  • 一个函数吃多形状。 input_chat_messages_from_genai_attributes 里,content 先试 JSON 多模态、失败退纯文本(spans.rs:1336),同一段代码同时处理"纯文本"和"图文混合",不用外部分支。
  • 手动埋点最高优先级。 用户显式 lmnr.span.input 覆盖一切自动猜测,靠"放在流水线最末尾"实现(spans.rs:1036),而非到处加 if。
  • 全有或全无的转换。 LangChain 转换要求 input+output 同时成功才落地(langchain.rs:190),避免产出前后不一致的消息数组。
  • 解析掉的属性从存储剔除。 should_keep_attribute(spans.rs:1214)把 gen_ai.prompt.* / ai.prompt.messages / lmnr.span.input 等"已被解析进 input/output 的键"过滤掉,既省存储也避免同一内容双重计费(见 03 的字节计量)。

8. 边界与局限(诚实)

  • 抽取是尽力而为,不保证完整。 认不出的格式,消息就落不进 input/output,只留原始属性;LangChain 片段转不了会被静默 skip(langchain.rs:280-293)。
  • 归一化只覆盖已知埋点库。 AISDK_OPERATION_PREFIXES(spans.rs:48)、cache-token 键名列表都是硬编码的白名单;新出现的 SDK 键名不在表里就不会被对齐,需要改代码。
  • 无模型名则无成本。 span 有 token 但没模型名时,成本算不出来,只打 warn(utils.rs:108-113)。
  • 带一堆历史兼容包袱。 provider_name() 里的 "Langchain" 特判、prompt_tokensinput_tokens 的顺手写回,都是为兼容老版埋点库,代码里明确标了"几个月后可删"(spans.rs:397-400)。
  • 本章不含去重与落库。 parse_and_enrich_attributes 产出的结构化 Span 之后如何按内容哈希去重、如何拆冷热存储,见 0304

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

主题文件关键符号
OTLP/JSON 解码app-server/src/traces/opentelemetry_json.rsdecode_export_trace_service_requestdecode_id_fielddeserialize_span_kind
Span 骨架构造(相 1)app-server/src/traces/spans.rsSpan::from_otel_span
语义抽取主流水(相 2)app-server/src/traces/spans.rsSpan::parse_and_enrich_attributes
属性访问器 / 键名对齐app-server/src/traces/spans.rsSpanAttributesnormalize_aisdk_attributesnormalize_if_absent
span 类型判定app-server/src/traces/spans.rsSpanAttributes::span_typeSpan::is_llm_span
索引式消息抽取app-server/src/traces/spans.rsinput_chat_messages_from_genai_attributesoutput_from_genai_attributes
工具调用抽取app-server/src/traces/spans.rsparse_tool_callsparse_ai_sdk_tool_callsconvert_ai_sdk_tool_calls
OTel GenAI 原生消息app-server/src/traces/spans.rsparse_genai_messages_attributeprepend_system_instructions
token/成本计算(相 4)app-server/src/traces/utils.rsget_llm_usage_for_spanbuild_span_cost_inputprepare_span_for_recording
token 拆分app-server/src/traces/spans.rsSpanAttributes::input_tokensInputTokens
属性存储过滤app-server/src/traces/spans.rsshould_keep_attribute
语义约定键常量app-server/src/traces/span_attributes.rsGEN_AI_*AISDK_*ASSOCIATION_PROPERTIES_PREFIX
provider 特化入口app-server/src/traces/provider/mod.rsconvert_span_to_provider_formatis_ai_sdk_llm_span
LangChain 转换app-server/src/traces/provider/langchain.rsconvert_span_to_langchainis_langchain_spanmessage_to_langchain_format
内部消息类型app-server/src/language_model/chat_message.rsChatMessageChatMessageContentPartfrom_instrumentation_content_part
Span/SpanType 定义app-server/src/db/spans.rsSpanSpanType
元数据补丁虚拟 spanapp-server/src/traces/metadata.rspublish_trace_metadata_patch