跳到主要内容

一次 LLM 调用如何变成 span/event/metric

30 秒导读: 上一章 讲了 openlit.init() 怎么用 wrapt 把 client.chat.completions.create 换成带遥测的壳。这一章钻进那层壳里:一次真实的 chat 调用返回后, OpenLIT 怎么把它拆成"输入/输出消息、token 数、美元成本、首字延迟",再一次性写成三种遥测信号—— 一条 trace span、一个结构化 event、一组 metric。这是整个 SDK 工程含量最高、也最值钱的一段: 别的可观测工具只告诉你"调了一次模型",OpenLIT 告诉你"这次调用花了 0.0023 美元、首字等了 380ms"。


1. 这章解决什么(先看全景)

OpenLIT 的卖点是把一次 LLM 调用变成可计量的遥测。难点不在"知道调用发生了"——wrapt 打桩已经解决; 难点在从 provider 返回的原始对象里,精确抽出人和机器都能用的结构化事实,并且这些事实要同时以三种 OTel 信号落地,还要对齐上游 OpenTelemetry 的 GenAI 语义约定(下称 gen-ai semconv)。

三种信号各自的角色,不要混:

信号是什么装什么谁消费
span(trace)这次调用的一段耗时记录请求参数、响应元数据、token、成本、(可选)消息全文看单次调用的瀑布图
event(log)一条结构化日志事件与 span 同源的元数据 + 结构化消息消息级检索、审计、回放
metric可聚合的数字token 计数、时延、成本(按维度打标签)仪表盘、告警、按模型/环境聚合

一次 chat 调用的信号生产线(从左到右,数据只在这一条链上流动):

被打桩的 create() 返回


┌───────────────────────┐ 非流式: 一次性拿到完整 response
│ wrapper (openai.py) │ 流式: 每个 chunk 累积, 收尾时结算
└───────────────────────┘
│ 把响应塞进一个 scope 对象(鸭子类型的属性袋)

┌────────────────────────────────────────────────┐
│ common_chat_logic() ← 本章主角(utils.py:1342) │
│ ① 结算 TBT/TTFT ② 估/取 token ③ 算成本 │
│ ④ 抽取&格式化消息 ⑤ 写 span 属性 │
│ ⑥ 发 event ⑦ 记 metric │
└────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
span event metric
(trace) (log) (聚合数字)

读法: 上半段是"谁把数据喂进来"(wrapper),下半段是"怎么把一份数据同时变成三种信号" (common_chat_logic)。本章几乎全部篇幅在讲下半段。

本章聚焦 chat 路径。embedding / image / audio / responses API 是同构的——同样的 scopecommon_*_logic → span+event+metric 套路,只是抽取字段和成本公式不同,末尾一句话带过。


2. 入口:wrapper 怎么把响应交给遥测层

2.1 两条路径,一个汇合点

被打桩的 chat 调用有两种形态,wrapper 分开处理,但最终都汇到同一个函数 common_chat_logic:

形态入口函数特点
非流式process_chat_response(utils.py:1706)一次拿到完整 response,当场结算
流式process_streaming_chat_response(utils.py:1677)先逐 chunk 累积,StopIteration 时结算

非流式的调用点在 wrapper 里,response 到手就交给它(openai.py:212 process_chat_response)。 流式则包了一层 TracedSyncStream:每 __next__ 一个 chunk 就调 process_chat_chunk 累积状态, 迭代耗尽(StopIteration)时才在 with self._span: 里结算(openai.py:98-119 TracedSyncStream.__next__)。

2.2 scope:一个鸭子类型的"属性袋"

两条路径都先造一个空对象当临时状态容器,OpenLIT 里到处叫它 scope。非流式版是当场 new 一个空壳, 把 response 里的字段一项项挂上去:

# 真实源码节选 · utils.py:1727 process_chat_response
scope = type("GenericScope", (), {})() # 空对象,纯当属性袋用
scope._start_time = start_time
scope._llmresponse = " ".join( # 把各 choice 的正文拼成一条字符串
(choice.get("message", {}).get("content") or "")
for choice in response_dict.get("choices", [])
)
scope._input_tokens = response_dict.get("usage", {}).get("prompt_tokens", 0)
scope._output_tokens = response_dict.get("usage", {}).get("completion_tokens", 0)

