去年我接到一个任务,把团队跑了好几个月的推荐模型从开发机搬到正式环境。听起来很简单,不就是加载模型、做个HTTP接口吗?真正动手才发现,开发环境里"跑得通"和正式环境里"扛得住"完全是两码事。从那次之后,我陆续把团队里大大小小的模型——包括后来上线的 LLM——都梳理了一遍,折腾过 FastAPI、Triton、vLLM、TensorRT-LLM 这些部署框架,踩了不少坑。今天干脆把这一路走过来的模型部署框架全景整理出来,覆盖从单模型服务到 LLM 推理平台的完整演进线,给正在做服务化、做推理选型的朋友一个参考。
这篇文章的核心话题就是"正式环境模型部署框架":你也许刚把第一个模型做成 HTTP 接口,也许正在为大模型应用选型推理平台,无论哪个阶段,下面这些内容应该都能帮你少走一些弯路。眼下大模型相关的热搜词满天飞,LLM、推理平台、知识库问答、Agent 应用对部署架构的要求越来越高,但很多团队连最基础的单模型服务都还没做扎实。所以我会从最底层的单模型服务讲起,一步步过渡到 LLM 推理平台,把每一层的原理、选型和坑都摊开来说。
1. 起点问题:开发环境"能跑"和正式环境"扛得住"是两个物种
1.1 先分清三件事:推理、服务化与部署体系
我们平时在 Jupyter Notebook 里调用model.predict(),那叫"推理";用 FastAPI 包一层接口让外部程序能调用,那叫"服务化";到了正式环境,你还需要解决发布、回滚、监控、限流、容量规划这些问题,这才叫"部署体系"。不少团队把三者混为一谈,以为写完接口部署上线就完事了,结果流量一来就崩,这是第一个认知误区。
正式环境的模型服务有三个层次:最底层是模型推理本身,中间是外部可调用的接口契约,最上面是支撑运行的各种工程能力。推理层关心的是显存够不够、延迟多少、吞吐多高;接口层关心的是参数格式、错误码、超时策略;工程层关心的是进程挂没挂、要不要重启、流量大了怎么扩容。这三个层次对部署框架的诉求完全不同,单模型阶段靠一个进程就能解决,多模型和 LLM 阶段就必须引入平台级组件了。
1.2 最小可用单模型服务是怎么搭出来的
如果你只有一两个传统模型(比如 BERT 系列分类模型、推荐排序模型),我建议直接用 FastAPI 做一层封装,不需要上来就上重型框架。这里有一个容易忽略的原则:模型必须在进程启动时加载,而不是在请求到达时加载。很多人图省事把from_pretrained写进请求处理函数里,第一次请求要等几十秒,等到第二次调用时显存又莫名其妙多了一份拷贝。
我的最小模板长这样:
from fastapi import FastAPI, HTTPException import torch from transformers import AutoModelForSequenceClassification, AutoTokenizer app = FastAPI() # 进程启动时就加载,模型常驻显存 model = AutoModelForSequenceClassification.from_pretrained( "/models/bert-base", torch_dtype=torch.float16 ).cuda() model.eval() tokenizer = AutoTokenizer.from_pretrained("/models/bert-base") # 预热:触发 CUDA kernel 和 cuDNN autotuning def warmup(): dummy = tokenizer("warmup", return_tensors="pt").to("cuda") with torch.no_grad(): model(**dummy) warmup() @app.get("/healthz") def healthz(): return {"status": "ok"} @app.post("/predict") def predict(payload: dict): text = payload.get("text", "") if not text: raise HTTPException(status_code=400, detail="text is required") inputs = tokenizer(text, return_tensors="pt", max_length=512, truncation=True).to("cuda") with torch.no_grad(): logits = model(**inputs).logits label = int(logits.argmax(-1)) return {"label": label}有几个细节值得展开。第一是预热,PyTorch 第一次调用某个算子时会做 kernel 选择甚至即时编译,不预热的话线上第一个请求的耗时可能是平均值的十倍,监控图上会莫名其妙出现一个尖刺。第二是健康检查,Kubernetes 的探针会周期性地打/healthz,这个接口绝不能做重型计算,也不能依赖模型的可用性——否则一次显存抖动就会导致整个 Pod 被杀掉重启。第三是并发控制,如果你用的是 Uvicorn 单进程,默认是异步事件循环,推理这种 CPU/GPU 密集操作会阻塞事件循环,我通常会加一个asyncio.Semaphore限制并发,再用asyncio.to_thread把推理丢到线程池里执行,避免一个慢请求堵住所有请求。
1.3 多进程陷阱与显存碎片治理
单模型服务最常见的坑,是用 Gunicorn 起多个 worker 进程。默认情况下每个 worker 都会独立加载一份模型,显存直接翻倍甚至翻三倍。我曾经见过一个团队在 32GB 显存的卡上部署一个 7GB 的模型,起了 4 个 worker,结果启动就 OOM。这是个很容易忽视的经典问题。
在正式环境里我推荐两种稳妥方案。方案 A 是 API 层用单进程加线程池,把并发控制放在应用层,适合中小流量;方案 B 是把模型推理单独拆成一个服务,比如用 Triton Inference Server 承载推理,API 层可以多副本横向扩展,推理层保持单实例或按需多实例。方案 B 的另一个好处是 Triton 自带动态批处理(dynamic batching),能把间隔几百毫秒内的多个请求合并成一个 batch 推理,吞吐提升非常明显,尤其是 embedding 这类计算密集但单请求很轻量的场景。
显存碎片也是容易被忽略的问题。PyTorch 默认的显存分配策略在长时间运行后会产生大量碎片,尤其是请求长度不固定的 NLP 任务。我建议在启动命令里加上环境变量PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,实测下来碎片问题能缓解不少。同时要把nvidia-smi的显存监控接入告警,不是等到 OOM 才处理,而是设置一个比如 80% 的阈值提前预警。
2. 多模型并存之后,"网关层"是迟早要补的一课
2.1 为什么不能继续"每个模型各自暴露端口"
当你只有两个模型时,每个模型各自暴露一个端口似乎没什么问题。但模型数量一旦到五个以上,调用方就要维护一堆 endpoint,模型版本迭代时要么改调用方代码,要么在模型服务后面再加一层转发,权限和限流也没法统一管理。更麻烦的是灰度发布——你没法在不改调用方配置的情况下把新版本模型只切 5% 的流量。
网关的价值在这个阶段开始显现。它像公司前台:外部只知道一个总机号码,前台根据你的需求转接到对应部门。网关层可以统一处理路由、灰度、限流、鉴权、审计这几件事。模型服务本身不再关心"谁在调用我",只关心"把推理做对"。
2.2 用 APISIX 或 Envoy 做模型路由与灰度发布
我实际用的是 APISIX,主要因为它配置热更新方便,团队不用为改路由而重新发版。核心配置思路如下:
routes: - uri: /v1/embedding/* plugins: limit-req: rate: 200 burst: 50 proxy-rewrite: regex_uri: ["^/v1/embedding/(.*)", "/$1"] upstream: type: roundrobin nodes: "embedding-service-v1:8000": 95 "embedding-service-v2:8000": 5uri前缀区分不同的模型服务,limit-req做接口级限流,proxy-rewrite把网关前缀剥掉再转发到后端,upstream里的权重就是流量灰度比例。新模型版本上线时先给 5% 流量,观察错误率和耗时指标正常后,再逐步把权重调到 50%、100%。这一步能让"模型上线"这个动作变成一个纯配置操作,不需要业务方配合。
灰度发布有一个前置条件:新旧版本必须有统一的返回格式和错误码,否则你很难自动化判断新版本是否正常。所以我在做网关之前会把模型的输入输出 schema 先固定下来,状态码、错误信息、超时行为全部对齐。这一步如果没做好,灰度切流的时候会连"正常"都定义不了。
2.3 网关在 LLM 场景下的特殊处理:流式、超时与重试策略
网关处理传统模型的经验不能直接套用到 LLM 上。传统模型响应通常在一秒内,网关全局超时设 3 秒没问题。LLM 一个请求动辄十几秒甚至几十秒,如果你在网关上用固定短超时,会发现大量请求被误杀,客户端重试又会把后端打爆,最终形成雪崩。
LLM 场景下网关必须支持流式转发。模型侧返回的是 SSE 流,网关不能等整个 body 攒完再发给客户端,必须边收边转。很多开源网关对 chunked transfer 的支持并不好,用之前要重点验证这个能力。另外,重试策略必须谨慎:LLM 请求不一定是幂等的,用户问一个问题,你重试两次可能就生成了两份回答,既浪费算力又可能产生资损。我习惯在网关层设置"不自动重试,只返回明确的超时错误码",让业务方决定是否重试,并且重试必须带指数退避。
3. LLM 推理平台:"包个 HTTP 接口"在这里彻底失效
3.1 生成式推理的独特节奏:Prefill 与 Decode 的不对称性
把大模型用 FastAPI 包一个接口,看起来也能跑,但一旦有并发流量就会出问题。这和 LLM 的自回归生成机制有关。一次请求在模型内部会被拆成两个差异巨大的阶段:Prefill(预填充)阶段一次性处理整个输入 prompt,并行计算所有 token 的注意力,是典型的计算密集型;Decode(解码)阶段则是循环逐个生成输出 token,每一步都要读取之前的 KV Cache,是典型的访存密集型。
从用户体验的指标就能看出这种差异:TTFT(time to first token)主要受 Prefill 影响,prompt 越长 TTFT 越高;TPOT(time per output token)受 Decode 影响,决定了生成速度。传统模型一次 forward 就出结果,LLM 需要循环 forward 与输出长度相等的次数,这就是为什么 LLM 部署不能照搬传统推理服务。
3.2 连续批处理(Continuous Batching)为什么让吞吐翻倍
传统推理框架的动态批处理,是把同时到达的一批请求打包成一个 batch,等整个 batch 全部推理完才释放资源。这个模式放在 LLM 上会非常低效:如果 batch 里有请求输出长度相差很大,短的早就生成了EOS,但只能等长的输出完才能一起退出,GPU 白白空转。
连续批处理(Continuous Batching)的思路是,以请求为单位做调度,每个 step 都检查哪些请求已经生成完毕,完成了就立刻腾出位置,让新到的请求补进来。TensorRT-LLM 里叫 in-flight batching,vLLM 也实现了类似机制。你可以把它类比成演唱会 vs 流水线餐厅:传统批处理是同时开场同时散场,中间的人只能干等;连续批处理则是流水线,有人买单离开,马上安排新客人入座,整张桌子始终满员。这个机制是 LLM 推理平台吞吐量远高于朴素封装方案的根本原因。
3.3 KV Cache 与 PagedAttention:从显存视角理解平台的取舍
聊到 Decode 就绕不开 KV Cache。生成下一个 token 时,只需要新的 Query 和历史上所有 key、value 做注意力计算,所以已经算过的 key、value 可以缓存下来,避免重复计算。但 KV Cache 的代价是显存,它随序列长度线性增长。你可以在部署前用这个公式估算:
每 token 的 KV Cache 大小 ≈ 2(K 和 V 各一份) × 层数 × hidden_size × 每元素字节数以 7B 模型、32 层、hidden size 4096、FP16 为例,每多生成一个 token 约需 512KB 显存。如果输出长度是 2048 个 token,一个请求的 KV Cache 就会占用约 1GB。这就是为什么max-model-len这个参数如此重要,也解释了为什么动辄 128K 上下文的模型推理成本很高——不只是权重占显存,KV Cache 才是大头。
vLLM 的 PagedAttention 就是从这个角度出发做的优化。它把 KV Cache 切成固定大小的页,按需分配,逻辑上连续但物理上可以离散,显著降低了碎片浪费,还支持同一前缀的共享。PagedAttention 不只是一个工程技巧,它重新定义了 LLM 服务的内存管理方式,这也是 vLLM 能成为主流推理框架的关键原因之一。
3.4 流式输出:体验刚需,也是运维复杂度的来源
LLM 响应动辄几十秒,要用户盯着转圈等完整结果是不现实的,所以正式环境几乎都走 SSE 流式输出,模型每生成一个 token 就推给前端。流式输出的实现不难,难在运维。一个持续 30 秒的请求,日志系统如果等请求结束再写,那这 30 秒的中间状态全丢了;监控系统如果只看完成后的总时延,就发现不了首字延迟过高的问题。
还要处理客户端断开。用户如果中途关掉页面,连接断了,如果服务端没有及时检测并取消推理,GPU 会继续白跑几十秒,流量一大就是显存和算力的双重浪费。所以在平台层一定要做连接状态追踪,断连即取消生成,这个细节我在好几个项目里都吃过亏。
4. 推理框架选型:vLLM、Triton、TensorRT-LLM、SGLang 四个流派一次说清
4.1 四个主流框架的定位差异
选型是 LLM 推理平台绕不开的话题。我先把当前主流框架的核心差异做一个对比:
| 框架 | 核心优化点 | 适合场景 | 上手成本 | 主要限制 |
|---|---|---|---|---|
| vLLM | PagedAttention、连续批处理、HuggingFace 生态无缝接入 | 多数在线 LLM 服务,追求快速上线 | 低 | 某些模型架构需要等版本适配 |
| TensorRT-LLM | NVIDIA 官方深度优化、in-flight batching、极致吞吐 | 单角色固定 GPU 环境、追求最低延迟/最高吞吐 | 高 | 需要模型转换编译,版本绑定严格 |
| Triton | 通用推理服务器、多后端、dynamic batching、ensemble | 传统模型与 LLM 混合负载 | 中 | 自带机制不针对 LLM 做连续批处理,要挂后端 |
| SGLang | RadixAttention(前缀缓存)、结构化输出、多模态 | Agent 类复杂调用、频繁 JSON/工具输出 | 中 | 生态相对新,资料少一些 |
4.2 我的选型逻辑与一条可复制的决策路径
很多朋友问我"到底用哪个框架",我给不了笼统的答案,但我可以分享决策时用的几个判断依据。
团队只有一两个 LLM,主要用 HuggingFace 生态,追求最快上线,直接选 vLLM。它的 OpenAPI 兼容接口意味着业务侧可以从 OpenAI 无缝切换,团队成本最低。
如果只有一台或少数几台 GPU,且对延迟和吞吐的成本优化已经到了极致,非 TensorRT-LLM 莫属。但前提是你有能接受模型编译和版本绑定的人员储备。TensorRT 的 engine 是针对特定 GPU 和特定精度编译的,换卡就要重新转换,这个工程负担必须算进去。
如果公司已经有 Triton 承载 embedding、rerank 等传统模型,想统一管理,那 Triton 做底座,LLM 单独挂 vLLM 或 TensorRT-LLM 后端是合理的混合架构。Triton 的价值在于统一了传统模型和 LLM 的运维入口,你不用维护两套监控和两套部署流程。
如果业务是复杂的 Agent 应用,经常多轮对话、频繁输出 JSON 和工具调用,SGLang 的前缀缓存和结构化输出能力有实打实的优势。同一个会话前缀只算一次,成本节约非常可观。
4.3 用 vLLM 搭建最小 LLM 推理平台:参数含义逐个说
vLLM 是目前上手最快、最稳的方案,我以它为例给一个可直接复制的启动配置:
python -m vllm.entrypoints.openai.api_server \ --model Qwen/Qwen2.5-7B-Instruct \ --tensor-parallel-size 1 \ --gpu-memory-utilization 0.9 \ --max-num-seqs 32 \ --max-model-len 8192几个参数背后都有讲究。gpu-memory-utilization不是设越高越好,它代表给 KV Cache 预留的显存比例上限,90% 是常见值,因为你必须给模型权重、激活值和 CUDA context 留出余量,设成 1.0 很容易在请求波动时直接 OOM。max-num-seqs是并发请求上限,超过上限的请求会排队等待,这个参数相当于平台的第一道保险闸,防止并发无限涌入拖垮显存。max-model-len限制的是单请求"输入+输出"的最大总长度,它直接决定了 KV Cache 的上限,设太大意味着最坏情况下显存会被单请求吃光,设太小又满足不了长文档场景。
tensor-parallel-size是张量并行卡数。单卡能装下模型时保持 1 即可,多卡环境按卡数扩展。要注意张量并行会引入卡间通信开销,8 卡以上的扩展收益会递减,不是越多越好。
4.4 混合负载场景:Triton 与 LLM 服务的分工方式
我目前最满意的架构是混合的:Triton 管 embedding、rerank、分类这些传统小模型,vLLM 管 LLM 生成,前面统一走 APISIX 网关。Triton 这边用 dynamic batching 聚合 embedding 请求,vLLM 这边用 continuous batching 支撑大模型高并发。业务方完全不用关心背后是哪个引擎,网关层一刀切到对应服务。
这样做还有一个好处:传统模型和大模型的扩缩容节奏不一样,混在一个引擎里反而互相拖累。传统模型流量平稳大模型流量突发,拆开部署后各自伸缩互不干扰,最终稳定性好很多。
5. 正式环境上线前的"最后一公里":监控指标、压测方法与故障预案
5.1 该盯的指标根本不是 GPU 利用率
上线 LLM 平台后,很多人第一件事是打开nvidia-smi看 GPU 利用率。这里有个容易踩的坑:nvidia-smi 显示的是 GPU 引擎处于活动状态的时间比例,Decode 阶段是访存密集,GPU 引擎虽然忙,但实际上算力利用率并不高。你看到 90% 的利用率可能只是假象,不代表吞吐已经达到上限。
真正该盯的是这组指标:
| 指标 | 含义 | 建议 |
|---|---|---|
| TTFT p95 | 首 token 延迟 | 长 prompt 会显著拉高,设置分位数告警 |
| TPOT p95 | 每 token 生成延迟 | 判断解码阶段是否健康,通常要求百毫秒内 |
| 总吞吐 tokens/s | 整卡每秒产出 token 数 | 容量规划的核心指标 |
| 排队请求数 | 超出并发上限的等待量 | 持续增长说明容量不足 |
| KV Cache 占用率 | 显存里 KV 缓存的压力 | 接近上限时极易 OOM |
| 显存使用率 | 整体显存水位 | 建议 80% 预警 |
TensorRT-LLM 有官方 profiling 工具,vLLM 也提供了 metrics 端点,应该把 SM 占有率、显存带宽利用率这些细粒度指标接入监控系统,而不是只依赖 nvidia-smi 的粗粒度数据。
5.2 一套贴近真实情况的压测方法
压测 LLM 平台最常犯的错是用固定短文本打流量。实际业务中 prompt 长度分布极不均匀——有人发短句,有人贴整篇文档,越长 prompt 的 Prefill 阶段越耗时,KV Cache 占用越高,吞吐差异能到 3 到 5 倍。所以压测脚本必须模拟真实的 prompt 长度分布和输出长度分布。
下面是一个简化的压测思路,用 Python asyncio 并发请求,记录关键分位数:
import asyncio, aiohttp, time async def one(session, prompt: str, max_tokens: int): url = "http://localhost:8000/v1/chat/completions" payload = { "model": "test", "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens, "stream": False, } start = time.perf_counter() async with session.post(url, json=payload) as resp: data = await resp.json() total = time.perf_counter() - start return total, data.get("usage", {}).get("completion_tokens", 0)统计完原始数据后,按并发数从小到大打点:5、10、20、40、80 并发,输出 TTFT、TPOT 和总吞吐的曲线,找到吞吐拐点。那个拐点就是你平台的实际承载上限,而不是配置里写的max-num-seqs。
5.3 三个高频故障:OOM、超时雪崩、长尾拖垮批次
上线到现在,我遇到的线上故障基本可以归为三类,每类都有对应的预案。
第一类是显存 OOM。触发原因不是并发设置得过高,就是某条请求的序列总长超过了预期。应对手段有三层:gpu-memory-utilization不要超过 0.95,max-num-seqs设置保守上限,同时在 API 层单独限制 prompt 长度,把超长请求在进入推理引擎之前就拦截掉。
第二类是超时雪崩。客户端设置 5 秒超时,LLM 正常响应就要 20 秒,客户端超时后不断重试,流量翻倍把服务打死。这类问题靠平台本身解决不了,必须在网关层做三件事:超时时间按 TTFT + TPOT × 预期输出长度估算,为 LLM 接口单独设置超时策略;流式接口不设总超时而设置空闲超时;禁止无脑自动重试,重试必须指数退避。
第三类是长尾请求拖垮整个 batch。一两个超长输出的请求会把显存和调度器资源占住,导致同一 batch 里的短请求也跟着变慢。平台层面max-model-len是硬约束,应用层面我还会做分级路由:短文本走快速通道,超长文本走单独的慢速实例,让两类请求互相不干扰。这个策略在多租户场景尤其重要,一个租户的长上下文请求不应该影响另一个租户的实时性。
我个人这几年在模型部署上学到最重要的一件事是:永远不要让"演示能跑"骗过你。正式环境拼的不是某个环节有多强,而是整条链路在故障下能不能兜底。另一个小技巧我每次都会用:新框架或新版本上线前,先拿压测工具往死里打,打到它 OOM,记下那个临界并发,再回退 30% 设置保护参数。这样后面线上出状况的时候,你至少清楚地知道天花板在哪里,不会被流量打得猝不及防。