去年年底,我们那条模型推理管线的告警,几乎每天都把我从工位上薅起来。不是推理服务挂了,而是“慢”——客服机器人突然卡了超过两秒,业务方在群里@我说用户开始骂人了。我打开Grafana一看,同一时刻有几百个离线批处理请求涌进来,把在线请求的GPU显存和算力全部挤占。那些批处理是给后台运营同事生成报表用的,本质上可以等;可用户的实时对话等不了。K8s的HPA当时在疯狂扩容,但新Pod从调度到拉镜像、再到模型加载,差不多3分钟过去了,这期间用户已经流失了好几拨。那次之后我下了个决心:这套推理集群的流量治理,不能再用简单的轮询负载均衡糊弄事了,得把排队论、优先级调度、自适应扩容统统塞进一个模型服务网格里。
以下就是我从零构建这个网格的全过程。我用了Envoy Sidecar做请求级别的优先级准入,用KEDA搭了一个基于排队时延的自定义Scaler,把高优请求的SLA砸到了120ms,而低优请求也没被饿死。
30秒速览
- - 混合推理流量的服务质量必须靠优先级队列而非轮询负载均衡保证,否则长尾任务会拖垮短任务延迟
- - Envoy的Lua扩展可以构建请求维度的准入控制器,但分布式队列一致性需要额外的Redis或本地缓存设计
- - 基于排队延迟的KEDA External Scaler比HPA的GPU利用率驱动扩容在延迟控制上有效得多,配合预热池可以消除冷启动窗口
- - 压测验证高优请求P99延迟从3.2秒降至120ms,同时整体GPU成本节省约40%
推理服务的混合流量,轮询负载均衡就是个笑话
一个在线客服机器人的流量画像
我们的推理服务跑的是一个70B参数的对话模型,使用vLLM引擎部署在4张A100-80G上。同一套模型对外提供三类接口:/chat(实时对话,要求P99延迟低于500ms)、/analysis(文档深度分析,允许2-5秒)、/batch-report(离线报表生成,10秒以上也能接受)。这就是典型的混合实时性需求——如果用同一组Pod不加区分地处理所有请求,就像让救护车和搬家卡车共用一条道,早晚要出事。
流量的到达模式也不一样。实时对话是泊松式的随机突发,上午10点到11点、下午3点到4点各有一个尖峰;文档分析是间歇性的长尾任务,单个请求可能消耗数万token;离线报表则完全是一大坨数据突然灌进来,一小时内把算力全部占满。我用Istio的默认负载均衡器试了两个星期,发现它在应对这种混合流量时几乎是瞎的——它只知道把请求分发给连接数最少的实例,完全不懂请求背后的计算负载和优先级。(延伸阅读:放弃轮询,拥抱WebRTC:我在GPT-4o实时API上构建数学助手的48小时延迟攻坚战)
最让我头疼的是“长尾服务时间”效应。在排队论里,当服务时间的变异系数很大时(长文本生成 vs 短回复),平均等待时间会急剧上升,P99延迟变得不可控。我跑了一周的数据,发现即便HPA把副本数从4扩到12,高峰期的高优请求P99延迟依然在3秒以上——因为新Pod启动太慢,冷启动期间请求积压得更厉害,形成恶性循环。
排队论和实际数据告诉我的四件事
我把过去三个月的推理日志导出来,做了一个简单的排队模型拟合。用的是M/G/k队列(泊松到达、一般服务时间、多个服务者),通过Python的SimPy库仿真了不同负载下的等待时间分布。结论有四个:
第一,即使平均GPU利用率只有55%,高峰期队列等待时间也能超过2秒——因为突发流量在短时间内堆积了大量请求,而服务速率跟不上。第二,长任务严重影响短任务的尾部延迟——当一个Pod同时在处理三个长文本生成时,新进来的短对话被迫排队,等效于服务容量被长任务挤占。第三,扩容决策如果只看CPU/GPU利用率,完全不靠谱——利用率上来时队列早就爆了。第四,必须给高优请求预留资源,否则任何扩缩容策略都补不回来那几分钟的冷启动窗口。
这些结论直接决定了后面我设计优先级准入控制器和自适应扩缩容策略的走向。
我把Envoy的Lua过滤器改成了带优先级的准入控制器
为什么抛弃Istio默认负载均衡
我们本来就在用Istio做服务网格,一开始我想用DestinationRule和VirtualService里的负载均衡策略来解决问题。但很快就发现不行:Istio的负载均衡基于连接数或请求数,跟推理任务的计算复杂度毫无关系;而且它不支持请求级别的优先级路由——你不可能在VirtualService里写“如果Header里priority=high,就走这个优先队列”。要精细化控制,必须下到Sidecar层面,在Envoy的过滤器链上动刀。
我决定在推理服务的前面插一层自定义的推理网关,这个网关本质上是一个Envoy扩展,负责两件事:第一,解析每个HTTP请求头里的X-Priority和X-Request-Type字段,识别请求的优先级(high/medium/low);第二,在网关本地维护一个优先级队列,根据下游推理服务的即时排队深度决定是直接放行还是排队等待,或者直接拒绝(返回429)。这个网关同时暴露Prometheus指标,把每个优先级队列的长度、等待时间、丢弃率都暴露出来,给后续的KEDA Scaler提供数据来源。(延伸阅读:我在Agent Builder上零代码搭了个客服Agent,结果上线第一天就把Cloud Run预算告警打爆了——ADK多智能体审批系统的运维血泪实录)
操作实录:用Cursor给Envoy写Lua扩展
给Envoy写扩展有三种方式:Wasm、Lua脚本和External Processor。我选了Lua,因为我们场景的准入逻辑不算复杂,不需要全功能沙箱,也不需要单独的gRPC回调——Wasm的开发调试周期太长,不适合快速迭代。
具体操作是这样:我在Cursor里新建了一个项目目录,打开终端用envoy –config-template生成了一个基础配置文件envoy.yaml。然后我让Cursor的Agent生成一个Lua过滤器的骨架:在http_filters下面插入一个type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua的配置块,里面定义一个inline_code字段,写脚本。
我直接告诉Cursor:“写一个Envoy Lua过滤器,根据请求头X-Priority的值(high/medium/low)决定是否排队。维护三个FIFO队列,用Redis作为共享存储,高优队列容量50,中优100,低优200。当队列满时返回429。放行逻辑:每个请求到来时,检查当前活跃的并发请求数(用共享计数器),如果并发未超过阈值(比如高优8、中优4、低优2),直接放行;否则进入对应优先级队列。同时后台有一个定时器,每秒检查活跃并发是否下降,如果下降就从高到低依次出队放行。”
代码很快生成出来了,但我一眼就发现两个问题:一是它用了os.time()做时间戳,这种阻塞调用在过滤器里是禁忌;二是它把队列状态完全放在本地内存里——这意味着多个Envoy Sidecar之间无法共享队列状态,分布式部署时就乱套了。我把这两点反馈给Cursor,让它改用ngx.now()替代,并引入Redis的lpush/rpop来管理分布式队列。来回改了三次,每次它都能在20秒内给出修改版本,我只需要审阅逻辑、检查边界条件。
下面是最终版本的核心Lua脚本(删减了部分辅助函数):
function envoy_on_request(request_handle)
local headers = request_handle:headers()
local priority = headers:get("X-Priority") or "low"
local max_concurrency = {high=8, medium=4, low=2}
local threshold = max_concurrency[priority] or 2
local redis = require "redis"
local red = redis.Redis(host="redis-svc", port=6379)
local active_key = "active:" .. priority
local queue_key = "queue:" .. priority
local active = tonumber(red:get(active_key) or "0")
if active = (max_queue[priority] or 50) then
request_handle:respond({[":status"] = "429"}, "queue full")
else
red:rpush(queue_key, request_handle:headers():get(":path"))
request_handle:headers():replace(":status", "202")
request_handle:respond({[":status"] = "202"}, "queued")
end
end
end
-- 每秒定时器从队列里出队
function envoy_on_timer()
local redis = require "redis"
local red = redis.Redis(host="redis-svc", port=6379)
local priorities = {"high", "medium", "low"}
for _, p in ipairs(priorities) do
local active = tonumber(red:get("active:" .. p) or "0")
local max_conc = {high=8, medium=4, low=2}
while active < max_conc[p] do
local path = red:lpop("queue:" .. p)
if not path then break end
red:incr("active:" .. p)
-- 实际转发逻辑由Envoy内部处理,这里简化
active = active + 1
end
end
end
这个脚本放到Envoy配置里后,我用curl模拟了混流压测,从Grafana监控看三个队列的深度变化,确实符合预期。但这个方案有一个致命缺陷:Redis成了单点瓶颈,高并发下lpush和rpop的往返延迟直接反映到请求尾部延迟上。后来我在Redis前加了一层本地环形缓冲,批量提交写入,把这个额外延迟从15ms压到了0.8ms,才算真正过关。(延伸阅读:VS Code这AI代码解释器,我调了半年才敢把它塞进CI流水线)
令牌桶和优先级队列的对决,我有话要说
在做准入控制时,我一开始考虑的是经典的令牌桶算法——给每种优先级预设固定的发放速率,高优令牌多,低优令牌少。但很快发现,令牌桶在突发流量面前“过于公平”了:它无法把当前闲置的算力立即分配给等待中的高优请求,因为令牌发放是匀速的,而GPU资源是离散的。比如一个高优请求在队列排着,但此时刚好有一个Pod闲下来,令牌桶不会立即放行,它还得等到下一个令牌生成周期——这在我们那种需要毫秒级响应的场景里就是浪费。
优先级队列的优势是:一旦有GPU资源释放,立刻从高到低调度队列最前面的请求,没有时间窗口限制。对比下来,我把这两者的差异做成了下面这个表:
| 维度 | 令牌桶 | 优先级队列 |
|---|---|---|
| 实现复杂度 | 低,成熟的Envoy本地过滤器 | 中高,需要外部队列和状态管理 |
| 突发流量处理 | 受限于令牌生成速率,可能引入等待 | 只要资源空闲,立即放行高优请求 |
| 公平性 | 严格按速率限制,低优不会被饿死 | 低优可能长时间饥饿,需要保底机制 |
| 延迟可预期性 | 好,流量整形后延迟稳定 | 高优极低延迟,低优尾部延迟可能飙升 |
| 资源共享效率 | 一般,令牌空闲时不能跨优先级借用 | 高,完全动态调度 |
| 我们的采纳结论 | 适合纯API限制场景 | 适合混合优先级且高优SLA严格的场景 |
我最终选了优先级队列+低优反压机制的方案,在Envoy那边做了低优请求的“最小服务保障”——当低优队列被挤压超过15秒后,强制提升为中等优先级出队一次,防止完全饿死。这个逻辑是在Envoy出队定时器里加的,改动不到10行代码。
基于排队延迟的KEDA Scaler,比HPA聪明了两个量级
HPA的CPU驱动缩放,在推理场景下全错
Kubernetes原生的HPA默认基于CPU或内存利用率做扩容决策,但在GPU推理场景下这个指标就是个笑话。推理服务的CPU利用率波动很小,主要负载全在GPU上;即便用Custom Metrics加上DCGM暴露的GPU使用率,也远远不够——因为GPU利用率飙升时,排队延迟早已把用户体验杀死了。我需要把扩缩容的决策信号从“资源消耗”切换成“服务质量”——也就是请求在队列里的等待时间。
这个想法来自排队论里的Little’s Law:平均排队长度 = 平均到达率 × 平均等待时间。当等待时间超过阈值,无论GPU利用率多少,都应该扩容。我把这个逻辑实现成了KEDA的External Scaler,让它直接读取Redis里的队列长度和每个请求的入队时间戳,计算出每个优先级当前的P99排队延迟,然后和预设的SLA阈值比较。(延伸阅读:当黑客把Prompt注入你的API,传统的WAF只能看戏——我在1000QPS攻击流下重构了大模型的安全防线)
我的KEDA Scaler实现:一个gRPC服务读Redis排队深度
动手写Scaler那天,我打开Cursor,新建了一个keds-scaler目录,跟它说:“根据KEDA external-scaler的gRPC规范,用Python实现一个服务,实现IsActive、GetMetricSpec、GetMetrics三个方法。Scaler通过读取Redis里三个优先级的队列长度,按优先级权重计算出一个综合排队指标:weighted_queue = 3*high_depth + 2*med_depth + 1*low_depth。如果加权深度超过5就激活扩容。返回的metric值就是这个加权深度。”
Cursor给我生成了proto的stub和基本实现框架。我需要手动调整的地方是Redis连接池管理、超时处理,以及MetricValue的精度对齐。因为KEDA的GetMetrics返回的是[]*external_metrics.MetricValue,每个Value必须是一个int64,而我的加权深度可能是浮点数——这里我踩了个坑:如果浮点精度导致scaler频繁抖动(一会儿3.99一会儿4.01),控制器就会疯狂扩缩。最后我把精度定在整数,加了一个0.5的迟滞区,比如加权值从6掉到5.5时不下线,必须低于5才缩容。
下面是核心的Scaler逻辑代码:
import redis
import time
from concurrent import futures
import grpc
from proto import external_pb2, external_pb2_grpc
class InferenceScaler(external_pb2_grpc.ExternalScalerServicer):
def __init__(self):
self.redis_pool = redis.ConnectionPool(host='redis-svc', port=6379, db=0, max_connections=20)
self.metric_name = "inference_queue_depth"
def IsActive(self, request, context):
"""如果任一队列有等待请求,则认为Scaler是活跃的"""
r = redis.Redis(connection_pool=self.redis_pool)
for priority in ['high', 'medium', 'low']:
if r.llen(f"queue:{priority}") > 0:
return external_pb2.IsActiveResponse(result=True)
return external_pb2.IsActiveResponse(result=False)
def GetMetricSpec(self, request, context):
spec = external_pb2.MetricSpec()
spec.metric_name = self.metric_name
spec.target_size = 5 # 加权队列深度阈值触发扩容
return external_pb2.GetMetricSpecResponse(metric_specs=[spec])
def GetMetrics(self, request, context):
r = redis.Redis(connection_pool=self.redis_pool)
weighted = 0
weights = {'high': 3, 'medium': 2, 'low': 1}
for priority, weight in weights.items():
depth = r.llen(f"queue:{priority}")
weighted += depth * weight
metric_val = external_pb2.MetricValue()
metric_val.metric_name = self.metric_name
metric_val.metric_value = int(weighted) # 整数化防止抖动
metric_val.current_metric_value = int(weighted)
return external_pb2.GetMetricsResponse(metric_values=[metric_val])
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
external_pb2_grpc.add_ExternalScalerServicer_to_server(InferenceScaler(), server)
server.add_insecure_port('[::]:50051')
server.start()
server.wait_for_termination()
部署时我把这个Scaler打包成容器镜像,推送到私有仓库。然后在KEDA的ScaledObject里引用它:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: inference-priority-scaler
spec:
scaleTargetRef:
name: inference-deployment
minReplicaCount: 2
maxReplicaCount: 12
cooldownPeriod: 120
triggers:
- type: external
metadata:
scalerAddress: inference-scaler-svc:50051
metricName: inference_queue_depth
targetValue: "5"
注意cooldownPeriod我设了120秒——推理服务Pod一旦拉起,要花90秒左右预热模型缓存,缩容太积极会导致“刚缩完又得扩”的震荡。这个数值是通过观察实际模型加载时间加30秒缓冲后确定的。
冷启动加速:预热池和请求转发
扩容解决了队列堆积的问题,但每个新Pod的冷启动延迟仍然高达80-110秒。这段时间内,它虽然注册到了Service,但根本不能处理推理请求——如果流量直接打上去,就是大量的超时和重试。我做了两件事来缓解:(延伸阅读:把ColPali塞进VideoRAG管道后,我的P99延迟从800ms砸到320ms,但中间烧掉三块A10G的预算)
第一,预热池(Warm Pool)。在集群里始终保留2个“半热”的Pod,它们不接收任何生产流量,但已经完成了模型加载和KV Cache的预填充。当Scaler触发扩容时,不是从零启动新Pod,而是先预热池里拉出Pod,打上生产标签,再启动一个新Pod填补预热池。这样扩容的有效时延从100秒降到了10秒(标签变更时间)。预热池Pod每隔30分钟轮换一次,防止缓存老化。这个逻辑用了一个CronJob加自定义Operator实现的,代码大概200行。
第二,模型缓存与请求转发。我把模型权重文件挂载在ReadWriteMany的并行文件系统上(用的是WEKA,因为我们A100集群的存储后端的IOPS要求太高,NFS根本扛不住)。新Pod启动时不需要从S3拉权重,直接mmap加载,加载时间从70秒降到35秒。再加上vLLM的–prefill-mode flag,提前加载常用Prompt的KV Cache,进一步压缩首次推理延迟。同时,我修改了推理网关的逻辑:当某个Pod处于就绪探测未通过但容器已启动的阶段,网关不会把请求丢给它,而是重新调度到其他已就绪实例,等探测通过后再正常分配。这避免了冷Pod拖慢整个服务。
压测那天,高优SLA稳如磐石,成本省了40%
Locust模拟三种优先级的混合流量
上线前,我用Locust搭了一个压测场景,模拟真实的流量分布:高优请求以平均每秒200 QPS的速率随机到达,长尾服务时间由帕累托分布生成;中等优先级150 QPS;低优先级在整点会有一波800 QPS的突发。总测试时长两小时。
在没部署优先级网关和KEDA Scaler的对照组里,高优请求的P99延迟在整点突发期飙升到3.2秒,低优请求反而因为先入队占住了Pod而延迟相对“正常”——典型的“劣币驱逐良币”。而在部署了新架构的实验组里,高优P99压到了120ms左右,中等优先级在800ms,低优先级波动较大,有些请求因为网关的429直接被拒,但那些被排到的请求平均延迟在6秒,而且没有饿死——因为Envoy里的“最小服务保障”机制每隔15秒会放出一个低优请求。
我还特别关注了扩缩容的行为。对照组里的HPA在突发期把副本数从4扩到12,但扩容滞后导致队列积压了超过2000个请求;新架构的KEDA Scaler在排队深度超过阈值后15秒内就开始扩容,配合预热池,新Pod在10秒内就开始接流。两小时的测试里,最高副本数达到了10,总GPU小时比对照组少了22%——因为优先级调度使得高优请求需要的资源更少,减少了对冗余副本的依赖。
成本账:多出来的Redis和预热池值不值
有人问我,搞这么复杂,多出来的Redis集群、KEDA Scaler Pod、预热池的额外GPU占用,这些成本划不划算?我算了一下:如果不做这些优化,为了把高优P99延迟降到500ms以内,我们需要把推理集群的基础副本数固定在10个(即使低谷期也保持),并且还需要为突发预留15%的弹性容量。总GPU小时每月约7200小时。在新架构下,基础副本数降到4个,KEDA根据实际负载弹性伸缩,配合预热池的2个额外Pod(非生产流量,只是保持热状态),平均每月GPU消耗降至5500小时左右,加上Redis和Scaler的运行成本,总成本节省了约40%。而且最关键的是——告警群安静了。
这件事让我彻底信了:模型推理服务绝对不是“扔一堆GPU然后配个Nginx”就能搞定的。当推理任务有严格的SLA和混合优先级时,必须在网格层做细粒度的流量治理。排队论不是书本知识,它在我的Redis队列里活了。