用于调试 LLM 延迟、成本和 GPU 饱和度的 ES|QL 查询
2026/5/22 17:20:05 网站建设 项目流程

作者:来自 Elastic Jeffrey Rengifo

学习如何使用 ES|QL 针对 OpenTelemetry traces 调查 LLM 延迟、token 成本和 GPU 饱和度,并获取根本原因,而不仅仅是表面症状。

仪表板告诉你哪里出问题;ES|QL 告诉你为什么。

针对 Elasticsearch 中 OpenTelemetry traces 数据的三条查询,识别出了:模型切换后成本回归 2.4 倍、某个 prompt 模板比另一个多生成 23 倍 output tokens,以及 GPU 在 43 个时间窗口中的 42 个窗口里持续高于 90% 负载——这一切都来自同一个集群,其中 EDOT 已经在发送 traces,DCGM 已经在发送 GPU metrics。

本文将展示如何在你自己的 LLM 工作负载中复现这三类调查。

在本文中,你将学习如何使用 ES|QL 查询基于 Elastic Distribution of OpenTelemetry( EDOT )和 OpenTelemetry Collector 收集的数据,回答关于 LLM 工作负载的三个真实调试问题。

前置条件

  • Elasticsearch 9.x+
  • Python 3.9+
  • 本地已安装 Ollama v0.5.12+
  • 本文中的所有查询和配置步骤都可以在配套 notebook 中找到。

AI 工作负载中的可观测性鸿沟

大多数运行基于 LLM 的应用的团队已经完成了第一步:为应用添加埋点以捕获 traces、token 数量和延迟。EDOT、OpenLIT 和 Langtrace 等工具让这件事变得很简单。数据正在持续流入。

下一步自然就是:当出现问题时,如何查询这些数据。

预构建的 dashboards 只能回答预先定义好的问题:“我的 p95 延迟是多少?” 或者 “我今天用了多少 tokens?”这些对于监控很有用,但调试是另一回事。调试意味着你有一个症状(“上周二延迟飙升”),然后你需要不断探索数据直到找到原因。这种探索需要的是查询语言,而不是 dashboard。

这正是 ES|QL 的用武之地。ES|QL 是 Elasticsearch 的基于 pipe 的查询语言,它允许你在单个查询中跨 traces 和 metrics 进行聚合、过滤和 join。应用到 LLM telemetry 上,它可以让你做如下事情:

  • 在一个查询中比较不同模型版本的 p95 延迟
  • 按自定义 prompt 标识符分组,找到消耗 tokens 最多的模板
  • 将 LLM trace 数据与 GPU metrics 进行 join,判断是否是基础设施瓶颈

在其他文章中,我们介绍了如何采集 LLM telemetry(使用 EDOT、OpenLIT 或 Langtrace)。本文则解释当出现问题时如何对这些 telemetry 进行调查。

技术栈:LLM telemetry 如何进入 Elastic

在开始调试之前,我们需要理解有哪些数据可用以及它们存在哪里。架构如下:

该技术栈包含两条数据路径:

LLM traces(应用层):你的 Python 应用通过 OpenAI client 调用 Ollama(或任何兼容 OpenAI 的端点)。EDOT Python 会自动对这些调用进行埋点,生成符合 OpenTelemetry GenAI 语义规范的 spans。当通过 Elastic Managed OTLP Endpoint 发送时,这些 spans 会进入 Elasticsearch 中的 traces-generic.otel-default 数据流。

GPU metrics(基础设施层):在运行 GPU 推理的主机上,NVIDIA 的 DCGM Exporter 会以 Prometheus endpoint 的形式暴露 GPU metrics。OpenTelemetry Collector 会抓取这些 metrics 并发送到 Elasticsearch,它们最终进入 metrics-* 数据流。

EDOT 自动捕获的内容

EDOT Python 包含 elastic-opentelemetry-instrumentation-openai,它会自动对 OpenAI client 库的每一次调用进行埋点。由于 Ollama 在 http://localhost:11434/v1/ 上提供 OpenAI 兼容 API,因此 EDOT 可以在无需任何代码修改的情况下对 Ollama 调用进行埋点。