流式版的 scope 就是 TracedSyncStream 自己:self._llmresponseself._toolsself._timestamps 等在 __init__ 里初始化(openai.py:64-82),然后逐 chunk 往上累加。

为什么这么设计: common_chat_logic 不关心数据来自"一次完整响应"还是"一堆 chunk 拼出来的", 它只认 scope.* 上那些约定好的下划线属性。于是流式和非流式能共用同一段结算逻辑——这是 OpenLIT 每个 provider instrumentation 的通用模式。

先把 response/chunk 归一成 dict 的是 response_as_dict(__helpers.py:241):Pydantic 模型走 model_dump,httpx 原始响应走 .parse(),已经是 dict 就原样返回。后面所有 .get(...) 都建立在 这层归一之上。


3. 流式计时:TTFT 与 TBT

非流式没有"逐字"概念,时延就是"从发出到拿到"一个数。流式才有两个关键体验指标:

指标全称白话语义约定 key
TTFTtime to first token首字延迟:发出到第一个 chunk 多久gen_ai.server.time_to_first_token
TBTtime between tokens字间延迟:相邻 chunk 的平均间隔gen_ai.server.time_per_output_token

计时的原料是每个 chunk 到达的时间戳,在 process_chat_chunk 里逐个记下,并在第一个 chunk 到达时立刻算出 TTFT:

# 真实源码节选 · utils.py:665 process_chat_chunk
end_time = time.time()
scope._timestamps.append(end_time)
if len(scope._timestamps) == 1: # 恰好第一个 chunk
scope._ttft = calculate_ttft(scope._timestamps, scope._start_time)

同一个函数还负责把碎片拼回整体:正文 delta.content 累加到 scope._llmresponse;工具调用参数 按 index 分片续接(utils.py:685-710);token 用量在带 include_usage末尾那个 choices 为空的 chunk里才出现,所以取 usage 的代码特意放在 choices 判断块之外(utils.py:726-729)。

TTFT/TBT 的实际公式很朴素(__helpers.py:465 calculate_ttft、:475 calculate_tbt):

  • TTFT = 第一个时间戳 - start_time
  • TBT = 相邻时间戳差值的算术平均;只有一个时间戳时为 0。

TBT 在结算时才算,因为要等所有 chunk 到齐:

# 真实源码节选 · utils.py:1358 common_chat_logic 开头
scope._end_time = time.time()
if len(scope._timestamps) > 1:
scope._tbt = calculate_tbt(scope._timestamps)

非流式路径把 scope._timestamps 设成空列表、_ttft 直接等于总耗时、_tbt=0(utils.py:1748-1749), 于是同一段结算逻辑对非流式也成立——又是"归一化后共用"的体现。


4. common_chat_logic 主干(七步)

这是本章的心脏(utils.py:1342 common_chat_logic)。它的输入是填好的 scope,输出是三种信号。 按执行顺序拆成七步:

① 结算时延 _tbt = calculate_tbt(...) (utils.py:1358)
② 定 token 有 usage 用真值; 否则 general_tokens 估 (utils.py:1374)
③ 算成本 get_chat_model_cost(...) (utils.py:1385)
④ 写公共属性 common_span_attributes(...) (utils.py:1396)
⑤ 写请求/响应 温度/seed/tool/token/cost → span 属性 (utils.py:1418-1543)
⑥ 抽取&格式化 build_input/output_messages(...) (utils.py:1548-1579)
⑦ 发 event emit_inference_event(...) (utils.py:1596)
记 metric record_completion_metrics(...) (utils.py:1655)

下面三节分别展开②③(成本)⑥(消息抽取)④⑤⑦+metric(三种落地)——这三块是全章精华。


5. 消息抽取与格式化

模型的输入输出要以两种形态落地,对应两类消费者:

  1. 一条给人读的字符串——用于 token 估算的原料。由 format_content 产出。
  2. 一个符合 gen-ai semconv 的结构化数组——用于 span 属性和 event。由 build_*_messages 产出。

