
1. 从零手搓AI工程为什么“调包”思维正在失效过去两年我面试过不下五十个自称“做过AI项目”的候选人。简历上清一色写着“基于某某大模型API搭建了智能问答系统”但当我问“如果API延迟突然从800毫秒飙到3秒你的系统会怎样”时超过七成的人答不上来。这不是个别现象而是整个行业在“调包时代”积累的集体短板——大家习惯了pip install之后直接调接口却对模型推理的底层链路、显存分配、批处理调度、量化误差传播这些真正决定系统生死的东西一无所知。ai-engineering-from-scratch这个标题之所以值得认真对待恰恰因为它代表了一种反潮流的路径不依赖现成的推理框架封装不把模型当黑盒而是从张量计算、注意力机制、KV Cache管理、算子融合这些最基础的环节开始亲手搭建一个能跑通、能优化、能排错的AI工程系统。这件事的意义不在于“重复造轮子”而在于当你亲手实现过一遍之后再回头看那些封装好的工具你会突然看懂它们的设计取舍知道什么场景该用什么方案出了问题该从哪里下手排查。这篇文章适合三类人第一类是有一定Python和深度学习基础但一直停留在“调库”层面想真正理解推理系统内部运作的工程师第二类是在实际项目中遇到了性能瓶颈比如显存不够、吞吐上不去、延迟波动大需要从底层找原因的技术负责人第三类是想系统补齐AI工程能力为后续做模型部署、推理优化、边缘端落地打基础的学习者。我会把整个从零构建的过程拆成可操作的步骤每一步都解释“为什么这样做”以及“不这样做会踩什么坑”。需要提前说明的是从零构建不等于所有东西都自己写。合理的做法是核心链路自己实现以理解原理非核心的辅助工具可以用成熟库。比如张量运算可以用NumPy或PyTorch的基础API但注意力计算、KV Cache管理、批处理调度这些关键环节必须自己动手写一遍。下面进入正题。2. 推理引擎的最小可行骨架先让模型跑起来再谈优化2.1 为什么第一步不是写注意力而是搭调度循环很多人一上来就急着实现Transformer的注意力公式结果写到一半发现模型权重怎么加载输入怎么分批输出怎么对齐这些“外围”问题反而卡住了进度。我的经验是先搭一个最简的推理调度循环哪怕里面调用的是PyTorch现成的nn.Linear也要先把数据从输入到输出的完整链路跑通。这个最小骨架包含四个核心模块权重加载器、输入预处理器、前向计算图、输出后处理器。权重加载器负责把模型文件通常是safetensors或bin格式映射到内存这里要注意的是内存映射方式的选择——用mmap可以避免一次性把整个模型读进RAM对于大模型来说这是必须的。输入预处理器做tokenization和padding输出后处理器做detokenization和截断。我建议用一个极小的模型来验证骨架比如TinyLlama或Qwen2-0.5B。不要一上来就上7B模型否则调试周期会拉得很长。骨架跑通的标准是给定一句输入能输出一句连贯的回复且整个过程的显存占用和延迟可以稳定测量。提示在这个阶段就要把日志和计时埋点加进去。每个模块的耗时、显存峰值、中间张量的形状全部打印出来。后面做优化时这些数据就是你的“体检报告”。2.2 权重加载中的内存陷阱与量化预处理权重加载看起来简单实际上坑很多。第一个坑是数据类型转换。很多模型权重保存为FP16但你的计算设备可能只支持FP32或者你想用BF16来节省显存。转换时机很关键如果在加载时逐层转换内存峰值会很高如果先全部加载再统一转换又可能直接OOM。我的做法是分块加载、分块转换、分块搬到目标设备每块处理完就释放原始数据。第二个坑是量化预处理。如果你打算后续做INT8或INT4量化权重加载阶段就要做好分组和缩放因子的计算。以INT8为例常见的做法是按通道计算绝对值的最大值然后除以127得到缩放因子。这个过程必须在加载时完成因为量化后的权重需要和缩放因子一起存储。我见过有人把量化放到推理时做结果每次前向传播都要重新计算缩放因子延迟直接翻倍。第三个坑是共享权重的处理。很多模型尤其是MoE架构存在权重共享比如embedding层和输出层共享同一个权重矩阵。加载时如果不做引用计数就会重复占用显存。处理方法是维护一个权重名称到内存地址的映射表遇到同名权重直接复用。2.3 前向计算图的动态构建与静态图取舍前向计算图有两种构建方式动态图和静态图。动态图就是每来一个输入就重新走一遍Python解释器灵活但慢静态图是先把计算图编译好输入直接喂进去快但不够灵活。从零构建时我建议先用动态图跑通再逐步把热点路径改成静态图。具体做法是用PyTorch的torch.jit.trace或者torch.compile对前向函数做编译。但要注意torch.compile对动态形状的支持有限如果你的输入长度变化很大编译出来的图可能只对特定形状有效。这时候需要做形状分桶把输入长度分成几个区间比如128、256、512、1024每个区间编译一个静态图运行时根据实际长度选择最近的桶。这个阶段的另一个重点是算子融合。比如LayerNorm后面的线性层可以融合成一个算子注意力计算中的QK^T和softmax也可以融合。融合的好处是减少kernel启动次数和中间张量的读写。我实测下来在中小模型上算子融合能带来15%到30%的延迟下降。2.4 输出后处理与流式生成的实现细节输出后处理不只是detokenization那么简单。如果你要做流式生成也就是一个字一个字往外吐后处理必须和生成循环紧密配合。核心逻辑是每生成一个token就立即做detokenization并推送到输出队列而不是等整个序列生成完再处理。这里有一个容易忽略的细节多字节字符的边界问题。比如中文和emoji一个字符可能由多个token组成。如果逐个token做detokenization可能会出现半个字符的情况。解决方案是维护一个缓冲区当缓冲区内容能构成完整字符时才输出。这个逻辑在HuggingFace的tokenizer里叫decode_with_prefix_space但自己实现时很容易漏掉。流式生成还需要处理停止条件。除了常见的EOS token还要考虑最大长度限制、重复惩罚触发、以及用户主动中断。这些条件要在生成循环里实时检查不能等生成完再判断。3. 注意力机制的从零实现不只是矩阵乘法3.1 标准多头注意力的分步拆解与形状变换注意力机制的核心公式大家都知道Attention(Q,K,V) softmax(QK^T / sqrt(d_k)) V。但真正写代码时形状变换才是最容易出错的地方。假设batch size为B序列长度为S头数为H每个头的维度为D那么输入hidden states的形状是(B, S, H*D)经过QKV线性层后形状变成(B, S, 3*H*D)需要reshape成(B, S, 3, H, D)再permute成(3, B, H, S, D)取出Q、K、V后各自形状为(B, H, S, D)QK^T得到(B, H, S, S)softmax后再和V相乘得到(B, H, S, D)最后transpose回(B, S, H, D)并reshape成(B, S, H*D)这一串变换里任何一步的维度顺序搞错结果都会完全不对。我的建议是每做完一步就打印形状和预期值对照。不要凭感觉写一定要用具体的数字验证。另一个关键点是缩放因子的位置。1/sqrt(d_k)是在softmax之前乘到QK^T上的不是之后。这个顺序影响数值稳定性——先缩放再softmax可以避免指数爆炸。3.2 KV Cache的显存布局与增量更新策略KV Cache是自回归生成的核心优化。没有它每生成一个token都要重新计算整个序列的K和V计算量随序列长度平方增长。有了它只需要计算新token的K和V然后拼接到缓存里。但KV Cache的显存布局很有讲究。常见的有两种连续布局和分页布局。连续布局就是为每个序列预分配一个最大长度的缓存简单但浪费显存分页布局借鉴了操作系统的虚拟内存思想把缓存分成固定大小的页按需分配。vLLM的PagedAttention就是分页布局的典型实现。从零实现时我建议先用连续布局跑通再考虑分页。连续布局的关键参数是max_seq_len和max_batch_size这两个值决定了预分配的显存大小。如果设得太大显存浪费严重设得太小长序列或大batch会OOM。我的经验值是max_seq_len设为模型支持的最大长度的1.2倍max_batch_size根据显存容量反推。增量更新的逻辑是每次生成新token时计算它的K和V然后写入缓存中对应的位置。这里要注意位置编码的同步更新。RoPE旋转位置编码需要知道当前token的绝对位置这个位置信息必须和缓存索引一致。3.3 注意力掩码的生成逻辑与常见错误注意力掩码决定了哪些位置可以互相“看见”。在训练时通常用因果掩码causal mask即每个位置只能看见自己和之前的位置。在推理时如果batch里有不同长度的序列还需要padding mask。生成掩码的常见错误有三个第一掩码的形状不对。因果掩码应该是(S, S)的下三角矩阵扩展到batch维度后是(B, 1, S, S)或(B, H, S, S)。第二掩码的值搞反。通常用-inf表示屏蔽0表示可见但有些实现用1和0混用就会出错。第三掩码和KV Cache的配合。当使用KV Cache时Q的长度是1只生成一个新token但K的长度是S包含缓存所以掩码的形状应该是(1, S)而不是(S, S)。我踩过的一个坑是在batch推理时不同序列的缓存长度不同如果统一用最大长度生成掩码短序列的padding位置会被错误地屏蔽或激活。解决方案是为每个序列单独生成掩码或者用一个二维的位置索引数组来动态构造掩码。3.4 Flash Attention的核心思想与手写简化版Flash Attention的核心思想是分块计算在线softmax避免把完整的(S, S)注意力矩阵写进显存。标准注意力需要O(S^2)的显存Flash Attention把它降到O(S)。手写一个简化版Flash Attention并不复杂。基本流程是把Q、K、V按行分块对每个Q块遍历所有K块计算局部的QK^T用在线softmax更新累积结果。在线softmax的公式是维护当前的最大值m和累积和l每来一个新的块就更新m和l并调整之前累积的结果。这个过程中数值稳定性是关键。如果不做在线softmax的修正直接累加很容易出现溢出或下溢。我建议在实现时先用小规模数据验证数值正确性再逐步扩大规模。注意手写Flash Attention主要是为了理解原理生产环境还是建议用官方实现或Triton版本。自己写的版本在性能上很难超过高度优化的库。4. 批处理与调度让吞吐量翻倍的关键环节4.1 连续批处理与静态批处理的本质区别静态批处理就是攒够一批请求一起送进模型等全部生成完再返回。问题是如果batch里有的序列很短有的很长短的必须等长的GPU利用率很低。连续批处理continuous batching则是每生成一个token就检查一次如果有序列生成完了就立即把新的请求插进来不用等整批结束。这个区别听起来简单但实现起来涉及很多状态管理。每个序列有自己的缓存、位置编码、停止条件调度器需要维护这些状态并在每一步决定哪些序列继续、哪些退出、哪些新加入。vLLM和TensorRT-LLM的核心竞争力之一就是高效的连续批处理调度。从零实现时我建议先用一个简单的调度策略维护一个活跃序列列表每步生成后把完成的序列移出把等待的序列移入。关键是缓存的重用退出的序列释放的缓存块要能被新加入的序列复用否则显存会很快耗尽。4.2 请求队列的优先级设计与公平性权衡实际系统中请求不是平等的。有的用户要求低延迟有的用户能接受高延迟但要求高吞吐。这就需要优先级队列。常见的做法是给每个请求打一个优先级标签调度器优先处理高优先级的请求。但优先级会带来饥饿问题低优先级请求可能永远排不上。解决方案是引入老化机制请求在队列里等待的时间越长优先级逐渐提升。这样既保证了高优先级请求的响应速度又避免了低优先级请求被无限期推迟。另一个权衡是公平性。如果完全按优先级调度高优先级请求会占据大部分计算资源。我的做法是把GPU时间切成小片每个时间片内按优先级调度但保证每个优先级至少获得一定比例的时间片。这个比例可以根据业务需求调整。4.3 显存池化管理与碎片整理批处理系统中显存的分配和释放非常频繁。如果每次都用cudaMalloc和cudaFree开销很大而且容易产生碎片。解决方案是实现一个显存池预先分配一大块显存然后自己管理分配和释放。显存池的核心是空闲块链表。每个空闲块记录起始地址和大小。分配时从链表中找到第一个足够大的块如果块比请求的大就分割成两块一块返回一块留在链表。释放时把块插回链表并检查相邻块是否可以合并。碎片整理是另一个难点。当空闲块很多但都很小时即使总空闲显存足够也无法满足大请求。这时候需要压缩把已分配的块移动到连续区域腾出大的空闲块。但移动数据有开销所以压缩不能太频繁。我的策略是当分配失败且空闲显存总量足够时触发一次压缩。4.4 批处理中的位置编码同步问题使用KV Cache和连续批处理时位置编码的同步很容易出错。每个序列有自己的当前位置这个位置必须和缓存索引一致。如果调度器在插入新序列时没有正确初始化位置或者缓存复用时没有重置位置生成结果就会错乱。我的做法是为每个序列维护一个position字段每次生成前用这个字段构造位置编码。缓存复用时把position重置为0。另外RoPE的旋转角度是位置的函数如果位置错了注意力分数就会完全不对。这个bug很难发现因为输出可能看起来“差不多”但质量明显下降。提示在调试批处理时先用batch size1验证单序列正确性再逐步增加batch size。如果batch size1正确但batch size1出错大概率是位置编码或掩码的问题。5. 性能剖析与调优从能跑到跑得快5.1 用Profiler定位真正的瓶颈很多人优化性能靠猜觉得注意力慢就优化注意力觉得矩阵乘法慢就换库。但真正的瓶颈往往在意想不到的地方。比如我遇到过一个案例模型前向传播只占30%的时间剩下70%花在tokenization和detokenization上。如果不做profiling根本发现不了。PyTorch的torch.profiler是必备工具。它可以记录每个算子的耗时、显存占用、kernel启动次数。使用方法很简单用with torch.profiler.profile(...)包住推理代码然后导出结果。关键是要分模块计时把权重加载、预处理、前向传播、后处理分开计时这样才能定位到具体环节。另一个工具是nsight systems它可以做GPU级别的profiling看到kernel的执行时间、SM利用率、显存带宽占用。如果发现某个kernel的SM利用率很低说明并行度不够如果显存带宽占用很高说明是内存瓶颈。5.2 算子融合与内存布局优化的实际收益算子融合的收益取决于模型结构和硬件。在A100上我实测过LayerNormLinear的融合延迟下降约12%QK^Tsoftmax的融合延迟下降约18%。但融合不是越多越好过度融合会增加编译时间而且可能因为寄存器压力导致occupancy下降。内存布局优化同样重要。比如把权重从(out_features, in_features)转置成(in_features, out_features)可以让矩阵乘法更好地利用缓存。再比如把KV Cache从(B, H, S, D)改成(B, S, H, D)可以让连续位置的访问更局部化提高缓存命中率。这些优化的收益需要实测验证。我的建议是每次只改一个变量测出收益后再改下一个。不要一次性做多个优化否则出了问题不知道是哪个引起的。5.3 量化对精度和速度的双重影响量化是推理优化的重头戏。INT8量化通常能带来2倍左右的速度提升和一半的显存节省但精度损失需要仔细评估。常见的量化方法有训练后量化PTQ和量化感知训练QAT。PTQ简单但精度损失大QAT精度好但需要重新训练。从零实现时我建议先做权重量化把FP16权重转成INT8激活值保持FP16。这样实现简单精度损失也可控。具体做法是对每个权重矩阵按通道计算缩放因子然后把权重除以缩放因子并四舍五入到INT8。推理时把INT8权重反量化回FP16再计算。更激进的方案是权重和激活都量化。这需要校准数据集来统计激活值的分布。校准集的选择很关键要用真实场景的数据不能用随机数据。我见过有人用随机数据校准结果量化后的模型在真实数据上完全不能用。注意量化后的模型一定要做精度评估。常用的指标有困惑度perplexity、BLEU、ROUGE等。如果精度下降超过可接受范围就要考虑混合精度量化对敏感层保持FP16对其他层用INT8。5.4 延迟与吞吐的权衡什么时候该牺牲哪个延迟和吞吐是一对矛盾。增大batch size可以提高吞吐但会增加延迟因为每个请求要等更久。减小batch size可以降低延迟但吞吐会下降。权衡的关键是业务场景。如果是实时对话系统延迟优先batch size要小甚至可以用batch size1如果是离线批量处理吞吐优先batch size可以尽量大。中间场景可以用动态批处理设置一个最大等待时间比如50毫秒在50毫秒内攒到的请求一起处理超过50毫秒就立即处理当前批次。另一个权衡是模型大小。大模型质量好但延迟高小模型延迟低但质量差。常见的做法是模型级联先用小模型处理简单请求如果小模型置信度低再交给大模型。这样可以在保证质量的同时降低平均延迟。6. 从单机到服务化把推理引擎变成可用系统6.1 HTTP服务层的设计要点与并发模型推理引擎跑通之后下一步是把它包装成HTTP服务。这里的关键是并发模型的选择。Python的GIL限制了多线程的并行能力所以常见的方案是用多进程每个进程加载一个模型副本请求分发到不同进程。但多进程会带来显存翻倍的问题。如果显存不够可以用单进程异步IO的方案用一个进程加载模型用asyncio处理HTTP请求推理时用线程池或CUDA流来并行。但要注意PyTorch的CUDA操作默认是同步的需要显式使用torch.cuda.Stream来实现异步。另一个要点是请求排队。如果请求到达速度超过处理速度必须有队列来缓冲。队列的长度要有限制否则会无限堆积导致OOM。当队列满时应该返回503错误而不是无限等待。6.2 健康检查、优雅关闭与异常恢复生产系统必须有健康检查接口。最简单的做法是返回一个/health端点检查模型是否加载成功、显存是否正常、队列是否可接受。更完善的健康检查还包括推理自检定期发一个固定输入检查输出是否符合预期。优雅关闭是指收到关闭信号后不再接受新请求等当前正在处理的请求完成后再退出。这需要维护一个活跃请求计数器关闭时等待计数器归零。同时要设置超时避免无限等待。异常恢复是指当推理过程中出现CUDA错误或OOM时能自动恢复。常见的做法是捕获异常清理显存重新加载模型。但重新加载模型很慢所以更好的做法是预留冗余显存在OOM时释放冗余显存而不是重新加载。6.3 监控指标与日志体系的最小集合监控指标至少包括请求量QPS、延迟分布P50、P95、P99、错误率、显存占用、GPU利用率、队列长度。这些指标可以用Prometheus采集用Grafana展示。日志体系要记录每个请求的完整生命周期到达时间、开始处理时间、生成token数、结束时间、是否出错。日志要结构化JSON格式方便后续分析。我建议在日志里加上请求ID这样可以把一个请求的所有日志串联起来。提示监控和日志的开销不能忽略。如果每个请求都打大量日志IO会成为瓶颈。我的做法是正常请求只记录关键指标错误请求记录完整上下文。6.4 压力测试与容量规划的实际方法压力测试的目的是找到系统的拐点在哪个并发量下延迟开始急剧上升。测试方法是逐步增加并发数观察QPS和延迟的变化。当延迟开始非线性增长时说明系统已经饱和。容量规划是根据压力测试结果推算需要多少资源才能支撑目标QPS。关键参数是单请求平均计算量和单卡吞吐上限。比如单卡每秒能处理100个token平均每个请求需要生成200个token那么单卡每秒能处理0.5个请求。要支撑10 QPS就需要20张卡。但实际规划还要考虑峰值系数和冗余系数。峰值系数是峰值QPS和平均QPS的比值冗余系数是预留的备用容量比例。我的经验值是峰值系数取3到5冗余系数取1.5到2。6.5 一个完整的部署清单与检查项部署前我通常会过一遍这个清单检查项标准常见问题模型加载成功加载且显存占用在预期范围权重格式不匹配、显存不足单请求推理输出正确且延迟在预期范围位置编码错误、掩码错误批处理推理batch size1时输出正确缓存复用错误、位置不同步流式生成逐token输出且字符完整多字节字符截断并发请求多请求同时到达时正确调度队列溢出、显存OOM异常处理CUDA错误后能恢复显存泄漏、状态未重置监控指标所有关键指标可采集指标缺失、采集开销过大日志请求全链路可追溯日志格式混乱、缺少请求ID压力测试找到系统拐点测试数据不真实、并发模型不对优雅关闭关闭时不丢请求活跃请求计数错误这个清单看起来简单但每一条背后都有无数踩坑经验。比如“缓存复用错误”这一条我调试了整整两天才发现是位置编码没有重置。再比如“多字节字符截断”是在用户反馈“输出有乱码”之后才定位到的。从零构建AI工程系统最大的收获不是写出了多少代码而是建立了一套从现象到根因的排查思维。当你亲手实现过每一个环节再遇到问题时你不再需要猜而是可以沿着数据流一步步定位。这种能力是调包永远给不了的。