许多团队将传统微服务网关的运维经验直接套用到大语言模型(LLM)调用链路上,随后很快发现监控面板出现了严重失真。在传统 RPC 或 REST 场景下,P99 延迟超过 1 秒通常意味着严重故障,错误率也以 HTTP 状态码 5xx 为基准。但在大模型场景下,一个包含多轮长上下文的生成式请求持续耗时 15 秒甚至更久是常态,即使 HTTP 响应状态码为 200,也可能因为上游算力耗尽而在输出几个 Token 后意外中断。
要在大模型服务落地过程中建立可靠的服务等级协议(SLA)与监控体系,必须重构度量指标,从传统的“请求-响应”维度转向“首字延迟、吞吐速率、流式完整性与有效可用性”的多维评价体系。
大模型核心 SLA 指标定义
针对大模型生成的特有生命周期,我们需要重点关注以下四个黄金指标:
| 指标名称 | 简称 | 定义与计算方式 | 业务影响 |
|---|---|---|---|
| 首字时间 | TTFT (Time to First Token) | 客户端发出请求到接收到第一个有效 Token 的耗时 | 直接决定用户感知的界面响应速度 |
| 单字生成耗时 | TPOT (Time Per Output Token) | 模型生成后续每个 Token 的平均耗时,即(总耗时 - TTFT) / (输出 Token 数 - 1) | 决定打字机效果的流畅度 |
| 输出吞吐率 | TPS (Tokens Per Second) | 客户端每秒接收到的 Token 数量,即输出 Token 数 / 总耗时 | 衡量整体生成效率与通道带宽 |
| 流式完成率 | Stream Completion Rate | 正常接收到结束符([DONE]或finish_reason: stop)的请求占比 | 衡量链路稳定性与截断异常 |
传统可用性公式简单以成功请求数 / 总请求数计算,而在大模型网关中,我们将其升级为有效可用性(Effective Availability):
$$\text{Effective Availability} = \frac{\text{总请求数} - (\text{HTTP 异常} + \text{TTFT 超时} + \text{意外截断} + \text{内容安全拦截误杀})}{\text{总请求数}} \times 100%$$
在我们的网关落地实践中,这套度量逻辑完全贯穿于 SSE 长连接的全生命周期:
当客户端请求到达网关时,网关在向上游模型集群转发请求的瞬间,首先打下纳秒级请求时间戳T0;
上游模型完成上下文 Prefill 阶段并吐出第一个 SSE Chunk 时,网关记录首包时间戳T1,二者相减即得出决定用户首屏感知的TTFT = T1 - T0,并立即向客户端转发首包;
随后在持续的 Decode 解码阶段,上游高频泵出数据块,网关在转发 Chunk 的同时递增累加 Token 计数,动态核算相邻 Token 之间的平摊耗时(TPOT)与实时速率;
直到上游最终推送[DONE]或终止信号Tn,网关才正式闭合本次会话上下文,将总耗时、输出 Token 总量以及流式完整性状态统一投递至 Prometheus 监控流水线。
要让这套高精度的时序度量在海量并发长连接下既不阻塞网络 I/O,又不引发堆内存膨胀,网关层的拦截器必须做到极度轻量:
网关层流式指标埋点实践
在 Spring Cloud Gateway 或基于 Netty 的自定义网关中,我们需要在全局过滤器(GlobalFilter)中拦截 SSE 流,记录时间戳并计算 Token 数据。
以下为基于 Micrometer 的大模型指标收集过滤器核心实现:
package com.example.gateway.filter; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.DistributionSummary; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import org.reactivestreams.Publisher; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.server.reactive.ServerHttpResponseDecorator; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @Component public class LlmMetricsFilter implements WebFilter { private final Timer ttftTimer; private final DistributionSummary tokenSummary; private final Counter streamErrorCounter; public LlmMetricsFilter(MeterRegistry registry) { this.ttftTimer = Timer.builder("llm.gateway.ttft") .description("大模型首字延迟时间") .publishPercentiles(0.5, 0.9, 0.99) .register(registry); this.tokenSummary = DistributionSummary.builder("llm.gateway.tokens.output") .description("单次请求输出 Token 数量") .register(registry); this.streamErrorCounter = Counter.builder("llm.gateway.stream.error") .description("流式生成异常中断计数") .register(registry); } @Override public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) { long startTime = System.currentTimeMillis(); AtomicLong firstTokenTime = new AtomicLong(0); AtomicInteger tokenCount = new AtomicInteger(0); AtomicBoolean isCompleted = new AtomicBoolean(false); ServerHttpResponseDecorator decoratedResponse = new ServerHttpResponseDecorator(exchange.getResponse()) { @Override public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) { if (body instanceof Flux) { Flux<? extends DataBuffer> fluxBody = (Flux<? extends DataBuffer>) body; return super.writeWith(fluxBody.map(dataBuffer -> { // 首次获取数据块时记录 TTFT if (firstTokenTime.compareAndSet(0, System.currentTimeMillis())) { long ttftMillis = firstTokenTime.get() - startTime; ttftTimer.record(ttftMillis, TimeUnit.MILLISECONDS); } byte[] content = new byte[dataBuffer.readableBytes()]; dataBuffer.read(content); dataBuffer.readPosition(0); // 重置读取游标供下游消费 String chunkText = new String(content, StandardCharsets.UTF_8); // 粗粒度估算或解析 SSE 内部 token 结构 tokenCount.addAndGet(estimateTokenCount(chunkText)); if (chunkText.contains("[DONE]")) { isCompleted.set(true); } return dataBuffer; }).doFinally(signalType -> { tokenSummary.record(tokenCount.get()); if (!isCompleted.get()) { streamErrorCounter.increment(); } })); } return super.writeWith(body); } }; return chain.filter(exchange.mutate().response(decoratedResponse).build()); } private int estimateTokenCount(String sseChunk) { // 实际场景中可解析 JSON 内的 usage 字段,或按字符比例估算 return Math.max(1, sseChunk.length() / 4); } }多模型提供商动态路由与降级治理
单一供应商的服务抖动是大模型系统中最常见的 SLA 杀手。在大模型网关中,度量指标不仅用于告警展示,还应直接参与动态调度。
通过动态探测多通道的实时 TTFT 与错误率,网关可以在上游服务响应恶化时,毫秒级切换至备用模型提供商:
llm: gateway: routing-rules: - model-group: "general-reasoning" primary-provider: "provider-a" backup-provider: "provider-b" degrade-threshold: max-ttft-ms: 3000 continuous-5xx-count: 5 window-seconds: 10当网关检测到主提供商的 TTFT 连续突破阈值,或者由于并发限流返回 HTTP 429 时,熔断器自动触发并进行平滑切换,避免直接向上游业务层抛出不可恢复的异常。
运营成本与配额度量
大模型的可用性管理与成本控制紧密耦合。一个请求即使响应成功,若消耗了数万个超出预期的 Token,也会对集群整体可用性造成资源挤兑。
因此,网关层的度量报表必须将财务维度与技术维度结合:
- 按部门/业务线打标:对每个请求注入
tenant_id、app_id与model_id标签,实现成本精细化核算。 - 动态滑动窗口配额:通过 Redis 记录各业务线在过去 1 小时内的 Token 消耗总量,超出预算后触发限流降级(如切换至轻量化小参数模型)。
- 缓存命中度量:记录语义缓存(Semantic Cache)的命中率,有效减轻 LLM 计算负载并显著降低整体 TTFT。