5.1 format_content:压平成一条字符串

format_content(utils.py:99)把 messages 数组压成 role: content\n role: content 的纯文本,同时:

  • 多模态 content(list)只保留文本和非 data-URI 的图片 URL——base64 图不塞进遥测(utils.py:138-157)。
  • content=None 的工具调用回合退化成 [N tool call(s)] 摘要(utils.py:120-127)。
  • 同时认 Chat Completions 格式(type: text)和 Responses API 格式(type: input_text)。

它的产物主要喂给 token 估算(见 §6),不是最终写进 span 的结构。

5.2 build_input_messages / build_output_messages:结构化成 OTel schema

真正写进 span/event 的是结构化数组,对齐上游 OTel 的 gen-ai-input-messages / output-messages schema (函数 docstring 直接贴了 spec 链接,utils.py:167-168)。形态是:

[ { "role": "user",
"parts": [ {"type":"text","content":"..."},
{"type":"uri","modality":"image","uri":"https://..."} ] } ]

四个抽取器各管一段,都写成"dict 和 Pydantic 对象都能吃"的防御式代码:

函数位置抽什么要点
build_input_messagesutils.py:165请求侧消息图片跳过 data URI;tool 角色转成 tool_call_response part(utils.py:286-300)
build_output_messagesutils.py:323响应正文 + 工具调用OpenAI 的 name/arguments 嵌在 function 键下,兼容扁平结构的其他 provider(utils.py:348-372)
build_system_instructions_from_messagesutils.py:478system 角色消息单独抽出 → gen_ai.system_instructions
build_tool_definitionsutils.py:422请求的 tools{type,name,description,parameters}

build_output_messages 还做了一件对齐 semconv 的小事:把 OpenAI 的 finish reason 映射成 OTel 词表—— tool_calls/function_call 都归一成 tool_call(utils.py:400-406)。

5.3 finish reason 映射(OpenAI → OTel)

OpenAI finish_reasonOTel finish_reason
stopstop
lengthlength
content_filtercontent_filter
tool_callstool_call
function_calltool_call

5.4 截断:capture_message_content 与 max_content_length

消息全文是否落地由 capture_message_content 开关控制。关键设计:即使不落全文,消息结构照样构建—— 因为下游的 event 还要用它们算 agent 版本哈希、发元数据(utils.py:1566-1579 注释说得很清楚)。真正把全文 写进 span 的只有开关打开时的这一步(utils.py:1584):

# 真实源码节选 · utils.py:1584 common_chat_logic
if capture_message_content:
_set_span_messages_as_array(scope._span, input_msgs, output_msgs)

_set_span_messages_as_array(utils.py:508)在 json.dumps 之前,先对每条消息就地截断:

# 真实源码节选 · utils.py:511
truncate_message_content(input_messages)
truncate_message_content(output_messages)

截断规则在 truncate_content(__helpers.py:177):读全局配置 OpenlitConfig.max_content_length, None/0/-1 都表示不截断;正整数则截到那么多字符并补 ...truncate_message_content (__helpers.py:201)负责走 parts 结构,对每个 text content 和 tool_call_responseresponse 字段逐个应用。


6. 成本计算:token → 美元

这是 OpenLIT 区别于普通 tracing 的核心增量。分三步:定 token → 查价 → 套公式。

6.1 token 从哪来:真值优先,估算兜底

# 真实源码节选 · utils.py:1374 common_chat_logic
if hasattr(scope, "_input_tokens") and scope._input_tokens:
input_tokens = scope._input_tokens # provider 回的真值
output_tokens = scope._output_tokens
else:
input_tokens = general_tokens(prompt) # 没有 usage 时的粗估
output_tokens = general_tokens(scope._llmresponse)

兜底的 general_tokens(__helpers.py:287)是个极粗的近似:math.ceil(len(text) / 2)——按"两个 字符约等于一个 token"拍脑袋。这只在 provider 没返回 usage(如某些流式没开 include_usage)时启用, 真值永远优先。

6.2 fetch_pricing_info:价格表从哪来

成本要有单价。单价来自一张 pricing 表,在 init一次性拉取(init.py:422 fetch_pricing_info),之后作为 pricing_info 一路透传进每次结算,不重复拉。

