1. 从零手搓AI工程:为什么我不建议你直接调包
很多人第一次接触AI工程,脑子里想的都是“调个API就完事了”。我刚开始也这么想,直到有一次线上服务在高峰期直接雪崩——模型推理延迟从200ms飙到8秒,GPU显存爆了,整个推荐链路跟着挂掉。那次事故让我彻底明白:调包能让你跑起来,但只有从零理解每一层,你才能在它崩掉的时候知道该拧哪颗螺丝。
“ai-engineering-from-scratch”这个标题,核心不是教你从零训练一个大模型——那是研究机构干的事。它真正要解决的是:当你手里有一个训练好的模型权重,怎么把它变成一套能扛住真实流量、能持续迭代、能定位问题的工程系统。这中间隔着数据管道、推理服务、显存管理、批处理调度、监控告警、版本回滚等一大堆脏活累活。适合谁看?适合已经会写Python、调过HuggingFace的pipeline()、但一上生产就心里发虚的工程师。如果你满足于在Jupyter Notebook里跑通demo,那这篇内容可能不太适合你;但如果你想搞清楚“为什么我的服务一压测就崩”“为什么同样的模型别人吞吐是我的三倍”,那咱们可以往下聊。
我打算按一个真实项目的推进顺序来拆:先搞清楚推理引擎到底在干什么,再动手搭一个最小可用的服务,然后处理批处理和显存这两个最容易翻车的地方,最后聊聊监控和迭代。每一步我都会告诉你“为什么这么选”,以及我踩过的那些坑。
2. 推理引擎不是黑盒:拆开看它到底在算什么
2.1 从一次矩阵乘法说起:模型推理的物理本质
你调model.generate()的时候,底层其实在做一件很朴素的事:把输入张量送进一堆矩阵乘法,再经过激活函数,输出下一个token的概率分布。以Transformer为例,一个7B参数的模型,每生成一个token,大约要做7×10⁹次浮点乘加运算。这个数字是什么概念?一块A100的FP16算力大约是312 TFLOPS,理论上每秒能跑4万多次这样的运算。但实际吞吐往往只有理论值的10%到30%,差距就藏在工程细节里。
为什么?因为矩阵乘法不是孤立的。每次计算都要从显存里读权重、读激活值、写结果。显存带宽成了真正的瓶颈。A100的显存带宽是2TB/s,7B模型FP16权重占14GB,每生成一个token至少要把这14GB读一遍——光读权重就要7ms。这就是为什么小批量推理时,你的GPU利用率可能只有5%,因为计算单元在等数据搬运。
理解这一点,你就能明白后面所有优化的方向:要么减少数据搬运(量化、算子融合),要么让搬运和计算重叠(流水线、多流),要么一次搬更多数据算更多活(批处理)。我见过太多人一上来就调torch.compile或者换TensorRT,但如果不清楚瓶颈在哪,这些工具可能一点效果都没有。
2.2 KV Cache:那个让推理速度翻倍却容易被忽略的机制
自回归生成有个特点:每生成一个新token,都要把之前所有token的Key和Value矩阵重新算一遍。如果不做任何优化,生成第n个token的计算量是O(n²)。KV Cache的思路很直接:把之前算过的Key和Value存下来,下一个token只算新的那一份。这样计算量降到O(n),但代价是显存占用随序列长度线性增长。
我实测过一个13B模型,序列长度2048,batch size为1时,KV Cache大约占1.5GB显存。听起来不多?但当batch size拉到32,序列长度到4096,KV Cache能吃掉30GB以上——比模型权重还大。这就是为什么你的服务在低并发时好好的,一上压力就OOM。
KV Cache的管理有几个关键决策:预分配还是动态增长?预分配速度快但浪费显存,动态增长省显存但可能产生碎片。我一般推荐预分配一个最大长度,然后配合PagedAttention这类技术做分页管理。如果你用的是vLLM,它默认就帮你做了这些;但如果你自己手写推理循环,这块必须自己管。
注意:KV Cache的显存占用公式是
2 × num_layers × num_heads × head_dim × seq_len × batch_size × dtype_size。部署前一定按这个公式算一遍,别等OOM了才后悔。
2.3 算子融合与量化:省显存的两个实操方向
算子融合的逻辑是:把多个小算子合并成一个大算子,减少kernel启动开销和中间结果的显存读写。比如LayerNorm后面接一个线性层,中间结果本来要写回显存再读出来,融合之后直接在寄存器里算完。PyTorch 2.0的torch.compile就是干这个的,实测在推理场景能有15%到30%的提升。
量化则是另一条路。FP16转INT8,显存直接减半,带宽压力也减半。但量化不是无损的,关键是找到合适的缩放因子。我试过PTQ(训练后量化)和QAT(量化感知训练),前者快但精度掉得多,后者精度好但需要重新训练。对于大多数应用,INT8的PTQ在7B以上模型上精度损失可以控制在1%以内,但小模型要谨慎。
这里有个坑:不是所有层都适合量化。Attention的QK矩阵乘法对精度敏感,MLP层相对鲁棒。我一般先用工具做逐层敏感度分析,再决定哪些层保持FP16。这个分析过程大概花半天,但能避免上线后效果崩掉。
3. 搭一个能扛住压测的最小推理服务
3.1 为什么FastAPI + Uvicorn是起步首选,但别止步于此
第一版服务我建议用FastAPI,原因很简单:异步支持好、生态成熟、调试方便。一个最简的推理接口大概长这样:
from fastapi import FastAPI from pydantic import BaseModel import torch app = FastAPI() model = None class Request(BaseModel): prompt: str max_tokens: int = 128 @app.on_event("startup") def load_model(): global model model = load_your_model() model.eval() @app.post("/generate") async def generate(req: Request): with torch.no_grad(): output = model.generate(req.prompt, max_new_tokens=req.max_tokens) return {"text": output}但这里有个致命问题:model.generate()是同步阻塞的,它会占住整个事件循环。并发请求进来只能排队,吞吐上不去。正确做法是把推理放到线程池或独立进程里,用run_in_executor包一层。或者更彻底一点,用Triton Inference Server这类专用推理服务,它自带动态批处理和并发调度。
我踩过的坑:一开始用Uvicorn默认的单worker,压测到50并发就超时。后来改成多worker + 线程池,但GPU显存又不够每个worker一份模型。最后方案是单worker多线程 + 动态批处理,让请求在服务内部攒批,一次推理处理多个请求。
3.2 动态批处理:吞吐量翻十倍的秘密
动态批处理的逻辑是:不立即处理每个请求,而是等一小段时间(比如10ms),把这段时间内到达的请求拼成一个batch一起推理。因为GPU在小batch下利用率低,拼批能显著提升吞吐。
实现上有个关键参数:最大等待时间。设太短,攒不到几个请求,效果不明显;设太长,单请求延迟增加。我一般从10ms开始调,根据P99延迟目标调整。另一个参数是最大batch size,受显存限制,需要实测。
实测数据:单请求推理延迟200ms,QPS约5。开启动态批处理后,batch size到8时,单请求延迟250ms,但QPS到32。吞吐提升6倍多,延迟只增加25%。对于大多数在线服务,这个 trade-off 是划算的。
但动态批处理有个前提:请求的长度要差不多。如果batch里有的请求生成10个token,有的生成500个,短的会被长的拖死。解决方案是按长度分桶,或者用连续批处理(continuous batching)——vLLM的核心卖点就是这个,它允许batch里的请求在不同时间完成,新请求随时插入。
3.3 显存池化与模型共享:多模型场景的省钱技巧
如果你要同时服务多个模型(比如一个embedding模型加一个生成模型),显存管理会更复杂。我的经验是:用PyTorch的显存池化机制,预分配一大块显存,然后手动切分给不同模型。这样避免频繁的cudaMalloc/cudaFree导致的碎片和延迟。
具体做法是在启动时设置torch.cuda.set_per_process_memory_fraction(),或者用PYTORCH_CUDA_ALLOC_CONF环境变量配置。更精细的控制可以用NVIDIA的MPS(Multi-Process Service),让多个进程共享GPU上下文,减少上下文切换开销。
但要注意:模型共享不是万能的。如果两个模型的计算图差异很大,共享显存池可能导致互相干扰。我一般只在模型架构相似、推理模式接近时才这么做。否则宁可多花点显存,也要保证隔离性。
4. 批处理与显存:两个最容易翻车的地方
4.1 批处理大小的计算:别靠猜,靠公式
很多人设batch size靠试,试到OOM为止。这效率太低。正确的做法是先算显存预算:
| 显存占用项 | 计算公式 | 示例(7B FP16, seq=2048, batch=8) |
|---|---|---|
| 模型权重 | params × dtype_size | 7B × 2B = 14GB |
| KV Cache | 2 × layers × heads × head_dim × seq × batch × dtype | 2×32×32×128×2048×8×2 ≈ 8.6GB |
| 激活值 | 与batch和seq相关,通常2-4GB | 约3GB |
| 框架开销 | 约1-2GB | 约1.5GB |
| 合计 | 约27GB |
一张A100 40GB能放下,但80GB更稳。如果batch再翻倍,KV Cache到17GB,总计超40GB,就得换卡或量化。
这个表我每次部署新模型都会填一遍,五分钟的计算能省掉两小时的试错。而且它还能帮你判断:如果显存不够,是该减batch、减序列长度,还是该上量化。
4.2 连续批处理的实现逻辑与踩坑
连续批处理(continuous batching)是vLLM、TensorRT-LLM这些框架的核心能力。它的思路是:不等整个batch完成,而是每生成一个token就检查哪些请求完成了,把完成的踢出去,把新请求加进来。这样GPU永远不会因为等最长的请求而空转。
自己实现的话,核心是一个调度循环:
while running_requests: # 1. 把新请求加入batch(如果有空位) # 2. 对当前batch做一次前向,生成一个token # 3. 检查哪些请求生成了EOS或达到max_tokens # 4. 移除完成的请求,释放KV Cache # 5. 重复听起来简单,但坑很多。第一个坑是KV Cache的碎片化:不同请求长度不同,释放后留下的空洞怎么复用?vLLM用PagedAttention把KV Cache分成固定大小的block,像操作系统管理内存页一样管理,基本解决了这个问题。
第二个坑是调度公平性:如果一直有新请求进来,老请求可能永远排不上。需要设置优先级或最大等待时间。我一般给每个请求打时间戳,超过阈值就优先调度。
第三个坑是prefill和decode的混合:prefill阶段(处理输入prompt)计算密集,decode阶段(生成token)显存密集。混在一起调度需要仔细平衡。有些框架会把prefill和decode分开到不同GPU上,但这样又增加了通信开销。
4.3 显存泄漏的排查:从nvidia-smi到torch.cuda.memory_summary
显存泄漏是推理服务最头疼的问题之一。表现是:服务跑几个小时,显存慢慢涨,最后OOM。排查步骤我总结了一个固定流程:
- 先用
nvidia-smi -l 1持续观察,确认是显存真的在涨,而不是缓存波动。 - 在代码里定期打印
torch.cuda.memory_summary(),看是哪个部分在涨。是模型权重?KV Cache?还是中间激活? - 检查是否有未释放的tensor引用。最常见的是把tensor存到了全局列表或缓存里,忘了清理。
- 检查CUDA Graph或编译缓存。
torch.compile第一次运行会缓存编译结果,如果输入shape变化频繁,缓存会一直涨。解决方案是限制shape种类,或者定期清理缓存。
我遇到过一次诡异泄漏:最后发现是日志里把每个请求的输入输出都存了tensor,日志文件涨到几十GB,显存也跟着涨。所以排查时别忘了看业务代码,不只是推理代码。
提示:PyTorch的
torch.cuda.memory_allocated()和torch.cuda.memory_reserved()是两回事。前者是实际用的,后者是缓存池占的。缓存池占着不一定是泄漏,可能是碎片。用torch.cuda.empty_cache()可以释放缓存,但会降低后续分配速度。
5. 监控、迭代与那些文档不会告诉你的经验
5.1 推理服务的监控指标:别只看QPS和延迟
QPS和P99延迟是基础,但远远不够。我必看的指标还包括:
- GPU利用率(SM Occupancy):低于30%说明计算单元在等数据,该优化数据管道了。
- 显存带宽利用率:如果接近100%,说明是带宽瓶颈,量化或算子融合会有帮助。
- KV Cache命中率:如果频繁重新计算,说明缓存管理有问题。
- 批处理大小分布:如果大部分请求batch size是1,动态批处理没生效。
- Token生成速度(tokens/s):比请求延迟更能反映模型本身的效率。
这些指标用Prometheus + Grafana就能搭起来。关键是设置合理的告警阈值。比如GPU利用率持续5分钟低于20%,或者KV Cache占用超过显存80%,都该触发告警。
5.2 模型版本迭代:如何做到不停机更新
模型迭代是常态。但直接重启服务会中断请求。我的做法是双缓冲切换:新模型在后台加载好,显存预分配,然后原子性地切换推理入口的指针。旧模型等所有进行中的请求完成后释放。
具体实现可以用一个全局的model_holder字典,加读写锁。新模型加载到model_holder['new'],切换时把model_holder['current']指向新模型,旧模型延迟释放。这样切换过程对客户端透明,延迟增加几乎为零。
但要注意:新模型的显存占用要和旧模型兼容。如果新模型更大,可能需要先释放旧模型再加载,那就做不到无缝了。所以规划时尽量保持模型大小稳定,或者预留足够的显存余量。
5.3 那些我踩过的坑和总结的经验
坑一:不要在生产环境用model.generate()的默认参数。默认的do_sample=True会引入随机性,而且beam search在长序列上极慢。生产环境我一般用greedy或beam search with early stopping,并且显式设置max_new_tokens。
坑二:输入长度要设上限。我见过用户传了一个10万字的prompt,直接把KV Cache撑爆。服务端必须做输入截断或拒绝,别指望客户端自觉。
坑三:温度参数不是越高越好。高温采样在创意场景有用,但在需要稳定输出的场景(比如代码生成、结构化抽取)会引入大量噪声。我一般默认temperature=0.1,需要创意时才调高。
坑四:别忘了CPU到GPU的数据传输。如果输入预处理在CPU上做,然后拷贝到GPU,这个拷贝时间可能比推理还长。解决方案是用DALI这类GPU加速的数据加载库,或者把预处理也放到GPU上。
坑五:日志别打太多。每个请求打完整输入输出,日志IO会成为瓶颈。我一般只打请求ID、输入长度、输出长度、延迟,详细内容采样打或者按需打。
最后分享一个我常用的调试技巧:用torch.profiler跑一次推理,看时间花在哪。很多时候你以为的瓶颈(比如矩阵乘法)其实只占20%,真正的大头在数据搬运或kernel启动。profile一次,比瞎猜一天都管用。
这个领域变化很快,新框架、新优化层出不穷。但底层的逻辑——显存、带宽、批处理、调度——这些是不变的。把这几样吃透,换什么框架都能快速上手。我在实际项目中的体会是:从零搭一遍,哪怕只是最小版本,你对整个系统的理解会比调包深十倍。下次服务再出问题,你至少知道该看哪几个指标、该改哪几行代码。