每一次 LLM 调用都会生成一个 span,并包含以下属性(遵循 OTel GenAI 语义规范):

属性捕获内容示例
gen_ai.operation.name操作类型chat
gen_ai.request.model你请求的模型gemma4:e4b
gen_ai.response.model实际响应的模型gemma4:e4b
gen_ai.usage.input_tokensprompt token 数量142
gen_ai.usage.output_tokenscompletion token 数量89
gen_ai.response.id唯一 completion IDchatcmpl-abc123

完成埋点后,每一次调用都会作为一个 span 出现在 Kibana 中,并附带所有这些属性:

EDOT 还会生成两个 metrics:gen_ai.client.token.usage( token 数量的 histogram )以及 gen_ai.client.operation.duration(请求延迟(秒)的 histogram )。

配置非常简单。将 OpenAI client 指向 Ollama,然后使用 EDOT 的自动埋点运行:

from openai import OpenAI client = OpenAI( base_url="http://localhost:11434/v1/", api_key="ollama", # required by the client but unused by Ollama )

如何向 OTel spans 添加自定义 prompt template ID

OTel GenAI 语义规范涵盖了模型追踪和 token 使用情况,但并不包含 prompt template 标识符。如果你正在运行多个 prompt templates( system prompts、few-shot 变体等),你需要知道究竟是哪一个导致了问题。

当前 OTel 规范中并不存在 gen_ai.prompt.id 这一约定。为了填补这一空白,你可以添加一个自定义 span 属性:

from opentelemetry import trace tracer = trace.get_tracer(__name__) with tracer.start_as_current_span("prompt-execution") as span: span.set_attribute("prompt.template.id", "summarize-v2") response = client.chat.completions.create( model="gemma4:e4b", messages=[{"role": "user", "content": prompt}] )

这个 prompt.template.id 属性会作为 span 的一部分流入 Elasticsearch,你可以像使用任何内置属性一样,在 ES|QL 查询中使用它。

GPU metrics:从 DCGM 到 Elastic

对于在 NVIDIA 硬件上运行自托管模型的团队来说,GPU metrics 是至关重要的上下文信息。NVIDIA 的 DCGM( Data Center GPU Manager )Exporter 会以 Prometheus endpoint 的形式在 9400 端口暴露 GPU 利用率、内存使用率、温度以及功耗等 metrics。

带有 Prometheus receiver 的 OpenTelemetry Collector 会抓取这些 metrics 并将其转发到 Elastic。resource processor 会为每一个 metric 添加 data_stream.dataset = nvidia_gpu 标签,从而将数据路由到 metrics-nvidia_gpu.otel-default 数据流中,以便与 Elastic 的 NVIDIA GPU OpenTelemetry integration 保持一致:

receivers: prometheus: config: scrape_configs: - job_name: nvidia_gpu scrape_interval: 10s static_configs: - targets: ["localhost:9400"] processors: resource/nvidia_gpu: attributes: - key: data_stream.dataset value: nvidia_gpu action: upsert - key: data_stream.namespace value: default action: upsert exporters: otlp: endpoint: "${OTEL_EXPORTER_OTLP_ENDPOINT}" service: pipelines: metrics: receivers: [prometheus] processors: [resource/nvidia_gpu] exporters: [otlp]

Elastic 提供了一流的 NVIDIA GPU OpenTelemetry integration,其中包含 Fleet 级 dashboards、六条告警规则(例如 thermal throttling 等情况),以及一个用于 GPU 热健康状态的 SLO 模板。

对于 LLM 调试而言,关键的 GPU metrics 包括:

Metric它告诉你的内容
DCGM_FI_DEV_GPU_UTILGPU 计算单元的繁忙程度( % )
DCGM_FI_DEV_FB_USEDGPU 内存( VRAM )使用量
DCGM_FI_DEV_GPU_TEMP是否可能存在 thermal throttling 从而影响性能
DCGM_FI_DEV_POWER_USAGE功耗,可用于判断是否存在持续高负载

注意:DCGM 需要 NVIDIA 数据中心 GPU( A100、H100、L40S )。对于消费级 GPU,基于 NVML 的工具(如 nvmlreceiver )可以提供类似的 metrics。而云托管 LLM 提供商( OpenAI、Bedrock、Azure OpenAI )则完全不会暴露 GPU metrics,因为底层硬件已被抽象化。

问题 1:我的新模型版本是否导致了延迟或成本退化?

场景:你一直在生产环境中运行 gemma4:e2b,并刚刚部署了 gemma4:e4b 以获得更好的质量。几天后,延迟告警触发,token 账单也暴涨。问题是:模型切换是否是根本原因?

OpenTelemetry GenAI 规范会自动捕获什么

gen_ai.request.model(你请求的模型)与 gen_ai.response.model(实际响应的模型)之间的区别非常重要。在使用 Ollama 时,这两者通常与指定的 model:tag 一致。但对于使用模型别名的云服务提供商(例如 gpt-4o 会解析为某个固定版本),response model 可能与 request model 不同。

在进行模型版本比较时,gen_ai.response.model 是更可靠的字段,因为它反映了实际运行的模型。

ES|QL 查询

FROM traces-generic.otel-default | WHERE attributes.gen_ai.operation.name == "chat" AND @timestamp >= NOW() - 7 days | EVAL is_failure = CASE(attributes.event.outcome == "failure", 1, 0) | STATS request_count = COUNT(*), avg_input_tokens = AVG(attributes.gen_ai.usage.input_tokens), avg_output_tokens = AVG(attributes.gen_ai.usage.output_tokens), p95_duration_us = PERCENTILE(transaction.duration.us, 95), error_count = SUM(is_failure) BY attributes.gen_ai.response.model | SORT p95_duration_us DESC

该查询会为你提供一个并排对比:每个模型版本在延迟、token 使用量以及错误率方面的表现。针对从两个 Gemma 4 变体收集的 120 个 chat spans 运行后,返回结果如下:

有两点特别值得注意。首先,两边的 prompts 完全相同(两者都是 99 个 input tokens ),因此延迟差距并不是由 prompt 大小导致的。其次,gemma4:e4b 平均实际生成的 output tokens 更少,但在第 95 百分位上的耗时却超过了两倍。这说明性能退化来自模型本身,而不是其工作负载。

使用 LOOKUP JOIN 添加成本分析

OTel GenAI 规范并不包含成本属性。虽然 token 数量是可用的,但要将其换算为成本,则需要知道每个模型的定价。这正是 ES|QL 的 LOOKUP JOIN 发挥作用的地方。

首先,创建一个包含模型定价信息的 lookup 索引:

PUT /model_pricing { "settings": { "index": { "mode": "lookup" } }, "mappings": { "properties": { "attributes.gen_ai.response.model": { "type": "keyword" }, "cost_per_1k_input_tokens": { "type": "float" }, "cost_per_1k_output_tokens": { "type": "float" } } } }

使用你的模型定价数据填充该索引,然后扩展查询:

FROM traces-generic.otel-default | WHERE attributes.gen_ai.operation.name == "chat" AND @timestamp >= NOW() - 7 days | STATS request_count = COUNT(*), total_input_tokens = SUM(attributes.gen_ai.usage.input_tokens), total_output_tokens = SUM(attributes.gen_ai.usage.output_tokens), p95_duration_us = PERCENTILE(span.duration.us, 95) BY attributes.gen_ai.response.model | LOOKUP JOIN model_pricing ON attributes.gen_ai.response.model | EVAL estimated_cost = (total_input_tokens / 1000.0) * cost_per_1k_input_tokens + (total_output_tokens / 1000.0) * cost_per_1k_output_tokens | SORT estimated_cost DESC

现在,你可以在同一个结果集中同时看到每个模型版本的延迟与成本。LOOKUP JOIN 会在查询时动态丰富你的 trace 数据,而无需将定价信息复制到每一个 span 中。

假设使用如下示例定价:gemma4:e2b 的价格为每 1K tokens 输入 $0.10 / 输出 $0.30,而 gemma4:e4b 的价格为输入 $0.25 / 输出 $0.75,那么同样每个模型处理 60 个请求后,会得到如下结果:

gemma4:e4b 的工作负载在完成相同任务时成本约高出 2.4 倍,尽管它生成的 output tokens 甚至略少。延迟与成本的退化,在同一个查询结果中就可以同时看到。

何时使用模型版本对比查询

当你在评估模型变更时,这种模式非常有用:例如不同模型版本之间的 A/B 测试、渐进式发布,或者基于任务复杂度将请求路由到不同模型的多模型策略。

问题 2:哪个 prompt 模板导致 token 激增?

场景:你的 token 使用量本周突然上涨 40%,但你并没有更换模型。你有三个 prompt 模板在轮换使用(summarization、extraction、classification),你需要找出到底是哪一个导致了问题。

为什么 prompt.template.id 是值得添加的自定义 OTel 属性

OTel GenAI 语义规范会追踪哪个模型处理了请求、用了多少 tokens、耗时多少,但它不会追踪使用的是哪个 prompt 模板,因为 prompt 管理属于应用层逻辑。

这是一个关键的调试缺口。如果所有 prompts 都通过同一个 gen_ai.operation.name == "chat" 操作流转,那么你无法区分一个表现正常的 summarization prompt 和一个失控的 extraction prompt,除非有自定义标识符。

添加 prompt.template.id 这个自定义 span 属性(如 stack 部分所示)可以解决这个问题。这是一种值得尽早采用的模式,因为缺失它的代价通常只有在系统出问题时才会显现。

ES|QL 查询

FROM traces-generic.otel-default | WHERE attributes.gen_ai.operation.name == "chat" AND @timestamp >= NOW() - 7 days | EVAL is_failure = CASE(attributes.event.outcome == "failure", 1.0, 0.0) | STATS request_count = COUNT(*), avg_output_tokens = AVG(attributes.gen_ai.usage.output_tokens), max_output_tokens = MAX(attributes.gen_ai.usage.output_tokens), error_rate = AVG(is_failure) * 100 BY attributes.prompt.template.id | SORT avg_output_tokens DESC

将该查询运行在我们 120 个 spans 上,会得到一个非常明确的“赢家”:

extraction-v3 每次请求产生的 token 数量约为 summarize-v2 的 5 倍,也约为 classify-v1 的 23 倍。max_output_tokens 列也很重要:少数极端响应可能会拉高平均值,因此同时查看这两个指标可以清楚看出,extraction-v3 的高 token 使用是结构性的“过度输出”,而不是由单个异常值导致的偏移。

将这一模式扩展到其他调试维度

prompt.template.id 模式可以扩展到任何你想要切分的调试维度(客户等级、使用场景、部署区域等)。它可以作为自定义 span 属性加入,并在 ES|QL 中进行分组分析。GenAI 规范提供的是模型和 token 层的数据,而自定义属性提供的是业务上下文层的数据。

问题 3:LLM 延迟是否与 GPU 饱和相关?

场景:过去一周推理延迟逐渐上升,但应用代码和模型都没有变化。你怀疑是基础设施问题。

这个问题是自托管模型特有的。当你使用云端 LLM 提供商( OpenAI、Bedrock、Azure OpenAI )时,GPU 资源是完全抽象的。你只能看到延迟上升,但无法判断是否是 provider 的 GPU 已经饱和。而在使用 NVIDIA 硬件自托管模型时,你可以同时观察问题的两侧。

GPU metrics 能告诉你什么

来自 DCGM Exporter 的 GPU metrics 为推理引擎提供了一个观察窗口:

  • DCGM_FI_DEV_GPU_UTIL 较高(超过 90%)意味着 GPU 计算单元已饱和,新推理请求会排队,从而增加延迟。
  • DCGM_FI_DEV_FB_USED 接近总显存容量意味着 GPU 内存压力增大,可能需要交换模型层,或者 GPU 无法进行更多 batch。
  • 升高的 DCGM_FI_DEV_GPU_TEMP 在超过 GPU 的降频阈值后会触发 thermal throttling,从而降低时钟频率,直接影响推理吞吐量。

将 traces 与 GPU metrics 关联

挑战在于 LLM traces 和 GPU metrics 位于不同的 indices,且 schema 不同。LLM spans 位于 traces-generic.otel-default,时间粒度是 request 级别;GPU metrics 位于 metrics-*,时间粒度是 scrape interval(通常 10–15 秒)。

ES|QL 的 LOOKUP JOIN 可以将两者结合起来。方法是:创建 lookup index,将 GPU metrics 聚合为按分钟 bucket 的数据,然后将 trace 数据与这些 bucket 进行 join。

首先,创建用于存放聚合 GPU metrics 的 lookup index:

PUT /gpu_metrics_by_minute { "settings": { "index": { "mode": "lookup" } }, "mappings": { "properties": { "time_bucket": { "type": "date" }, "gpu_utilization": { "type": "float" }, "gpu_memory_used": { "type": "float" }, "gpu_temperature": { "type": "float" } } } }

然后,将原始 DCGM metrics 聚合为按分钟划分的时间桶:

FROM metrics-* | WHERE metrics.DCGM_FI_DEV_GPU_UTIL IS NOT NULL AND @timestamp >= NOW() - 7 days | EVAL time_bucket = DATE_TRUNC(1 minute, @timestamp) | STATS gpu_utilization = AVG(metrics.DCGM_FI_DEV_GPU_UTIL), gpu_memory_used = AVG(metrics.DCGM_FI_DEV_FB_USED), gpu_temperature = AVG(metrics.DCGM_FI_DEV_GPU_TEMP) BY time_bucket

使用 Elasticsearch bulk API 将聚合后的结果写入 gpu_metrics_by_minute 索引。在生产环境中,如果 GPU metrics 持续被采集并写入,可以使用 Elasticsearch transform 自动维护这个 lookup 索引,使其保持实时更新。

FROM traces-generic.otel-default | WHERE attributes.gen_ai.operation.name == "chat" AND @timestamp >= NOW() - 7 days | EVAL time_bucket = DATE_TRUNC(1 minute, @timestamp) | STATS avg_duration_us = AVG(transaction.duration.us), request_count = COUNT(*) BY time_bucket | LOOKUP JOIN gpu_metrics_by_minute ON time_bucket | WHERE gpu_utilization IS NOT NULL | EVAL latency_vs_gpu = CASE( gpu_utilization > 90 AND avg_duration_us > 5000000, "saturated + slow", gpu_utilization > 90 AND avg_duration_us <= 5000000, "saturated but ok", gpu_utilization <= 90 AND avg_duration_us > 5000000, "slow without gpu cause", "normal" ) | SORT time_bucket DESC

注意:由于 GPU metrics 每 10 秒抓取一次,而 LLM spans 是按请求粒度记录时间戳,因此两者在进行 join 时需要统一粒度。lookup index 会将原始 metrics 聚合为按分钟的平均值,而在 trace 侧使用 DATE_TRUNC(1 minute, @timestamp) 将 spans 对齐到相同的时间桶。

如何解读 latency_vs_gpu 分类

latency_vs_gpu 列会对每个时间窗口进行分类:

  • “saturated + slow”:GPU 是瓶颈。你需要扩展 GPU 容量、减少 batch size,或者使用更小的模型。
  • “saturated but ok”:GPU 已经很忙,但延迟仍然可接受。你已经接近上限但还没有超出。
  • “slow without gpu cause”:延迟来自其他因素(网络、预处理、队列深度)。GPU 不是问题所在。
  • “normal”:一切正常。

将我们的 120 个 chat spans 与按分钟聚合的 GPU buckets 进行 join 后,共得到 43 个同时包含 LLM activity 和 GPU coverage 的时间窗口:

latency_vs_gpu分钟数GPU 利用率范围平均请求耗时
saturated + slow4290.8% - 98.2%5.98s - 70.5s
saturated but ok193.9%4.77s

从问题到调查

上面的三个查询只是起点。ES|QL 的管道式语法使它们具有可组合性,因此你可以在调查深入时不断组合这些模式。

例如,你可以将问题 1 和问题 2 结合起来:“在新模型版本中,哪些 prompt 模板的 token 效率最差?”:

FROM traces-generic.otel-default | WHERE attributes.gen_ai.operation.name == "chat" AND @timestamp >= NOW() - 7 days | STATS avg_output_tokens = AVG(attributes.gen_ai.usage.output_tokens), request_count = COUNT(*) BY attributes.gen_ai.response.model, attributes.prompt.template.id | SORT avg_output_tokens DESC

将数据按两个维度同时切分后,会暴露出单一查询无法看到的行为模式。classify-v1 prompt 只要求返回一个单词标签,而 gemma4:e4b 在该 prompt 下基本遵守这一约束,每次响应约 20 个 tokens。相比之下,gemma4:e2b 在同样的 prompt 上平均输出约 121 个 tokens(多出约 6 倍),因为它倾向于在标签之外额外添加解释。这类退化无法通过平均值发现,只有在同时按 model 和 prompt 进行切分时才能显现。

从调试走向告警

一旦你通过临时 ES|QL 查询识别出某种模式,就可以将其转化为检测规则。Elastic 的告警系统支持基于 ES|QL 的规则,因此帮助你定位问题的同一条查询,也可以变成未来检测问题的告警:

  • 每个 prompt template 的 token 使用量超过阈值

  • 模型版本的延迟退化超过某个百分比

  • GPU 利用率持续高于 90% 且推理延迟下降

Kibana 内置的 LLM 可观测性

对于希望在 ES|QL 查询之外同时使用预构建视图的团队,Elastic 提供开箱即用的 LLM Observability dashboards(自 Elastic Observability 9.0 起 GA)。这些仪表板覆盖 OpenAI、Amazon Bedrock、Azure AI 和 Google Vertex AI,展示 token 使用量、延迟分布以及成本拆解。

对于 GPU 基础设施,NVIDIA GPU OpenTelemetry integration 提供 Fleet 级 dashboards,包含 GPU 利用率、显存、温度和功耗等指标,并预置六条针对关键 GPU 状态的告警规则。

这些 dashboards 与 ES|QL 方法是互补的。使用 dashboards 进行持续监控和健康检查,而当你需要深入分析具体问题时,则使用 ES|QL。

结论

我们涵盖了以下内容:

  • 缺口:LLM telemetry 的采集已经解决,但如何调试仍然是难题。ES|QL 通过即席查询弥补了这一缺口。
  • 三种调试模式:模型版本对比( STATS + LOOKUP JOIN )、prompt 模板隔离(自定义属性 + GROUP BY )、以及 GPU 关联分析(跨 trace 与 metric index 的 LOOKUP JOIN)。
  • LOOKUP JOIN 的价值:在查询时将外部上下文(定价、GPU metrics)注入 trace 数据,而无需修改埋点逻辑。
  • 自定义属性:在 OTel GenAI 规范之外扩展领域字段(如 prompt.template.id),以支持规范本身未覆盖的调试维度。

该方法适用于任何 OpenAI 兼容的 LLM endpoint(Ollama、vLLM、TGI),只要通过 EDOT 进行埋点,并且 ES|QL 查询可以在任意支持对应 data streams 的 Elasticsearch 集群中运行。

下一步

  • 试用配套 notebook,完整了解埋点与查询流程

  • 探索 Elastic 的 LLM Observability dashboards,用于开箱即用的监控视图

  • 阅读 ES|QL LOOKUP JOIN,了解更多数据增强模式

  • 查看 OpenTelemetry GenAI semantic conventions 获取最新属性定义

  • 学习基于 OpenTelemetry 与 Elastic 的 ML 与 AI Ops 可观测性

原文:https://www.elastic.co/observability-labs/blog/esql-llm-opentelemetry-debugging

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询