☰
从零手搓AI工程:推理服务与动态批处理实战
2026/10/3 3:47:46 网站建设 项目流程

1. 从零手搓AI工程:为什么我不建议你直接调包

很多人一听到“AI工程”这四个字,第一反应就是打开某个云平台,调几个API,把模型接进去跑通就完事了。我刚开始接触这个方向的时候也是这么想的,直到有一次线上服务在高峰期直接雪崩,排查了整整两天才发现,问题出在我对推理批处理机制的理解几乎为零。那次事故之后,我开始系统性地从底层重新学习AI工程,也就是今天想跟你聊的“ai-engineering-from-scratch”这个思路——不依赖现成的高级封装,从最基础的组件开始,一步步搭建起一个能扛住真实流量的AI服务。

这个内容适合谁?如果你已经会用Python写点脚本,对机器学习有基本概念,但每次遇到性能问题、部署问题就抓瞎,那这篇东西就是写给你的。我不会假设你懂CUDA,也不会假设你懂分布式系统,但我会假设你愿意动手写代码、愿意看日志、愿意为了搞明白一个延迟毛刺去翻源码。整篇内容会围绕一个核心问题展开:当你把模型训练好之后,从模型文件到线上可用的服务,中间到底需要哪些工程环节,每个环节的关键决策点在哪里,以及我踩过的那些坑。

关键词“ai-engineering-from-scratch”拆开来看,其实包含三层意思:第一层是AI,也就是模型本身的能力边界;第二层是Engineering,也就是让模型在真实环境中稳定运行的系统设计;第三层是From Scratch,也就是不依赖黑盒工具,自己把关键路径实现一遍。这三层缺一不可,只懂模型不懂工程,做出来的东西只能跑Demo;只懂工程不懂模型,优化方向可能完全错误;只会调包不会从零实现,遇到框架解决不了的问题就只能干等。

2. 推理服务的骨架:从模型文件到HTTP接口

2.1 模型加载这一步,坑比你想的多

把训练好的模型文件变成内存里可以调用的对象,听起来简单,实际上有很多细节决定了后续服务的稳定性和性能。我见过太多人直接用一个load_model()函数就完事,结果上线后发现内存占用是模型文件大小的三到四倍,或者第一次请求延迟高达几十秒。

先说过内存这件事。一个FP32的模型,参数量如果是1亿,那文件大小大约是400MB。但加载到内存后,如果你用的是PyTorch的默认行为,它会同时保留参数、梯度、优化器状态,即使你只是做推理,这些东西也可能被分配出来。正确的做法是在加载时明确指定map_location到CPU或GPU,并且用torch.no_grad()上下文来避免计算图的构建。更彻底一点,你可以把模型转成推理专用格式,比如ONNX或者TorchScript,这样加载后的内存占用会显著降低。

再说加载时间。如果你的模型文件放在网络存储上,每次服务重启都要重新下载,那启动时间会非常不可控。我的做法是在构建镜像的时候就把模型文件打包进去,或者至少放在本地SSD上。如果模型太大放不进镜像,那就用一个独立的初始化容器提前把模型拉到本地卷,主服务只负责加载。这里有个经验值:一个1GB左右的模型,从本地SSD加载到GPU显存,大概需要3到5秒;如果从网络存储加载,可能要30秒以上,而且不稳定。

还有一个容易被忽略的点是模型版本管理。你肯定不希望服务重启后加载了一个不兼容的新模型,导致输出格式全变了。我的做法是在模型文件旁边放一个manifest.json,里面记录模型版本、输入输出签名、预处理参数。服务启动时先读这个文件,校验版本号,如果不匹配就直接拒绝启动,而不是带着错误的模型跑起来。

2.2 请求预处理:别让Python拖慢你的吞吐

模型加载好之后,下一个环节是接收HTTP请求,把原始输入转换成模型能吃的张量。这一步看起来只是数据格式转换,但实际上往往是整个链路的性能瓶颈。

