LLM 推理延迟监控:从 Token 级指标到全链路可观测性方案
LLM 推理延迟监控:从 Token 级指标到全链路可观测性方案
一、大模型推理的延迟盲区:为什么传统 APM 不够用
大模型推理的延迟分布与传统 HTTP 请求有本质区别。一个 Chat Completion 请求的端到端延迟包含多个阶段:请求排队等待、Prompt 编码(Prefill)、Token 逐个生成(Decode)、网络传输。其中 Decode 阶段的耗时与输出 Token 数成正比,可能占总延迟的 80% 以上。传统 APM 只能看到请求的总耗时,无法区分各阶段耗时,更无法回答"延迟升高是因为排队还是因为 Decode 变慢"。
更棘手的是流式响应场景。SSE 推送模式下,首 Token 延迟(Time To First Token, TTFT)和 Token 间延迟(Inter-Token Latency, ITL)是用户体验的核心指标。TTFT 决定用户等待多久看到第一个字,ITL 决定"打字"速度是否流畅。传统 APM 的请求级指标无法捕获这些 Token 级别的延迟特征。
二、LLM 推理延迟指标体系:从请求级到 Token 级
完整的 LLM 推理监控需要三层指标:请求级指标(端到端延迟、成功率)、Token 级指标(TTFT、ITL、吞吐量)和资源级指标(GPU 利用率、显存占用、KV Cache 命中率)。三层指标之间有因果关系——资源级指标异常会导致 Token 级指标劣化,Token 级指标劣化最终反映在请求级指标上。
flowchart TB subgraph 请求级指标 A1[端到端延迟 P50/P99] A2[请求成功率] A3[并发连接数] end subgraph Token级指标 B1[TTFT 首 Token 延迟] B2[ITL Token 间延迟] B3[Token 吞吐量 tokens/s] end subgraph 资源级指标 C1[GPU 利用率] C2[显存占用率] C3[KV Cache 命中率] end C1 --> B2 C2 --> B1 C3 --> B1 B1 --> A1 B2 --> A1 B3 --> A2TTFT 升高通常指向 Prefill 阶段的瓶颈——Prompt 过长或 GPU 资源竞争。ITL 升高则指向 Decode 阶段的问题——Batch Size 过大导致单步推理变慢,或 KV Cache 溢出导致频繁换页。资源级指标帮助定位根因,Token 级指标量化用户感知,请求级指标评估整体健康度。
三、生产级代码实现:Token 级指标采集与告警
3.1 流式响应的 Token 级指标采集
@Service public class LlmMetricsCollector { private final MeterRegistry meterRegistry; private final Timer ttftTimer; private final DistributionSummary itlSummary; private final Counter totalTokensCounter; public LlmMetricsCollector(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; // TTFT 使用 Timer 记录分布,关注 P99 this.ttftTimer = Timer.builder("llm.ttft") .description("首 Token 延迟") .publishPercentiles(0.5, 0.9, 0.95, 0.99) .publishPercentileHistogram() .register(meterRegistry); // ITL 使用 DistributionSummary,因为单次延迟极短 this.itlSummary = DistributionSummary.builder("llm.itl") .description("Token 间延迟(ms)") .publishPercentiles(0.5, 0.9, 0.99) .register(meterRegistry); this.totalTokensCounter = Counter.builder("llm.tokens.total") .description("Token 总消耗量") .register(meterRegistry); } public Flux<ServerSentEvent<ChatChunk>> streamWithMetrics( ChatRequest request, Flux<ServerSentEvent<ChatChunk>> stream) { long requestStart = System.nanoTime(); AtomicLong lastTokenTime = new AtomicLong(0); AtomicBoolean firstTokenReceived = new AtomicBoolean(false); return stream.doOnNext(event -> { long now = System.nanoTime(); if (!firstTokenReceived.getAndSet(true)) { // 记录 TTFT long ttftMs = (now - requestStart) / 1_000_000; ttftTimer.record(ttftMs, TimeUnit.MILLISECONDS); } else if (lastTokenTime.get() > 0) { // 记录 ITL long itlMs = (now - lastTokenTime.get()) / 1_000_000; itlSummary.record(itlMs); } lastTokenTime.set(now); // 统计 Token 消耗 if (event.data() != null && event.data().getTokens() != null) { totalTokensCounter.increment( event.data().getTokens()); } }); } }3.2 GPU 资源指标采集(NVIDIA DCGM)
@Component public class GpuMetricsCollector { private final MeterRegistry meterRegistry; @Scheduled(fixedRate = 5000) public void collectGpuMetrics() { try { // 通过 nvidia-smi 采集 GPU 指标 // 为什么用 nvidia-smi 而非 DCGM Exporter: // 单机场景下 nvidia-smi 更简单,无需额外部署; // 集群场景建议用 DCGM Exporter + Prometheus ProcessBuilder pb = new ProcessBuilder( "nvidia-smi", "--query-gpu=utilization.gpu,memory.used,memory.total", "--format=csv,noheader,nounits"); Process process = pb.start(); String output = new String( process.getInputStream().readAllBytes()); String[] parts = output.trim().split(","); if (parts.length >= 3) { double gpuUtil = Double.parseDouble(parts[0].trim()); double memUsed = Double.parseDouble(parts[1].trim()); double memTotal = Double.parseDouble(parts[2].trim()); meterRegistry.gauge("llm.gpu.utilization", gpuUtil); meterRegistry.gauge("llm.gpu.memory.used", memUsed); meterRegistry.gauge("llm.gpu.memory.total", memTotal); meterRegistry.gauge("llm.gpu.memory.ratio", memUsed / memTotal * 100); } } catch (Exception e) { log.warn("GPU 指标采集失败: {}", e.getMessage()); } } }3.3 延迟异常告警规则
@Configuration public class LlmAlertConfig { @Bean public MeterFilter llmAlertFilter() { return MeterFilter.accept(); } // TTFT P99 超过 5 秒告警 // 为什么用 P99 而非均值:均值会被大量正常请求稀释, // P99 能捕捉到尾部延迟异常,更符合用户体验的真实感知 @Bean public ttftAlertRule(MeterRegistry registry) { return AlertRule.builder() .name("llm-ttft-p99-high") .metric("llm.ttft") .statistic(Statistic.Percentile) .percentile(0.99) .threshold(5000.0) // 5 秒 .duration(Duration.ofMinutes(3)) .action(() -> log.error( "TTFT P99 超过 5 秒,可能存在 Prefill 瓶颈")) .build(); } }3.4 全链路 Trace 集成
@Service public class TracedLlmService { private final LlmClient llmClient; private final Tracer tracer; public Flux<ChatChunk> chatWithTrace(ChatRequest request) { Span span = tracer.nextSpan() .name("llm.inference") .tag("model", request.getModel()) .tag("prompt.tokens", String.valueOf( estimateTokens(request.getMessage()))) .start(); return llmClient.streamChat(request) .doOnNext(chunk -> { // 在 Span 上记录关键事件 if (chunk.isFirstToken()) { span.event("first_token_received"); } }) .doOnError(span::error) .doFinally(signal -> { span.tag("stream.completed", signal == SignalType.ON_COMPLETE ? "true" : "false"); span.end(); }); } }四、LLM 可观测性的架构权衡:采样率、指标基数与存储成本
Token 级指标的基数爆炸:每个请求的每个 Token 都产生一条 ITL 记录,高并发下指标量级远超传统 HTTP 请求。Prometheus 的指标存储按基数(Label 组合数)线性增长,如果 ITL 按 model + endpoint + status_code 分组,基数可能达到数万。建议 ITL 只按 model 分组,详细维度通过 Trace 追溯。
采样策略的精度损失:高并发场景下不可能对每个请求都记录完整的 Token 级指标。采样率设为 1% 时,P99 指标的置信区间会变宽,可能漏掉真实的尾部延迟。建议对 TTFT 全量采集(每个请求只记录一次),对 ITL 采样采集(每个请求只记录部分 Token 间延迟)。
Trace 的存储成本:流式请求的 Span 持续时间可能长达数十秒,远超普通 HTTP 请求。如果每个 Token 都产生一个 Span Event,单个请求的 Trace 数据量可能达到数百 KB。建议只在异常请求(TTFT > 阈值或 ITL > 阈值)上记录详细 Trace,正常请求只记录摘要。
GPU 指标的采集频率:nvidia-smi 的调用本身有约 100ms 的开销,5 秒采集一次对性能影响可忽略。但如果部署了多个推理实例,每个实例都独立采集会导致指标重复。建议在集群级别用 DCGM Exporter 统一采集,避免重复。
五、总结
LLM 推理延迟监控的核心是建立 Token 级指标体系,TTFT 和 ITL 是比端到端延迟更有价值的观测维度。TTFT 升高指向 Prefill 瓶颈,ITL 升高指向 Decode 瓶颈,GPU 资源指标帮助定位根因。落地时建议先实现 TTFT 和 ITL 两个核心指标的采集和告警,再逐步补充 GPU 资源指标和全链路 Trace。指标基数和存储成本是需要持续优化的维度,采样策略应根据业务规模动态调整。
