大模型推理服务自动伸缩实战:从云端到Jetson边缘的弹性调度

大模型推理服务自动伸缩实战:从云端到Jetson边缘的弹性调度 大模型推理服务的自动伸缩是我最近折腾最多的事。之前做普通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 metricsllama.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.stateLOADING观测到/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 64GBJetPack 5.1.2L4T 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_CUDAON -DCMAKE_CUDA_ARCHITECTURES87 cmake --build build --config Release -j $(nproc)这里的CMAKE_CUDA_ARCHITECTURES87对应Orin的Ampere架构compute capability 8.7如果是上一代Xavier NX要改成72。如果跳过这一步CMake可能按默认架构编译在Orin上无法加载CUDA kernel或者只能回退CPU。编译完成后模型建议用GGUF格式的量化版本。7B模型用q4_K_M量化后大约4.4GB14B模型大约9GB64GB内存完全放得下但别把整个内存都塞满还要给系统、输入输出和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{namespacedefault} [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增加replicaOOM重启并发和上下文过大看显存/KV cache指标限制最大并发缩上下文长度重复生成回答网关盲目重试查网关access log关闭不安全重试使用唯一请求ID边缘转发云上反而变慢云端链路劣化统计云端RT熔断降级本地排队兜底在Jetson AGX Orin上跑llama.cpp这段时间我最大的感受是“弹性伸缩”不一定等于“增加机器”。云端可以靠Pod副本数解决问题边缘设备只能在调度和优先级上做文章。实测下来把--parallel从4改成2TTFT的P99下降非常明显但吞吐并不总是下滑因为更少的并发降低了KV cache的占用和计算资源冲突很多请求反而更快。所以如果你也刚接触大模型推理伸缩先别急着搭特别复杂的控制面从“把并发限制调正确”开始再逐步加上指标采集、自动扩缩和云边联动。这套路走下去哪怕是Jetson这类固定资源设备也能在有限空间里给用户提供相对平滑的推理体验。