模型服务架构踩坑记录

最初的模型服务架构简单得不能再简单:一台 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 卡服务器,部署完全相同的服务。架构变成了这样:

graph LR A[用户请求] --> B[Nginx 负载均衡] B --> C[模型服务器 1] B --> D[模型服务器 2] C --> E[模型实例 1] C --> F[模型实例 2] D --> G[模型实例 3] D --> H[模型实例 4]

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,凌晨只有个位数。模型推理是重资源操作,为了应对峰值我们准备了大量资源,但大部分时间都闲置。

引入消息队列是个自然的选择:请求先进入队列,后台服务按自己的节奏消费处理。这样既能削峰填谷,又能避免突发流量压垮服务。

架构调整成这样:

graph LR A[用户请求] --> B[Web 服务] B --> C[Redis 消息队列] C --> D[Worker 1] C --> E[Worker 2] C --> F[Worker 3] D --> G[模型服务器 1] E --> G F --> H[模型服务器 2] G --> I[结果存储] H --> I I --> J[轮询服务] J --> K[用户]

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 内存占用飙升,最后把其他业务也挤爆。后来加了队列长度监控和限流机制,队列超过阈值时直接拒绝新请求。

高可用架构:最终的实践版本

经过多次迭代,我们的模型服务架构最终演变成了这个样子:

graph TB A[用户请求] --> B[负载均衡器 LVS] B --> C[Web 节点 1] B --> D[Web 节点 2] B --> E[Web 节点 3] C --> F[Redis Cluster 队列] D --> F E --> F F --> G[Worker 集群] G --> H[模型服务器组 1] G --> I[模型服务器组 2] H --> J[结果存储 PostgreSQL] I --> J J --> K[轮询服务] K --> L[用户] M[监控系统 Prometheus] --> H M --> I M --> F M --> G N[告警系统 AlertManager] --> O[运维人员]

关键组件和配置:

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_timeoutproxy_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/) 转载或引用必须申明原指尖魔法屋来源及源地址!