
1. 从零手搓AI工程为什么我不建议你直接调包很多人一听到“AI工程”这四个字第一反应就是打开某个云平台拖几个组件调几个API然后跑通一个Demo就觉得自己已经入门了。我刚开始接触这个领域的时候也是这么想的直到有一次线上模型推理服务在高峰期直接雪崩日志里全是显存溢出和请求超时我才意识到——只会调包的人根本不知道系统在哪个环节出了问题更别提优化了。ai-engineering-from-scratch这个标题核心不在于“AI”而在于“from scratch”。它强调的是一种从底层构建、从原理出发的工程思维。这篇文章适合那些已经会用现成框架跑模型但想搞清楚“模型部署之后到底发生了什么”的开发者也适合刚入行、不想只做“调参侠”的AI工程师。我会从计算图、内存管理、推理调度、服务化封装这几个维度把AI工程从零搭建的完整链路拆开来讲每一步都告诉你为什么这么做以及不这么做会踩什么坑。先明确一个概念AI工程不等于算法研究。算法研究关心的是模型精度能不能再涨一个点而AI工程关心的是这个模型能不能在给定硬件上、在可接受的延迟内、稳定地服务成千上万个并发请求。两者的目标函数完全不同。我见过太多算法很强的团队模型在论文数据集上刷到SOTA一上线就崩原因往往不是模型本身而是工程链路里某个不起眼的环节没处理好。所以从零构建AI工程能力第一步不是去学什么新框架而是把“模型从文件到服务”这条路径上的每个环节都亲手实现一遍。下面我会按照实际搭建顺序逐层拆解。1.1 先搞清楚“从零”的边界在哪里“From scratch”不是让你从晶体管开始造芯片也不是让你手写CUDA内核。它的合理边界是不依赖高度封装的推理服务框架比如某些一键部署平台但可以使用基础的数学库和硬件驱动接口。换句话说你要自己管理模型加载、内存分配、请求批处理、并发调度这些事而不是交给一个黑盒。我给自己定的“从零”标准是这样的模型权重文件自己解析计算图自己构建推理会话自己管理HTTP服务自己写。听起来很原始但只有这样你才能在每个环节插入监控、做针对性优化。举个例子当你自己管理内存池的时候你才能精确知道每个请求占用了多少显存什么时候该触发回收而不是等OOM了才去猜。这个边界设定还有一个好处它让你对“依赖”有清醒的认识。每引入一个第三方库你都要问自己它帮我省了哪部分工作代价是什么。比如某些推理运行时确实能自动做算子融合但它也可能在你不知情的情况下分配了额外的中间缓冲区。自己实现一遍之后你再看那些框架的文档就能一眼看出它的设计取舍。1.2 一个最小可用的推理循环长什么样在讲复杂调度之前先看一个最朴素的推理循环。假设你有一个训练好的模型权重已经导出为二进制文件输入是一个固定形状的张量。从零实现的话你需要做这几件事读取权重文件、按层构建计算逻辑、分配输入输出缓冲区、执行前向计算、返回结果。用伪代码表示大概是这样# 加载权重 weights load_binary(model.bin) # 构建层 layers build_layers(weights) # 分配缓冲区 input_buf alloc_tensor(shape(1, 3, 224, 224)) output_buf alloc_tensor(shape(1, 1000)) # 推理循环 while True: data receive_request() copy_to_buffer(data, input_buf) for layer in layers: layer.forward(input_buf, output_buf) swap(input_buf, output_buf) send_response(output_buf)这个循环能跑通但性能极差。问题出在几个地方每次请求都重新分配缓冲区、层与层之间没有并行、没有批处理、没有异步。但它的价值在于你有了一个完全可控的基线。接下来所有的优化都是在这个基线上做增量改进每改一处你都能测量到具体的收益。我建议每个想深入AI工程的人都先把这个循环手写一遍哪怕用Python加NumPy都行。写完之后你再去用那些高级框架就会有一种“原来它帮我做了这些”的顿悟感。2. 计算图与算子融合从解释执行到编译执行当你把最小推理循环跑通之后下一步自然会想能不能让计算更快这时候就绕不开计算图这个概念。计算图本质上是对模型前向计算过程的一种中间表示它把每个算子当作节点把张量流动当作边。有了计算图你就有机会在真正执行之前对计算过程做全局优化。2.1 为什么解释执行慢逐算子调用的开销在最小循环里每个算子都是独立调用的。假设一个模型有200个算子每个算子调用涉及一次函数跳转、一次参数检查、一次内存读写。单次开销看起来很小但乘以200再乘以每秒上千次请求累积起来就非常可观。更严重的是逐算子执行意味着你无法跨算子做优化。比如连续的卷积和激活函数其实可以合并成一个融合算子减少一次中间结果的写回和读取。我实测过一个中等规模的视觉模型在逐算子解释执行下单次推理耗时约45毫秒。把其中12组可以融合的算子合并之后耗时降到28毫秒提升接近40%。这还只是算子融合的收益没有算上内存复用和指令重排。所以从零构建AI工程计算图不是可选项而是必经之路。你需要自己设计一个简单的图结构把模型解析成节点和边然后实现一个调度器决定哪些节点可以合并、哪些可以并行、哪些可以提前计算。2.2 手写一个极简计算图调度器一个极简的计算图调度器可以这样设计首先定义节点结构每个节点包含算子类型、输入张量列表、输出张量列表、以及一个执行函数。然后构建依赖关系通过拓扑排序确定执行顺序。最后在调度时检查相邻节点是否满足融合条件。融合条件通常包括算子类型兼容比如卷积后接ReLU、张量形状匹配、没有分支依赖。满足条件的节点可以合并成一个复合节点执行时只分配一次输出缓冲区中间结果留在寄存器或共享内存里。这里有一个关键决策融合是在图构建阶段做还是在运行时动态做。构建阶段融合更简单但灵活性差运行时融合可以根据实际输入形状做自适应但调度开销更大。我的经验是对于输入形状固定的服务场景构建阶段融合就够了对于输入形状多变的场景可以维护多个融合方案运行时根据形状选择。手写调度器的时候还要注意内存分配策略。每个中间张量都需要一块内存如果每个节点都单独分配内存碎片和分配开销会拖垮性能。更好的做法是实现一个内存池预先分配一大块内存然后按照张量的生命周期做复用。具体来说你可以用引用计数或者生命周期分析确定每个张量从什么时候开始需要到什么时候不再需要然后让生命周期不重叠的张量共享同一块内存。2.3 算子融合的边界与实测数据算子融合不是越多越好。有些融合会导致寄存器压力过大反而降低 occupancy有些融合会改变数值精度比如把多个浮点运算合并后中间结果的舍入误差累积方式变了。我在实际项目里遇到过融合后精度下降0.3%的情况虽然不大但在某些对精度敏感的场景里就是不可接受的。所以做算子融合的时候一定要有回归测试。每融合一组算子就在验证集上跑一遍确认精度没有明显下降。同时用性能分析工具测量融合前后的耗时变化确保收益是正的。下面这张表是我在一个实际项目中做的融合策略对比供你参考融合策略推理延迟ms显存占用MB精度变化无融合45.21024基准仅融合卷积激活34.7896-0.05%融合卷积激活池化29.1832-0.12%全融合含残差连接27.8768-0.31%从数据可以看出融合程度越高延迟和显存收益越大但精度损失也越明显。实际选择时要根据业务对精度和延迟的容忍度来权衡。我的建议是先做保守融合把收益拿到手再逐步尝试更激进的融合方案每步都做好精度监控。3. 内存管理与批处理把显存用到极致AI工程里最容易被忽视、也最容易出问题的环节就是内存管理。模型训练的时候显存不够可以减小batch size但推理服务上线后显存是固定的请求量是波动的你必须在一个确定的显存预算内尽可能多地处理请求。这就涉及到内存池设计、动态批处理、以及请求排队策略。3.1 显存池化为什么malloc在推理服务里是灾难在推理服务里每次请求都调用系统malloc分配显存会带来两个问题一是分配本身有开销二是频繁分配释放会产生碎片。碎片积累到一定程度即使总空闲显存足够也可能因为找不到连续的大块而分配失败。我见过一个线上服务运行了三天之后开始间歇性OOM重启就恢复。排查后发现就是显存碎片导致的。后来改成显存池化预先分配一大块显存所有请求都从池子里取用完还回去碎片问题就消失了。显存池的实现思路很简单维护一个空闲块列表每个块记录起始地址和大小。分配时从列表里找第一个足够大的块切分后返回释放时把块还回列表并尝试与相邻空闲块合并。这个逻辑和操作系统内存管理里的伙伴系统很像你可以直接借鉴。但显存池有一个坑不同请求需要的张量形状可能不同如果池子里的块大小固定就会浪费。解决办法是分级池化把块按大小分成几档比如小请求用小池子大请求用大池子。这样既减少了碎片又提高了分配效率。3.2 动态批处理把零散请求攒成一批批处理是提升推理吞吐最有效的手段之一。原理很简单把多个请求的输入拼成一个更大的张量一次性送进模型计算计算完再拆开返回。这样硬件利用率更高单位请求的延迟摊薄了。但动态批处理有两个关键参数最大批大小和最大等待时间。最大批大小受显存限制不能无限大最大等待时间决定了你愿意为了攒批而让请求等多久。这两个参数需要根据实际流量调优。我的经验是先用一个保守的配置跑起来比如最大批大小设为8最大等待时间设为10毫秒。然后观察两个指标平均批大小和请求延迟分布。如果平均批大小远小于最大批大小说明流量不够可以适当降低等待时间如果延迟的P99很高说明等待时间太长了需要调小。还有一个细节批处理里的请求可能长短不一比如有的输入序列长度是10有的是100。如果直接拼成一个矩形张量短序列要补零浪费计算。更好的做法是按长度分桶把长度相近的请求放在一批里减少填充浪费。这个优化在自然语言处理场景里效果特别明显我实测过分桶之后吞吐能提升30%以上。3.3 请求排队与优先级别让一个慢请求拖垮所有人有了批处理还需要一个请求队列来管理待处理的请求。队列的设计直接影响公平性和延迟。最简单的做法是先进先出但这样会导致一个长请求堵住后面所有短请求。更好的做法是优先级队列根据请求的预估计算量或者业务优先级来排序。我一般会实现两级队列高优先级队列放实时性要求高的请求低优先级队列放可以容忍延迟的请求。调度器每次从高优先级队列取取空了再从低优先级取。同时设置一个超时机制如果某个请求在队列里等太久就提升它的优先级避免饿死。这里有一个容易忽略的点队列长度需要限制。如果请求来得太快处理不过来队列会无限增长最终耗尽内存。所以必须设置一个最大队列长度超过就拒绝新请求返回一个明确的错误码。这比让整个服务崩溃要好得多。4. 服务化封装从脚本到高可用API模型推理逻辑写好了内存和批处理也优化了最后一步是把它封装成一个对外服务的API。这一步看似简单其实有很多工程细节决定成败。我见过太多模型效果很好但服务化做得一塌糊涂的项目最后上线效果大打折扣。4.1 推理服务的进程模型选择第一个决策是进程模型。常见的有三种单进程单线程、单进程多线程、多进程多线程。每种都有适用场景。单进程单线程最简单但无法利用多核而且一个请求阻塞会卡住所有请求。单进程多线程可以并发处理请求但Python有GIL限制计算密集型任务实际上还是串行的。多进程多线程最复杂但能充分利用多核而且一个进程崩溃不会影响其他进程。我的建议是如果推理后端是C或者Rust写的可以用单进程多线程因为计算时释放了GIL如果是Python写的直接用多进程每个进程绑定一个CPU核进程内再用异步IO处理网络请求。这样既避免了GIL又实现了隔离。多进程模型还有一个好处可以做滚动更新。新版本模型启动新的进程组等健康检查通过后再把流量切过去旧进程组优雅退出。这个过程对用户完全无感。4.2 健康检查与优雅退出上线前的最后一道保险健康检查不是简单地返回一个200就完事了。真正的健康检查要验证模型是否加载成功、显存是否充足、推理是否正常。我一般会实现两个端点一个轻量级的存活检查只返回进程状态一个重量级的就绪检查实际跑一次小推理确认整个链路通畅。优雅退出同样重要。当服务收到终止信号时不能直接杀掉进程而是要先停止接受新请求把队列里已有的请求处理完释放显存和文件句柄然后再退出。这个过程需要设置一个超时时间比如30秒超过就强制退出避免卡死。我踩过一次坑服务没有做优雅退出滚动更新的时候旧进程被直接杀掉导致正在处理的请求全部失败监控上出现了一个明显的错误尖峰。后来加上优雅退出逻辑滚动更新就完全平滑了。4.3 监控指标没有度量就没有优化服务上线之后你必须知道它运行得怎么样。最基础的监控指标包括请求量、延迟分布P50、P95、P99、错误率、显存使用率、批处理平均大小、队列长度。这些指标要暴露成一个标准的监控端点方便接入现有的监控系统。延迟分布特别重要。只看平均延迟会掩盖很多问题比如平均延迟10毫秒但P99是500毫秒说明有少量请求体验极差。这些慢请求往往是因为排队等待或者批处理里混入了长请求。通过分析延迟分布你能定位到具体的瓶颈。还有一个指标容易被忽略批处理填充率。也就是实际计算量除以理论最大计算量的比例。如果填充率很低说明批处理里有很多无效计算需要调整分桶策略或者批大小。5. 从零构建的踩坑记录与经验总结上面讲了整个链路的构建思路这一节我想分享几个实际踩过的坑以及从中总结出的经验。这些内容在官方文档里通常找不到但却是决定项目成败的关键。5.1 数值精度一个被低估的稳定性杀手在从零实现算子的时候数值精度是最容易出问题的地方。比如累加的顺序不同浮点结果就会有差异再比如某些激活函数在特定输入范围下会溢出。这些问题在单次测试中可能看不出来但在长时间运行或者极端输入下就会暴露。我的做法是每实现一个算子都跟参考实现比如NumPy或者PyTorch做逐元素对比确保误差在1e-5以内。对于累加类算子使用Kahan求和或者成对求和来减少误差。对于指数、对数等敏感函数做输入范围裁剪避免溢出。还有一个经验在服务化的时候保留一个“参考推理路径”也就是用高精度但慢速的方式跑一遍定期跟快速路径的结果做对比。如果偏差超过阈值就触发告警。这样能在精度问题影响用户之前就发现它。5.2 并发下的资源竞争锁不是万能的多线程或者多进程环境下资源竞争是另一个大坑。比如多个线程同时向显存池申请内存如果不加锁就会导致分配冲突但如果加锁粒度太大又会成为性能瓶颈。我的解决方案是使用无锁数据结构或者细粒度锁。比如显存池可以按大小分成多个独立的子池每个子池有自己的锁这样不同大小的分配请求就不会互相阻塞。对于请求队列可以使用无锁队列生产者消费者各自操作不同的指针减少竞争。但无锁编程的复杂度很高容易出bug。如果团队里没有对并发编程特别熟悉的人我建议先用粗粒度锁把功能跑通再逐步优化。毕竟正确性比性能更重要。5.3 版本管理与回滚别让一次更新变成事故模型更新是常态但每次更新都是一次风险。我见过一次更新后新模型的输出分布跟旧模型差异很大导致下游业务逻辑出错。所以版本管理和回滚机制必须从第一天就设计好。具体来说每个模型版本要有唯一的标识服务启动时记录当前版本。更新时新版本先在小流量上灰度对比关键指标延迟、错误率、输出分布跟旧版本是否一致。如果一致再逐步扩大流量如果不一致立即回滚。回滚要能做到秒级。这意味着旧版本的模型文件、配置、甚至进程都要保留着随时可以切回去。我一般会保留最近三个版本磁盘空间不够就保留两个但绝对不能只保留一个。6. 这套从零方案适合谁以及后续怎么扩展写到这里整个从零构建AI工程的链路基本讲完了。从计算图到内存管理从批处理到服务化每个环节我都给出了具体的实现思路和踩坑经验。这套方案不是要替代现有的成熟框架而是帮你建立对AI工程全链路的掌控力。如果你是一个刚入行的AI工程师我建议你至少把计算图和内存管理这两部分亲手实现一遍。不用追求性能极致重点是理解每个决策背后的权衡。当你再去看那些推理框架的源码时会发现很多设计你都能看懂甚至能看出它的不足之处。如果你是一个带团队的Tech Lead这套方案可以作为团队内部的技术基线。让每个成员都了解从模型文件到线上服务的完整路径这样在排查问题时大家有共同的语言和认知框架效率会高很多。后续扩展的方向有几个一是支持多模型共存在同一套服务里加载多个模型根据请求路由到不同的模型二是支持模型热更新不重启服务就能加载新版本三是引入更复杂的调度策略比如根据请求的SLA动态调整批大小和优先级。这些都是在基础链路稳固之后自然延伸出来的需求。我个人在实际操作中的体会是从零构建的过程虽然辛苦但每一次踩坑和修复都会让你对系统的理解加深一层。这种理解是看多少篇文档和教程都换不来的。当你能够闭着眼睛画出从请求进来到结果返回的完整数据流并且知道每个环节可能出什么问题时你才算真正入了AI工程的门。