我做过一个测试:一个文本分类服务,模型推理本身只占用了15毫秒,但整个请求的端到端延迟是80毫秒。多出来的65毫秒花在哪里了?大部分花在了JSON解析、字符串处理、分词、张量构建这些Python层面的操作上。Python的GIL意味着这些操作是串行的,你的多核CPU在这里帮不上忙。

怎么解决?第一个思路是把预处理逻辑从Python里挪出去。比如分词可以用C++实现的库,或者用Rust写一个预处理服务,Python只负责调用。第二个思路是批处理。单个请求的预处理开销大,但如果你把多个请求攒在一起,一次性做分词和張量构建,均摊到每个请求上的开销就小很多。这就是动态批处理的核心思想,后面会详细讲。

还有一个细节是输入校验。很多人为了性能,把输入校验做得非常宽松,结果模型收到非法输入后产生奇怪的输出,排查起来非常痛苦。我的建议是在预处理阶段做严格的类型和范围检查,比如文本长度是否超过模型最大长度、数值是否在合理范围内。这些检查用纯Python做确实慢,但你可以用Pydantic或者marshmallow这类库,它们底层有C扩展,性能可以接受。

2.3 推理执行:同步、异步还是批处理

到了真正调用模型推理的这一步,选择哪种执行模式直接决定了你的服务能扛多少并发。

最简单的模式是同步执行:来一个请求,预处理,调用模型,后处理,返回结果。这种模式在低并发下没问题,但一旦并发上来,请求就会排队。因为模型推理通常要占用GPU,而GPU同一时间只能执行一个计算任务(除非你用MPS或者多流),所以同步模式下GPU的利用率其实很低——大部分时间在等数据搬运和Python开销。

异步模式是把推理调用放到一个单独的线程或者进程里,HTTP处理线程不阻塞。这样虽然能提高并发连接数,但GPU依然是瓶颈,请求还是在排队,只是排队的位置从HTTP层挪到了推理层。而且异步模式引入了线程安全问题,如果你不小心在多个线程里共享了同一个模型对象,可能会遇到各种奇怪的错误。

真正能提高吞吐的是批处理模式。核心思路是:不立即执行每个请求的推理,而是把一小段时间内到达的请求攒成一个批次,一次性送给GPU计算。GPU处理一个批次和處理单个样本的时间差距远小于批次大小的倍数,所以吞吐量会大幅提升。比如单个样本推理需要10毫秒,批次大小为8时,处理整个批次可能只需要25毫秒,平均每个样本3毫秒多一点。

但批处理也带来了新的问题:延迟。如果你攒批的时间窗口是50毫秒,那即使GPU空闲,第一个请求也要等50毫秒才能被处理。所以攒批窗口需要根据你的服务等级目标来调整。我的经验是,对于延迟敏感的服务,窗口设5到10毫秒;对于吞吐优先的服务,可以设到50甚至100毫秒。另外,批处理的大小上限也要考虑显存限制,别攒着攒着把显存撑爆了。

3. 动态批处理的实现细节与性能拐点

3.1 攒批队列的数据结构选择

实现动态批处理,第一个要决定的是用什么数据结构来攒批。最直观的是用一个列表,每次有新请求就append进去,然后有一个后台线程定期检查列表是否达到批处理条件。但列表在并发环境下需要加锁,锁的竞争会成为新的瓶颈。

我试过几种方案,最后比较满意的是用queue.Queue加上一个独立的批处理线程。Queue本身是线程安全的,生产者(HTTP处理线程)往队列里放请求,消费者(批处理线程)从队列里取。消费者不是取一个就处理一个,而是取一个之后,再非阻塞地取若干个,直到达到批次大小上限或者队列为空。这样既避免了锁竞争,又能灵活控制批次大小。

但Queue有一个问题:它不支持超时批量获取。也就是说,如果队列里只有一个请求,消费者取到之后想等一会儿看看有没有新请求进来,Queue没有提供“等待N毫秒或者直到有M个元素”这样的接口。我的做法是用一个Condition变量配合一个列表来实现。消费者在条件变量上等待,生产者放入元素后通知条件变量。消费者被唤醒后,检查列表长度和时间窗口,决定是否立即处理。