fetch_pricing_info(__helpers.py:414)的取值优先级:

传了 pricing_json?
├─ 是 URL → requests.get 拉远程 JSON
├─ 是文件路径 → 本地 open 读 JSON
└─ 没传 → 拉官方默认表
raw.githubusercontent.com/openlit/openlit/main/assets/pricing.json
任何失败 → 返回 {} (于是后续成本一律算 0,不炸)

表的结构是按模态分区的嵌套 dict:{"chat": {model: {...}}, "embeddings": {...}, "images": {...}, "audio": {...}}

6.3 get_chat_model_cost:套公式 + 缓存感知

# 真实源码节选 · utils.py:1385 common_chat_logic
cost = get_chat_model_cost(
request_model, pricing_info, input_tokens, output_tokens,
cache_read_tokens=getattr(scope, "_cache_read_input_tokens", 0),
cache_creation_tokens=getattr(scope, "_cache_creation_input_tokens", 0),
prompt_tokens_include_cache=True, # OpenAI 的 prompt_tokens 已含缓存读
)

get_chat_model_cost(__helpers.py:295)的基本公式很简单——按千 token 计价:

cost = (prompt_tokens/1000)*promptPrice + (completion_tokens/1000)*completionPrice

精妙在缓存感知定价(__helpers.py:343-357):某些模型有 cacheReadPrice/cacheCreationPrice, 命中缓存的 token 按更便宜的缓存价单独结算。这里有个双重计费陷阱——不同 provider 对 prompt_tokens 的口径不一样:

provider 口径prompt_tokens 含义处理
Anthropic 原生不含缓存 token,需另加prompt_tokens_include_cache=False(默认)
OpenAI / LangChain 归一已含缓存 tokenprompt_tokens_include_cache=True,把缓存 token 从计费基数里减掉

OpenAI 走后者:先按缓存价给缓存 token 计费,再把这部分从 billable_prompt_tokens 里扣掉,避免同一批 token 被普通价和缓存价算两遍。没有配置缓存价的模型完全不受影响,行为和旧版一致。model 名带 / (如 azure/gpt-4o)时会退一步用斜杠后半段再查一次表(__helpers.py:332-333)。

6.4 其他模态一句话带过

同构:embedding 用 get_embed_model_cost(按 token 单价,__helpers.py:369);image 用 get_image_model_cost(按 [model][quality][size] 三级查表直接取价,__helpers.py:387);audio 用 get_audio_model_cost(按字符数或时长,__helpers.py:399)。都遵循"查 pricing_info 对应分区,查不到 返回 0"的同一套路。


7. 落到三种信号

token 和成本算完,common_chat_logic 把这份数据同时写进 span、event、metric。

7.1 span:公共属性 + 自定义属性 + 消息

先写一批所有 provider 共享的骨架属性(common_span_attributes,__helpers.py:802):operation、 provider、server 地址端口、环境、service name、is_stream、TBT、TTFT、SDK 版本。它的最后一步会调 _apply_custom_span_attributes

再写 chat 特有的请求/响应/成本属性(utils.py:1418-1543):温度、seed、frequency/presence penalty、 stop、tool 的 name/id/args、gen_ai.usage.input_tokensoutput_tokensgen_ai.usage.cost、缓存 token(即使是 0 也写,utils.py:1533-1543)。

_apply_custom_span_attributes(__helpers.py:1014)是用户注入自定义标签的挂钩:先盖全局属性 (init 时设的),再盖 context 级属性(using_attributes 注入的),后者覆盖前者。这让用户能给某段代码 下的所有 span 打统一业务标签(如 tenant_id)。

7.2 event:结构化 inference 事件

无论 capture_message_content 开没开,只要配了 event provider,就发一个 inference event (utils.py:1596)——因为 token 数、finish reason、agent 版本哈希这些元数据也值得进事件流:

