Dashboard 告诉你"出问题了";ES|QL 告诉你"为什么"。
通过三个 ES|QL 查询,基于 OpenTelemetry 追踪数据排查 LLM 的延迟异常、Token 成本飙升和 GPU 饱和问题------不仅找到症状,更要定位根因。
通过针对 OpenTelemetry 追踪数据的三个查询,我们发现了:一次模型替换导致的 2.4 倍成本回归 、某个提示模板比另一个多产生 23 倍输出 Token、以及在 43 个时间窗口中有 42 个窗口 GPU 利用率超过 90%。而这一切,都来自于同一个已经通过 EDOT 发送追踪数据、通过 DCGM 发送 GPU 指标的集群。
前置条件
- Elasticsearch 9.x+
- Python 3.9+
- Ollama v0.5.12+(本地安装)
AI 工作负载中的可观测性缺口
大多数运行 LLM 应用的团队已经完成了第一步:通过 EDOT、OpenLIT、Langtrace 等工具为应用添加追踪(Trace)埋点,捕获延迟、Token 用量等数据。数据已经在流动。
但下一步------当问题发生时如何查询这些数据------才是真正的挑战。
预置的 Dashboard 只能回答预设问题:"P95 延迟是多少?""今天用了多少 Token?"这些对监控有用,但对调试 (Debugging)远远不够。调试意味着你有一个症状("上周二延迟突然飙升"),需要在数据中探索 直到找到根因。这种探索需要的是查询语言,而不是固定的仪表盘。
这就是 ES|QL 的价值所在。
ES|QL
ES|QL 是 Elasticsearch 的管道式查询语言,可以在单一查询中完成聚合、过滤、跨索引关联。应用于 LLM 遥测数据时,它能让你:
- 在一个查询中对比不同模型版本的 P95 延迟
- 按自定义提示标识符分组,找到那个"烧钱"的模板
- 将 LLM 追踪数据与 GPU 指标关联,判断基础设施是否为瓶颈
在之前的文章中,我们介绍了如何采集 LLM 遥测数据(通过 EDOT、OpenLIT 或 Langtrace)。本文则专注于:当出现问题时,如何调查这些数据。
技术栈:LLM 遥测数据如何进入 Elastic
在调试之前,我们需要了解有哪些数据可用、它们存储在哪里。
Elastic 集群
基础设施层
应用层
调用
EDOT 自动埋点
DCGM Exporter
OTel Collector
Elastic Managed OTLP Endpoint
Elastic Managed OTLP Endpoint
Python 应用
OpenAI Client
Ollama
localhost:11434
OTel Traces
NVIDIA GPU
Prometheus Metrics
:9400
OTel Metrics
traces-generic.otel-default
metrics-nvidia_gpu.otel-default
整个技术栈包含两条数据路径:
路径一:LLM 追踪(应用层)
你的 Python 应用通过 OpenAI 客户端调用 Ollama(或任何兼容 OpenAI API 的端点)。EDOT Python 会自动为这些调用添加埋点,生成遵循 OpenTelemetry GenAI 语义约定 的 Span。
当这些数据通过 Elastic Managed OTLP Endpoint 发送后,会落入 Elasticsearch 的 traces-generic.otel-default 数据流中。
路径二:GPU 指标(基础设施层)
在运行 GPU 推理的主机上,NVIDIA 的 DCGM Exporter 将 GPU 指标以 Prometheus 格式暴露在 9400 端口。OpenTelemetry Collector 采集这些指标并转发到 Elasticsearch,落入 metrics-* 数据流。
EDOT 自动捕获了哪些数据?
EDOT Python 包含 elastic-opentelemetry-instrumentation-openai,它会自动为 OpenAI 客户端库的每次调用添加埋点。由于 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_tokens |
输入 Token 数 | 142 |
gen_ai.usage.output_tokens |
输出 Token 数 | 89 |
gen_ai.response.id |
唯一完成 ID | chatcmpl-abc123 |
在 Kibana 的 APM 追踪视图中,每次调用都会显示为一个带有上述属性的 Span:
Trace: chat
Span: chat
gen_ai.operation.name: chat
Attributes
gen_ai.request.model: gemma4:e4b
gen_ai.usage.input_tokens: 142
gen_ai.usage.output_tokens: 89
gen_ai.response.id: chatcmpl-abc123
此外,EDOT 还会发出两个指标:
gen_ai.client.token.usage:Token 数量的直方图gen_ai.client.operation.duration:请求延迟的直方图(秒)
配置极其简单,只需将 OpenAI 客户端指向 Ollama,并用 EDOT 的自动埋点运行:
python
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:11434/v1/",
api_key="ollama", # 客户端要求提供,但 Ollama 并不使用
)
如何为 OTel Span 添加自定义提示模板 ID
OTel GenAI 语义约定涵盖了模型追踪和 Token 用量,但没有包含提示模板标识符。如果你运行着多个提示模板(系统提示、少样本变体等),你需要知道是哪个模板导致了问题。
当前 OTel 规范中不存在 gen_ai.prompt.id 这样的约定。为了填补这个空白,你可以添加自定义 Span 属性:
python
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 指标:从 DCGM 到 Elastic
对于在 NVIDIA 硬件上运行自托管模型的团队,GPU 指标是关键的上下文信息。NVIDIA 的 DCGM(Data Center GPU Manager)Exporter 将 GPU 利用率、显存使用、温度、功耗等指标以 Prometheus 格式暴露在 9400 端口。
OpenTelemetry Collector 通过 Prometheus Receiver 采集这些指标并转发到 Elastic。Resource Processor 为每个指标打上 data_stream.dataset = nvidia_gpu 的标签,将数据路由到 metrics-nvidia_gpu.otel-default 数据流,与 Elastic 的 NVIDIA GPU OpenTelemetry 集成对齐:
yaml
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 集成,包含 Fleet 级别的 Dashboard、六个告警规则(如热节流检测)以及 GPU 热健康 SLO 模板。
LLM 调试的关键 GPU 指标:
| 指标 | 含义 |
|---|---|
DCGM_FI_DEV_GPU_UTIL |
GPU 计算单元有多忙(%) |
DCGM_FI_DEV_FB_USED |
GPU 显存(VRAM)消耗了多少 |
DCGM_FI_DEV_GPU_TEMP |
是否可能因热节流影响性能 |
DCGM_FI_DEV_POWER_USAGE |
功耗,可指示持续高负载 |
注意 :DCGM 需要 NVIDIA 数据中心级 GPU(A100、H100、L40S)。对于消费级 GPU,可使用基于 NVML 的工具(如
nvmlreceiver)。云托管 LLM 提供商(OpenAI、Bedrock、Azure OpenAI)完全不暴露 GPU 指标,因为硬件已被抽象。
问题一:新版本模型是否拖慢了速度、抬高了成本?
场景
你一直在生产环境使用 gemma4:e2b,刚刚部署了 gemma4:e4b 以获得更好的生成质量。几天后,延迟告警触发,Token 账单也上涨了。是模型替换导致的吗?
关键字段:request.model vs response.model
gen_ai.request.model(你请求的模型)和 gen_ai.response.model(实际响应的模型)之间的区别很重要。使用 Ollama 时,两者通常与你指定的 model:tag 一致。但在使用云提供商的模型别名时(如 gpt-4o 解析到特定固定版本),响应模型可能与请求不同。
对于模型版本对比,gen_ai.response.model 是更可靠的字段,因为它反映了实际运行的模型。
ES|QL 查询
esql
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 Span 运行此查询,结果如下:
| 模型 | 请求数 | 平均输入 Token | 平均输出 Token | P95 延迟 | 错误数 |
|---|---|---|---|---|---|
| gemma4:e4b | 60 | 99 | 85 | 12.3s | 0 |
| gemma4:e2b | 60 | 99 | 92 | 5.1s | 0 |
两个关键发现:
- 输入提示完全相同 (两边都是 99 个输入 Token),因此延迟差距不是由提示长度驱动的。
gemma4:e4b的平均输出 Token 实际上更少 ,但 P95 延迟却超过两倍 。这说明回归出在模型本身,而非工作负载。
用 LOOKUP JOIN 添加成本维度
OTel GenAI 规范不包含成本属性。Token 数量可用,但要换算成成本,需要知道每个模型的定价。这正是 ES|QL 的 LOOKUP JOIN 的用武之地。
第一步:创建定价查找索引
json
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" }
}
}
}
填入你的模型定价数据,然后扩展查询:
esql
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 在查询时丰富了追踪数据,无需将定价信息冗余到每个 Span 中。
假设 gemma4:e2b 的定价为 0.10/0.30(输入/输出,每 1K Token),gemma4:e4b 为 0.25/0.75,同样的 60 次请求产生:
| 模型 | 总输入 Token | 总输出 Token | 估计成本 |
|---|---|---|---|
| gemma4:e4b | 5,940 | 5,100 | $5.34 |
| gemma4:e2b | 5,940 | 5,520 | $2.21 |
gemma4:e4b 的工作负载成本约为 gemma4:e2b 的 2.4 倍 ,尽管它生成的输出 Token 还略少一些。延迟回归和成本回归,一个查询全部定位。
STATS BY model
LOOKUP JOIN
追踪数据
traces-generic.otel-default
聚合结果
定价表
model_pricing
输出: 延迟 + 成本
并排对比
何时使用模型版本对比查询?
此模式适用于任何评估模型变更的场景:模型版本的 A/B 测试、渐进式发布、或基于请求复杂度的多模型路由策略。
问题二:哪个提示模板在疯狂消耗 Token?
场景
本周 Token 用量飙升了 40%,但你没有更换模型。你有三个提示模板在轮换使用(摘要、提取、分类),需要找出罪魁祸首。
为什么 prompt.template.id 值得添加?
OTel GenAI 语义约定追踪了处理请求的模型、使用的 Token 数量、耗时,但不追踪使用了哪个提示模板------因为提示管理是应用特定的。
这个缺口对调试影响很大。如果所有提示都通过同一个 gen_ai.operation.name == "chat" 操作,没有自定义标识符,你就无法区分表现良好的摘要提示和失控的提取提示。
添加 prompt.template.id 作为自定义 Span 属性(如前文所示)解决了这个问题。这是一个值得尽早采用的模式,因为"没有它的代价"只有在出问题时才显现。
ES|QL 查询
esql
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 条 Span 运行此查询,结果一目了然:
| 提示模板 | 请求数 | 平均输出 Token | 最大输出 Token | 错误率 |
|---|---|---|---|---|
| extraction-v3 | 40 | 458 | 2,847 | 0% |
| summarize-v2 | 40 | 89 | 156 | 0% |
| classify-v1 | 40 | 20 | 23 | 0% |
extraction-v3 每次请求产生的 Token 约为 summarize-v2 的 5 倍,约为 classify-v1 的 23 倍。
max_output_tokens 列也很重要:少数极端响应可能拉高平均值,同时看平均值和最大值可以确认 extraction-v3 是结构性的话痨,而非被单个异常值扭曲。
所有 chat 操作
按 prompt.template.id 分组
extraction-v3
avg: 458 tokens
summarize-v2
avg: 89 tokens
classify-v1
avg: 20 tokens
🔥 Token 消耗大户
扩展到其他调试维度
prompt.template.id 模式可以扩展到任何你想切分的调试维度:客户等级、用例、部署区域。都可以作为自定义 Span 属性添加,并在 ES|QL 中分组。GenAI 约定给了你模型和 Token 层,自定义属性给了你业务上下文层。
问题三:LLM 延迟是否与 GPU 饱和度相关?
场景
过去一周推理延迟逐渐增加,但应用代码和模型都没有变化。你怀疑是基础设施的问题。
这个问题是自托管模型独有的 。使用云 LLM 提供商(OpenAI、Bedrock、Azure OpenAI)时,GPU 资源完全抽象,你只能看到延迟飙升,但无法检查提供商的 GPU 是否饱和。而在自托管的 NVIDIA 硬件上,你可以同时看到等式的两边。
GPU 指标告诉我们什么?
来自 DCGM Exporter 的 GPU 指标为推理引擎提供了窗口:
- 高
DCGM_FI_DEV_GPU_UTIL(>90%):GPU 计算单元饱和,新的推理请求排队,延迟增加。 - 高
DCGM_FI_DEV_FB_USED接近总显存:GPU 显存压力,模型层可能需要交换,或 GPU 无法批量处理那么多请求。 DCGM_FI_DEV_GPU_TEMP升高:一旦超过 GPU 的节流阈值,会触发热节流,GPU 降低时钟频率以管理热量,直接影响推理吞吐量。
关联追踪与 GPU 指标
挑战在于 LLM 追踪和 GPU 指标存储在不同索引 中,拥有不同 Schema:
- LLM Span 在
traces-generic.otel-default中,时间戳是请求级别的 - GPU 指标在
metrics-*中,时间戳是采集间隔(通常每 10-15 秒)
ES|QL 的 LOOKUP JOIN 可以将它们关联起来。思路是:创建查找索引 → 将 GPU 指标聚合成每分钟桶 → 索引这些桶 → 将追踪数据与之关联。
关联查询
预处理
原始数据
DATE_TRUNC 1 minute
AVG 聚合
DATE_TRUNC 1 minute
LOOKUP JOIN
LLM Spans
traces-generic.otel-default
时间戳: 请求级
GPU Metrics
metrics-*<
时间戳: 10s 采集间隔
gpu_metrics_by_minute
每分钟桶
按分钟聚合 Span
结果: 延迟 + GPU 利用率
同时间窗口对齐
第一步:创建存放聚合 GPU 指标的查找索引
json
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 指标聚合成每分钟桶
esql
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。在生产环境中,可以使用 Elasticsearch Transform 自动保持查找索引更新。
第三步:关联追踪数据
esql
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 指标每 10 秒采集一次,而 LLM Span 是请求级时间戳,两边需要共同的粒度才能关联。查找索引将原始指标聚合为每分钟平均值 ,追踪侧通过
DATE_TRUNC(1 minute, @timestamp)将 Span 对齐到同样的桶。
如何解读 latency_vs_gpu 分类
latency_vs_gpu 列将每个时间窗口分类:
| 分类 | 含义 | 行动建议 |
|---|---|---|
saturated + slow |
GPU 是瓶颈 | 扩展 GPU 容量、减小批次大小、使用更小模型 |
saturated but ok |
GPU 繁忙但延迟可接受 | 接近极限但尚未过载 |
slow without gpu cause |
其他因素导致延迟 | 排查网络、预处理、队列深度等 |
normal |
一切正常 | 无需行动 |
将 120 条 chat Span 与每分钟的 GPU 桶关联后,返回了 43 个同时有 LLM 活动和 GPU 数据的时间窗口:
| latency_vs_gpu | 分钟数 | GPU 利用率范围 | 平均请求延迟 |
|---|---|---|---|
| saturated + slow | 42 | 90.8% - 98.2% | 5.98s - 70.5s |
| saturated but ok | 1 | 93.9% | 4.77s |
在整个测试窗口中,GPU 利用率持续高于 90%,同时平均请求延迟在每个分钟都保持在 5 秒以上------除了一个分钟 。这正是 saturated + slow 模式:GPU 是瓶颈,不是提示、不是模型加载器、不是网络。
唯一一个 saturated but ok 的分钟(平均 4.77s)展示了阈值所在:GPU 仍然满载,但该分钟的请求混合较轻,使延迟保持在 5 秒以下。
从调试走向告警
一旦你通过临时 ES|QL 查询识别了模式,就可以将其转化为检测规则。Elastic 的告警支持基于 ES|QL 的规则,因此帮助你发现问题的同一个查询,可以成为下次捕获问题的告警:
- 单个提示模板的 Token 用量超过阈值
- 模型版本延迟回归超过百分比
- GPU 利用率持续高于 90% 且推理延迟恶化
Kibana 内置的 LLM 可观测性
对于希望在使用 ES|QL 查询的同时拥有预置视图的用户,Elastic 在 Observability 9.0 中提供了开箱即用的 LLM 可观测性 Dashboard。这些 Dashboard 涵盖 OpenAI、Amazon Bedrock、Azure AI 和 Google Vertex AI,展示 Token 用量、延迟分布和成本分解。
对于 GPU 基础设施,NVIDIA GPU OpenTelemetry 集成 增加了 Fleet 级别的 Dashboard,包含 GPU 利用率、显存、温度和功耗指标,以及六个针对关键 GPU 条件的预配置告警规则。
Dashboard 与 ES|QL 的关系:
- Dashboard:用于持续监控和健康检查
- ES|QL:用于深入排查特定问题
两者互补,而非替代。
结论
| 主题 | 核心要点 |
|---|---|
| 可观测性缺口 | 采集 LLM 遥测已解决,调试它才是挑战。ES |
| 三大调试模式 | 1) 模型版本对比(STATS + LOOKUP JOIN)< 2) 提示模板隔离(自定义属性 + GROUP BY)< 3) GPU 关联(跨追踪和指标索引的 LOOKUP JOIN) |
| LOOKUP JOIN 的价值 | 在查询时用外部上下文(定价、GPU 指标)丰富追踪数据,无需修改埋点。 |
| 自定义属性 | 用 prompt.template.id 等域特定字段扩展 OTel GenAI 约定,覆盖规范尚未包含的调试维度。 |
适用范围
此方法适用于任何通过 EDOT 埋点的兼容 OpenAI 的 LLM 端点(Ollama、vLLM、TGI),且 ES|QL 查询可在任何拥有相应数据流的 Elastic 集群中运行。
调试分析层
Elastic 存储层
数据采集层
STATS / LOOKUP JOIN
GROUP BY 自定义属性
LOOKUP JOIN
EDOT 自动埋点
OpenAI Client
DCGM Exporter
NVIDIA GPU
traces-generic.otel-default
metrics-nvidia_gpu.otel-default
模型对比
成本计算
提示模板分析
Token 效率
GPU 指标关联
根因定位
核心 takeaway:Dashboard 告诉你"有异常",ES|QL 告诉你"为什么有异常"。将两者结合,你就拥有了从监控到调试的完整 LLM 可观测性闭环。