这里有个细节:时间窗口的计时起点应该是第一个请求入队的时刻,而不是消费者被唤醒的时刻。否则如果消费者被频繁唤醒,时间窗口会被不断重置,导致请求迟迟得不到处理。我的实现是在第一个请求入队时记录一个时间戳,消费者每次检查时用当前时间减去这个时间戳,如果超过窗口大小就立即处理,不管批次是否满。

3.2 批次大小与延迟的权衡曲线

批次大小不是越大越好,也不是越小越好,它和延迟之间有一条明确的权衡曲线。我做过一组实测,在一个BERT-base模型上,输入序列长度固定为128,GPU是单张T4,结果如下:

批次大小单批次推理耗时平均每样本耗时端到端P99延迟
112ms12ms18ms
418ms4.5ms32ms
826ms3.25ms48ms
1642ms2.6ms75ms
3278ms2.4ms130ms

从表里可以清楚看到,批次从1增加到8,每样本耗时下降了将近四倍,但P99延迟从18毫秒涨到了48毫秒。继续增加到32,每样本耗时只下降了不到30%,但延迟涨到了130毫秒。所以拐点大概在批次8到16之间。具体选哪个,取决于你的服务等级目标:如果要求P99在50毫秒以内,那批次8是上限;如果可以放宽到100毫秒,批次16能带来更好的吞吐。

还有一个因素是序列长度。上面的测试是固定长度,但真实请求的序列长度是变化的。如果批次里混了长序列和短序列,GPU需要按最长序列来分配计算资源,短序列的样本就被浪费了。解决办法是按长度分桶,把长度相近的请求放在同一个批次里。这需要在攒批阶段就拿到序列长度信息,所以预处理的一部分工作要提前到入队之前完成。

3.3 显存碎片与批次上限的动态调整

批次大小还受显存限制。但显存不是简单地“总量除以单样本占用”就能算出来的,因为CUDA的内存分配器会产生碎片。你可能会遇到这种情况:显存明明还有2GB空闲,但分配一个需要1.5GB的批次就是失败。

我的应对策略是设置一个保守的批次上限,比如理论最大值的70%,然后监控实际显存使用率。如果连续多个批次都远低于上限,可以逐步提高上限;如果出现分配失败,就立即降低上限并记录日志。这个自适应逻辑用简单的滑动窗口就能实现,不需要复杂的控制算法。

另外,PyTorch的CUDA缓存分配器可以通过设置环境变量PYTORCH_CUDA_ALLOC_CONF来调整行为。比如设置max_split_size_mb可以控制内存块的最大分割尺寸,减少碎片。这个参数没有万能值,需要根据你的模型大小和批次模式来调。我的经验是从128MB开始试,如果碎片问题严重就降低,如果分配失败频繁就提高。

4. 后处理与响应组装:容易被忽视的延迟来源

4.1 解码策略对响应时间的影响

模型输出通常是一个张量,需要经过解码才能变成人类可读的结果。对于分类模型,解码就是取argmax,很快。但对于生成式模型,解码是一个循环过程,每一步都要调用模型,直到生成结束符或者达到最大长度。这个循环是串行的,每一步都依赖上一步的输出,所以延迟很高。

优化生成式解码的延迟,有几个方向。第一个是减少解码步数,比如用更激进的停止条件,或者用推测解码(speculative decoding)——用一个小的草稿模型先生成几个token,然后用大模型一次性验证。第二个是减少每步的计算量,比如用KV缓存避免重复计算注意力。第三个是并行化,比如beam search的多个beam可以并行计算,但要注意显存占用会成倍增加。

我实测过一个7B参数的生成模型,不用KV缓存时,生成100个token需要大约8秒;用了KV缓存后降到2秒左右;再加上批处理,同时生成4个请求的100个token也只需要3秒。所以KV缓存和批处理是生成式服务必须做的优化,没有这两个,线上服务基本不可用。

4.2 响应序列化的性能陷阱

