大模型上线后,"慢"和"贵"是最常见的两个抱怨。但慢在哪、贵在哪——是 prompt 太长?是推理排队?还是 GPU 已经跑满了?传统监控只告诉你 P99 延迟升高,却不告诉你为什么。ES|QL 配合 OpenTelemetry traces,能让你从一条慢请求一路追到具体的 token 计数、模型调用和 GPU 利用率,拿到根因而不是症状。
从一条慢请求开始
假设你的 LLM 服务通过 OpenTelemetry 采集了 traces,每个 span 携带了 LLM 调用的关键属性:llm.request.type、llm.response.token_count、llm.model.name 等。当用户反馈"回复要等 8 秒",第一步不是猜,而是用 ES|QL 把那条 trace 拉出来:
FROM traces-*
| WHERE @timestamp > NOW() - 1h
AND service.name == "llm-gateway"
AND duration.us > 5000000
| KEEP trace.id, span.id, span.name, duration.us, llm.model.name, llm.response.token_count
| SORT duration.us DESC
| LIMIT 20
这条查询筛出最近一小时 llm-gateway 服务中耗时超过 5 秒的 span,只保留你关心的字段,按耗时降序排列。你立刻能看到:慢请求集中在哪个模型、返回了多少 token。
Token 成本:不是"贵",而是"哪一步贵"
LLM 的成本几乎等于 token 数乘以单价。但一次对话可能包含 prompt 构建、上下文拼接、推理、后处理等多个 span。要定位成本热点,需要把 token 计数按步骤拆开:
FROM traces-*
| WHERE @timestamp > NOW() - 24h
AND service.name == "llm-gateway"
| STATS total_input_tokens = SUM(llm.request.token_count),
total_output_tokens = SUM(llm.response.token_count),
avg_input_per_call = AVG(llm.request.token_count),
call_count = COUNT()
BY span.name, llm.model.name
| SORT total_input_tokens DESC
| LIMIT 10
结果会告诉你:比如 chat-completion 这个 span 在 gpt-4o 上消耗了 80% 的 input token,而 context-retrieval span 在 gpt-3.5-turbo 上只占 5%。优化方向立刻清晰——压缩 prompt 或换小模型处理检索步骤,而不是笼统地"减少调用"。
GPU 饱和:延迟飙升的隐藏推手
LLM 延迟升高时,人们常归咎于模型本身,但真正的瓶颈可能是 GPU 排队。OpenTelemetry 可以从基础设施层采集 GPU 指标(通过 Prometheus node exporter 或 DCGM exporter),写入 Elasticsearch 后与 traces 关联分析。
先看 GPU 利用率的时间分布:
FROM metrics-*
| WHERE @timestamp > NOW() - 1h
AND metric.name == "gpu.utilization"
| STATS avg_gpu_util = AVG(metric.value),
max_gpu_util = MAX(metric.value),
p95_gpu_util = PERCENTILE(metric.value, 95)
BY host.name
| EVAL saturated = CASE(max_gpu_util > 90, "YES", "NO")
| SORT max_gpu_util DESC
如果某台机器的 max_gpu_util 超过 90%,接下来把 GPU 饱和时段和 LLM 慢请求对齐:
FROM traces-*
| WHERE @timestamp > NOW() - 1h
AND service.name == "llm-gateway"
AND duration.us > 3000000
| STATS slow_count = COUNT(),
avg_duration_ms = AVG(duration.us) / 1000
BY @timestamp.bucket(5m), llm.model.name
| SORT slow_count DESC
| LIMIT 30
把这两组结果按 5 分钟桶对齐看,你会发现:GPU 利用率冲到 95% 的那几个 5 分钟窗口,恰好也是慢请求密集的时段。根因不是模型慢,而是排队等 GPU。
实战:一条查询同时拉出延迟、Token 和 GPU 信号
ES|QL 支持跨索引关联(通过 LOOKUP 或 JOIN 的思路,实际用 STATS ... BY 分桶对齐)。下面是一个把 traces 和 metrics 放在一起分析的完整示例:
-- Step 1: 从 traces 取 LLM 调用的延迟和 token 细节
FROM traces-*
| WHERE @timestamp > NOW() - 2h
AND service.name == "llm-gateway"
| EVAL duration_ms = duration.us / 1000,
cost_usd = llm.request.token_count * 0.00003 + llm.response.token_count * 0.00006
| STATS p95_latency_ms = PERCENTILE(duration_ms, 95),
total_cost_usd = SUM(cost_usd),
total_input_tokens = SUM(llm.request.token_count),
total_output_tokens = SUM(llm.response.token_count),
call_count = COUNT()
BY @timestamp.bucket(5m), llm.model.name
-- Step 2: 从 metrics 取同一时段的 GPU 利用率
| LOOKUP gpu_util_summary
ON @timestamp.bucket(5m), host.name
注意:
LOOKUP需要预先把 GPU 指标聚合为 lookup 表。如果你的 Elasticsearch 版本不支持跨索引 JOIN,可以分别跑两条查询,在笔记本或 Grafana 面板中按时间桶手动对齐——效果一样,只是多一步手动合并。
成本估算中的单价(0.00003 和 0.00006)是示例值,替换成你实际使用的模型定价即可。
采纳建议与注意事项
-
先补齐 span 属性。OpenTelemetry 的 LLM semantic conventions 还在演进,确保你的 instrumentation 把
llm.request.token_count、llm.response.token_count、llm.model.name写进 span attributes。没有这些字段,ES|QL 再强也无米下锅。 -
GPU 指标要单独采集。Elasticsearch 不自动收 GPU 数据,你需要部署 NVIDIA DCGM Exporter 或 GPU-enabled Node Exporter,通过 Prometheus 写入 Elasticsearch 的 metrics 索引。
-
时间桶对齐是关键。traces 和 metrics 的采样频率不同,用
@timestamp.bucket(5m)或1m把两边归到同一网格,才能准确关联。 -
ES|QL 的
PERCENTILE和STATS性能。对大规模 trace 数据做百分位聚合,建议先加时间范围和 service 过滤(WHERE子句),再聚合,避免全索引扫描。 -
成本公式要定期更新。模型厂商调价频繁,把单价做成配置或 lookup 表,不要硬编码在查询里。
-
从症状到根因的思维转变。看到延迟升高,不要停在"加机器"——先查是 token 爆了、GPU 排队了、还是某个下游服务拖了。ES|QL 的价值在于让你一条查询就从症状走到根因。
把 OpenTelemetry traces 和 GPU metrics 写进 Elasticsearch,用 ES|QL 做时间对齐的聚合分析,你就能在同一个视图中看到:哪个模型吃 token、哪台 GPU 在排队、哪个时间段成本失控。这不是事后看报表,而是调试时的实时根因定位。