模型服务架构踩坑记录
最初的模型服务架构简单得不能再简单:一台 8 卡 A100 服务器,部署一个 Flask 服务,模型加载在显存里,外部请求通过 Nginx 转发。
服务器 OOM,模型进程挂了,整个服务不可用。
单机时代:一切都很简单,直到出问题
最初的模型服务架构简单得不能再简单:一台 8 卡 A100 服务器,部署一个 Flask 服务,模型加载在显存里,外部请求通过 Nginx 转发。配置文件大概长这样:
# app.py
from flask import Flask, request, jsonify
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
app = Flask(__name__)
model_name = "THUDM/chatglm3-6b"
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
model_name,
trust_remote_code=True,
torch_dtype=torch.float16,
device_map="auto"
).eval()
@app.route("/generate", methods=["POST"])
def generate():
data = request.json
prompt = data.get("prompt", "")
max_length = data.get("max_length", 2048)
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
outputs = model.generate(
**inputs,
max_length=max_length,
do_sample=True,
top_p=0.7,
temperature=0.95
)
response = tokenizer.decode(outputs[0], skip_special_tokens=True)
return jsonify({"response": response})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, threaded=False)
Nginx 配置也很直白:
upstream model_backend {
server 127.0.0.1:5000;
}
server {
listen 80;
server_name model.example.com;
location / {
proxy_pass http://model_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 300s;
}
}
这套架构刚开始工作得不错,QPS 不高,延迟也能接受。但问题很快就来了:
显存泄漏:模型推理过程中,中间结果有时候没有及时清理,显存使用率缓慢爬升,最后 OOM。
进程假死:某些异常请求会导致进程进入僵死状态,不再响应新请求,但进程本身没有退出。
单点故障:这台服务器任何硬件问题(GPU 故障、网络中断、磁盘满)都会导致服务完全不可用。
最致命的是那次 OOM。监控显示显存使用率在 3 小时内从 65% 慢慢涨到 98%,然后某个请求触发了最后的 2%,进程被系统杀掉。没有自动重启,没有备用节点,完全靠人工恢复。
第一次改进:进程守护和资源监控
出事后做的第一件事是把服务改成 daemon 模式,用 Supervisor 管理进程:
[program:model-service]
command=/opt/conda/bin/gunicorn -w 4 -b 0.0.0.0:5000 app:app
directory=/home/user/model-service
user=user
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/model-service.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=10
environment=PYTHONUNBUFFERED="1"
同时加了 Prometheus 监控,采集显存、GPU 利用率、进程存活状态等指标:
from prometheus_client import start_http_server, Gauge
import pynvml
# 初始化 GPU 监控
pynvml.nvmlInit()
gpu_count = pynvml.nvmlDeviceGetCount()
# 定义指标
gpu_memory_used = Gauge('model_gpu_memory_used_mb', 'GPU memory used in MB', ['gpu_id'])
gpu_memory_total = Gauge('model_gpu_memory_total_mb', 'GPU total memory in MB', ['gpu_id'])
gpu_utilization = Gauge('model_gpu_utilization_percent', 'GPU utilization percentage', ['gpu_id'])
def update_gpu_metrics():
for i in range(gpu_count):
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle)
util = pynvml.nvmlDeviceGetUtilizationRates(handle)
gpu_memory_used.labels(gpu_id=i).set(mem_info.used / 1024 / 1024)
gpu_memory_total.labels(gpu_id=i).set(mem_info.total / 1024 / 1024)
gpu_utilization.labels(gpu_id=i).set(util.gpu)
# 启动监控服务
start_http_server(8000)
这次改进解决了一些问题:进程崩溃后会自动重启,资源状态可以实时监控。但单点问题依然存在,Supervisor 只能保证进程重启,无法解决服务器级别的故障。
而且引入了新问题:Gunicorn 的多个 worker 进程会导致模型重复加载到显存,显存占用翻倍。后来改成单 worker 多线程模式,但又受限于 GIL,并发性能上不去。
多机部署:从单机到集群
解决单点问题最直接的办法就是多加机器。我们又准备了一台同样的 8 卡服务器,部署完全相同的服务。架构变成了这样:
Nginx 配置也相应调整:
upstream model_backend {
server 10.0.1.10:5000 max_fails=3 fail_timeout=30s;
server 10.0.1.11:5000 max_fails=3 fail_timeout=30s;
least_conn;
}
server {
listen 80;
server_name model.example.com;
location / {
proxy_pass http://model_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_read_timeout 300s;
}
}
这里用了 least_conn 负载均衡策略,因为模型推理是 CPU 密集型任务,连接数少但处理时间长,基于连接数的均衡比轮询更合理。
多机部署确实提高了可用性:一台机器挂了,另一台还能继续工作。但也带来了新问题:
模型一致性:两台服务器上的模型必须完全一致,版本、权重、配置都不能有差异,否则会出现不同请求返回不同结果的情况。
负载不均:least_conn 只考虑连接数,但模型推理的 CPU 使用率和内存占用差异很大,某些复杂请求会长时间占用资源,导致负载实际不均衡。
状态同步:如果模型需要上下文缓存或者状态维护,多机之间就需要同步机制,架构复杂度会大幅上升。
我们踩过一个坑:一次模型更新时,服务器 1 更新成功,服务器 2 因为网络问题更新失败,导致两台服务器运行着不同版本的模型。客户发现同一个请求有时候返回 A 答案,有时候返回 B 答案,排查了半天才定位到版本不一致问题。
后来加了部署前检查和部署后验证,确保所有节点同步:
#!/bin/bash
# 部署前检查
check_sync() {
expected_version=$1
for server in 10.0.1.10 10.0.1.11; do
current_version=$(ssh $server "cat /home/user/model-service/version.txt")
if [ "$current_version" != "$expected_version" ]; then
echo "Server $server version mismatch: expected $expected_version, got $current_version"
exit 1
fi
done
}
# 部署后验证
verify_deployment() {
test_prompt="测试请求"
expected_answer="预期答案"
for server in 10.0.1.10 10.0.1.11; do
response=$(curl -s -X POST http://$server:5000/generate \
-H "Content-Type: application/json" \
-d "{\"prompt\": \"$test_prompt\"}" | jq -r '.response')
if [ "$response" != "$expected_answer" ]; then
echo "Server $server response verification failed"
exit 1
fi
done
}
消息队列:削峰填谷和异步处理
随着业务增长,请求量开始出现明显波峰波谷。白天高峰期 QPS 可能到 100,凌晨只有个位数。模型推理是重资源操作,为了应对峰值我们准备了大量资源,但大部分时间都闲置。
引入消息队列是个自然的选择:请求先进入队列,后台服务按自己的节奏消费处理。这样既能削峰填谷,又能避免突发流量压垮服务。
架构调整成这样:
Web 服务只负责接收请求和返回任务 ID,不直接调用模型:
# web_service.py
from flask import Flask, request, jsonify
import redis
import uuid
app = Flask(__name__)
r = redis.Redis(host='localhost', port=6379, db=0)
@app.route("/submit", methods=["POST"])
def submit_task():
data = request.json
task_id = str(uuid.uuid4())
task = {
"id": task_id,
"prompt": data.get("prompt", ""),
"max_length": data.get("max_length", 2048),
"status": "pending",
"result": None
}
# 存储任务信息
r.set(f"task:{task_id}", json.dumps(task))
# 加入处理队列
r.lpush("model_tasks", task_id)
return jsonify({"task_id": task_id, "status": "pending"})
@app.route("/result/<task_id>", methods=["GET"])
def get_result(task_id):
task_data = r.get(f"task:{task_id}")
if not task_data:
return jsonify({"error": "Task not found"}), 404
task = json.loads(task_data)
return jsonify({
"status": task["status"],
"result": task.get("result"),
"error": task.get("error")
})
Worker 从队列取任务并调用模型:
# worker.py
import redis
import json
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
r = redis.Redis(host='localhost', port=6379, db=0)
model_name = "THUDM/chatglm3-6b"
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
model_name,
trust_remote_code=True,
torch_dtype=torch.float16,
device_map="auto"
).eval()
def process_task(task_id):
task_data = r.get(f"task:{task_id}")
if not task_data:
return
task = json.loads(task_data)
try:
# 更新状态为处理中
task["status"] = "processing"
r.set(f"task:{task_id}", json.dumps(task))
# 模型推理
inputs = tokenizer(task["prompt"], return_tensors="pt").to(model.device)
with torch.no_grad():
outputs = model.generate(
**inputs,
max_length=task["max_length"],
do_sample=True,
top_p=0.7,
temperature=0.95
)
result = tokenizer.decode(outputs[0], skip_special_tokens=True)
# 更新任务状态
task["status"] = "completed"
task["result"] = result
r.set(f"task:{task_id}", json.dumps(task))
r.setex(f"task:{task_id}", 3600, json.dumps(task)) # 1小时后过期
except Exception as e:
task["status"] = "failed"
task["error"] = str(e)
r.set(f"task:{task_id}", json.dumps(task))
while True:
task_id = r.brpop("model_tasks", timeout=5)
if task_id:
process_task(task_id[1].decode())
这个架构解决了几个问题:流量削峰、异步处理、失败重试。但也带来了新挑战:
延迟增加:同步调用变成异步,用户需要轮询结果或者等待回调,实时性变差。
复杂性上升:任务状态管理、结果存储、过期清理都是新增的复杂性。
监控难度:队列长度、处理延迟、失败率都需要额外监控,单一指标很难反映整体健康状况。
我们曾遇到过队列积压问题:某次模型服务响应变慢,新任务持续进入队列,Redis 内存占用飙升,最后把其他业务也挤爆。后来加了队列长度监控和限流机制,队列超过阈值时直接拒绝新请求。
高可用架构:最终的实践版本
经过多次迭代,我们的模型服务架构最终演变成了这个样子:
关键组件和配置:
Web 层:多节点部署,无状态,通过 LVS 做四层负载均衡
upstream redis_cluster {
server 10.0.2.10:7000;
server 10.0.2.11:7000;
server 10.0.2.12:7000;
}
upstream web_backend {
server 10.0.3.10:8000;
server 10.0.3.11:8000;
server 10.0.3.12:8000;
check interval=3000 rise=2 fall=3 timeout=1000;
}
消息队列:使用 Redis Cluster 提供高可用和数据分片
# Redis Cluster 配置
cluster-enabled yes
cluster-config-file nodes-7000.conf
cluster-node-timeout 5000
appendonly yes
appendfilename "appendonly-7000.aof"
maxmemory 8gb
maxmemory-policy allkeys-lru
Worker 层:部署在 Kubernetes 上,自动扩缩容
apiVersion: apps/v1
kind: Deployment
metadata:
name: model-worker
spec:
replicas: 10
selector:
matchLabels:
app: model-worker
template:
metadata:
labels:
app: model-worker
spec:
containers:
- name: worker
image: model-worker:latest
resources:
requests:
memory: "4Gi"
cpu: "2"
limits:
memory: "8Gi"
cpu: "4"
env:
- name: REDIS_CLUSTER_NODES
value: "10.0.2.10:7000,10.0.2.11:7000,10.0.2.12:7000"
livenessProbe:
exec:
command:
- /bin/sh
- -c
- "redis-cli -c -h ${REDIS_CLUSTER_NODES} ping | grep PONG"
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
exec:
command:
- /bin/sh
- -c
- "redis-cli -c -h ${REDIS_CLUSTER_NODES} cluster info | grep cluster_state:ok"
initialDelaySeconds: 10
periodSeconds: 5
模型服务层:多组 GPU 服务器,每组内部负载均衡,组之间互为备份
# 模型服务健康检查
from flask import Flask, request, jsonify
import torch
app = Flask(__name__)
@app.route("/health", methods=["GET"])
def health_check():
try:
# 检查 GPU 可用性
if torch.cuda.is_available():
gpu_status = "ok"
gpu_memory = torch.cuda.memory_allocated() / 1024**3 # GB
else:
gpu_status = "unavailable"
gpu_memory = 0
# 检查模型加载状态
model_loaded = 'model' in globals()
return jsonify({
"status": "healthy" if model_loaded else "degraded",
"gpu_status": gpu_status,
"gpu_memory_gb": round(gpu_memory, 2),
"model_loaded": model_loaded
})
except Exception as e:
return jsonify({
"status": "unhealthy",
"error": str(e)
}), 500
监控和告警:全链路监控,关键指标告警
# Prometheus 告警规则
groups:
- name: model_service_alerts
rules:
- alert: HighQueueLength
expr: redis_queue_length{queue="model_tasks"} > 1000
for: 5m
labels:
severity: warning
annotations:
summary: "模型任务队列积压"
description: "队列 {{ $labels.queue }} 长度为 {{ $value }},超过阈值 1000"
- alert: ModelServiceDown
expr: up{job="model-service"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "模型服务不可用"
description: "模型服务 {{ $labels.instance }} 已经下线超过 1 分钟"
- alert: HighGPUMemory
expr: model_gpu_memory_usage_percent > 90
for: 10m
labels:
severity: warning
annotations:
summary: "GPU 显存使用率过高"
description: "GPU {{ $labels.gpu_id }} 显存使用率为 {{ $value }}%"
这套架构在几个关键方面做了加强:
无状态设计:Web 节点无状态,可以随时扩缩容;Worker 也设计为无状态,失败后可以重新分配任务。
故障隔离:不同组件之间通过网络隔离,一个组件的问题不会轻易扩散到其他组件。
自动恢复:Kubernetes 的健康检查和自动重启、Redis Cluster 的故障转移、LVS 的健康检查,都在不同层面提供自动恢复能力。
可观测性:从请求进入系统到返回结果的完整链路都有监控,出现问题可以快速定位。
踩过的坑和教训
这套架构不是一蹴而就的,中间踩过不少坑:
冷启动问题:模型加载需要 3-5 分钟,容器重启期间无法服务。我们做了预热机制,新 Pod 启动后先加载模型并通过健康检查再接收流量。
显存碎片:频繁的推理请求导致显存碎片化,可用显存越来越少。后来改成定期重启 Worker 和显存整理机制。
请求超时:某些长文本推理需要几分钟,Nginx 的默认超时时间太短。根据实际情况调整了 proxy_read_timeout 和 proxy_connect_timeout。
序列化开销:任务信息在队列和存储之间反复序列化,CPU 开销不小。后来改用更高效的序列化格式(MessagePack),并减少了不必要的数据传输。
监控误报:某次网络抖动导致大量误报,后来调整了告警阈值和持续时间,减少无效告警。
还有一个印象深刻的坑:我们最初用任务 ID 做 Redis key,直接用 UUID 字符串。结果有一天发现 Redis 内存占用异常高,排查发现是因为任务完成后 key 没有及时过期。改成带 TTL 的 key 后问题解决,但也说明在设计异步系统时,资源清理很容易被忽略。
一些实际数据的参考
这套架构上线后运行了半年,记录了一些实际数据:
请求量:平均 QPS 50-80,峰值可达 200+(削峰后)
延迟:P50 约 2秒,P95 约 8秒,P99 约 15秒(取决于输入长度)
可用性:月度可用性 99.8%,主要故障来自网络抖动和依赖服务
资源利用率:GPU 平均利用率 60-75%,峰值 85%+
成本:相比最初的纯峰值准备方案,总成本降低了约 40%
数据不是最好的,但对我们目前的业务规模来说够用。而且架构有一定的弹性,业务增长时可以相对平滑地扩容。
写在后面
从单机到高可用,不是简单的堆机器和加组件,而是对系统理解的逐步深入。每一步演进都解决了特定问题,也带来了新的复杂性。架构设计从来不是寻找"完美方案",而是在特定约束条件下做出最合理的取舍。
这套架构还会继续演进,可能会引入模型量化、推理优化、缓存机制等。但不管怎么变,有几个原则不会变:简单可靠、可观测、能恢复。复杂度是工程师的最大敌人,任何架构改进都要问自己:新增的复杂性是否带来了相应的价值?
那次凌晨三点的宕机教训现在想起来还觉得亏,但也正是那次教训推动了整个架构的重构。系统的问题往往在你最不想它出问题的时候暴露出来,而每一次暴露都是改进的机会。只要能从坑里爬出来并学到东西,就不算白掉进去。
版权声明: 本文首发于 指尖魔法屋-模型服务架构踩坑记录(https://blog.thinkmoon.cn/post/187-model-service-ha-architecture/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。