推理引擎这个词这两年出现的频率越来越高,但很多人第一次听到它的时候,脑子里其实没有清晰的画面。有人以为它是某种模型格式转换工具,有人觉得它跟训练框架是一回事,还有人把它和模型本身混为一谈。我刚开始接触这块的时候也走过类似的弯路,直到真正把 vLLM、SGLang、TensorRT-LLM、llama.cpp 这几个主流方案都跑过一遍,才慢慢摸清楚它们各自在解决什么问题、边界在哪里。这篇内容就是把我这段时间的实践和踩坑整理出来,围绕推理引擎的核心机制、主流方案的差异、部署实操以及常见故障排查展开,适合已经跑过模型推理、想进一步搞清楚底层调度逻辑的开发者,也适合正准备选型、纠结该用哪套方案落地的朋友。
1. 推理引擎到底在解决什么问题
1.1 从"能跑"到"跑得快"的分水岭
很多人第一次部署大模型,用的是最朴素的方式:加载权重,写一个循环,把输入喂进去,拿到输出。这种方式在单条请求、短文本的场景下完全够用,一旦并发上来,问题立刻暴露。显存被 KV Cache 撑爆、GPU 利用率忽高忽低、请求排队时间远超计算时间,这些都是典型的症状。
推理引擎要解决的核心矛盾,就是计算资源的有限性和请求负载的波动性之间的不匹配。它做的事情可以拆成三层:最底层是算子层面的优化,比如把注意力计算融合成一个 kernel、用量化降低显存占用;中间层是内存管理,典型代表就是 PagedAttention 这类把 KV Cache 分页管理的机制;最上层是调度,决定哪些请求先算、哪些请求拼成一个 batch、显存不够时先挤掉谁。
这三层里,最容易被忽视但影响最大的是调度层。我见过不少团队把模型换成了量化版本、把 kernel 换成了更快的实现,吞吐提升却只有百分之十几,原因就在于调度策略没有跟着调整,GPU 大部分时间在等显存分配或者等某个长请求算完。
1.2 推理引擎和训练框架的边界
一个常见的误解是把推理引擎当成训练框架的附属品。实际上两者的设计目标几乎是相反的。训练追求的是单步计算的极致吞吐和梯度正确性,显存可以开得很大,batch 可以堆得很高;推理追求的是首 token 延迟和单 token 延迟的平衡,同时要在有限的显存里塞下尽可能多的并发请求。
这就导致推理引擎必须做很多训练框架不会做的事:动态 batch 拼接、请求级别的抢占和恢复、KV Cache 的复用与淘汰、流式输出的增量解码。这些机制在训练场景里要么不需要,要么会拖慢速度。所以你会看到 vLLM 这类项目虽然也支持一些训练相关的接口,但它的核心代码几乎全部围绕推理调度展开。
理解这条边界很重要,因为它决定了你在选型和调优时的关注点。如果你拿训练框架的思路去调推理引擎,比如一味追求大 batch,很可能首 token 延迟会高到用户无法接受。
1.3 主流方案的定位差异
目前市面上讨论度最高的几套方案,定位其实差别很大,不能简单地用"谁更快"来比较。
| 方案 | 核心定位 | 典型场景 | 调度特点 |
|---|---|---|---|
| vLLM | 通用高吞吐服务 | 在线 API、批量推理 | PagedAttention + 连续批处理 |
| SGLang | 结构化生成与高并发 | 多轮对话、约束解码 | RadixAttention + 前缀共享 |
| TensorRT-LLM | 极致单卡性能 | 延迟敏感的线上服务 | 编译期图优化 + In-flight Batching |
| llama.cpp | 端侧与低资源部署 | 本地运行、边缘设备 | CPU/GPU 混合 + 量化优先 |
这张表只是粗略定位,实际选型还要看你的硬件、模型结构、并发模式和延迟要求。比如同样是部署一个 7B 模型,如果你只有一张消费级显卡,llama.cpp 的量化方案可能比 vLLM 更实际;如果你要跑多轮对话且系统提示词很长,SGLang 的前缀缓存收益会非常明显。
2. vLLM 的 EngineCore、Scheduler 与 Executor 是怎么协作的
2.1 一次请求从进入到吐字的完整链路
要理解 vLLM 的调度逻辑,最好的方式是跟着一条请求走一遍。当一条请求通过 OpenAI 兼容接口进来,它首先被 API Server 接收并转成内部的请求对象,然后交给 EngineCore。EngineCore 是整个引擎的中枢,它持有 Scheduler 和 Executor 两个关键组件。
Scheduler 负责决定这一步要处理哪些请求。它维护着几个队列:等待队列、运行队列、交换队列。每一步开始时,Scheduler 会尝试从等待队列里取出请求,检查显存是否够用,够用就放进运行队列,不够就留在原地或者触发抢占。Executor 则负责真正执行模型前向计算,它管理着 GPU 上的模型副本和 KV Cache 的物理块。
这个链路里有一个容易被忽略的细节:Scheduler 的决策和 Executor 的执行是解耦的。Scheduler 只负责产出"这一步要算哪些 token"的计划,Executor 拿到计划后执行,执行完把结果回传给 EngineCore,再进入下一步调度。这种解耦让 vLLM 可以支持多种后端,比如 GPU、CPU、甚至分布式多机。
2.2 连续批处理为什么是吞吐的关键
连续批处理是 vLLM 吞吐优势的主要来源。传统的静态批处理要求一个 batch 里的所有请求同时开始、同时结束,只要有一条请求生成长文本,整个 batch 都得等它。连续批处理打破了这个限制,每一步都可以重新组 batch,已经算完的请求立刻退出,新来的请求立刻补位。
这个机制的效果在长短请求混合的场景下特别明显。我实测过一个场景,固定 batch 下 GPU 利用率只有四成左右,换成连续批处理后能稳定在七成以上。原因很简单,静态批处理里 GPU 有大量时间在等最慢的那条请求,而连续批处理让每一步的计算量都尽量填满。
不过连续批处理也带来一个副作用:单条请求的延迟会变得不那么确定。因为每一步的 batch 组成都在变,某条请求可能因为同 batch 里有长请求而被拖慢。如果你的业务对尾延迟极其敏感,就需要在调度参数上做取舍,比如限制单步的最大 token 数。
2.3 抢占与交换:显存不够时发生了什么
显存不够是推理服务里最常见的压力来源。当运行队列里的请求占用的 KV Cache 超过可用显存时,Scheduler 必须做出选择:要么拒绝新请求,要么把某些正在运行的请求换出去。
vLLM 的做法是抢占。被抢占的请求有两种处理方式:一种是直接丢弃它的 KV Cache,等重新调度时从头算;另一种是把它换到 CPU 内存里,等显存空出来再换回来。前者实现简单但浪费计算,后者节省计算但增加传输开销。
这里有个实操经验:如果你的请求普遍较长,交换的收益会明显高于重算,因为重算的代价随序列长度线性增长。但如果请求都很短,交换的传输开销可能反而更亏。vLLM 提供了相关参数来控制这个策略,调优时需要结合你的实际序列长度分布来决定。
2.4 调度参数里最值得动的几个
vLLM 暴露了不少调度相关的参数,但并不是每个都值得调。根据我的经验,下面这几个对性能影响最直接:
max_num_seqs:单步最多处理多少条请求。调大能提升吞吐,但会挤占单条请求的显存,可能拉高延迟。max_num_batched_tokens:单步最多处理多少 token。这个参数直接决定 prefill 阶段的 batch 大小,对首 token 延迟影响很大。gpu_memory_utilization:显存使用上限。默认值偏保守,适当调高能容纳更多 KV Cache,但要留出余量防止 OOM。swap_space:交换空间大小。请求长且并发高时可以适当调大。
调这些参数没有万能公式,我的做法是先固定其他变量,单独调一个,观察吞吐和延迟曲线的拐点。通常max_num_batched_tokens调到某个值之后吞吐就不再明显增长,那个点就是比较合适的配置。
3. SGLang 与 vLLM 的差异到底在哪
3.1 RadixAttention 解决的是前缀重复问题
SGLang 最核心的卖点是 RadixAttention,它用一棵基数树来管理 KV Cache,让多个请求共享相同的前缀。这个设计在多轮对话和少样本提示的场景下收益巨大,因为这类场景里系统提示词和对话历史往往高度重复。
举个具体的例子,假设你的系统提示词有两千个 token,每次对话都要带上。用 vLLM 的话,这两千个 token 的 KV Cache 每条请求都要单独算一遍、单独占一份显存。用 SGLang 的话,这部分只算一次,后续请求直接复用,显存占用和计算量都能省下来。
我实测过一个多轮对话场景,系统提示词一千五百 token,平均对话轮次五轮。SGLang 的首 token 延迟比 vLLM 低了大约三成,显存占用也少了将近四成。这个差距随着提示词变长和并发变高还会进一步拉大。
3.2 结构化输出的实现路径不同
SGLang 另一个有特色的地方是它对结构化输出的支持。它把约束解码做进了调度层,可以在生成过程中直接限制输出符合某个语法或正则。vLLM 也有类似能力,但更多是通过外挂的 logits processor 实现,灵活性和性能上略有差异。
如果你的业务需要模型稳定输出 JSON、SQL 或者某种固定格式,SGLang 的这套机制能省掉不少后处理的工作。不过要注意,约束解码本身会带来一定的计算开销,约束越复杂开销越大,需要在格式稳定性和吞吐之间做权衡。
3.3 两者在部署形态上的取舍
从部署角度看,vLLM 的生态更成熟一些。它的 OpenAI 兼容接口做得比较完整,周边工具链、监控集成、多机部署方案都更丰富。SGLang 起步晚一些,但在特定场景下的性能优势确实存在。
我的建议是:如果你的场景前缀重复度高、或者需要强约束输出,优先试 SGLang;如果是通用 API 服务、追求生态和稳定性,vLLM 更稳妥。两者并不是非此即彼,实际项目里也有把不同模型分别部署在两套引擎上的做法。
4. TensorRT-LLM 和 llama.cpp 各自的主场
4.1 TensorRT-LLM 的编译期优化逻辑
TensorRT-LLM 走的是另一条路。它不像 vLLM 那样在运行时动态调度,而是在编译期就把模型图优化好,把能融合的算子融合掉,把能预计算的权重预计算掉。这种方式的优势是单次前向的延迟极低,特别适合对延迟敏感的线上服务。
代价是灵活性下降。模型结构变了要重新编译,支持的模型种类也不如 vLLM 那么广。而且编译过程本身比较耗时,对快速迭代不太友好。我的经验是,如果你的模型固定、延迟要求苛刻、硬件是 NVIDIA 的较新卡,TensorRT-LLM 值得投入;如果模型经常换或者需要快速试错,vLLM 更合适。
4.2 llama.cpp 在端侧和低资源场景的价值
llama.cpp 的定位和前两者完全不同。它的核心优势是量化做得极其细致,从 2 bit 到 8 bit 有各种粒度的方案,而且对 CPU 和混合推理的支持很好。这让它能在没有独立显卡的机器上跑起来,甚至能在一些边缘设备上运行。
量化带来的精度损失是必须正视的问题。低比特量化在通用对话上可能看不出明显差异,但在需要精确推理的任务上,比如数学计算或者代码生成,损失会比较明显。我的做法是先用较高比特的量化跑一遍基线,再逐步降低比特数,观察任务指标的变化,找到精度和资源占用的平衡点。
4.3 选型时容易忽略的隐性成本
选型时大家容易只看吞吐和延迟这两个显性指标,忽略了一些隐性成本。比如运维复杂度,TensorRT-LLM 的编译和版本管理比 vLLM 麻烦不少;比如社区活跃度,遇到问题时能不能快速找到答案;比如硬件适配,某些方案对特定型号的卡优化更好。
这些隐性成本在项目初期不明显,到了规模化阶段会集中爆发。我踩过的一个坑是早期选了一个性能很好但社区很小的方案,后来遇到一个算子兼容问题,卡了整整一周才解决。所以选型时除了看跑分,也要看这个方案背后的生态是否健康。
5. 部署实操:从镜像拉取到服务跑通
5.1 镜像选择与模型加载的常见误区
用 Docker 部署 vLLM 是最常见的方式,但镜像选择上有几个坑。首先,官方镜像里通常不带模型权重,模型需要单独挂载或者从模型仓库拉取。很多人第一次跑的时候以为镜像自带模型,结果启动就报找不到权重。
其次,镜像的版本和模型的支持范围是绑定的。新模型往往需要较新的镜像版本才能支持,而新版本有时又会引入性能回退。我遇到过升级镜像后吞吐下降的情况,排查下来是新版本的调度默认值变了。所以升级前最好在测试环境跑一遍基准,确认没有回退再上生产。
挂载模型时要注意路径权限和磁盘空间。大模型的权重动辄几十 GB,如果挂载的卷空间不够,加载到一半会失败,而且报错信息不一定直观。
5.2 启动参数怎么配才不踩坑
启动 vLLM 服务时,参数配置直接决定服务能不能稳定跑起来。下面是我常用的一套基础配置思路:
python -m vllm.entrypoints.openai.api_server \ --model /path/to/model \ --served-model-name my-model \ --tensor-parallel-size 1 \ --gpu-memory-utilization 0.9 \ --max-model-len 8192 \ --max-num-seqs 256 \ --port 8000几个关键点说明一下。tensor-parallel-size要和你的显卡数量匹配,单卡就填 1,多卡要填能整除注意力头数的值。max-model-len不要盲目设大,它直接决定 KV Cache 的单请求占用,设得太大显存很快就不够。gpu-memory-utilization默认是 0.9,如果发现启动时 OOM,可以适当降到 0.85 左右。
还有一个容易忽略的点是模型的最大长度和实际业务需求要匹配。如果你的业务输入输出加起来最多两千 token,却把max-model-len设成三万二,那显存会被大量浪费在预留空间上,能承载的并发数会大幅下降。
5.3 验证服务是否真的跑通
服务启动后不要只看日志里有没有报错,要做实际的请求验证。我通常分三步:先用一条短请求确认基本通路,再用一条长请求确认长序列没问题,最后用并发请求确认调度正常。
curl http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "my-model", "messages": [{"role": "user", "content": "你好"}], "max_tokens": 64 }'如果这一步返回正常,再逐步加大max_tokens和并发数。并发测试可以用简单的脚本或者压测工具,观察吞吐和延迟随并发数的变化曲线。曲线出现明显拐点的地方,往往就是当前配置的容量上限。
6. 性能回退与常见故障的排查链路
6.1 升级后吞吐下降该怎么定位
版本升级后性能回退是让人很头疼的问题,因为变量太多。我的排查顺序是这样的:先确认是不是配置默认值变了,对比新旧版本的启动参数和默认值差异;再确认是不是调度策略调整了,比如抢占阈值或者 batch 组成逻辑变了;最后才怀疑是不是算子实现有变化。
定位过程中,基准测试的可复现性至关重要。我习惯在升级前把当前版本的吞吐、延迟、显存占用都记录下来,用固定的请求集和固定的并发模式。升级后用同样的条件再跑一遍,差异一目了然。如果没有这个基线,光靠感觉判断"好像变慢了"是很难定位的。
6.2 显存溢出和请求被拒的区分
显存溢出和请求被拒是两种不同的故障,但表现上有时候很像,都是请求失败。区分的方法是看日志和监控:显存溢出通常伴随 OOM 报错,GPU 显存曲线会顶到上限;请求被拒则更多是调度层面的决策,显存可能还有余量,但调度器判断当前不适合再接收新请求。
处理方式也不同。显存溢出要降gpu-memory-utilization或者降max-model-len;请求被拒则要调调度参数,比如放宽max_num_seqs或者调整抢占策略。搞混了这两个,调优方向就完全错了。
6.3 长请求拖垮整体延迟的处理
长请求是推理服务里的"钉子户",一条超长请求可能让整个 batch 的延迟飙升。处理这个问题有几个思路:一是限制单请求的最大输出长度,从源头控制;二是把长请求和短请求分开部署,用不同的实例承载;三是在调度层做优先级区分,让短请求优先得到服务。
我比较推荐第二种,因为长短请求混在一起时,调度层很难同时兼顾两边的体验。分开部署虽然增加了资源占用,但延迟表现会稳定很多。如果资源实在紧张,至少要在监控上把长请求的比例和延迟单独统计出来,这样才能及时发现它对整体指标的影响。
7. 一些实际调优中的经验体会
调优这件事,最忌讳的是没有基线就乱改参数。我见过太多人一上来就把各种参数调到极限,结果性能没提升,反而把服务搞得不稳定。正确的做法是先建立一个可复现的基准,然后每次只改一个变量,观察变化,确认有效再继续。
另一个体会是,监控比调优本身更重要。推理服务的性能受请求分布影响很大,今天调好的参数明天可能就不适用了。只有把吞吐、延迟、显存、队列长度这些指标持续监控起来,才能在问题出现的早期发现它。我现在的习惯是给每个部署实例都配上基础的指标采集,哪怕只是简单的日志统计,也比两眼一抹黑强。
还有一点是关于量化的取舍。量化确实能省显存、提吞吐,但它对任务效果的影响需要单独评估。我的做法是在量化前后各跑一遍业务相关的评测集,确认关键指标没有明显下降再上线。通用评测集上的表现和你的实际业务表现可能差别很大,不能只看通用分数。
最后说一个容易被忽略的细节:推理引擎的版本和模型权重的格式要匹配。有些模型权重是用特定工具转换过的,换引擎或者换版本时可能需要重新转换。这个转换过程有时会引入精度损失,尤其是涉及量化的时候。上线前一定要做端到端的验证,不能只验证引擎本身能启动就完事。