
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/s7B模型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模型序列长度2048batch size为1时KV Cache大约占1.5GB显存。听起来不多但当batch size拉到32序列长度到4096KV 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_tokensreq.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受显存限制需要实测。实测数据单请求推理延迟200msQPS约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的MPSMulti-Process Service让多个进程共享GPU上下文减少上下文切换开销。但要注意模型共享不是万能的。如果两个模型的计算图差异很大共享显存池可能导致互相干扰。我一般只在模型架构相似、推理模式接近时才这么做。否则宁可多花点显存也要保证隔离性。4. 批处理与显存两个最容易翻车的地方4.1 批处理大小的计算别靠猜靠公式很多人设batch size靠试试到OOM为止。这效率太低。正确的做法是先算显存预算显存占用项计算公式示例7B FP16, seq2048, batch8模型权重params × dtype_size7B × 2B 14GBKV Cache2 × layers × heads × head_dim × seq × batch × dtype2×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_sampleTrue会引入随机性而且beam search在长序列上极慢。生产环境我一般用greedy或beam search with early stopping并且显式设置max_new_tokens。坑二输入长度要设上限。我见过用户传了一个10万字的prompt直接把KV Cache撑爆。服务端必须做输入截断或拒绝别指望客户端自觉。坑三温度参数不是越高越好。高温采样在创意场景有用但在需要稳定输出的场景比如代码生成、结构化抽取会引入大量噪声。我一般默认temperature0.1需要创意时才调高。坑四别忘了CPU到GPU的数据传输。如果输入预处理在CPU上做然后拷贝到GPU这个拷贝时间可能比推理还长。解决方案是用DALI这类GPU加速的数据加载库或者把预处理也放到GPU上。坑五日志别打太多。每个请求打完整输入输出日志IO会成为瓶颈。我一般只打请求ID、输入长度、输出长度、延迟详细内容采样打或者按需打。最后分享一个我常用的调试技巧用torch.profiler跑一次推理看时间花在哪。很多时候你以为的瓶颈比如矩阵乘法其实只占20%真正的大头在数据搬运或kernel启动。profile一次比瞎猜一天都管用。这个领域变化很快新框架、新优化层出不穷。但底层的逻辑——显存、带宽、批处理、调度——这些是不变的。把这几样吃透换什么框架都能快速上手。我在实际项目中的体会是从零搭一遍哪怕只是最小版本你对整个系统的理解会比调包深十倍。下次服务再出问题你至少知道该看哪几个指标、该改哪几行代码。