1. 大模型推理到底在做什么:从一次对话说起
你每次在聊天框里敲下一句话,按下回车,几秒钟后屏幕上开始一个字一个字往外蹦——这个过程背后跑的就是LLM inference,也就是大模型推理。很多人把它和训练混为一谈,其实两者完全是两码事。训练是让模型从海量文本里学规律,动辄几千张卡跑几个月;推理是拿着已经训好的权重,根据你给的输入算出下一个词该是什么,然后一个词一个词往外吐。前者是"上学",后者是"考试答题"。
我刚开始接触这块的时候,以为推理就是"把模型跑起来"这么简单。真正上手才发现,一个能聊天的demo和一个能扛住线上流量的推理服务,中间隔着的工程量比想象中大得多。你要考虑显存够不够、首字延迟能不能压到一秒以内、并发上来之后吞吐会不会崩、batch怎么组、KV Cache怎么管、量化到什么精度还不掉点……这些问题没有一个能靠"调个库"糊弄过去。
这篇内容适合三类人看:一是刚入门想搞清楚推理链路上到底有哪些环节的工程师;二是已经在做推理服务、但总觉得性能上不去想系统梳理一遍的开发者;三是做技术选型、需要判断不同方案取舍的架构同学。我会尽量把每个环节"为什么这么设计"讲透,而不是只丢一堆参数让你抄。涉及具体数字的地方,我会说明这是基于常见实践的经验值,不同硬件和模型会有出入,你按自己的场景校准。
核心关键词就两个:LLM和inference。围绕这两个词,我们把整条推理链路拆开揉碎讲一遍。
2. 推理链路的整体设计:为什么是这套架构
2.1 一次推理请求的完整生命周期
先建立全局视角。当你发一条消息给推理服务,大致会经过这么几个阶段:
- 请求接入与排队:请求到达服务端,进入调度队列,等待被分配计算资源。
- Tokenization:把你的文本切成token序列,这一步看着简单,但分词器的选择会影响后续所有计算。
- Prefill(预填充):把整个输入序列一次性喂进模型,计算出每个位置的KV(Key-Value)并缓存起来。这一步是计算密集型的,输入越长越慢。
- Decode(解码):基于缓存,一个token一个token地生成输出。每一步只算一个新token,但要把之前所有token的KV都读一遍,所以这一步是显存带宽密集型的。
- Detokenization与流式返回:把生成的token转回文本,边生成边推给前端。
- 资源回收:请求结束,释放KV Cache占用的显存。
这个生命周期里,Prefill和Decode的特性完全不同,这是理解所有推理优化的一把钥匙。Prefill像是一次性搬一大堆砖,算力吃满;Decode像是每次只搬一块砖但要把整个仓库的门都开一遍,带宽吃满。很多优化手段本质上就是在分别对付这两个阶段的瓶颈。
2.2 为什么推理和训练是两套工程
有人会问,既然都是前向传播,为什么不能复用训练的框架?原因有几个:
- 批处理方式不同:训练用大batch追求吞吐,推理要兼顾延迟,batch往往很小甚至为1。
- 显存压力点不同:训练要存激活值、梯度、优化器状态,推理只需要存权重和KV Cache。
- 精度要求不同:训练对数值稳定性极敏感,推理可以大胆用低精度量化。
- 调度目标不同:训练追求单位时间算得多,推理追求每个请求又快又稳。
我踩过的一个坑是:早期直接拿训练脚本改吧改吧当推理服务用,结果单请求延迟还行,一上并发就雪崩。后来才明白,训练框架的调度逻辑根本没为"多请求交错执行"设计过。这就是为什么现在有vLLM、TensorRT-LLM、SGLang这些专门为推理打造的框架。
2.3 方案选型的三个核心维度
选推理方案,我一般看三个维度:
| 维度 | 关注点 | 典型取舍 |
|---|---|---|
| 延迟 | 首字延迟TTFT、每token延迟TPOT | 低延迟往往牺牲吞吐 |
| 吞吐 | 每秒处理token数、并发请求数 | 高吞吐需要大batch和高效调度 |
| 成本 | 显存占用、硬件利用率 | 量化省显存但可能掉精度 |
这三个维度是互相拉扯的。你要极致低延迟,就得小batch、少排队,硬件利用率上不去,成本就高;你要极致吞吐,就得攒大batch,单个请求的延迟就上来了。没有全能方案,只有匹配场景的方案。线上对话场景通常优先保TTFT,因为用户对"第一句话多久出来"最敏感;离线批量生成场景则优先保吞吐。
3. 核心细节解析:那些决定性能的关键点
3.1 KV Cache:推理优化的命门
KV Cache是整个推理优化里最重要的概念,没有之一。简单说,自注意力机制在生成每个新token时,需要用到之前所有token的Key和Value。如果不缓存,每生成一个token都要把前面所有token重新算一遍KV,计算量随序列长度平方增长。缓存之后,每步只需要算新token的KV,然后和缓存拼接。
用生活类比:你在写一篇文章,每写一个字都要回顾前面所有字。KV Cache相当于你把前面每个字的"要点笔记"都记下来了,写新字时直接翻笔记,不用重新读全文。
但KV Cache是有代价的——它吃显存。计算公式大致是:
KV Cache大小 = 2 × 层数 × 注意力头数 × 头维度 × 序列长度 × batch大小 × 精度字节数举个例子,一个70亿参数、32层、32头的模型,头维度128,序列长度4096,batch为1,用FP16(2字节):
2 × 32 × 32 × 128 × 4096 × 1 × 2 ≈ 2.1 GB单请求就2GB,如果并发32个请求,光KV Cache就要67GB,比模型权重本身还大。这就是为什么显存管理是推理服务的核心难题。
注意:很多人只盯着模型权重占多少显存,忽略了KV Cache。实际线上跑起来,KV Cache往往是显存杀手。
3.2 PagedAttention:把显存碎片治明白
传统KV Cache是给每个请求预分配一块连续显存,按最大可能长度分配。问题是:大部分请求根本用不到最大长度,预分配的空间白白浪费,而且不同请求长度不一,显存碎片严重。
vLLM提出的PagedAttention借鉴了操作系统虚拟内存分页的思路:把KV Cache切成固定大小的block(比如16个token一块),按需分配,不要求连续。这样一来:
- 显存利用率大幅提升,浪费从"按最大长度预留"变成"按实际使用分配"。
- 不同请求可以共享block,比如同一个prompt的多个采样结果可以共享前缀的KV。
- 碎片问题基本消失。
实测下来,PagedAttention能把吞吐提升2到4倍,具体倍数取决于请求长度分布。这个技术现在已经是主流推理框架的标配。
3.3 连续批处理:别让GPU闲着
传统批处理是"静态batch":攒够一批请求,一起跑完,再跑下一批。问题是同一批里有的请求生成10个token就结束了,有的要生成500个,短的得等长的,GPU在等待期间大量空转。
连续批处理(Continuous Batching)的思路是:不等整批结束,某个请求一完成就立刻把新请求塞进来。这样GPU几乎一直在干活,利用率大幅提升。这个机制配合PagedAttention,是vLLM高吞吐的两大支柱。
我实测过一个对比:同样硬件、同样模型,静态batch的吞吐大概是连续batch的三分之一到一半。请求长度差异越大,差距越明显。
3.4 量化:用精度换显存和速度
量化是把模型权重和激活值从高精度(FP16/BF16)压到低精度(INT8/INT4)的过程。好处很直接:
- 显存占用降低,能塞下更大的模型或更多并发。
- 显存带宽压力降低,Decode阶段提速。
- 部分硬件上低精度计算本身更快。
常见的量化方案:
| 方案 | 精度 | 特点 | 适用场景 |
|---|---|---|---|
| FP16/BF16 | 16位 | 无损,基线 | 显存充足 |
| INT8 | 8位 | 掉点小,提速明显 | 通用推荐 |
| INT4 | 4位 | 省显存多,可能掉点 | 显存紧张 |
| GPTQ/AWQ | 4位 | 训练后量化,质量较好 | 本地部署 |
| GGUF | 多档 | 面向CPU/边缘设备 | 安卓本地运行 |
提示:量化不是免费的午餐。INT4在数学推理、代码生成这类任务上掉点可能比较明显,对话类任务通常还好。上线前一定要在你的实际任务上评测,别只看论文里的平均分。
3.5 投机解码:用小模型加速大模型
投机解码(Speculative Decoding)是个很巧的思路:用一个小的"草稿模型"快速生成几个候选token,然后让大模型一次性验证这几个token对不对。因为大模型验证一批token的成本和生成一个token差不多,如果草稿命中率高,整体就能提速。
关键指标是接受率——草稿模型猜的token有多少被大模型认可。接受率高,提速明显;接受率低,反而拖慢。一般来说,草稿模型和主模型越"像",接受率越高。实践中常见2到3倍的提速。
这个技术特别适合延迟敏感的场景,因为它不改变输出分布(理论上无损),只是加速。
4. 实操过程:从零搭一个能用的推理服务
4.1 环境准备与框架选择
假设你要在单机多卡上部署一个开源模型,我推荐的技术栈:
- 推理框架:vLLM(生态成熟、上手快)或TensorRT-LLM(极致性能、配置复杂)
- 模型格式:HuggingFace格式或已量化版本
- 服务层:框架自带的OpenAI兼容API,或自己套一层FastAPI
- 监控:Prometheus + Grafana,盯TTFT、TPOT、吞吐、显存
安装vLLM大致是这样:
pip install vllm启动一个服务:
python -m vllm.entrypoints.openai.api_server \ --model /path/to/your/model \ --tensor-parallel-size 2 \ --max-model-len 8192 \ --gpu-memory-utilization 0.9几个参数解释一下:
--tensor-parallel-size:张量并行度,等于用几张卡。2就是两张卡分摊模型。--max-model-len:最大序列长度,直接影响KV Cache预留。设太大浪费显存,设太小长请求会被拒。--gpu-memory-utilization:显存使用上限比例。0.9表示用90%,留10%给系统和其他开销。
注意:
max-model-len别拍脑袋设。先统计你线上请求的长度分布,取P99再留点余量。设成模型支持的最大值(比如32K)会导致KV Cache预留过多,并发上不去。
4.2 关键参数的计算与调优
并发数怎么估?显存减去模型权重,剩下的给KV Cache。用前面公式反推能放多少个请求。比如剩40GB给KV Cache,单请求2GB,理论上并发20。但实际要留余量,按15算比较稳。
batch大小怎么定?不是越大越好。batch太大,TTFT会明显上升,因为新请求要等当前batch算完。我一般从较小值起步,逐步加压观察TTFT和吞吐的拐点。
精度怎么选?显存够就BF16,不够上INT8,再不够INT4。每次降精度都要跑一遍评测集,确认掉点在可接受范围。
4.3 压测与性能观测
搭好服务必须压测。我常用的工具是locust或wrk,构造不同长度、不同并发的请求,重点看四个指标:
| 指标 | 含义 | 健康参考 |
|---|---|---|
| TTFT | 首字延迟 | 对话场景<1s |
| TPOT | 每token延迟 | <50ms较流畅 |
| 吞吐 | 每秒token数 | 越高越好 |
| 显存占用 | GPU显存使用 | 留10%余量 |
压测时一定要模拟真实长度分布。只测短请求会给你虚假的乐观,一上长请求就崩。我吃过这个亏:demo阶段全是短问答,性能漂亮得很,上线后用户开始贴长文档,延迟直接翻好几倍。
4.4 一个真实的调优记录
分享一次我调优的经历。初始配置:单卡A100 80G,7B模型,BF16,max-model-len设32K。压测结果:并发8时TTFT就超过2秒,吞吐上不去。
排查发现两个问题:一是max-model-len设太大,KV Cache预留过多,实际并发上不去;二是没开连续批处理的高级调度。
调整:max-model-len降到8K(覆盖P99),开启chunked prefill(把长prefill切成小块,避免阻塞decode)。调整后并发16时TTFT降到800ms,吞吐提升约2.5倍。
这个案例说明:参数不是越大越好,要匹配真实负载。
5. 常见问题与排查技巧实录
5.1 显存溢出(OOM)怎么破
OOM是推理服务最常见的故障。排查顺序:
- 看是加载时OOM还是运行时OOM。加载时OOM说明模型太大,得量化或加卡。
- 运行时OOM多半是KV Cache爆了。降低max-model-len,或限制最大并发。
- 检查是否有请求长度异常。有些用户会贴超长文本,需要在前置层做长度截断。
实操心得:给服务加一个"最大输入长度"的硬限制,超了直接拒绝并返回友好提示。别指望模型自己扛,长请求会把整个服务的显存拖垮。
5.2 首字延迟高得离谱
TTFT高通常有几个原因:
- 排队严重:并发超过服务能力,请求在队列里等。解决靠扩容或限流。
- Prefill太长:输入序列很长,Prefill计算量大。可以用chunked prefill缓解。
- 调度策略问题:新请求被长请求阻塞。检查是否开启了合理的调度优先级。
5.3 吞吐上不去
吞吐低往往是GPU利用率低。检查:
- batch是不是太小,GPU没吃饱。
- 有没有开连续批处理。
- KV Cache是不是碎片化严重,导致频繁换入换出。
- 是不是被某个超长请求拖住了整条流水线。
5.4 输出质量下降
如果发现量化后输出变差:
- 先确认是不是量化导致的,用同一批prompt对比BF16和量化版本的输出。
- 检查量化方案是否匹配你的任务。代码任务对量化更敏感。
- 考虑混合精度:关键层保持高精度,其他层量化。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| OOM | KV Cache过大 | 降max-model-len、限并发 |
| TTFT高 | 排队或Prefill长 | 扩容、chunked prefill |
| 吞吐低 | GPU空转 | 开连续批处理、调batch |
| 输出乱码 | 量化掉点 | 换量化方案、混合精度 |
| 服务不稳定 | 长请求拖累 | 输入长度硬限制 |
6. 进阶方向:还能往哪优化
6.1 前缀缓存复用
很多场景下,不同请求共享相同的前缀,比如系统提示词、few-shot示例。前缀缓存(Prefix Caching)可以把这部分KV缓存下来复用,避免重复计算。对于系统提示词很长的应用,提速非常明显。vLLM和SGLang都支持这个特性,开启后TTFT能降不少。
6.2 分离式架构
Prefill和Decode特性不同,混在一起跑会互相干扰。分离式架构(Disaggregated Serving)把两个阶段拆到不同机器上:Prefill机器算力强,Decode机器带宽强。这样各自都能跑到最优,整体效率更高。这个方向现在很热,但工程复杂度也高,适合大规模部署。
6.3 边缘侧推理
如果要在手机、嵌入式设备上跑LLM,思路完全不同。这时候算力和显存都极其有限,得靠:
- 极致量化,比如GGUF的Q4_K_M档。
- 小模型,1B到3B参数级别。
- CPU+NPU混合计算。
安卓上跑GGUF格式的模型现在有不少成熟方案,适合做离线、隐私敏感的场景。但别指望速度和云端比,能跑起来、能用就是胜利。
6.4 智能体场景下的推理挑战
当LLM作为智能体(Agent)自主决策、调用工具、多轮循环时,推理服务的压力模式和单轮对话完全不同:
- 请求数暴增:一个任务可能触发几十次模型调用。
- 上下文累积:每轮都要带上历史,序列越来越长。
- 容错要求高:智能体自主运行时,推理失败会导致整个任务链断裂。
这就要求推理服务不仅快,还要稳、要能容错。实践中我会给智能体场景单独配一套推理资源,和普通对话隔离,避免互相影响。同时做好重试和降级:主模型超时就切备用小模型,保证任务能继续。
7. 我踩过的坑和几条实在建议
做推理工程这几年,踩的坑比写的代码还多。挑几个最有代表性的说说。
第一个坑:迷信benchmark数字。论文和官方文档里的吞吐数字都是在理想条件下测的,固定长度、固定并发。真实流量是长尾分布,一堆短请求里混着几个超长请求,性能表现和benchmark差很远。永远用自己的真实数据压测。
第二个坑:忽略冷启动。模型加载、CUDA kernel编译、缓存预热都需要时间。线上扩容时新实例冷启动可能几十秒,这段时间请求会打到老实例上。要么提前预热,要么做好流量调度。
第三个坑:量化一刀切。以为所有任务都能无脑INT4,结果代码生成任务掉点严重。后来改成按任务分模型:对话用INT4,代码用INT8,各取所需。
第四个坑:监控只看平均值。平均延迟好看,但P99可能惨不忍睹。用户感知的是最差的那几次体验。盯P95和P99,别只盯平均。
最后分享一个实用技巧:给推理服务加一个"降级开关"。当负载超过阈值时,自动降低max-model-len或切换到更小的模型,保证服务不崩。宁可回答得糙一点,也别整个服务挂掉。这个开关在流量突增时救过我好几次。
推理工程没有银弹,每个场景都要具体调。但只要你理解了Prefill和Decode的本质差异、KV Cache的显存账、连续批处理和量化的取舍逻辑,大部分问题都能找到方向。剩下的就是动手压测、看数据、调参数,一轮一轮磨出来。