1. 从零手搓AI工程:为什么我不建议你直接调包
很多人一上来就想跑通一个能对话的模型,或者直接拉一个开源仓库改改就上线。我见过太多团队,模型效果在Demo阶段惊艳,一进生产环境就崩——延迟飙到几秒、显存动不动爆掉、并发一上来就排队。问题出在哪?不是模型不行,是AI工程这层没搭好。
ai-engineering-from-scratch这个标题,核心不在“AI”,而在“engineering”和“from scratch”。它讲的不是怎么调API,而是从最底层把一套AI系统该有的东西自己搭出来:数据怎么流、推理怎么调度、显存怎么管、服务怎么扩。适合谁看?适合那些已经会写Python、跑过几个模型,但一遇到“上线”就发怵的工程师;也适合想真正理解推理框架内部在干什么、不想永远当调包侠的人。
我自己走过这条路。最早做推理服务,直接拿现成框架套,结果一个batch size设错,P99延迟从200ms跳到2s。后来逼着自己从零写了一遍调度逻辑,才明白那些框架里的参数到底在权衡什么。这篇就把我从零搭AI工程链路时踩过的关键节点、做过的取舍、以及那些文档里不会写的经验,完整拆一遍。
2. 先想清楚:从零搭AI工程到底在搭什么
2.1 把AI工程拆成四层,别一锅炖
很多人说“搭AI系统”,脑子里是一团浆糊。我的习惯是拆成四层,每层职责分明:
- 数据层:负责样本的读取、预处理、批处理。这层的关键是吞吐和顺序。训练时要做shuffle,推理时往往要保序。
- 模型层:模型结构定义、权重加载、前向计算。这层的关键是显存布局和计算图。
- 调度层:请求怎么排队、怎么组batch、超时怎么处理。这层是AI工程和普通后端最大的区别所在。
- 服务层:对外暴露接口、健康检查、扩缩容。这层反而最接近传统后端。
为什么这么拆?因为每一层的瓶颈和优化手段完全不同。数据层卡I/O,模型层卡显存,调度层卡策略,服务层卡网络。你如果不拆开,出了问题根本不知道从哪下手。我见过一个服务,QPS上不去,团队一直在加机器,最后发现是调度层用了全局锁,加机器根本没用。
2.2 “from scratch”不是让你重写CUDA
这里要澄清一个误区。from scratch不是让你从汇编开始写,也不是让你重写CUDA kernel。它的意思是:不依赖黑盒框架,自己把控制流和数据流串起来。你可以用PyTorch做张量计算,用NumPy做数据处理,但调度、组batch、显存复用这些逻辑,得自己写。
为什么?因为只有自己写一遍,你才知道框架帮你做了什么。比如动态batch,框架里就是一个参数,但自己实现时你要考虑:新请求来了是等还是立刻发?等的超时怎么设?不同长度的序列怎么padding才不浪费?这些决策直接影响成本和延迟。我自己的经验是,自己写过一遍调度之后,再用任何框架,看参数都能猜到它内部大概怎么实现的,调参不再是玄学。
2.3 一个最小可用的AI工程骨架长什么样
先给一个我常用的最小骨架,后面所有讨论都围绕它展开:
# 伪代码,展示核心结构 class InferenceEngine: def __init__(self, model, max_batch_size, max_seq_len): self.model = model self.queue = RequestQueue() self.scheduler = BatchScheduler(max_batch_size, max_seq_len) self.memory_pool = MemoryPool() def run(self): while True: batch = self.scheduler.form_batch(self.queue) if batch: inputs = self.preprocess(batch) outputs = self.model(inputs) self.postprocess(outputs, batch)这个骨架里,RequestQueue管请求排队,BatchScheduler管组batch,MemoryPool管显存复用。看起来简单,但每个模块都有坑。下面逐个拆。
3. 请求队列与动态组batch:延迟和吞吐的拉锯战
3.1 为什么不能来一个请求就推理一次
最朴素的实现是:一个请求进来,直接调模型,返回结果。这在单用户场景没问题,但一旦并发上来,GPU利用率会低得可怜。因为模型推理是计算密集型的,单条请求的计算量往往喂不饱GPU。GPU大部分时间在等数据搬运,而不是在算。
动态组batch就是解决这个的。核心思想:攒一小段时间的请求,凑成一个batch一起推理。这样GPU一次算多条,吞吐能提升几倍到几十倍。但代价是延迟——每个请求都要等一会儿才能被处理。
这里有个关键权衡:等多久。等太久,延迟高;等太短,batch太小,吞吐上不去。我的经验值是,在线服务一般等5-20ms。这个数字怎么来的?假设你的模型单条推理要50ms,那么等10ms组batch,batch size到8,总延迟是10+50=60ms,比单条50ms只多了10ms,但吞吐翻了8倍。这笔账很划算。
3.2 组batch时最容易忽略的padding浪费
组batch有个隐蔽的坑:序列长度不一致。比如一个batch里,有的请求输入长度是10,有的是500。如果直接padding到500,那短序列的计算全是浪费。我实测过一个场景,平均长度80,最大长度512,如果无脑padding到512,有效计算只有15%左右,GPU大部分算力在算padding。
解决办法有两种。一种是分桶:把长度相近的请求放一个batch。比如0-64一桶,64-128一桶。这样padding浪费小。另一种是动态padding:每个batch只padding到当前batch的最大长度,而不是全局最大长度。这两种可以结合用。
分桶的实现要注意:桶的边界怎么定。定太细,每个桶请求少,组不成大batch;定太粗,padding浪费又上来了。我的做法是先统计线上请求的长度分布,按分位数来定桶边界。比如P50、P80、P95各切一刀,形成4个桶。这样大部分请求落在前几个桶里,padding浪费可控。
3.3 超时和优先级:别让一个慢请求拖垮整个队列
队列里最怕什么?最怕一个超长请求卡住。比如一个请求输入长度是10000,其他都是100。如果它和别的请求组一个batch,整个batch都要padding到10000,其他请求全被拖慢。
我的处理方式是设长度上限。超过上限的请求直接拒绝,或者走单独的低优先级通道。这个上限怎么定?看你的业务。如果是对话场景,一般512或1024就够了。如果是文档处理,可能要到4096。但不管多少,一定要有上限,否则显存会被撑爆。
另外,队列要有优先级。比如健康检查请求、内部调用请求,应该比外部用户请求优先级高。实现上可以用多个队列,调度时先从高优先级队列取。但要注意,高优先级队列不能饿死低优先级队列,否则低优先级请求永远排不上。我一般给每个队列设一个配额,比如每轮调度至少从低优先级取一个。
4. 显存管理:AI工程里最硬的骨头
4.1 显存都去哪了:算一笔账
很多人对显存没概念,觉得模型加载进去就行了。实际上显存占用分好几块:
| 占用项 | 说明 | 典型占比 |
|---|---|---|
| 模型权重 | 参数本身 | 30-50% |
| 激活值 | 前向计算的中间结果 | 20-40% |
| 输入输出 | batch数据 | 10-20% |
| 碎片 | 分配释放产生的空洞 | 5-15% |
模型权重是固定的,但激活值和输入输出随batch size和序列长度变化。这就是为什么batch size不能无限大——激活值会线性增长。我见过一个模型,权重只占4GB,但batch size开到32时,激活值占了12GB,直接OOM。
算这笔账的意义在于:你要知道你的显存预算怎么分配。我的习惯是留20%的余量,剩下的按权重、激活、输入输出分。如果激活值占比太高,就要考虑用梯度检查点(虽然推理用不上,但训练时有用)或者减小batch size。
4.2 显存池:为什么malloc会拖慢推理
如果你每次推理都新分配显存,推理完再释放,会有两个问题。一是分配本身有开销,虽然单次不大,但高频调用会累积。二是碎片化,反复分配释放不同大小的块,显存会变得千疮百孔,最后明明总量够,却分配不出连续的大块。
解决办法是显存池。启动时一次性申请一大块显存,之后所有分配都从池子里切,释放时还给池子,不还给系统。这样分配释放都是O(1),而且没有碎片。
实现显存池的关键是块大小。如果块太大,小请求浪费;如果块太小,大请求要拼多个块,管理复杂。我的做法是按2的幂次分块,比如256MB、512MB、1GB、2GB。请求来了,向上取整到最近的块大小。这样内部碎片最多50%,但管理简单,实测下来比精细管理更稳。
4.3 显存复用的边界:什么时候不能复用
显存复用听起来很美,但不是所有场景都能用。有个关键前提:复用块的生命周期不能重叠。比如两个请求同时在线,它们的输入输出不能共用一块显存。
我踩过一个坑:为了省显存,把输入和输出的buffer复用了。结果模型是in-place操作,输出直接覆盖了输入,导致后续处理拿到的是脏数据。排查了半天才发现是复用边界没划清。
所以复用要满足两个条件:一是时间上不重叠,前一个请求彻底处理完,才能把块还给池子;二是逻辑上不依赖,输出不依赖输入的原值。第二条尤其要注意,很多模型有残差连接,输出依赖输入,这种就不能复用。
5. 推理调度:让GPU一直忙起来
5.1 同步推理 vs 异步推理:选哪个
同步推理就是:发一个batch,等结果,再发下一个。异步推理是:发一个batch,不等结果,继续发下一个,结果通过回调或future返回。
同步的好处是简单,逻辑清晰。坏处是GPU会有空档——等结果的时候GPU在闲着。异步能填满空档,但复杂度高,要处理结果乱序、错误传播等问题。
我的建议是:如果单batch推理时间远大于调度开销,用同步就够了。比如单batch要100ms,调度只要1ms,那同步的1%空档可以接受。但如果单batch只要5ms,调度要1ms,那20%的空档就值得用异步了。
异步的实现可以用CUDA stream。每个batch绑一个stream,多个stream可以并发。但要注意,stream之间如果有依赖,要加event同步。我一般用两个stream交替,一个在算的时候,另一个在准备数据,这样能重叠计算和数据搬运。
5.2 连续批处理:一个被低估的优化
连续批处理(continuous batching)是这两年推理优化的热点。传统组batch是“静态”的:凑齐一个batch,一起算,算完一起返回。连续批处理是“动态”的:batch里的请求算完一个就移出一个,同时移入新请求。这样batch size始终是满的,GPU利用率更高。
实现连续批处理的关键是attention的mask。因为batch里不同请求的序列长度不同,而且有的请求已经算完了,有的还在算,所以attention要能处理这种“部分完成”的状态。这需要改attention的实现,不能直接用现成的。
我实测下来,连续批处理在长序列场景下提升明显,能到2-3倍吞吐。但短序列场景提升有限,因为请求很快就算完了,移入移出的开销占比高。所以要不要上,看你的业务场景。
5.3 调度策略:FCFS、优先级、还是公平调度
调度策略决定了请求的处理顺序。常见的有三种:
- FCFS(先来先服务):简单公平,但一个慢请求会拖住后面所有请求。
- 优先级:重要请求先处理,但低优先级可能饿死。
- 公平调度:每个用户或每个来源分一个配额,保证都能得到服务。
我的经验是,混合用。默认FCFS,但对超长请求降级,对高优先级请求插队。具体实现可以用一个优先队列,优先级由请求类型和等待时间共同决定。等待时间越长,优先级越高,这样能防止饿死。
这里有个细节:优先级不能只看类型,还要看等待时间。否则高优先级请求源源不断,低优先级永远排不上。我一般设一个老化因子,每等100ms,优先级提升一级。这样最坏情况下,低优先级请求等几秒也能被处理。
6. 服务化与压测:从能跑到能扛
6.1 接口设计:别把内部结构暴露出去
服务化第一步是定接口。我的原则是:接口要稳定,内部随便改。比如对外只暴露一个/predict,接收文本,返回结果。内部的batch size、调度策略、显存管理,都不应该出现在接口里。
但有一个例外:超时时间。这个应该让调用方指定,因为不同业务对延迟的容忍度不同。我一般设一个默认值,比如5秒,调用方可以覆盖。但覆盖有上限,比如最多30秒,防止有人设个无限大把服务拖死。
另外,接口要支持流式返回。对于生成式模型,用户不想等全部生成完才看到结果。流式返回可以边生成边推,体验好很多。实现上可以用SSE或WebSocket。SSE更简单,单向推送就够了。
6.2 压测:怎么测才准
压测最容易犯的错是用固定输入压。比如所有请求都是同样长度、同样内容。这样测出来的吞吐是虚高的,因为padding浪费最小、缓存命中率最高。真实场景下,输入长度是变化的,内容也是变化的。
我的做法是:用真实流量回放。把线上请求录下来,压测时按真实分布发。如果没有真实流量,就构造一个长度分布,比如按P50、P80、P95、P99各占一定比例。这样测出来的数字才有参考价值。
压测还要看P99延迟,不能只看平均。平均延迟可能很好看,但P99可能爆表。我见过一个服务,平均延迟50ms,P99到了3秒。原因是少数超长请求拖慢了整体。这种问题只有看P99才能发现。
6.3 扩缩容:什么时候加机器,什么时候优化代码
QPS上不去,第一反应是加机器。但加机器之前,先看GPU利用率。如果GPU利用率已经90%以上,那加机器有用。如果GPU利用率只有30%,那加机器是浪费,问题在调度或数据层。
我一般看三个指标:GPU利用率、队列等待时间、batch size。如果GPU利用率低、队列等待时间长、batch size小,说明调度有问题,请求没凑成足够大的batch。这时候应该优化调度策略,而不是加机器。
如果GPU利用率高、队列等待时间长,那说明确实算力不够,该加机器了。但加机器之前,还可以考虑模型量化或蒸馏,把单次推理的计算量降下来。这比加机器更省钱。
7. 那些文档里不会写的实操心得
7.1 日志要记什么,不记什么
AI服务的日志和普通后端不一样。普通后端记请求响应就够了,AI服务还要记batch信息。比如每个batch的size、序列长度分布、推理耗时。这些信息是调优的依据。
但日志不能记太多,否则I/O会成为瓶颈。我的做法是:采样记。比如每100个batch记一条详细日志,其余只记摘要。摘要包括:batch size、平均长度、推理耗时。详细日志包括:每个请求的ID、长度、等待时间。
还有一个坑:不要在推理线程里写日志。写日志是I/O操作,会阻塞推理。我一般用一个单独的日志线程,推理线程把日志丢进队列,日志线程异步写。
7.2 模型加载:冷启动怎么优化
服务重启时,模型加载要时间。大模型加载可能要几十秒甚至几分钟。这期间服务不可用,用户体验很差。
优化手段有几个。一是预热:服务启动后,先用几个假请求跑一遍,把显存池、CUDA context都初始化好。这样第一个真实请求不会特别慢。二是懒加载:不是所有模型都一开始就加载,按需加载。但懒加载有个问题:第一次请求会特别慢。所以适合低频模型。三是模型分片:把大模型切成几块,加载一块就能提供部分服务。但这需要模型支持分片推理,实现复杂。
我的经验是,预热最划算。实现简单,效果明显。预热请求用真实分布的数据,跑几十个就够了。
7.3 错误处理:模型推理失败了怎么办
模型推理可能失败,原因很多:输入超长、显存不足、CUDA错误。失败之后怎么办?直接返回500?那用户体验很差。
我的做法是分级处理。输入超长,直接返回400,告诉用户输入太长。显存不足,先尝试减小batch size重试,如果还不行,返回503,告诉用户服务繁忙。CUDA错误,这个比较严重,可能是硬件问题,记录日志并返回500,同时触发告警。
重试要注意幂等性。推理一般是幂等的,同样的输入应该得到同样的输出。但生成式模型有随机性,重试可能得到不同结果。如果业务要求确定性,要设随机种子。
7.4 一个容易被忽略的细节:时钟同步
分布式部署时,多个实例的时钟可能不同步。这会导致日志时间戳错乱,排查问题时很痛苦。我踩过一次坑:两个实例的日志时间差了3秒,导致我以为请求先到了A再到B,实际上是反的。
解决办法是用NTP同步时钟,并且在日志里记录相对时间,比如从服务启动开始的毫秒数。这样即使绝对时间有偏差,相对顺序是对的。
8. 从零搭完之后,我学到了什么
自己从零搭一遍AI工程链路,最大的收获不是代码本身,而是对权衡的理解。以前调框架参数,是试出来的;现在调参数,是算出来的。比如batch size设多少,我会先算显存预算,再看延迟要求,最后定一个值。而不是试32不行试16,试16不行试8。
另一个收获是对瓶颈的敏感度。现在看到QPS上不去,我会先看GPU利用率,再看队列等待,再看batch size,基本能定位到是哪一层的问题。而不是盲目加机器或改代码。
还有一个体会是:简单方案往往更稳。我一开始想搞很复杂的调度策略,结果bug一堆。后来退回到FCFS加超时,反而稳定运行了很久。复杂策略不是不好,而是要先证明简单方案不够用,再上复杂的。
最后分享一个小技巧:给每个请求打一个trace ID,从进队列到出结果,全链路记录。这样排查问题时,能完整看到请求在每一层的耗时。我靠这个定位过好几次性能问题,比看聚合指标有用得多。