后处理完成之后,要把结果序列化成JSON返回给客户端。这一步看起来简单,但如果你用的是Python标准库的json.dumps,在结果很大的时候会成为瓶颈。比如一个目标检测模型返回100个框,每个框有坐标、类别、置信度,序列化可能要几毫秒。如果QPS是1000,这几毫秒就会累积成严重的延迟。

优化方法有几个:一是用更快的JSON库,比如orjson或者ujson,它们底层是C或Rust实现的,比标准库快好几倍。二是减少返回的数据量,比如只返回置信度最高的前N个结果,或者用更紧凑的格式比如MessagePack。三是把序列化放到批处理线程之外,用单独的线程池来做,避免阻塞推理线程。

还有一个坑是浮点数精度。Python的json.dumps默认会把浮点数序列化成17位有效数字,这既浪费带宽又没有必要。你可以用round函数先把浮点数截断到合理精度,比如4位小数,这样序列化后的字符串会短很多,网络传输也更快。

4.3 错误处理与降级策略

线上服务不可能永远不出错,关键是在出错的时候能优雅降级,而不是整个服务挂掉。我见过一个服务,因为一个请求的输入触发了模型内部的断言错误,导致整个推理进程崩溃,所有并发请求都失败了。这种单点故障必须避免。

我的做法是在推理调用外面包一层异常捕获,任何异常都转换成标准的错误响应返回给客户端,同时记录详细的日志。但要注意,不是所有异常都能安全捕获——比如CUDA的显存溢出错误,捕获后GPU状态可能已经不一致了,继续使用可能会产生错误结果。对于这类错误,我的策略是标记当前GPU为不可用,把请求路由到其他GPU,然后异步重启这个GPU上的推理进程。

降级策略也很重要。如果模型推理超时或者失败,可以返回一个默认结果或者缓存结果,而不是直接报错。比如推荐服务可以在模型不可用时返回热门物品,搜索服务可以返回基于关键词的简单匹配结果。这些降级逻辑需要在服务设计阶段就考虑好,而不是等出了问题再临时加。

5. 监控与压测:让性能问题无处遁形

5.1 必须监控的四个核心指标

AI服务的监控和普通Web服务不太一样,除了CPU、内存、网络这些常规指标,还有几个AI特有的指标必须盯着。

第一个是GPU利用率。这个指标能告诉你GPU到底在干活还是在空转。如果GPU利用率长期低于30%,说明你的批处理或者数据管道有问题,GPU在等数据。如果长期高于90%,说明GPU是瓶颈,需要考虑加卡或者优化模型。

第二个是推理队列长度。这个指标反映了请求积压的程度。如果队列长度持续增长,说明处理速度跟不上到达速度,需要扩容或者优化。如果队列长度经常为0,说明资源有浪费,可以适当降低批次大小来减少延迟。

第三个是批次大小分布。这个指标能帮你判断攒批策略是否合理。如果大部分批次的真实大小远小于上限,说明请求到达率太低或者攒批窗口太小。如果批次大小经常触顶,说明上限设得太低或者请求量太大。

第四个是每样本推理耗时。这个指标是模型效率的直接体现。如果这个指标突然升高,可能是模型被换成了更大的版本,或者输入序列变长了,或者GPU被其他进程占用了。

这四个指标我建议用Prometheus采集,Grafana展示,设置合理的告警阈值。比如GPU利用率持续5分钟低于20%就告警,队列长度持续1分钟大于100就告警。

5.2 压测工具的选择与压测方法

压测AI服务和压测普通Web服务有一个本质区别:AI服务的处理时间不是固定的,它和输入数据强相关。一个短文本请求可能只要10毫秒,一个长文本请求可能要500毫秒。如果你用固定的请求间隔去压测,结果会很不准确。

我的做法是用真实流量的采样数据来压测,保持请求的分布和线上一致。压测工具我用过Locust和wrk,Locust更适合模拟复杂的请求序列,wrk更适合测纯吞吐。对于AI服务,我倾向于用Locust,因为可以方便地控制请求内容和并发模式。

