1. 大模型推理到底在做什么
很多人第一次接触大模型推理,脑子里浮现的画面是“输入一句话,模型吐出一段话”,看起来跟搜索引擎差不多。但真把模型部署到线上跑起来,你会发现事情远没有这么简单。大模型推理端到端流程,说白了就是从用户敲下回车那一刻起,到屏幕上逐字蹦出回答为止,中间经过的完整链路。这条链路上有请求接入、分词、前向计算、采样、流式返回、资源调度等一大堆环节,任何一个环节卡住,用户感受到的就是“转圈圈”或者“答非所问”。
我做过好几个不同规模的推理服务,从单卡跑7B小模型到多机多卡跑百B级模型都趟过一遍。踩过的坑包括但不限于:显存碎片导致服务跑几小时就崩、批处理没做好导致GPU利用率只有20%、流式输出被反向代理缓冲住变成“一次性吐出来”。这些问题在论文里基本不会讲,但线上环境天天遇到。
这篇文章适合谁看?如果你是刚接触大模型部署的工程师,想搞清楚从请求到响应到底发生了什么;或者你已经能跑通demo,但想把服务做得更稳、更快、更省资源;再或者你是后端/运维同学,需要理解推理服务的资源特征来做好容量规划——那这篇内容应该能帮到你。我会按端到端的顺序,把每个环节拆开讲清楚,包括为什么这么设计、参数怎么算、坑在哪里。
2. 端到端流程的整体拆解与设计思路
2.1 从请求到响应的完整链路
先给一个全局视角。一个典型的大模型推理请求,走完整个流程大致经历这些阶段:
- 请求接入与鉴权:网关收到HTTP请求,校验token、限流、记录日志。
- 请求排队与批处理调度:请求进入队列,调度器决定和哪些请求凑成一批。
- 输入预处理(分词):把文本转成token id序列,处理padding和attention mask。
- 前向计算(Prefill阶段):对输入的所有token做一次完整的前向传播,生成KV Cache。
- 自回归解码(Decode阶段):逐个token生成,每步只算一个新token,复用KV Cache。
- 采样与后处理:根据logits做采样(贪心/温度/top-p等),得到下一个token。
- 流式返回:把生成的token逐步解码成文本,通过SSE或WebSocket推给客户端。
- 资源回收:请求结束,释放KV Cache占用的显存块。
这八个阶段里,Prefill和Decode的计算特征完全不同,这是理解整个推理优化的关键。Prefill是计算密集型,矩阵乘法规模大,GPU算力吃满;Decode是访存密集型,每步只生成一个token,但要把整个KV Cache读一遍,显存带宽是瓶颈。很多优化手段就是围绕这个差异做文章。
2.2 为什么要把Prefill和Decode分开看
我刚开始做推理服务时,把Prefill和Decode混在一起调度,结果发现GPU利用率忽高忽低,延迟抖动特别大。后来才想明白:Prefill请求和Decode请求对资源的需求是冲突的。一个长输入的Prefill可能占用大量算力,把正在Decode的请求卡住,导致首token延迟(TTFT)和每token延迟(TPOT)都变差。
现在主流的做法是Prefill和Decode分离部署,或者至少在同一张卡上做分时调度。分离部署的好处是:Prefill集群用高算力卡,Decode集群用高显存带宽卡,各自优化。代价是KV Cache需要在集群间传输,网络带宽成了新瓶颈。所以小规模场景下,同卡分时调度更实际;大规模场景才值得上分离架构。
注意:分离部署不是银弹。KV Cache传输量 = 层数 × 2 × 头数 × 头维度 × 序列长度 × 精度字节数。一个70B模型、序列长度4096、FP16精度,单请求KV Cache大约几个GB,传输开销很可观。
2.3 批处理策略的选择逻辑
批处理是提升吞吐最直接的手段,但怎么批有讲究。静态批处理(Static Batching)是把固定数量的请求凑一起,等最长的那个生成完才释放。问题是短请求被长请求拖累,GPU空转。连续批处理(Continuous Batching)则是每生成一步就检查有没有请求完成,完成了就移出,空出的位置立刻填入新请求。这样GPU几乎不空转,吞吐能提升好几倍。
我实测过一个7B模型,静态批处理在混合长度请求下GPU利用率只有35%左右,换成连续批处理后拉到70%以上。代价是调度逻辑复杂很多,要维护每个请求的状态、KV Cache块的分配和回收。现在vLLM、TensorRT-LLM这些框架都内置了连续批处理,自己从零实现的话要小心内存管理。
3. 核心环节的细节与实操要点
3.1 分词与输入预处理
分词看起来简单,其实暗坑不少。不同模型用的分词器不一样,BPE、SentencePiece、WordPiece各有各的规则。最关键的一点是:推理时的分词必须和训练时完全一致,否则模型看到的就是“乱码”。我见过有人用错分词器版本,模型输出全是重复词,排查了半天。
预处理阶段还要处理几个参数:
- max_length:输入截断长度。设太小会丢上下文,设太大浪费显存。
- padding:批处理时对齐长度。建议用left padding,因为自回归生成是从右边续,left padding能让有效token对齐。
- attention mask:标记哪些是真实token,哪些是padding。
# 典型的分词预处理示例 from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("model_path", padding_side="left") inputs = tokenizer( batch_texts, return_tensors="pt", padding=True, truncation=True, max_length=4096 ) # inputs.input_ids 和 inputs.attention_mask 送入模型实操心得:如果服务要处理超长输入,建议在网关层就做长度检查,超限直接拒绝,别等到进了模型才报错。显存被一个超长请求吃满,整个批次都受影响。
3.2 KV Cache的原理与显存计算
KV Cache是自回归生成的核心优化。没有它,每生成一个token都要把前面所有token重新算一遍,复杂度是O(n²)。有了它,每步只算新token的K和V,前面缓存的直接复用,复杂度降到O(n)。
但KV Cache吃显存很凶。计算公式:
KV Cache大小 = 2 × 层数 × 头数 × 头维度 × 序列长度 × 批大小 × 精度字节数以一个13B模型为例:40层、40头、头维度128、序列长度2048、批大小8、FP16(2字节):
2 × 40 × 40 × 128 × 2048 × 8 × 2 = 约21.5 GB光KV Cache就20多GB,加上模型权重26GB,一张40GB的卡根本放不下。所以实际部署要么减批大小,要么用量化,要么上PagedAttention这类显存管理技术。
PagedAttention的思路借鉴了操作系统的虚拟内存分页:把KV Cache切成固定大小的块(block),不要求连续存储,用块表来索引。这样显存碎片大大减少,还能做块级别的共享(比如多个请求共享同一个system prompt的KV)。vLLM就是靠这个把吞吐提升了好几倍。
3.3 采样策略的参数选择
模型输出的是logits,怎么从logits变成token,就是采样策略的事。常见的有:
| 策略 | 参数 | 特点 | 适用场景 |
|---|---|---|---|
| 贪心搜索 | 无 | 每步选概率最大的 | 确定性任务,如代码生成 |
| 温度采样 | temperature | 温度高更随机 | 创意写作 |
| Top-k | k | 只从前k个里采样 | 控制多样性 |
| Top-p | p | 从累积概率达p的最小集合采样 | 通用推荐 |
| 重复惩罚 | repetition_penalty | 降低已出现token概率 | 防止复读 |
温度参数我一般设0.7左右做通用对话,代码生成设0.1~0.2。Top-p设0.9比较稳。重复惩罚别设太高,1.1~1.2就够了,设到1.5以上容易让输出变得不连贯。
注意:采样是在GPU上做的,每步都要把logits传到CPU再采样的话,延迟会明显增加。建议用GPU上的采样算子,或者至少把logits传输和采样异步化。
4. 完整实操流程与关键环节实现
4.1 环境准备与依赖安装
假设我们用vLLM来搭一个推理服务,这是目前比较省心的方案。环境准备大致如下:
# 创建虚拟环境 python -m venv venv source venv/bin/activate # 安装vLLM(注意版本要和CUDA匹配) pip install vllm==0.4.0 # 如果要用OpenAI兼容接口 pip install openaiCUDA版本很关键。vLLM 0.4.0需要CUDA 12.1以上,驱动版本要对应。我踩过一次坑:驱动是525,CUDA 12.0,装完vLLM跑起来报“no kernel image available”,折腾半天才发现是版本不匹配。建议先用nvidia-smi确认驱动和CUDA版本,再去vLLM文档查兼容表。
4.2 启动推理服务
vLLM提供了OpenAI兼容的API server,启动命令:
python -m vllm.entrypoints.openai.api_server \ --model /path/to/model \ --tensor-parallel-size 2 \ --max-model-len 4096 \ --gpu-memory-utilization 0.9 \ --max-num-seqs 64 \ --served-model-name my-model几个关键参数解释:
- tensor-parallel-size:张量并行度,等于用的GPU数量。2就是两张卡分模型。
- max-model-len:最大序列长度,包括输入和输出。设太大会预留过多显存。
- gpu-memory-utilization:GPU显存使用上限比例。0.9表示用90%,留10%给系统。
- max-num-seqs:最大并发序列数。这个直接影响吞吐和延迟的平衡。
启动后,服务默认监听8000端口,可以用curl测试:
curl http://localhost:8000/v1/completions \ -H "Content-Type: application/json" \ -d '{ "model": "my-model", "prompt": "介绍一下大模型推理", "max_tokens": 128, "temperature": 0.7, "stream": true }'4.3 流式输出的实现细节
流式输出是提升用户体验的关键。用户不用等整段生成完,首token出来就能看到。实现上一般用SSE(Server-Sent Events),服务端每生成一个token就推一次。
但这里有个常见坑:反向代理会缓冲响应。Nginx默认会等buffer满了才转发,导致流式变成“攒一批再吐”。解决办法是在Nginx配置里关掉缓冲:
location /v1/ { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding off; }另外,流式输出的token要按“词”而不是“字”来推。有些token是半个词(比如中文的一个字可能由多个token组成),直接推会看到乱码。要用tokenizer的decode方法做增量解码,维护一个已输出文本的buffer,每次只推新增部分。
4.4 性能压测与参数调优
服务搭起来只是第一步,能不能扛住流量要压测才知道。我一般用locust或者自己写脚本,模拟不同并发下的请求。
压测要关注几个指标:
- TTFT(Time To First Token):首token延迟,影响用户感知。
- TPOT(Time Per Output Token):每token延迟,影响生成速度。
- 吞吐(tokens/s):整体生成速度。
- GPU利用率:算力和显存带宽的使用情况。
调优的顺序一般是:先调max-num-seqs找到吞吐和延迟的平衡点,再调gpu-memory-utilization看能不能塞更多KV Cache,最后考虑量化(FP8/INT8)进一步压显存。
我实测过一个7B模型在单张A100上的表现:max-num-seqs=32时,吞吐约1200 tokens/s,TTFT约200ms;调到64时,吞吐到1800 tokens/s,但TTFT涨到400ms。所以并发数不是越高越好,要看业务能接受的延迟。
5. 常见问题与排查技巧实录
5.1 显存溢出(OOM)的排查思路
OOM是推理服务最常见的故障。排查步骤:
- 看日志确认是模型加载时OOM还是运行时OOM。加载时OOM说明模型太大,要上量化或张量并行;运行时OOM说明KV Cache爆了,要减max-model-len或max-num-seqs。
- 用nvidia-smi看显存占用曲线。如果是一路涨到爆,说明有显存泄漏,检查KV Cache块有没有正确回收。
- 检查是否有超长请求。一个max_length=32768的请求,KV Cache可能是正常请求的8倍。
避坑技巧:在网关层加输入长度限制,超限直接返回错误,别让请求进到模型。另外可以给每个请求设一个生成token上限,防止模型“刹不住车”一直生成。
5.2 输出质量问题的定位
输出质量差,可能的原因很多,按概率排序:
- 分词器不匹配:最常见。确认推理用的tokenizer和训练时一致。
- 采样参数不当:温度太高输出发散,太低输出重复。试试temperature=0.7、top_p=0.9。
- 精度问题:用了INT4量化但模型对精度敏感。换FP16或INT8试试。
- 上下文截断:max_length设太小,关键上下文被截掉了。
排查时建议先用贪心搜索(temperature=0)跑一个简单prompt,如果贪心输出都不对,那基本是模型或分词器的问题;如果贪心对但采样不对,那就是采样参数的事。
5.3 延迟抖动的常见原因
延迟忽高忽低,用户体验很差。常见原因:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| TTFT周期性变高 | 有长Prefill请求插队 | Prefill/Decode分离调度 |
| TPOT逐渐变慢 | KV Cache碎片化 | 用PagedAttention |
| 整体延迟突然翻倍 | 显存不足触发换页 | 降低并发或加卡 |
| 流式输出卡顿 | 反向代理缓冲 | 关闭proxy_buffering |
我遇到过一次TPOT从50ms慢慢涨到200ms的情况,查了半天发现是KV Cache块碎片化,连续内存分配失败,触发了显存整理。换成vLLM的PagedAttention后问题消失。
5.4 常见问题速查表
| 问题 | 排查方向 | 快速验证 |
|---|---|---|
| 服务启动失败 | CUDA版本、模型路径 | 看启动日志报错 |
| 首token特别慢 | Prefill排队、输入太长 | 测短输入TTFT |
| 生成速度慢 | 批大小、显存带宽 | 看GPU利用率 |
| 输出乱码 | 分词器、解码方式 | 贪心搜索测试 |
| 服务跑一段时间崩 | 显存泄漏、KV Cache回收 | 监控显存曲线 |
| 并发上不去 | max-num-seqs、显存 | 逐步加压测 |
6. 几个容易被忽略的工程细节
6.1 模型加载的冷启动优化
模型加载慢是另一个痛点。一个70B模型从磁盘加载到GPU,可能要几分钟。如果服务重启,这段时间完全不可用。优化手段:
- 用更快的存储:NVMe SSD比普通SSD快好几倍。
- 模型分片并行加载:多卡同时加载各自的分片。
- 预热:服务启动后先跑几个请求,把CUDA kernel编译缓存建好。
我试过用内存文件系统缓存模型权重,加载时间从3分钟降到40秒。代价是内存占用大,适合内存充裕的机器。
6.2 多模型共存的资源隔离
一个服务要跑多个模型时,资源隔离很重要。否则一个模型的流量高峰会把另一个模型饿死。做法有:
- 分卡部署:每个模型独占GPU,最简单但成本高。
- MPS(Multi-Process Service):让多个进程共享GPU,但隔离性一般。
- 时间片轮转:调度器按时间片切换模型,实现复杂。
小规模场景建议分卡,省心。大规模场景可以考虑用推理框架的多模型支持,比如Triton Inference Server,它内置了模型编排和资源管理。
6.3 监控与告警的搭建
线上服务没有监控就是裸奔。至少要监控:
- 请求量、成功率、延迟分布(P50/P95/P99)
- GPU利用率、显存占用、温度
- 队列长度、批大小分布
- KV Cache使用率
告警阈值我一般设:P99延迟超过1秒告警,显存占用超过90%告警,错误率超过1%告警。监控数据用Prometheus采集,Grafana展示,这套组合比较成熟。
实操心得:延迟监控一定要分Prefill和Decode两个阶段看。混在一起看平均值会掩盖问题。我见过平均延迟200ms但P99到5秒的情况,就是长Prefill请求导致的。
7. 关于推理优化的一些个人体会
做推理服务这两年,最大的感受是:没有银弹,只有权衡。吞吐和延迟是矛盾的,成本和体验是矛盾的,通用性和性能是矛盾的。每次优化都是在这些矛盾里找平衡点。
另一个体会是:先跑通,再优化。我见过太多人一上来就追求极致性能,结果连基本功能都没跑通。正确的顺序是:先用最朴素的方式把端到端流程跑通,确认输入输出正确,然后压测找到瓶颈,再针对性优化。优化的时候一次只改一个变量,改完压测对比,别一次改一堆,出了问题都不知道是哪个改动导致的。
最后分享一个小技巧:把推理服务的日志打全。每个请求记录输入长度、输出长度、TTFT、TPOT、批大小、KV Cache使用量。这些数据在排查问题和容量规划时非常有用。我现在的服务日志里,每个请求一行JSON,用ELK收集,查问题的时候直接搜,比翻代码快多了。
这个领域变化很快,新的框架和优化技术层出不穷。但底层的原理——Prefill和Decode的计算特征、KV Cache的显存管理、批处理的调度逻辑——这些是不变的。把原理搞清楚了,学新框架就是看文档的事。