大模型推理服务部署:从 GPU 资源调度到弹性伸缩的工程实践
大模型推理服务部署:从 GPU 资源调度到弹性伸缩的工程实践
一、GPU 资源瓶颈与推理延迟:大模型部署的双重约束
将大语言模型从开发环境推向生产环境,面临两个硬性约束:GPU 资源稀缺和推理延迟不可控。
GPU 资源稀缺体现在两个方面。一是成本:一块 A100 80G GPU 的月租约 1.5 万元人民币,一个 70B 参数的模型需要 4-8 块 A100 才能以合理延迟运行,月度硬件成本就超过 10 万元。二是供应:在算力紧张时期,GPU 实例的等待时间可能长达数周,无法按需弹性扩容。
推理延迟不可控则源于大模型的计算特性。自回归生成需要逐 Token 解码,每个 Token 的生成都需要一次完整的前向传播。70B 模型在 A100 上单 Token 生成延迟约 30-50ms,生成 500 Token 的回答需要 15-25 秒。更关键的是,推理延迟与输入长度线性相关——长上下文的 Prefill 阶段计算量巨大,可能导致首个 Token 的响应时间(TTFT)超过 10 秒。
这两个约束的交叉点就是部署架构的核心挑战:如何在有限的 GPU 资源下,最大化推理吞吐量,同时满足延迟 SLA。
二、大模型推理服务的部署架构
大模型推理服务的部署架构需要解决三个核心问题:模型加载与内存管理、请求调度与批处理、弹性伸缩与故障恢复。
graph TD
A[客户端请求] --> B[推理网关<br/>请求路由与限流]
B --> C[请求队列<br/>动态批处理]
C --> D[推理引擎<br/>vLLM/TensorRT-LLM]
D --> E[模型实例 A<br/>GPU 0-1]
D --> F[模型实例 B<br/>GPU 2-3]
D --> G[模型实例 C<br/>CPU 卸载]
E --> H[PagedAttention<br/>KV Cache 管理]
F --> H
G --> I[模型卸载<br/>CPU/GPU 内存交换]
B --> J[弹性伸缩控制器]
J -->|指标驱动| K[HPA: 请求队列深度]
J -->|定时策略| L[Cron: 业务高峰预扩容]
H --> M[流式响应<br/>SSE/WebSocket]
I --> M
style B fill:#f9f,stroke:#333
style C fill:#bbf,stroke:#333
style H fill:#bfb,stroke:#333
上图的关键设计在于"动态批处理"和"PagedAttention"。动态批处理将多个并发请求合并为一次前向传播,大幅提升 GPU 利用率。PagedAttention 是 vLLM 的核心创新,它借鉴操作系统的虚拟内存分页机制,将 KV Cache 分页管理,解决了传统推理引擎中 KV Cache 预分配导致的内存碎片问题,将 GPU 内存利用率从 20%-40% 提升到 90% 以上。
三、推理服务部署的生产级实现
3.1 推理网关与请求调度
/**
* 推理网关:请求路由、限流与优先级调度。
* 为什么推理服务需要专门的网关?
* 因为大模型推理与普通 HTTP 请求有本质区别:
* 1. 推理延迟高(秒级),需要流式响应
* 2. GPU 资源有限,需要精确的并发控制
* 3. 不同请求的优先级不同(付费用户优先)
* 普通 API 网关无法满足这些需求。
*/
@Service
public class InferenceGateway {
private final List<InferenceBackend> backends;
private final RequestPriorityQueue requestQueue;
private final TokenBucketRateLimiter rateLimiter;
/**
* 提交推理请求。
* 为什么使用优先级队列而非 FIFO?
* 因为不同用户的请求价值不同:
* - 付费用户的请求应该优先处理
* - 实时对话请求应该优先于批量处理请求
* FIFO 队列无法区分优先级,
* 可能导致付费用户等待免费用户的长请求完成。
*/
public Flux<TokenChunk> submit(InferenceRequest request) {
// 第一步:限流校验
if (!rateLimiter.tryAcquire(request.getEstimatedTokens())) {
return Flux.error(new RateLimitExceededException(
"推理服务当前负载过高,请稍后重试"
));
}
// 第二步:选择推理后端
InferenceBackend backend = selectBackend(request);
// 第三步:提交请求并返回流式响应
// 为什么返回 Flux 而非 Mono?
// 因为大模型是逐 Token 生成的,
// 流式返回可以让用户实时看到生成进度,
// 而非等待全部生成完毕后一次性返回。
return backend.generate(request)
.onErrorResume(e -> {
// 推理失败:尝试降级到备用后端
if (backend != getFallbackBackend()) {
return getFallbackBackend().generate(request);
}
return Flux.error(e);
});
}
/**
* 选择推理后端:基于模型类型和当前负载。
* 为什么需要多后端?
* 因为不同模型的 GPU 需求不同:
* - 小模型(7B)可以单卡运行
* - 大模型(70B)需要多卡张量并行
* - 不同模型可能需要不同的推理引擎
* 单一后端无法高效满足所有需求。
*/
private InferenceBackend selectBackend(InferenceRequest request) {
return backends.stream()
.filter(b -> b.supportsModel(request.getModel()))
.filter(b -> b.getCurrentLoad() < b.getMaxLoad())
.min(Comparator.comparingDouble(InferenceBackend::getCurrentLoad))
.orElseThrow(() -> new ServiceUnavailableException(
"所有推理后端已满载,请稍后重试"
));
}
}
3.2 Kubernetes 弹性伸缩配置
# 推理服务的 Kubernetes 部署配置
# 为什么用 StatefulSet 而非 Deployment?
# 因为推理服务需要稳定的网络标识和有序的部署/终止:
# 1. 模型加载需要 2-5 分钟,有序终止确保正在处理的请求完成
# 2. GPU 绑定需要稳定的设备分配
# 3. 分布式推理(张量并行)需要实例间稳定的网络通信
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: llm-inference
spec:
replicas: 2
podManagementPolicy: Parallel # 并行创建,加速扩容
selector:
matchLabels:
app: llm-inference
template:
metadata:
labels:
app: llm-inference
spec:
# 优先调度到 GPU 节点
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: nvidia.com/gpu.product
operator: Exists
containers:
- name: inference-server
image: llm-inference:v1.0
ports:
- containerPort: 8000
resources:
requests:
nvidia.com/gpu: 2 # 请求 2 块 GPU
limits:
nvidia.com/gpu: 2 # 限制 2 块 GPU
# 健康检查:推理服务就绪需要模型加载完成
# 为什么用启动探针而非就绪探针?
# 因为模型加载需要 2-5 分钟,
# 就绪探针的默认失败阈值太小,
# 会导致 Pod 在模型加载期间被重启
startupProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 10
failureThreshold: 30 # 最多等待 5 分钟
readinessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 5
lifecycle:
# 优雅终止:等待当前请求处理完成
# 为什么需要优雅终止?
# 因为推理请求可能需要 10-30 秒完成,
# 强制终止会导致用户收到不完整的回答
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 30"]
env:
- name: MODEL_NAME
value: "Qwen2-72B-Instruct"
- name: TENSOR_PARALLEL_SIZE
value: "2"
- name: GPU_MEMORY_UTILIZATION
value: "0.90" # GPU 内存利用率上限
- name: MAX_NUM_SEQS
value: "32" # 最大并发序列数
- name: MAX_MODEL_LEN
value: "8192" # 最大上下文长度
---
# HPA:基于自定义指标的弹性伸缩
# 为什么用自定义指标而非 CPU 利用率?
# 因为 GPU 推理的瓶颈不在 CPU,
# CPU 利用率无法反映 GPU 的真实负载。
# 自定义指标(请求队列深度)更准确地反映负载状态。
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: llm-inference-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: StatefulSet
name: llm-inference
minReplicas: 2
maxReplicas: 8
metrics:
# 基于请求队列深度伸缩
- type: Pods
pods:
metric:
name: vllm_request_queue_depth
target:
type: AverageValue
averageValue: "5" # 平均队列深度超过 5 则扩容
# 基于 GPU 利用率伸缩
- type: Pods
pods:
metric:
name: gpu_utilization_percent
target:
type: AverageValue
averageValue: "80" # GPU 利用率超过 80% 则扩容
behavior:
scaleUp:
stabilizationWindowSeconds: 60 # 扩容稳定窗口 1 分钟
policies:
- type: Pods
value: 2 # 每次最多扩 2 个 Pod
periodSeconds: 120
scaleDown:
stabilizationWindowSeconds: 600 # 缩容稳定窗口 10 分钟
# 为什么缩容窗口比扩容长?
# 因为模型加载需要 2-5 分钟,
# 过早缩容可能导致刚加载的模型被卸载,
# 下次扩容又需要重新加载,浪费时间和资源
policies:
- type: Pods
value: 1 # 每次最多缩 1 个 Pod
periodSeconds: 300
3.3 模型预热与冷启动优化
/**
* 模型预热服务:减少冷启动延迟。
* 为什么需要预热?
# 新启动的推理实例需要 2-5 分钟加载模型,
# 在模型加载完成前,实例无法处理请求。
# 如果等到流量到达时才启动新实例,
# 用户会经历长时间的等待。
# 预热策略是在流量高峰前提前启动实例,
# 确保流量到达时实例已就绪。
*/
@Service
public class ModelWarmupService {
private final InferenceBackend inferenceBackend;
private final KubernetesClient k8sClient;
/**
* 定时预热:根据历史流量模式预扩容。
* 为什么用定时预热而非纯响应式扩容?
* 因为 HPA 的响应式扩容有延迟:
* 1. 指标采集延迟(15-60秒)
* 2. 扩容决策延迟(HPA 检查间隔)
* 3. Pod 创建延迟(调度+拉取镜像)
* 4. 模型加载延迟(2-5分钟)
* 总延迟可能达到 5-8 分钟,
* 对于可预测的流量高峰(如工作日早 9 点),
* 提前预热比响应式扩容更可靠。
*/
@Scheduled(cron = "0 50 8 * * MON-FRI") // 工作日 8:50 预热
public void warmupForMorningPeak() {
int targetReplicas = calculateTargetReplicas("morning_peak");
int currentReplicas = getCurrentReplicas();
if (targetReplicas > currentReplicas) {
// 扩容到目标副本数
scaleTo(targetReplicas);
// 等待新实例就绪后发送预热请求
// 为什么发送预热请求?
// 因为模型加载完成后,首次推理的延迟
# 比后续推理高 2-3 倍(JIT 编译、缓存冷启动)。
# 预热请求触发首次推理,消除冷启动延迟。
waitForInstancesReady(targetReplicas);
sendWarmupRequests();
}
}
/**
* 发送预热请求:触发模型推理的首次执行。
* 为什么用简短请求而非真实业务请求?
* 因为预热请求的目的是触发模型加载和缓存初始化,
# 不需要生成有意义的输出。
# 简短请求消耗最少的 Token,降低预热成本。
*/
private void sendWarmupRequests() {
InferenceRequest warmupRequest = InferenceRequest.builder()
.model("Qwen2-72B-Instruct")
.prompt("Hello")
.maxTokens(1) # 只生成 1 个 Token
.temperature(0.0)
.build();
// 对每个新实例发送预热请求
for (String instanceUrl : getNewInstanceUrls()) {
try {
inferenceBackend.generate(warmupRequest, instanceUrl)
.blockLast(Duration.ofSeconds(30));
} catch (Exception e) {
// 预热失败不影响实例上线
// 首次真实请求会完成隐式预热
}
}
}
}
四、推理服务部署的代价与适用边界
大模型推理服务的部署架构在带来性能提升的同时,也引入了显著的运维复杂度。
第一,GPU 资源的利用率困境。 GPU 是昂贵的专用资源,但推理服务的流量通常有明显的波峰波谷。在工作日白天,GPU 利用率可能达到 90%;在凌晨低谷期,利用率可能只有 10%。但 GPU 实例无法像 CPU 实例那样快速启停——模型加载需要数分钟,频繁缩容再扩容会浪费大量时间在模型加载上。解决方案是:低谷期不完全缩容到零,而是保留最小可用实例数,同时利用低谷期执行离线批处理任务(如文档向量化),提高 GPU 资源的综合利用率。
第二,多卡张量并行的通信开销。 70B 及以上模型需要多卡张量并行,每次前向传播都需要在 GPU 之间同步中间结果。NVLink 的带宽约 600GB/s,PCIe 4.0 的带宽约 64GB/s。如果 GPU 之间只有 PCIe 连接,通信开销可能占推理时间的 30%-50%。因此,多卡推理必须使用 NVLink 互联的 GPU 服务器,而非普通的 PCIe 服务器。
第三,弹性伸缩的滞后性。 从 HPA 触发扩容到新实例真正就绪,可能需要 5-8 分钟(包括 Pod 调度、镜像拉取、模型加载、预热)。在这段时间内,已有实例可能已经过载。解决方案是设置合理的限流阈值——当队列深度超过安全水位时,主动拒绝新请求,而非让所有请求都排队等待。
适用边界:自建推理服务适合日均调用量超过 10 万次、对数据隐私有严格要求(如金融、医疗)、或需要深度定制推理流程的场景。对于调用量较低或对隐私要求不高的场景,直接使用云厂商的托管推理服务(如 Azure OpenAI、AWS Bedrock)成本更低、运维更简单。
禁用场景:如果团队没有 GPU 运维经验,自建推理服务的故障排查成本极高。GPU 驱动兼容性问题、CUDA 版本冲突、NCCL 通信超时等问题的排查需要专门的硬件知识。在这种情况下,托管推理服务是更务实的选择。
五、总结
大模型推理服务的部署是一个系统工程,涉及 GPU 资源管理、请求调度、弹性伸缩、模型预热等多个环节。核心目标是在有限的 GPU 资源下,最大化推理吞吐量,同时满足延迟 SLA。
落地路线建议:第一步,选择合适的推理引擎(vLLM 或 TensorRT-LLM),在单机环境验证模型的推理性能和内存占用。第二步,构建推理网关,实现请求路由、限流和流式响应,这是推理服务对外提供 API 的基础。第三步,部署到 Kubernetes,配置基于自定义指标的 HPA,实现弹性伸缩。第四步,根据历史流量模式配置定时预热策略,减少流量高峰期的冷启动延迟。第五步,建设 GPU 利用率和推理延迟的监控看板,持续优化部署参数。
推理服务的部署没有"最优"配置,只有在成本、延迟和吞吐量三者之间找到适合业务需求的平衡点。关键是建立可观测性,用数据驱动优化决策,而非凭经验猜测。
更多推荐




所有评论(0)