背景痛点:实时推理的三座大山
把 ChatGPT 模型搬上生产环境,最先撞上的不是“效果好不好”,而是“能不能跑得动”。
我去年接到的需求看似简单:在现有客服机器人里接入 7B 参数的 ChatGPT 模型,QPS 从 5 提到 20,延迟 P99 保持在 800 ms 以内。结果第一版裸模型上线,实测数据一出来直接劝退:
- QPS≈4,单卡 A10 显存 22 GB 被吃满
- 平均响应 1.9 s,P99 3.2 s
- 并发一高就 OOM,GPU Util 却不到 40%,大量算力空转
问题根因可以归结为“三高”:
- 计算量高——自回归生成每一步都要重新跑完整 Transformer,计算复杂度 O(n²) 随序列长度暴涨。
- 显存占用高——FP32 权重 28 GB,KV Cache 随 batch*seq_len 线性增长,长文本直接爆显存。
- 成本敏感——GPU 按小时计费,每多一张卡都是白花花的预算。
技术方案对比:把“大象”塞进“冰箱”的三件套
1. 模型量化:FP16 vs INT8
| 精度 | 模型大小 | 显存 | 平均 BLEU(dev) | 延迟(ms) |
|---|---|---|---|---|
| FP32 | 28 GB | 22 GB | 100(基准) | 1900 |
| FP16 | 14 GB | 14 GB | 99.7 | 1100 |
| INT8 | 7 GB | 8 GB | 98.1 | 650 |
结论:INT8 几乎砍半显存,延迟下降 3×,BLEU 掉 1.9 个点;在客服场景人工抽检 200 条,用户侧无感,可接受。
2. 动态批处理:让 GPU“挤地铁”
传统方案一个请求一条推理,GPU Kernel 频繁切换。开启 continuous batching(也叫 dynamic batching)后,新请求只要 token 总量没超上限就实时插空,推理引擎把一次 forward 做成“一车人”一起跑。实测同样 8×A10,QPS 从 20 提到 78,提升约 4 倍。
3. KV Cache 复用:把“记忆”留下来
自回归生成每次 forward 都要算过去所有 token 的 Key/Value。把 KV Tensor 缓存下来,下一轮只算新增部分,计算量从 O(n²) 降到 O(n)。再叠加 PagedAttention 把 Cache 分块存储,显存碎片率降低 35%,长文本 4k+ 也能稳得住。
核心实现:代码级拆招
HuggingFace 量化加载示例
from transformers import AutoTokenizer, AutoModelForCausalLM import torch model_id = "lmsys/vicuna-7b-v1.5" tokenizer = AutoTokenizer.from_pretrained(model_id) # INT8 量化配置 quant_config = dict( load_in_8bit=True, llm_int8_threshold=6.0, # 异常值通道阈值 llm_int8_skip_modules=["lm_head"] # 输出层保留 FP16 保精度 ) model = AutoModelForCausalLM.from_pretrained( model_id, torch_dtype=torch.float16, device_map="auto", **quant_config )Triton Inference Server 配置片段
# model_repo/chatgpt/config.pbtxt name: "chatgpt" backend: "python" max_batch_size: 64 dynamic_batch: true preferred_batch_size: [8, 16, 32] max_queue_delay_microseconds: 10000 instance_group [ { count: 2, kind: KIND_GPU, gpus: [0,1] } ] parameters { key: "FORCE_PYTORCH" value: { string_value: "yes" } }Prometheus 显存监控
import pynvml, time, prometheus_client pynvml.nvmlInit() h = pynvml.nvmlDeviceGetHandleByIndex(0) gauge = prometheus_client.Gauge('gpu_mem_used_mb', 'MB used') def collect(): info = pynvml.nvmlDeviceGetMemoryInfo(h) gauge.set(info.used // 1021024) prometheus_client.start_http_server(8000) while True: collect() time.sleep(5)把指标接入 Grafana,设置 90% 显存红线,自动触发扩容或拒绝新请求。
生产考量:压测、灰度、熔断一个都不能少
压测:Locust 脚本
from locust import HttpUser, task, between class ChatUser(HttpUser): wait_time = between(0.5, 2.0) @task def ask(self): self.client.post("/generate", json={"prompt": "如何修改快递地址?", "max_tokens": 128}, timeout=5)本地起 2000 并发,阶梯加压到 10k,观察 P99 延迟与 GPU Util 拐点,找到最优 batch_size。
熔断:基于平均响应时间的 Circuit Breaker
class LatencyBreaker: def __init__(self, threshold=1200, fail_rate=0.5): self.threshold = threshold self.fail_rate = fail_rate self.records = [] def call(self, latency_ms): self.records.append(latency_ms) if len(self.records) > 100: self.records.pop(0) fail = sum(1 for x in self.records if x > self.threshold) if fail / len(self.records) > self.fail_rate: raise RuntimeError("Circuit breaker open")当最近 100 次请求失败率过半直接返回 503,上游网关自动切到降级模型,避免雪崩。
避坑指南:踩过的坑,写进代码注释里
- 量化掉点 BLEU 怎么办?
在训练侧做 5% 数据回炉,加入 LoRA 微调 1 个 epoch,BLEU 回升 1.3,基本打平 FP16。 - 长文本显存溢出?
设置max_seq_len=3072,超出截断并提示用户;同时打开enable_memory_efficient_attention,显存峰值再降 18%。 - 热更新内存泄漏?
旧模型del后调用torch.cuda.empty_cache()还不够,一定要在 Triton 侧把 Python backend 的__del__写全,并用gc.collect()双保险;否则每更新一次涨 2 GB,凌晨三点被报警叫醒不是梦。
开放讨论
当请求超时与精度损失不可兼得时,您的业务更倾向哪种权衡?
——是把量化进行到底,还是保留 FP16 多花一张卡的钱?欢迎留言聊聊你的场景。
把“玩具”变“产品”的捷径
上面这套流程我前后折腾了两个月,如果你也想快速验证,推荐直接上手这个动手实验:从0打造个人豆包实时通话AI。实验把 ASR+LLM+TTS 串成一条完整链路,内置量化、批处理、缓存优化,代码全开源,本地 Docker 一键起。
我跟着做了一遍,大概 30 分钟就搭出可对话的 Web 页面,比自己从零攒省力太多。小白也能跑通,建议先玩起来,再回头啃生产化的细节。