状态:草案(2026-10-07,2026-10-09 补充了 Collector 开销的实测数据)。 本文中的所有数值均为作者亲自测量。运行环境为:单张 8GB 级 GPU、vLLM 0.31.0、Qwen3-1.7B、OpenTelemetry Collector contrib 0.162.0、单台服务器。由于 GenAI 语义约定仍处于 开发(Development)阶段 ,因此其名称仍在变动(就在写这篇文章的当天,有一个指标名称就变了),且 SGLang 与 Dynamo 的追踪、追踪存储(Tempo、Jaeger 等)以及指标存储均为 未实测 。 编写规则:以实测数据为基础,未能测出的内容标注为「未实测」。
目录
- 1. 架构
- 2. vLLM 发出的信号
- 5. 3. 开启开销
- 6. 4. 与标准的差距,以及用 Collector 弥补
- 7. 5. 如何查找慢请求
- 10. 6. 遇到的问题
- 11. 7. OTCA 范围与延伸阅读
- 12. 8. 局限与未实测
- 参考资料
当 LLM 服务器变慢时,我们通常想知道三件事:请求在队列中等待了多久、计算花费了多少时间、这个请求是从哪个服务的哪次调用开始发起的。OpenTelemetry (OTel) 是一种将这三者以相同方式记录下来的标准,而考试(OTCA)考察的 Collector、采样、语义约定和上下文传播都在这里得到了应用。本文将其真实接入到单个 vLLM 实例中,并解答了五个问题:
- vLLM 会为单个请求留下哪些 Span 和属性,指标的名称是什么。
- 从客户端发起的追踪是否能够延续到 vLLM 内部。
- 开启的开销是多少。
- 它与标准(GenAI 语义约定)有多少差距,能否通过 Collector 弥补。
- 仅保留慢请求的采样是如何运作的,它与概率采样有什么不同。
简短回答。 每个请求都在 llm_request 后面留下了一个 Span,仅凭该属性就可以将首字延迟拆解为等待时间和计算时间。开启追踪的开销在误差范围内。属性名称虽然与标准不同(如 traceparent 对比prompt_tokens ),但可以通过 Collector 的转换处理器进行修改。仅保留慢请求的尾部采样成功留下了全部 19 个慢请求,而 10% 的概率采样在 22 个慢请求中只保留了 1 个。 input_tokens1. 架构
vLLM 0.31.0
클라이언트(계측, traceparent 전송) ──HTTP──▶ vLLM(--otlp-traces-endpoint) ──OTLP/gRPC──▶ OTel Collector ─▶ 파일(JSON)
└─ /metrics(Prometheus) ◀──스크레이프── Collector(prometheus 수신기)
- 。其镜像内部已经内置了 OpenTelemetry SDK (1.40.0) 和 OTLP 导出功能。
vllm serve Qwen/Qwen3-1.7B --otlp-traces-endpoint http://127.0.0.1:4317Collector 使用的是 contrib 发行版 0.162.0 的二进制文件,在同一个 Pod 内运行。接收器为 OTLP (gRPC) 和 prometheus,导出器为文件导出器(每行 JSON 对应一个批次)。 - 未接入存储(Tempo、Jaeger、Prometheus 等)。本文仅探讨「会输出什么」,而「存储在哪里以及如何查看」属于
- 未实测 范畴。 脚本和原始数据位于仓库的
- 中。
docs/experiments/2026-10-07-otel-llm-serving/2. vLLM 发出的信号
2.1 追踪:每个请求对应一个
实体 llm_request 客户端创建了父 Span,并携带 W3C
标头发送了三个请求。到达 Collector 的三个 vLLM Span 均为 traceparent (类型为 SERVER),其 llm_request父 Span ID 与客户端 Span 的 ID 相同 ,Trace ID 也相同。也就是说,从客户端发起的追踪一直延续到了 vLLM 内部。 单个 Span 具有十二个属性,无论如何更改配置,这十二个属性都保持不变。
属性
| 值示例 | 含义 | 0.0195(秒) |
|---|---|---|
gen_ai.latency.time_to_first_token |
从请求进入到生成第一个 Token 为止 | 0.00002(秒) |
gen_ai.latency.time_in_queue |
在队列中等待的时间 | 预填充(输入处理) |
gen_ai.latency.time_in_model_prefill |
0.0176 | 解码(Token 生成) |
gen_ai.latency.time_in_model_decode |
0.4504 | 模型工作的总体情况 |
gen_ai.latency.time_in_model_inference |
0.4680 | 整个请求 |
gen_ai.latency.e2e |
0.4697 | 输入和输出 Token 数量 |
gen_ai.usage.prompt_tokens, gen_ai.usage.completion_tokens |
10, 32 | 请求信息 |
gen_ai.request.id, max_tokens, n, top_p |
cmpl-…, 32, 1, 1 |
缺失的内容同样重要。模型名称( |
)和gen_ai.request.model在此 Span 中不存在,且资源的 gen_ai.operation.name 为 service.name 。如果不指定服务名称,来自多个服务器的 Span 就会混在同一个名称下。可以通过环境变量 unknown_service 来设置,或者使用 Collector 的 OTEL_SERVICE_NAME 处理器来填充 resource (我们在第 4 节中使用了后者)。 service.name 服务器启动的过程也会作为追踪记录下来。
除了 llm_request 之外,启动时的数十个 Span 也作为一个追踪输出。对于已经存在模型文件的四台服务器的平均数据,其 Overall Loading 为 18.7 秒, EngineCoreProc init 为 14.1 秒, Prepare model 为 6.7 秒, Executor init 为 6.1 秒, Load model 为 3.4 秒, Warmup (GPU) 为 2.7 秒, Load weights 为 1.8 秒(由于 Span 之间存在父子包含关系,因此不能直接相加)。首次启动的服务器下载权重花费了 298 秒,其 Overall Loading 为 341 秒。我们无需翻看日志,通过 Span 的耗时就能了解「服务器为什么启动得这么慢」。
2.2 指标:不是 OTLP,而是 Prometheus /metrics
在此版本中,指标仅能通过 /metrics(Prometheus 格式)进行确认。通过 Collector 的 prometheus 接收器每 5 秒抓取一次,共输出了 93 个名称,其中有用的如下(全部带有 vllm: 前缀)。
| 指标 | 类型 | 解读方法 |
|---|---|---|
time_to_first_token_seconds |
直方图 | 直到首个 Token |
inter_token_latency_seconds |
直方图 | Token 间间隔 |
e2e_request_latency_seconds |
直方图 | 整个请求 |
request_queue_time_seconds, request_prefill_time_seconds, request_decode_time_seconds |
直方图 | 分阶段耗时 |
num_requests_running, num_requests_waiting |
仪表盘 | 当前排队与等待情况 |
kv_cache_usage_perc |
仪表盘 | KV 缓存利用率 |
prefix_cache_queries_total, prefix_cache_hits_total |
总计 | 前缀缓存命中 |
prompt_tokens_total, generation_tokens_total |
总计 | Token 吞吐量 |
num_preemptions_total |
总计 | 抢占 (KV 不足) |
我们检查了这三个方面。
_created系列指标正在泄漏。 Prometheus 格式的_created行在接收端被转换为仪表盘,导致将近十分之四(36 个)的名称变得毫无用处。如果在抓取配置中通过metric_relabel_configs丢弃.*_created,则减少到了 57 个。- 直方图是累积的(cumulative),且单位为空。 接收端创建的 TTFT 直方图的
aggregationTemporality是累积的(2),且unit为空(标准单位为秒s),标签为engine和model_name。在 120 个请求的负载之后,总计为 51.76 秒,因此平均值为 0.43 秒,但桶分布(0.25~0.5 秒 49 个,0.5~0.75 秒 19 个,1~2.5 秒 7 个)显示其比平均值更多。 - 指标名称中的冒号(
vllm:...)得以保留。
3. 开启的开销
在三种配置下,通过重启服务器两次对相同的负载(输入 512,输出 128,并发 1、8、32)进行了测量。由于每次重启服务器时 KV 缓存的位置可能会有所不同(第 14 篇),我们检查了服务器日志,所有六台服务器均为 26,560 个 Token。
| 配置 | 并发 1 吞吐量 / TTFT | 并发 8 | 并发 32 |
|---|---|---|---|
| 关闭 | 66 tok/s / 73ms, 73ms | 384, 384 tok/s / 349, 353ms | 833, 831 tok/s / 670, 669ms |
--otlp-traces-endpoint |
66, 66 / 73, 73ms | 385, 384 / 350, 348ms | 832, 832 / 668, 668ms |
上文 + --collect-detailed-traces all |
66, 66 / 74, 73ms | 385, 384 / 349, 351ms | 832, 834 / 667, 667ms |
- 即使开启追踪,吞吐量和首 Token 时间也在 1% 的误差范围内保持一致。 每个请求创建一个 Span 的开销相对于该模型的单个请求(每个 Token 约 15ms)而言较小。在更小的模型或更快的 Token 速度下,这一比例可能会增大,但我们未进行测量(未测量).
--collect-detailed-traces all在此版本中既没有增加 Span,也没有增加属性。 四台服务器(两台开启,两台详细)收集的 500 个请求 Span 均具有相同的 12 个属性。尽管选项说明中提到“可能会影响性能”,但在本次测试中没有观察到差异。- 存储体积方面,500 个 Span 的 JSON 大小为 612KB(每个 Span 约 1.2KB)。这是文件导出的 JSON 大小,并非 OTLP 传输大小或存储大小。
4. 与标准的差距及通过 Collector 进行弥补
OpenTelemetry 的 GenAI 语义约定已移至独立仓库(semantic-conventions-genai),其状态为 开发中 。将确认时(2026-10-06 的最新提交)的内容与 vLLM 的输出进行对比,结果如下。
| 项目 | GenAI 约定 | vLLM 0.31.0 |
|---|---|---|
| 推理 Span | 名称 gen_ai.client.inference 类型,Span 名称 {operation} {model},必需属性 gen_ai.operation.name, gen_ai.provider.name |
llm_request,服务端 Span,无上述必需属性 |
| Token 数量 | gen_ai.usage.input_tokens, gen_ai.usage.output_tokens |
gen_ai.usage.prompt_tokens, gen_ai.usage.completion_tokens(旧名称) |
| 模型名称 | gen_ai.request.model |
Span 中无,指标中有 model_name 标签 |
| 首 Token 时间 | 服务端指标 gen_ai.server.time_to_first_token(直方图,秒) |
Span 属性 gen_ai.latency.time_to_first_token(自定义名称),指标 vllm:time_to_first_token_seconds |
| Token 间隔,请求耗时 | gen_ai.server.time_per_output_token, gen_ai.server.request.duration |
vllm:inter_token_latency_seconds, vllm:e2e_request_latency_seconds |
由于标准仍处于开发阶段,与其说是哪一方“错了”,不如说是 vLLM 在标准确定之前使用了旧名称。对于使用者而言,通过 Collector 进行转换则无需修改服务器。我们通过实际运行以下配置进行了验证。
processors:
resource/service:
attributes:
- {key: service.name, value: vllm, action: upsert}
transform/genai:
trace_statements:
- context: span
statements:
- set(attributes["gen_ai.usage.input_tokens"], attributes["gen_ai.usage.prompt_tokens"]) where attributes["gen_ai.usage.prompt_tokens"] != nil
- set(attributes["gen_ai.usage.output_tokens"], attributes["gen_ai.usage.completion_tokens"]) where attributes["gen_ai.usage.completion_tokens"] != nil
- delete_key(attributes, "gen_ai.usage.prompt_tokens")
- delete_key(attributes, "gen_ai.usage.completion_tokens")
metrics_transform/genai:
transforms:
- {include: "vllm:time_to_first_token_seconds", action: update, new_name: gen_ai.server.time_to_first_token}
- {include: "vllm:inter_token_latency_seconds", action: update, new_name: gen_ai.server.time_per_output_token}
- {include: "vllm:e2e_request_latency_seconds", action: update, new_name: gen_ai.server.request.duration}
结果是,Span 的服务名称变成了 vllm ,Token 数量属性更改为了 input_tokens, output_tokens (旧名称已消失),并且三个指标名称更改为了 gen_ai.server.* 。不过,仅凭名称无法得知值的 含义 是否与标准一致。例如,标准中的首 Token 时间是指“成功响应的第一个 Token 为止”,我们并未确认 vLLM 的值是否为同一区间(未测量)。并且正如前面所看到的,单位为空,需要单独补充。
5. 如何查找慢请求
5.1 仅通过 Span 属性区分首 Token 时间
我们使用从四台服务器(两台开启追踪,两台开启详细)收集的 500 个请求 Span,对首 Token 耗时超过 1 秒的 69 个请求与少于 0.2 秒的 38 个请求进行了对比。
| 请求 | 首 Token 平均值 | 等待 | 预填充 (Prefill) |
|---|---|---|---|
| 耗时超过 1 秒的 69 个 | 1,494ms | 886ms | 491ms |
| 少于 0.2 秒的 38 个 | 74ms | 0ms | 65ms |
在慢请求的首 Token 时间中,等待所占比例的中位数为 59%。慢请求变慢不是因为计算慢,而是因为排队导致的,这得出的结论不是“更换更快的 GPU”,而是“提高并发处理上限、增加副本数或限制请求”。首 Token 时间并不完全等于等待时间和预填充时间的总和,差值的中位数为 78ms,最大为 221ms(推测是由 Token 化或首次解码阶段等引起的,但未作细分)。请求的总耗时与首 Token 时间和解码时间的总和在 1.1ms 的误差范围内一致。
5.2 尾部采样与概率采样
发送了 120 个请求,带有混合负载(输入长度为 64、512、1,500 个词元,并发数为 16)。客户端测得的请求耗时中位数为 2.08 秒,p90 为 2.73 秒,最大为 3.02 秒;从服务器端的 Span 耗时来看,有 19 个请求的耗时超过了 2.5 秒。
| 策略 | 保留的 Trace | 慢速请求(≥ 2.5秒) |
|---|---|---|
| 全部保留(仅转换) | 120 | 19 |
尾部采样(Tail Sampling), latency 策略 ≥ 2,500ms |
19(15.8%) | 19(全部) |
| 概率采样 10%(300 个请求负载) | 39(13%) | 22 个中的 1 个 |
- 尾部采样会在 Trace 结束后根据延迟进行判定,因此将慢速请求全部保留了。 保留的 Trace 的最小延迟为 2,551ms,且整个 Trace 中仅保留了 16%。
- 概率采样在抽取时并不知道是否会变慢,因此慢速请求仅按采样比例保留。 抽取了 10%,但实际保留了 13%(哈希偏差),其中属于慢速的只有 1 个。虽然平均延迟看起来差不多,但最希望看到的尾部数据却消失了。
- 尾部采样的代价在于必须由同一个收集器(Collector)收集整个 Trace,并在
decision_wait期间将其保存在内存中。由于本次测试的 Trace 只有 120–300 个,因此成本并不明显。我们在 Mac 笔记本(Apple M1 Max)上使用合成负载(每个 Trace 有 2 个 Span,12 个属性)对相同的收集器(0.162.0)和相同的策略进行了单独测量。为了在做决定前不至于丢失数据,如果将num_traces设为「每秒 Trace 数 × decision_wait × 1.2」,则内存中每个 Trace 会使 Go 堆内存增加约 4KB(2.6–4.7KB)。在每秒 1 万个 Span 且 decision_wait 分别为 5 秒、10 秒、30 秒时,堆内存分别为 161MB、293MB、850MB(RSS 分别为 385MB、546MB、1.2GB,30 秒是 CPU 竞争较少时的一次测量值);在每秒 4.3 万–4.5 万个 Span 且 decision_wait 分别为 10 秒和 30 秒时,堆内存分别为 1.2GB 和 3.3GB(RSS 分别为 1.7GB、4.0GB)。在相同速率下,没有尾部采样的相同管道的 RSS 为 268–292MB。内存归根结底与「Span 速率 × decision_wait」成正比。 num_traces如果将 设得较小(10,000)却承受了巨大负载,就会悄悄丢弃 Trace。如果映射(Map)被填满,就会从最早的 Trace 开始删除,但在做出决策之前被删除的 Trace 不会被保留。在每秒 1,000 个 Span 时没有丢失任何数据,但在每秒 1 万个 Span 时,有 88.5–88.7% 的新 Trace 在决策前消失;在每秒 3.9 万–4.7 万个 Span 时,有 97.3–97.6% 的新 Trace 消失,10% 的概率策略实际保留下来的仅为 1.2% 和 0.3%。收集器日志中没有任何警告,只在otelcol_processor_tail_sampling_sampling_trace_dropped_too_early指标中有所体现。num_traces最好设置得至少大于「每秒 Trace 数 × decision_wait」,并针对此指标设置警报会更安全。- 在跨越多个服务的 Trace 中,绝不能让不同服务流向不同的收集器,必须通过 Trace ID 将它们汇聚到同一个地方(这是官方文档的说明,本次测试中未进行验证)。
6. 遇到的问题
tail_sampling的numeric_attribute策略在此属性下没有保留任何内容。 第一次尝试时,我们通过首个 Token 时间属性(浮点数,单位为秒)的最小值进行过滤,结果保留的 Trace 数量为 0,且没有任何警告或错误。未能确定原因(文档中的示例是整数值,而min_value似乎也是整数)。后来将其更改为查看 Trace 长度的latency策略后才正常运作。当策略未保留任何内容时却保持沉默,这一点非常危险。更改采样时,务必清点并检查保留的数量。metricstransform名称已不再推荐使用。 收集器启动时警告称「别名已被废弃,请使用metrics_transform」。- 如果对收集器打开的文件进行截断重写(Truncate),文件头部会被 NUL 字节填满。 用
: > file清空导出的文件后,收集器继续在原位置追加写入,导致前半部分被\0填满,JSON 读取在第一行失败。要清空文件,停止收集器或使用新路径会更安全。 - Prometheus 接收器在服务器启动之前,每 5 秒发出一次抓取(Scrape)失败警告。 该警告在服务器准备就绪后消失,对数据没有影响。
- 本次测试中也出现了 vLLM 服务器的 KV 缓存空间在首次启动的服务器上最小的现象(第 14 篇)。 首次启动的服务器(22,048 个词元)与之后的服务器(26,560 个词元)有所不同,开销对比仅使用了之后的服务器。
7. OTCA 范围与延伸阅读
总结本实验直接展示了测试范围中的哪些项目。
| 测试范围 | 本文中确认的内容 |
|---|---|
| 上下文传播 | traceparent 通过 |
| 将客户端 Span 与 vLLM Span 连接为父子关系 | service.name 资源与语义约定 unknown_service如果没有 |
| 收集器管道 | 接收器(otlp、prometheus)、处理器(resource、transform、metrics_transform、tail_sampling、batch)、导出器(file)以及处理器的顺序 |
| 采样 | 概率采样和尾部采样在保留慢速请求方面的程度差异 |
| 指标 | 累积直方图、缺失单位、聚合与求和(Gauge and Sum) |
LabHub 的 OTCA 课程增加了一个模块(otca-llm-serving,实作第 7 阶段),用于使用实际的 Span 和直方图亲自计算这些内容。在部署完成的环境中可见。
8. 局限性与未测量项
- 仅使用单个服务器、单个模型(1.7B)和单张 GPU。未测量 Trace 如何在多个服务器之间延续(网关、路由器、分离式服务的工作节点之间)(未测量).
- 未测量 SGLang 的追踪以及 Dynamo 的 OTel 导出功能(未测量)。不将 vLLM 的数值泛化到其他引擎。
- 未接入 Trace 和指标存储库。可视化、告警和存储成本属于 未测量项 。
- 未涉及将提示词(Prompt)和响应内容保留为 Span 或事件的配置,以及随之而来的隐私问题。
- 收集器本身的成本是在 Mac 笔记本(Apple M1 Max)上使用相同的收集器(0.162.0)和合成负载单独测量的。CPU 几乎与 Span 速率成正比:每秒 1 万个 Span 时消耗 0.13–0.15 个核心(仅接收并写入文件),加上转换处理器后为 0.14–0.16 个核心;在每秒 4.5 万–4.7 万个 Span 时消耗 0.57–0.63 个核心,RSS 约为 290MB。几乎处于闲置状态时,RSS 也在 186–199MB 之间。在所有条件下,拒绝接收和导出失败均为 0。在延迟方面,收集器自身的处理中位数为 3.5ms,其余由配置决定。如果采用本文中的
batch1 秒以及文件导出的 1 秒刷盘(Flush),Span 出现在文件中的时间为 0.94–1.99 秒(中位数为 1.46 秒);如果加上尾部采样,则为 6.0–7.0 秒。由于这些数值是在其他实验同时运行、CPU 几乎饱和的 Mac 笔记本上测得的,因此可能与服务器 CPU 的数值有所不同。与其关注绝对值,不如相信以下方向:「CPU 与速率成正比」、「延迟由批处理和 decision_wait 决定」。通过 OTLP 发送到远程存储时的网络和重试成本、收集器开始拒绝接收的极限速率均未进行测量(未测量). - )。开销是基于服务器启动两次的数值计算的,未求标准差。虽然“在 1% 以内”这一结论的方向是确定的,但小数点后不宜轻信。
- GenAI 约定尚处于开发阶段,因此本文中的表格截至 2026-10-06,可能会随时更改。
参考资料
- OpenTelemetry GenAI 语义约定(仓库):https://github.com/open-telemetry/semantic-conventions-genai(推理 Span
docs/gen-ai/client-inference.md,服务器指标docs/gen-ai/gen-ai-metrics.md) - OpenTelemetry Collector contrib 0.162.0 与尾部采样处理器文档:https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/v0.162.0/processor/tailsamplingprocessor
- vLLM 的追踪:
vllm serve --help=all的--otlp-traces-endpoint,--collect-detailed-traces,以及安装包中的vllm/tracing/代码 - 本系列的第 14 篇(vLLM 负载与 KV 缓存)、第 24 篇(Dynamo)、第 20 篇(实时语音延迟的分解)