大模型推理服务的自动伸缩,是我最近折腾最多的事。之前做普通API网关,QPS一上来就加副本,简单直接;换成大模型推理之后,这套经验完全失效。一个请求可能会占着GPU几十秒,还会在显存里留下一大块KV cache,稍不留神就是OOM。更麻烦的是,我开始在Jetson AGX Orin上用llama.cpp跑7B模型做边缘推理,边缘设备没有弹性资源池,想扩都没得扩。所以我今天想聊的,不光是云上怎么扩缩容,还包括边缘这类固定资源环境下,怎么通过调度、排队和降级让工作负载保持平稳。
这篇文章的目标读者是做推理平台、模型服务或边缘AI部署的工程师。我会围绕指标设计、伸缩策略、Jetson AGX Orin上llama.cpp的实战参考,以及一套能跑的架构方案展开。该看公式看公式,该给配置给配置,希望能给你实际项目一个可复用的起点。
1. 大模型推理工作负载到底特殊在哪里
1.1 不是所有请求都能随意副本化
传统Web服务是无状态的,请求在哪个实例处理都一样,所以负载均衡器只管把流量分散到多个副本就行。大模型推理不是这样。请求在推理过程中需要保存KV cache,而且这个cache可能持续占用显存;如果某个推理请求在A实例上执行到一半,网关因为A负载高把后续请求转到B,之前的上下文肯定带不过去。虽然我们可以用“无状态”思路设计服务——模型参数共享、上下文放在外置缓存——但推理执行阶段的KV cache天然落在单张GPU上,迁移成本极高。
所以推理服务的水平扩容,第一件事不是“多起几个Pod”,而是让负载均衡意识到每个实例能处理多少并发、当前排队多长、模型是否已加载完成。一个还在加载7B权重的实例,如果直接收到请求,很可能直接把请求挂到超时,甚至让进程崩溃。我在实际项目里踩过这个坑:自动扩出来两个副本,readiness还没就绪就被网关引流,结果用户看到大量502。后来改成在网关层做实例状态感知,只把流量打到Ready且还有并发余量的副本上,才稳定下来。
我们还需要理解,大模型推理请求不像普通API几十毫秒就结束。一次流式对话可能要几十秒,中间用户可能逐字看到结果,但服务端一直在占用GPU计算资源。如果你把“平均延迟”当作扩缩容指标,会发现它在正常负载和异常负载下没有明显差异,因为长尾请求会拉高平均值。真正能反映压力的,是并发请求数、排队长度和KV cache的占用率。
1.2 指标选错,整个伸缩系统会乱
先别急着写HPA。Kubernetes默认的HPA基于CPU/内存,这对大模型推理几乎没用。GPU利用率高不代表服务“忙不过来”,因为它可能正在把多个请求batch到一起,虽然GPU满负荷,但队列里根本没人等着;反过来,如果推理框架配置了最大并发限制,GPU利用率可能只有30%,但队列里已经排了50个请求。这种情况下看GPU利用率,伸缩系统会误判为“负载不高,不扩容”,用户体验却已经很差。
我建议核心指标至少包含这五类:当前正在处理的请求数(running)、排队请求数(waiting)、GPU显存占用率、KV cache使用率、首token延迟(TTFT)。其中running和waiting直接反映压力,显存反映容量瓶颈,KV cache使用率反映能否承载更多并发,TTFT是服务质量兜底。采集方式上,vLLM自带Prometheus metrics,llama.cpp新版server也提供了metrics端点,Triton同样可以导出。到这一步你已经有原始数据了,剩下就是怎么把它们转换成伸缩决策。
要注意指标粒度。边缘设备上和云端容器里的指标语义可能不一样。云端一个实例通常一张卡,排队数可以直接反映;边缘设备上如果同时跑对话和离线任务,指标是按进程分的,你需要按模型服务分开打标签,否则平均聚合之后会把压力冲淡。我见过有人把所有模型进程的排队数求和,结果一个模型堵死但平均值看起来正常,扩容永远不触发。
2. 弹性伸缩的三个关键设计
2.1 先搞清楚你需要的“扩”是哪一种
大模型推理服务的伸缩,不应该局限于“水平加副本”这一种方式。我习惯把伸缩拆成三类,实际项目里往往混着用。
| 类型 | 做法 | 适用场景 | 主要问题 |
|---|---|---|---|
| 垂直伸缩 | 换更大显存的卡/实例 | 模型固定,单卡放得下 | 停机时间较长,云上成本陡增 |
| 水平伸缩 | 增加模型服务副本,由网关分担流量 | 并发压力大,需要多卡/多机 | KV cache亲和性、模型预热、路由复杂度 |
| 任务级伸缩 | 离线批量推理按队列长度起任务Worker | 离线评测、批量摘要、数据处理 | 需要独立的任务队列和控制面 |
垂直伸缩最简单,但违背了“弹性”的初衷——你总不能大半夜让运维手动换GPU实例。水平伸缩是主流方案,但要注意推理服务不是“起了副本就能立刻服务”。任务级伸缩往往被忽略,其实离线任务在大模型场景很常见,这些任务不需要低延迟,只需要吞吐,用队列消费时长来控制worker数量,弹性效果非常好。我在做云上批量评测时,就是用一个消息队列,Prometheus监听队列积压数量,积压超过100条就起一个worker,处理完自动消失,比用K8s HPA稳定很多。
2.2 伸缩策略不能只看瞬间数值
确定了指标,下一步是把“什么时候扩、什么时候缩”变成规则。我给自己的规则起名叫“快扩慢缩”。扩容要快,因为用户已经在排队;缩容要慢,因为刚处理完一波高峰,不代表下一秒没有新请求。比较稳妥的做法是:用最近2-3分钟的指标做滑动平均,连续超过阈值才扩;连续低于缩容阈值至少10分钟才缩。
可以写一个简单的伪代码:
while true: queue_len = avg_over_time(waiting_requests[2m]) if queue_len > 5: scale_up(1) elif queue_len < 2 for 10min: scale_down(1) else: keep这里的“5”和“2”不是拍脑袋,要结合单实例最大并发推算。比如单实例通过--parallel 4限制了最多同时处理4个请求,那么等待队列超过4就意味着新请求无法立即得到服务,扩容阈值就可以设在4;缩容阈值至少小于并发上限的一半,防止刚缩完又因为瞬时流量触发扩容。冷却时间也很关键,建议扩容冷却1分钟,缩容冷却5-10分钟。K8s HPA默认的扩容周期控制不了这么细,可以借助KEDA或者自己写一个控制器。
2.3 扩容不等于“起一个Pod”
很多人以为扩容动作就是把容器的replica从2改成3。对普通服务成立,对推理服务不行。一个推理Pod起来之后,要拉镜像、加载模型权重、编译CUDA kernel、申请显存、初始化KV cache。我实测7B模型权重从本地NVMe加载,大概需要10-20秒;如果从远端仓库拉权重,时间要到分钟级。在这之前,Pod不应该收到任何请求。
所以实例状态必须至少区分三态:Loading、Ready、Draining。Loading阶段,网关不路由流量,只有健康检查通过后切到Ready;缩容时先标记Draining,不再收新请求,等正在执行的推理完成后才真正退出。我用过的一个方案是,在Pod的标签上维护serving.state=LOADING,观测到/ready返回200后再改成READY,网关只认READY的实例。这部分在K8s原生readinessProbe里也能做,但要在网关层额外维护,避免readiness检查通过到实际可服务之间还有窗口。
3. 边缘场景:Jetson AGX Orin上跑llama.cpp的伸缩思路
3.1 固定资源设备,也要有“调度弹性”
聊完云端,说说边缘。Jetson AGX Orin这种设备,整机算力有限,内存虽然大但共享给CPU和GPU,不可能像云上那样动态新开一台实例。但边缘推理同样有“负载波动”:白天用户交互多,对话服务需要优先保障;晚上离线任务跑批量摘要,占满GPU也问题不大。这时候需要的不是扩容,而是让不同工作负载在固定资源上“弹性地交错运行”。
我最初的失败做法是:在Orin上同时拉起对话服务和离线任务服务,指望操作系统去调度。结果两个llama.cpp进程都在吃显存和算力,对话TTFT从0.3秒飙到4秒以上,用户直接投诉。后来改成“时段+优先级”的调度:白天对话模型常驻,离线任务暂停;深夜切换到离线任务,对话服务保留一个最小副本兜底。看起来像老式的批处理调度,但确实是最简单有效的边缘弹性方案。
3.2 Jetson AGX Orin上编译和启动llama.cpp
先说明环境:我的设备是Jetson AGX Orin 64GB,JetPack 5.1.2(L4T 35.4.1),自带CUDA 11.4。llama.cpp在Orin上可以用CUDA后端,但需要手动指定目标架构,否则编译出来可能在CPU上跑,性能差好几倍。编译命令大概长这样:
sudo apt install -y cmake build-essential git clone https://github.com/ggerganov/llama.cpp.git cd llama.cpp cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=87 cmake --build build --config Release -j $(nproc)这里的CMAKE_CUDA_ARCHITECTURES=87对应Orin的Ampere架构(compute capability 8.7),如果是上一代Xavier NX,要改成72。如果跳过这一步,CMake可能按默认架构编译,在Orin上无法加载CUDA kernel,或者只能回退CPU。编译完成后,模型建议用GGUF格式的量化版本。7B模型用q4_K_M量化后大约4.4GB,14B模型大约9GB,64GB内存完全放得下,但别把整个内存都塞满,还要给系统、输入输出和KV cache留余地。
启动命令我一般这么写:
./build/bin/llama-server \ -m models/qwen2.5-7b-instruct-q4_K_M.gguf \ --host 0.0.0.0 --port 8080 \ --n-gpu-layers 99 \ --parallel 4 \ --ctx-size 4096 \ --metrics--n-gpu-layers 99表示全部层扔到GPU,--parallel 4是同时推理的最大请求数,直接影响KV cache占用和GPU内存。--ctx-size 4096是单请求上下文长度,如果设成8192,每个并发请求占用的KV cache会翻倍,总并发数要跟着降。--metrics会开启一个Prometheus格式的指标端点,默认在/metrics,这是伸缩系统能拿到边缘实际数据的关键。实测下来,7B q4模型在Orin上--parallel 2时TTFT稳定在0.5秒左右,--parallel 6虽然吞吐高了,TTFT的P99会明显恶化,所以并发数不是越大越好。
3.3 边缘侧的三种“弹性”变通做法
第一种是模型级动态切换。在Orin上同时放一个7B对话模型和一个13B离线模型,用一个轻量调度器监控请求队列。白天对话队列超过阈值时,保证对话模型资源,暂停离线任务;晚上对话流量低了,再把离线任务拉起来。这个过程中不改代码,只改进程优先级和GPU上下文占用,可以用工具动态调整进程使用的CPU核数和GPU频率,避免两个模型互相抢资源。
第二种是云边联动。边缘设备毕竟算力有限,当本地请求排队时间超过一定阈值时,把部分请求转发到云端的大模型API。这其实是“外向弹性”:本地资源不够时,弹性借云端资源。关键是路由策略要分级,比如简单对话留在本地,复杂文档生成转到云端;同时设置好本地队列的最大容忍时间,不能等到本地已经堵死再转发,那样用户早就超时了。实际我在Orin上做过一个转发测试,本地排队超过3秒就把请求发到云端,用户侧的P95延迟从之前的15秒降到了6秒,成本多了一些,但体验提升非常明显。
第三种是多设备编排。如果你手上有不止一台Jetson,可以尝试用K3s组成一个小型边缘集群。但这套方案工程复杂度高,需要处理设备间网络、模型分片、共享存储等问题。除非确实需要横向扩展多路并发,否则我建议先做好模型级切换和云边联动,这两件事投入产出比更高。
4. 一套可落地的推理弹性伸缩架构
4.1 组件清单与职责划分
把云端和边缘的经验融合在一起,我习惯搭一套统一的推理服务控制面,核心组件包括五个:入口网关负责路由和流量感知;指标采集器负责从各推理框架拉取metrics;伸缩控制器负责根据规则计算期望副本数;模型实例池负责维护多个推理副本;模型仓库负责提供权重和版本。听起来和普通微服务架构很像,但关键差异在于模型实例池里的每个副本都有明确的状态机,伸缩控制器不仅控制副本数量,还要控制实例在状态机里如何流转。
入口网关这里我推荐使用支持自定义负载均衡策略的网关,比如nginx或者envoy。默认的round-robin在推理场景并不合适,因为你不知道每个实例的并发余量。更合适的是根据实例上报的queue_len做加权:队列越短权重越高。我在一个项目中用envoy的自定义filter,从Prometheus查询每个Pod的排队数,动态生成cluster的endpoints权重,效果立竿见影。如果你不想做太深,至少在网关层对每个Instance的/healthcheck多打几次,保证流量只到存活实例。
4.2 基于Prometheus + KEDA的自动扩缩配置
如果你用K8s,最省事的方式是KEDA。它能基于Prometheus指标创建ScaledObject,底层生成HPA,并且支持自定义冷却时间和扩容阈值。下面是一个我跑过的配置示例:
apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: llm-inference-scaler spec: scaleTargetRef: name: llm-inference kind: Deployment minReplicaCount: 2 maxReplicaCount: 8 cooldownPeriod: 120 pollingInterval: 15 triggers: - type: prometheus metricType: AverageValue metadata: serverAddress: http://prometheus.monitoring.svc:9090 query: > avg_over_time( vllm_num_requests_waiting{namespace="default"} [2m] ) threshold: "2" advanced: horizontalPodAutoscalerConfig: behavior: scaleUp: stabilizationWindowSeconds: 0 policies: - type: Pods value: 1 periodSeconds: 60 scaleDown: stabilizationWindowSeconds: 600 policies: - type: Pods value: 1 periodSeconds: 120这里用vllm_num_requests_waiting作为指标,阈值设为2,意思是平均排队超过2个请求就扩容一个副本。scaleUp不设置稳定窗口,让扩容立即响应;scaleDown稳定窗口10分钟,避免频繁缩。需要注意的是,如果你用llama.cpp的metrics,指标名要换成llama-server暴露的对应命名,字段语义类似。阈值不是固定不变的,它要跟着单实例并发上限走。比如单实例--parallel 4,那么running到4的时候已经打满,此时一有waiting出现就说明容量不足,阈值可以设成1或2;如果单实例并发上限是8,阈值可以相应提高。
4.3 缩容与优雅关闭的工程细节
缩容时最大的坑是“Pod被杀,但推理还没做完”。默认的K8s滚动更新会在Pod收到SIGTERM后直接终止容器,正在处理的HTTP连接会被断开,用户得到一个不完整的流式响应。要避免这个,必须在容器里做优雅关闭。
两个层面配合:一是readinessProbe和lifecycle。准备一个/ready端点,只返回模型是否可服务;生命周期里配置preStop钩子,在真正终止前等待存量请求处理完。脚本可以这样写:
#!/bin/bash # 让网关把当前实例标记为不可用 curl -X POST http://127.0.0.1:8080/drain # 等待所有排队的请求处理完成,超时最多90秒 timeout 90 bash -c 'while curl -s http://127.0.0.1:8080/health | grep -q "inflight"; do sleep 2; done'当然这只是一个示意,不同框架的drain接口不一样。vLLM本身有优雅退出机制,但会在退出前等待一定时间;llama.cpp server没有原生的drain接口,所以在边缘设备上我们更多是让前置网关先把该实例的权重设为0,然后sleep一段时间再退出容器。另一个细节是,缩容前一定要先摘流量,再等待,顺序反了会丢请求。不要寄希望于K8s默认的terminationGracePeriodSeconds,默认30秒对一个几秒钟的推理请求可能不够,建议设到120秒以上。
5. 实测中的常见问题与排查速查表
5.1 模型加载慢,扩容之后还在“空转”
现象:自动扩了一个副本,Pod状态变成Running,但请求还是大量超时。排查会发现副本的模型加载了50秒还没完成,最终超时。根因是readinessProbe检查的时机不对,或者模型权重从远端拉取太慢。解决办法是把权重放到实例本地,启动时用共享内存或NVMe缓存;再不然就把preloading做成独立步骤,在Deployment里用initContainer提前把模型从仓库拉到本地目录,业务容器启动后直接加载本地文件。这样虽然Pod启动时间没有缩短,但至少Ready时间可控,不会出现“副本还在加载就被导流”的情况。
5.2 显存超售与OOM
现象:并发升高后,推理容器报CUDA out of memory,整个Pod被重启。根因是扩容阈值只看排队,但没看显存和KV cache容量。单实例能跑到多少并发,是由模型大小和上下文长度决定的。7B模型q4量化大约占用4-5GB显存,如果每请求使用4K上下文,KV cache大概还要占几百MB,当--parallel设成8时,KV cache总量已经相当可观。如果Orin是64GB统一内存,看起来够大,但一次性给GPU缓存分配太多,会挤压系统内存,最终性能反而下降。我建议在扩容规则里叠加一个硬性条件:只有当前Pod的KV cache使用率低于80%时才允许继续增大并发,否则直接扩容副本而不是堆并发。
5.3 网关超时和重复请求的陷阱
现象:用户反馈同一个问题重复出现两次回答,或者部分请求变成502。根因是客户端超时后,网关或上游服务自动重试了同一个请求,但第一次推理并没有被取消,还在后台继续执行,于是同一份请求被处理了两次。大模型推理的特点决定它天然“慢”,网关重试要特别谨慎。我现在的做法是关闭不安全的自动重试,只在连接层做有限重试,并且给每个请求带上全局唯一ID,推理服务端预留去重能力。你可以把网关超时设置成比推理服务最长允许时间多几秒,比如推理最长60秒,网关超时75秒,然后宁可等也不盲目发第二次请求。
5.4 边缘和云端联动的链路问题
现象:本地排队压力下转发到云端,延迟没有降低,反而因为云端模型响应慢,导致整体超时。根因是转发判断只看本地队列长度,没考虑云端可用性和往返延迟。我建议在路由服务里同时维护云端API的健康状态和平均响应时间,如果云端响应已经劣化到超过5000ms,就停止转发,让请求在本地排队而不是全部压到云端。否则边缘设备的“弹性”变成了两边都堵死。真实项目里,我会在转发决策前先跑一次/health,并且用一个滑动窗口统计最近5次云端调用的耗时,超过阈值就自动降级。
下面把典型问题整理成一张速查表:
| 现象 | 可能原因 | 快速排查方法 | 解决方向 |
|---|---|---|---|
| 扩容后仍大量超时 | 模型加载未完成 | 看Pod日志/readinessProbe | 权重放本地,initContainer预热 |
| GPU利用率低但响应慢 | 并发限制过小,排队严重 | 查waiting指标 | 调整--parallel,增加replica |
| OOM重启 | 并发和上下文过大 | 看显存/KV cache指标 | 限制最大并发,缩上下文长度 |
| 重复生成回答 | 网关盲目重试 | 查网关access log | 关闭不安全重试,使用唯一请求ID |
| 边缘转发云上反而变慢 | 云端链路劣化 | 统计云端RT | 熔断降级,本地排队兜底 |
在Jetson AGX Orin上跑llama.cpp这段时间,我最大的感受是“弹性伸缩”不一定等于“增加机器”。云端可以靠Pod副本数解决问题,边缘设备只能在调度和优先级上做文章。实测下来,把--parallel从4改成2,TTFT的P99下降非常明显,但吞吐并不总是下滑,因为更少的并发降低了KV cache的占用和计算资源冲突,很多请求反而更快。所以如果你也刚接触大模型推理伸缩,先别急着搭特别复杂的控制面,从“把并发限制调正确”开始,再逐步加上指标采集、自动扩缩和云边联动。这套路走下去,哪怕是Jetson这类固定资源设备,也能在有限空间里给用户提供相对平滑的推理体验。