压测的时候要逐步增加并发,观察各个指标的变化。不要一上来就压到最高并发,那样你只能看到服务崩溃的结果,看不到性能拐点在哪里。我的做法是从10并发开始,每5分钟增加10并发,直到P99延迟超过服务等级目标或者错误率超过1%。记录下每个并发级别下的吞吐和延迟,画出曲线,找到拐点。

还有一个细节是压测数据的预热。模型在第一次推理时会有额外的开销,比如CUDA核函数的编译、内存池的初始化。如果你不预热就直接压测,前几个请求的延迟会非常高,影响结果。我的做法是在压测开始前,用一批典型请求先跑几百次,让服务进入稳定状态。

5.3 从监控数据反推瓶颈的实战案例

有一次我们的服务在高峰期P99延迟突然从50毫秒涨到了200毫秒,但GPU利用率反而下降了。这个现象很反直觉:如果GPU是瓶颈,利用率应该上升才对。我按照下面的顺序排查:

第一步,看队列长度。队列长度在延迟上涨的同时也上涨了,说明请求在排队。但GPU利用率下降,说明GPU没有在处理这些排队的请求。

第二步,看批次大小分布。发现批次大小从平均12降到了平均3。批次变小了,GPU处理一个批次的时间虽然短了,但批次之间的间隔变长了,所以整体吞吐下降。

第三步,看请求到达率。到达率没有明显变化,那为什么批次会变小?继续看攒批窗口的计时逻辑,发现有一个后台任务在高峰期频繁触发,抢占了批处理线程的CPU时间,导致攒批窗口经常超时但批次还没攒够。

第四步,定位那个后台任务。原来是一个日志轮转任务,在高峰期恰好触发了,它持有GIL导致批处理线程无法及时执行。把日志轮转改成异步的、低优先级的任务后,问题解决。

这个案例说明,AI服务的性能问题不一定出在GPU上,CPU侧的干扰同样致命。监控数据要结合起来看,单独看任何一个指标都可能得出错误结论。

6. 从单机到多卡:扩展时的新问题

6.1 数据并行与模型并行的选择

当单卡扛不住流量时,就需要扩展到多卡。扩展方式主要有两种:数据并行和模型并行。

数据并行是把模型复制到每张卡上,每张卡处理不同的请求。这种方式实现简单,适合模型能完整放进单卡显存的情况。但要注意,每张卡上的模型是独立的,显存占用会成倍增加。如果模型是1GB,4张卡就是4GB,加上中间激活值和批次数据,实际占用可能更多。

模型并行是把模型切分成多份,每张卡放一部分。这种方式适合模型太大、单卡放不下的情况。但模型并行的通信开销很大,因为每层计算都需要跨卡同步。除非模型真的放不下,否则我一般优先选数据并行。

数据并行的实现可以用PyTorch的DataParallel或者DistributedDataParallel。DataParallel是单进程多线程,受GIL限制,性能不好。DistributedDataParallel是多进程,每个进程一张卡,性能好得多。但多进程带来了新的问题:进程间通信、负载均衡、故障恢复。

6.2 负载均衡策略与请求路由

多卡环境下,请求怎么分配到不同的卡上,直接影响了整体利用率。最简单的策略是轮询,每个请求依次分配给下一张卡。但轮询不考虑每张卡的当前负载,如果某张卡上恰好有一个长请求在处理,轮询还会继续给它分配新请求,导致排队。

更好的策略是基于队列长度的负载均衡。每个卡对应一个队列,路由器选择队列最短的卡来分配请求。但队列长度需要实时同步,同步本身有开销。我的做法是每个卡定期上报自己的队列长度到共享内存,路由器读取共享内存来做决策。上报频率不用太高,每秒一次就够了,因为队列长度的变化不会那么快。

还有一个策略是基于响应时间的负载均衡。路由器记录每个卡最近一段时间的平均响应时间,选择响应时间最短的卡。这个策略对异构硬件(比如不同型号的GPU)更友好,但实现起来更复杂,需要维护滑动窗口统计。

6.3 故障隔离与优雅降级

多卡环境下,一张卡出故障不应该影响其他卡。我见过一个服务,因为一张卡的显存溢出导致整个进程崩溃,所有卡上的服务都挂了。这种设计是不可接受的。

