去年我接到一个任务把团队跑了好几个月的推荐模型从开发机搬到正式环境。听起来很简单不就是加载模型、做个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_dtypetorch.float16 ).cuda() model.eval() tokenizer AutoTokenizer.from_pretrained(/models/bert-base) # 预热触发 CUDA kernel 和 cuDNN autotuning def warmup(): dummy tokenizer(warmup, return_tensorspt).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_code400, detailtext is required) inputs tokenizer(text, return_tensorspt, max_length512, truncationTrue).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_CONFexpandable_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是典型的访存密集型。从用户体验的指标就能看出这种差异TTFTtime to first token主要受 Prefill 影响prompt 越长 TTFT 越高TPOTtime 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 batchingvLLM 也实现了类似机制。你可以把它类比成演唱会 vs 流水线餐厅传统批处理是同时开场同时散场中间的人只能干等连续批处理则是流水线有人买单离开马上安排新客人入座整张桌子始终满员。这个机制是 LLM 推理平台吞吐量远高于朴素封装方案的根本原因。3.3 KV Cache 与 PagedAttention从显存视角理解平台的取舍聊到 Decode 就绕不开 KV Cache。生成下一个 token 时只需要新的 Query 和历史上所有 key、value 做注意力计算所以已经算过的 key、value 可以缓存下来避免重复计算。但 KV Cache 的代价是显存它随序列长度线性增长。你可以在部署前用这个公式估算每 token 的 KV Cache 大小 ≈ 2K 和 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 推理平台绕不开的话题。我先把当前主流框架的核心差异做一个对比框架核心优化点适合场景上手成本主要限制vLLMPagedAttention、连续批处理、HuggingFace 生态无缝接入多数在线 LLM 服务追求快速上线低某些模型架构需要等版本适配TensorRT-LLMNVIDIA 官方深度优化、in-flight batching、极致吞吐单角色固定 GPU 环境、追求最低延迟/最高吞吐高需要模型转换编译版本绑定严格Triton通用推理服务器、多后端、dynamic batching、ensemble传统模型与 LLM 混合负载中自带机制不针对 LLM 做连续批处理要挂后端SGLangRadixAttention前缀缓存、结构化输出、多模态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, jsonpayload) 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.95max-num-seqs设置保守上限同时在 API 层单独限制 prompt 长度把超长请求在进入推理引擎之前就拦截掉。第二类是超时雪崩。客户端设置 5 秒超时LLM 正常响应就要 20 秒客户端超时后不断重试流量翻倍把服务打死。这类问题靠平台本身解决不了必须在网关层做三件事超时时间按 TTFT TPOT × 预期输出长度估算为 LLM 接口单独设置超时策略流式接口不设总超时而设置空闲超时禁止无脑自动重试重试必须指数退避。第三类是长尾请求拖垮整个 batch。一两个超长输出的请求会把显存和调度器资源占住导致同一 batch 里的短请求也跟着变慢。平台层面max-model-len是硬约束应用层面我还会做分级路由短文本走快速通道超长文本走单独的慢速实例让两类请求互相不干扰。这个策略在多租户场景尤其重要一个租户的长上下文请求不应该影响另一个租户的实时性。我个人这几年在模型部署上学到最重要的一件事是永远不要让演示能跑骗过你。正式环境拼的不是某个环节有多强而是整条链路在故障下能不能兜底。另一个小技巧我每次都会用新框架或新版本上线前先拿压测工具往死里打打到它 OOM记下那个临界并发再回退 30% 设置保护参数。这样后面线上出状况的时候你至少清楚地知道天花板在哪里不会被流量打得猝不及防。