# 真实源码节选 · utils.py:1630 common_chat_logic
emit_inference_event(
event_provider=event_provider,
operation_name=SemanticConvention.GEN_AI_OPERATION_TYPE_CHAT,
request_model=request_model,
response_model=scope._response_model,
input_messages=input_msgs if capture_message_content else [], # 关就发空数组
output_messages=output_msgs if capture_message_content else [],
tool_definitions=tool_defs,
**extra, # response_id、finish_reasons、温度、token 计数、缓存 token...
)

emit_inference_event(utils.py:531)干两件事:把一堆 **extra_attrs 映射到 semconv 的正式 key (一大串 if key == ...,utils.py:590-644),然后交给 otel_event 造出一条 LogRecord (事件名 gen_ai.client.inference.operation.details),body 留空——所有数据都进 attributes, 这是 spec 的要求(utils.py:646-651)。

otel_event(__helpers.py:672)在造 LogRecord 前也会合并全局/context 自定义属性,和 span 那边对称。 OpenlitConfig.disable_events 为真时不发(utils.py:653)。

7.3 metric:可聚合的数字

最后记 metric(utils.py:1655 record_completion_metrics)。这段负责把数字打上维度标签、灌进 OTel 的 instrument。标签由 create_metrics_attributes(__helpers.py:488)统一构造:service、环境、 operation、provider、请求/响应模型、server 地址端口——都是低基数维度,能安全聚合。

record_completion_metrics(__helpers.py:845)记的这几组数字,注意 token type 的 semconv 约束:

metrickey备注
token 用量gen_ai.client.token.usage只能打 input/output 两种 type,不能记 reasoning/total
操作时长gen_ai.client.operation.duration出错时带 error.type
首字延迟gen_ai.server.time_to_first_token仅流式
字间延迟gen_ai.server.time_per_output_token有 TBT 才记
成本gen_ai.usage.costOpenLIT 自家扩展,不属 OTel gen-ai semconv

失败路径也记 metric:wrapper 里 provider 抛异常时,用 0 token / 0 成本 + error_type 记一条,让失败 调用也进得了聚合(openai.py:189-208)。


8. 语义约定层:SemanticConvention 与三层对齐

上面所有 SemanticConvention.XXX 都来自一个集中的常量类(semcov/init.py)。它的价值是:让 OpenLIT 尽量用上游 OTel 定义的官方 key,官方没有的才自己造,并且分三层标清来源:

是什么例子
TIER 1官方 OTel gen-ai / server / error 属性gen_ai.operation.nameserver.addresserror.type
TIER 2OpenAI / provider 专有openai.api.type(chat_completions/responses)
TIER 3OpenLIT 自家扩展gen_ai.usage.costgen_ai.usage.input_tokens

TIER 1 的常量不是硬编码字符串,而是从上游库里取,取不到再兜底。这层兜底靠 _get_otel_attr (semcov/init.py:11):

# 真实源码节选 · semcov/__init__.py:180
GEN_AI_REQUEST_MODEL = _get_otel_attr(
OTelGenAIAttributes, "GEN_AI_REQUEST_MODEL", "gen_ai.request.model"
)

_get_otel_attr 尝试从 opentelemetry.semconv._incubating.attributes 读官方常量;若这个可选依赖没装 (HAS_OFFICIAL_OTEL_SEMCONV=False,semcov/init.py:33-53),或某个属性在当前版本里不存在,就退回 第三个参数那个字面量。好处: 上游 semconv 升级、常量改名时,只要装了官方库就自动跟随;没装也能靠兜底 字符串照常工作——既对齐标准又不硬依赖。

至于 §7.2 里那个 gen_ai.client.inference.operation.details 事件名和 gen_ai.input.messages/ gen_ai.output.messages/gen_ai.system_instructions,是 OTel semconv v1.29+ 才有的新约定,直接写成 常量(semcov/init.py:124-132)。