正确的做法是每张卡对应一个独立的进程,进程之间通过IPC通信。如果某个进程崩溃,主进程能检测到并重启它,同时把请求路由到其他健康的卡上。重启期间,整体容量会下降,但服务不会完全不可用。

故障检测可以用心跳机制。每个工作进程定期向主进程发送心跳,如果主进程连续几个周期没收到心跳,就认为该进程挂了。重启策略要设置退避,避免频繁重启导致资源耗尽。比如第一次崩溃后立即重启,第二次崩溃后等5秒,第三次等30秒,以此类推。

优雅降级也很重要。当可用卡的数量减少时,服务应该自动降低批次大小上限,减少攒批窗口,优先保证延迟而不是吞吐。这些降级策略可以配置成规则,根据当前健康卡的数量自动调整。

7. 我踩过的那些坑与对应的解决方案

7.1 内存泄漏:从显存缓慢增长到OOM

显存泄漏是AI服务最隐蔽的问题之一。它不会立即导致崩溃,而是显存使用量缓慢增长,可能几天后才触发OOM。排查显存泄漏非常痛苦,因为你需要区分是正常的缓存增长还是真正的泄漏。

我遇到过一次显存泄漏,原因是PyTorch的CUDA缓存分配器在每次推理时都会分配新的内存块,但旧的内存块没有被及时释放。原因是我们在推理时用了torch.no_grad(),但没有用torch.cuda.empty_cache()。缓存分配器会保留已分配的内存块以备复用,但如果批次大小变化很大,保留的内存块可能永远用不上,就变成了事实上的泄漏。

解决办法是定期调用torch.cuda.empty_cache(),但不要每次推理都调,那样会破坏缓存的效果。我的做法是设置一个显存使用率阈值,比如90%,当超过阈值时调用一次empty_cache。另外,尽量保持批次大小稳定,避免频繁的大幅变化。

还有一个坑是Python对象的循环引用。如果你在请求处理过程中创建了包含张量的对象,并且这些对象之间有循环引用,Python的垃圾回收器可能无法及时回收它们,导致显存被占用。解决办法是尽量用上下文管理器来管理资源,避免手动创建复杂的对象图。

7.2 批处理导致的请求超时

批处理虽然能提高吞吐,但如果攒批窗口设置不当,会导致请求超时。我遇到过一次,攒批窗口设了100毫秒,结果P99延迟直接飙到150毫秒,因为大部分请求都要等满100毫秒才能被处理。

这个问题的根源是我把攒批窗口和最大等待时间搞混了。攒批窗口应该是“最多等这么久”,而不是“至少等这么久”。正确的逻辑是:第一个请求入队时记录时间戳,后续请求入队时检查是否已经超过窗口大小,如果超过就立即触发处理,不管批次是否满。这样第一个请求最多等窗口大小的时间,后续请求可能等得更短。

另外,窗口大小应该根据请求到达率动态调整。如果到达率很高,窗口可以设小一点,因为很快就能攒够一批。如果到达率很低,窗口设大了也没用,因为根本攒不够,反而增加了延迟。我的做法是用一个简单的反馈控制:如果最近几个批次的平均大小远小于上限,就减小窗口;如果经常触顶,就增大窗口。

7.3 模型更新导致的服务中断

模型更新是另一个容易出问题的环节。如果你直接替换模型文件然后重启服务,重启期间服务不可用,而且如果新模型有问题,回滚也很麻烦。

我的做法是蓝绿部署。同时运行两个版本的服务,旧版本处理线上流量,新版本先接少量流量做验证。验证通过后,逐步把流量切换到新版本。如果新版本出问题,立即切回旧版本。这个过程对客户端透明,不会中断服务。

但蓝绿部署需要双倍的资源,对于GPU服务来说成本很高。折中方案是滚动更新:逐个替换工作进程,每次只替换一个,替换完成后观察一段时间,没问题再替换下一个。这样只需要额外一个进程的资源,但更新期间整体容量会略有下降。