9. 巧妙之处(值得借鉴)

  • 归一化 + 共用结算:流式和非流式先各自把状态填进 scope,再共用 common_chat_logic。避免两套 几乎一样的结算代码各写各的漂移。(utils.py:1342)
  • 消息构建与内容捕获解耦:build_*_messages 永远执行,只有全文落地受 capture_message_content 控制。于是关掉内容捕获后,token/finish reason/版本哈希等元数据仍进 event。(utils.py:1566-1584)
  • 缓存感知定价防双重计费:用一个 prompt_tokens_include_cache 布尔吸收 provider 间 token 口径差异, 只在配了缓存价时才动手,无缓存价的模型行为零变化。(__helpers.py:307-357)
  • semconv 软依赖:_get_otel_attr 让"对齐官方标准"和"不强制安装官方库"两全。(semcov/init.py:11)
  • 成本永不炸:查价链路任何一步失败都返回 0 而非抛异常——遥测绝不能拖垮业务调用。 (__helpers.py:364、fetch_pricing_info 全 try/except)

10. 边界与局限(诚实)

  • 成本准确性依赖那张 pricing 表。表里没有的模型一律算 0(__helpers.py:334-335);新模型上线到表更新 之间有空窗。
  • 无 usage 时 token 是粗估general_tokens 的"字符数/2"和真实 tokenizer 差得远,只是兜底 (__helpers.py:292)。想要准,流式得开 stream_options={"include_usage": True}
  • base64 图片、超长文本不进遥测:data URI 被跳过(utils.py:144),文本按 max_content_length 截断。 这是刻意的隐私/体积取舍,不是 bug。
  • 成本 metric 不是 OTel 标准:gen_ai.usage.cost 是 OpenLIT 私有扩展(semcov/init.py:516), 换成纯 OTel 后端消费时要知道它不在 gen-ai semconv 里。

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

用符号名 grep 定位,比行号抗上游漂移。

主题文件符号
chat 结算主干(七步)sdk/python/src/openlit/instrumentation/openai/utils.pycommon_chat_logic
非流式入口sdk/python/src/openlit/instrumentation/openai/utils.pyprocess_chat_response
流式入口sdk/python/src/openlit/instrumentation/openai/utils.pyprocess_streaming_chat_response
流式逐 chunk 累积 + 计时sdk/python/src/openlit/instrumentation/openai/utils.pyprocess_chat_chunk
消息压平成字符串sdk/python/src/openlit/instrumentation/openai/utils.pyformat_content
结构化输入消息sdk/python/src/openlit/instrumentation/openai/utils.pybuild_input_messages
结构化输出消息 + finish 映射sdk/python/src/openlit/instrumentation/openai/utils.pybuild_output_messages
抽 system 指令 / tool 定义sdk/python/src/openlit/instrumentation/openai/utils.pybuild_system_instructions_from_messages / build_tool_definitions
消息写进 span(含截断)sdk/python/src/openlit/instrumentation/openai/utils.py_set_span_messages_as_array
发 inference eventsdk/python/src/openlit/instrumentation/openai/utils.pyemit_inference_event
chat 成本(缓存感知)sdk/python/src/openlit/__helpers.pyget_chat_model_cost
embed/image/audio 成本sdk/python/src/openlit/__helpers.pyget_embed_model_cost / get_image_model_cost / get_audio_model_cost
拉价格表sdk/python/src/openlit/__helpers.pyfetch_pricing_info
token 粗估兜底sdk/python/src/openlit/__helpers.pygeneral_tokens
TTFT / TBTsdk/python/src/openlit/__helpers.pycalculate_ttft / calculate_tbt
公共 span 属性sdk/python/src/openlit/__helpers.pycommon_span_attributes
自定义 span 属性挂钩sdk/python/src/openlit/__helpers.py_apply_custom_span_attributes
造 LogRecord 事件sdk/python/src/openlit/__helpers.pyotel_event
记 completion metricsdk/python/src/openlit/__helpers.pyrecord_completion_metrics
metric 维度标签sdk/python/src/openlit/__helpers.pycreate_metrics_attributes
内容截断sdk/python/src/openlit/__helpers.pytruncate_content / truncate_message_content
语义约定常量 + 软依赖兜底sdk/python/src/openlit/semcov/init.pySemanticConvention / _get_otel_attr
wrapper(打桩壳 + 流式包装)sdk/python/src/openlit/instrumentation/openai/openai.pychat_completions / TracedSyncStream

相邻章节: 上游装配见 01-instrumentation-core.md;遥测落库见 03-backend-collector-clickhouse.md;全景见 index.md