还有一个细节是模型文件的版本管理。新模型和旧模型的输入输出格式可能不兼容,如果客户端还在用旧格式请求,新模型会报错。解决办法是在模型文件里包含输入输出签名,服务启动时校验签名,如果不匹配就拒绝启动。同时,客户端的请求里也要带版本号,服务根据版本号路由到对应的模型。

7.4 日志与追踪带来的性能开销

最后说一个容易被忽视的坑:日志。AI服务的日志量通常很大,因为每个请求都要记录输入、输出、耗时等信息。如果日志是同步写的,会严重拖慢请求处理速度。

我做过一个测试:关闭日志时QPS是800,开启同步日志后QPS降到了300。原因是日志写磁盘是阻塞操作,而且Python的logging模块在多线程下会有锁竞争。

解决办法是用异步日志。把日志消息先放到一个内存队列里,由一个单独的线程负责写磁盘。这样请求处理线程只负责往队列里放消息,不阻塞。但要注意队列不能无限增长,否则内存会爆。我的做法是设置队列上限,满了就丢弃低优先级的日志,保证高优先级的日志不丢。

追踪也是类似的道理。分布式追踪能帮你定位跨服务的延迟问题,但每个请求都生成追踪数据开销很大。我的做法是采样追踪,只对1%的请求开启完整追踪,其他请求只记录基本指标。这样既能发现系统性问题,又不会带来太大开销。

8. 一些让我少走弯路的工具与配置

在从零搭建AI工程的过程中,我逐渐积累了一些工具和配置,它们不是必须的,但能显著提高开发效率和运行稳定性。

第一个是py-spy,这是一个Python性能分析工具,可以在不重启服务的情况下采样调用栈。当你发现CPU使用率异常但不知道哪里在消耗时,py-spy能直接告诉你。用法很简单:py-spy top --pid <pid>,它会实时显示各个函数的CPU占用。

第二个是nvidia-smi的循环监控模式。nvidia-smi -l 1每秒刷新一次GPU状态,能帮你观察显存和利用率的实时变化。配合--query-gpu参数可以输出CSV格式,方便后续分析。比如nvidia-smi --query-gpu=timestamp,utilization.gpu,memory.used --format=csv -l 1。

第三个是PyTorch的torch.profiler。它能给出每个算子的耗时,帮你定位模型内部的性能瓶颈。用法是在推理代码外面包一层with torch.profiler.profile() as prof:,然后prof.key_averages().table()输出结果。注意profiler本身有开销,只在排查问题时开启。

第四个是uvicorn的--workers参数。如果你用FastAPI或者Starlette做HTTP层,uvicorn支持多工作进程。但要注意,每个工作进程会独立加载模型,显存占用会成倍增加。所以--workers的数量不能超过GPU数量,而且每个进程要绑定到不同的GPU上。

第五个是Docker的--gpus参数和NVIDIA_VISIBLE_DEVICES环境变量。在容器里用GPU需要正确配置这两个东西。--gpus all让容器能看到所有GPU,NVIDIA_VISIBLE_DEVICES=0,1限制容器只能看到指定的GPU。这个配置在多卡环境下特别重要,能避免进程之间抢卡。

最后分享一个我常用的压测脚本模板,用Locust写的,能模拟变长请求:

from locust import HttpUser, task, between import random class AIUser(HttpUser): wait_time = between(0.01, 0.05) @task def predict(self): length = random.choice([16, 32, 64, 128, 256]) text = "token " * length self.client.post("/predict", json={"text": text})

这个脚本的关键点是wait_time设得很短,模拟高并发场景;请求长度随机变化,模拟真实流量。压测时观察服务端的P99延迟和GPU利用率,找到性能拐点。

这些工具和配置都是我在实际项目中反复使用后留下来的,它们帮我节省了大量排查时间。当然,工具只是辅助,真正重要的是对原理的理解和对细节的关注。AI工程是一个实践性极强的领域,很多问题只有亲手做过才会遇到,也只有在解决这些问题的过程中,才能真正掌握从零搭建AI服务的能力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询