ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从零手搓AI工程:推理引擎选型、服务编排与性能调优实战

从零手搓AI工程:推理引擎选型、服务编排与性能调优实战 1. 从零手搓AI工程为什么我不建议你直接调包很多人一听到“AI工程”这四个字第一反应就是打开某个云平台调一个现成的大模型API写几行Python把请求发过去然后看着返回的JSON沾沾自喜觉得自己已经跨进了AI的大门。我刚开始接触这个方向的时候也是这么想的直到有一次线上服务在高峰期直接被打爆日志里全是超时和限流报错我才意识到——会调接口和懂AI工程中间隔着的不是一层窗户纸而是一整套需要从底层理解的知识体系。ai-engineering-from-scratch这个标题本身就说明了一件事它不打算教你做一个API调用工程师而是想让你从最基础的零件开始亲手把一台能跑的机器组装起来。这就像学开车你可以只会踩油门和刹车但一旦发动机异响、变速箱顿挫你连引擎盖都不敢打开。AI工程也是一样当你的推理延迟突然从200毫秒飙到3秒当显存莫名其妙被吃满当批量请求的吞吐量死活上不去如果你不知道底层发生了什么就只能干瞪眼。这篇文章适合三类人第一类是有一定编程基础但没系统接触过AI系统设计的开发者第二类是做过一些模型训练或推理脚本但没考虑过工程化落地的算法同学第三类是想理解AI服务背后到底在跑什么的技术管理者。我会从最核心的推理引擎讲起一路覆盖到服务编排、性能调优和可观测性建设尽量把每个环节的“为什么”讲透而不是只丢给你一堆配置参数。需要提前说明的是这篇文章不会涉及任何特定云厂商的产品推荐也不会教你绕过某些限制去访问不该访问的东西。我们讨论的是通用的AI工程方法论所有内容都可以在本地环境或合规的服务器上复现。你需要的只是一台带GPU的机器没有GPU用CPU也能跑通流程只是速度慢一些以及一颗愿意从底层理解问题的心。2. 推理引擎选型别被“一键部署”惯坏了2.1 从一次显存泄漏事故说起去年我帮一个团队排查他们的AI服务问题现象很诡异服务刚启动时一切正常推理延迟稳定在150毫秒左右但运行大约6个小时后延迟开始阶梯式上升最终在8小时左右彻底卡死。重启之后又能撑6到8小时周而复始。他们用的是某款号称“开箱即用”的推理框架文档里写着“自动管理显存”团队里没人深究过它到底是怎么管理的。我让他们把推理过程中的显存分配日志打开结果发现每次请求结束后框架并没有真正释放中间激活值占用的显存而是标记为“可复用”。问题在于他们的请求长度分布很不均匀短请求和长请求交替出现导致显存碎片化越来越严重最终没有一块连续空间能容纳新的请求。这就是典型的“抽象泄漏”——框架帮你屏蔽了细节但细节并没有消失只是在某个你意想不到的地方爆发出来。如果你从零开始搭建推理服务第一件要做的事就是理解推理引擎的内存管理策略。目前主流的方案大致分为两类一类是基于计算图优化的静态内存规划另一类是基于动态分配的运行时管理。前者在启动时就会根据模型结构预先分配好所有中间张量的内存空间运行时不再进行新的分配优点是稳定、可预测缺点是对变长输入的支持不够灵活后者则根据每次请求的实际需要动态申请和释放内存灵活性强但容易产生碎片。2.2 不同推理后端的适用边界选推理后端不是选“最好的”而是选“最合适的”。我整理了一个对比表格基于我在实际项目中的使用体验后端类型内存管理方式适合场景需要注意的坑静态图引擎预分配零运行时分配固定输入长度、高吞吐批处理变长输入需要padding浪费算力动态图引擎运行时按需分配变长输入、交互式推理显存碎片长时间运行需重启混合引擎分池管理大块预分配小块动态通用场景配置复杂需要调参我个人的经验是如果你的服务QPS稳定、输入长度方差小优先选静态图方案省心且性能上限高。如果你的场景是聊天机器人这种输入长度完全不可预测的那就得接受动态方案的碎片问题但一定要加上定期重启或显存整理机制。混合引擎听起来很美但配置项多到让人头大没有足够的调优经验很容易踩坑。还有一个容易被忽略的点推理引擎的批处理策略。很多框架默认开启“动态批处理”把短时间内到达的多个请求合并成一个批次一起推理以提高GPU利用率。这个策略在请求量大的时候效果很好但在请求稀疏的时候会导致额外的等待延迟——框架会等一个“批处理窗口”时间看看有没有更多请求进来。如果你的服务对延迟敏感一定要把这个窗口时间调小或者干脆关掉动态批处理改用固定批大小。2.3 量化省显存还是省精度量化是AI工程里绕不开的话题。简单说就是把模型权重和激活值从高精度浮点数比如FP16转换成低精度整数比如INT8或INT4从而减少显存占用和计算量。但量化不是免费的午餐它引入的精度损失在某些任务上可能是致命的。我做过一个实验在一个文本分类任务上把模型从FP16量化到INT8准确率只掉了0.3个百分点推理速度提升了近一倍显存占用减少了40%。但在另一个生成式任务上同样的量化策略导致输出质量明显下降模型开始重复输出无意义的短语。原因在于生成式任务对数值精度的敏感度远高于分类任务尤其是注意力机制中的softmax计算低精度下的舍入误差会被放大。如果你打算做量化我的建议是分三步走第一步先在不量化的版本上建立性能基线记录延迟、吞吐量和质量指标第二步尝试训练后量化用一小批校准数据让量化算法确定合适的缩放因子第三步如果训练后量化效果不达标再考虑量化感知训练在训练过程中模拟量化误差让模型自己去适应。整个过程一定要有自动化评测不能凭感觉判断“好像还行”。3. 服务编排层把推理引擎变成可用的API3.1 请求队列与背压机制推理引擎本身只是一个计算核心它不会自动处理并发请求、不会做负载均衡、不会在过载时优雅降级。这些都需要在服务编排层实现。我见过太多项目直接把推理引擎暴露成一个HTTP接口然后祈祷它不会崩——这种做法的后果就是一旦请求量超过处理能力整个服务会像多米诺骨牌一样倒下。正确的做法是在推理引擎前面加一个请求队列。所有到达的请求先进入队列然后由一组工作线程从队列中取出请求交给推理引擎执行。队列的作用是缓冲突发流量让推理引擎始终以稳定的速率工作。但队列不能无限长否则延迟会无限累积。你需要设置一个最大队列长度当队列满时新的请求直接返回“服务繁忙”的错误而不是傻等着。这就是背压机制的核心思想让上游知道下游的处理能力已经到极限了不要再往里灌请求了。实现背压的方式有很多种最简单的就是基于队列长度的阈值判断复杂一点的可以用令牌桶或漏桶算法做速率限制。关键是要有一个明确的拒绝策略并且这个策略要对客户端透明——返回一个明确的错误码和重试建议而不是让请求超时。3.2 批处理调度器的设计细节批处理是提升推理吞吐量最有效的手段但调度器的设计直接决定了批处理的效果。一个常见的错误是“来一个请求就等一会儿看看有没有更多请求”这种策略在低负载时会导致每个请求都白白等待一个窗口时间延迟反而比不批处理更高。我比较推荐的方案是“双阈值调度”设置一个最小批大小和一个最大等待时间。调度器会持续从队列中取请求直到达到最小批大小或者等待时间超过阈值然后立即执行这一批。这样在负载高的时候很快就能凑够最小批大小延迟低在负载低的时候最多等一个阈值时间就会执行不会无限等待。还有一个细节是批内请求的长度对齐。如果一批请求的长度差异很大短请求会被padding到最长请求的长度浪费大量算力。解决办法是按长度分桶把长度相近的请求放在同一个批次里。这需要在请求入队时就提取长度信息然后维护多个不同长度的队列。实现起来稍微复杂一些但对吞吐量的提升非常明显尤其是在输入长度分布很广的场景下。3.3 超时控制与优雅关闭超时控制是服务编排层的安全网。每个请求都应该有一个最大处理时间超过这个时间就强制终止并返回错误。没有超时控制的服务一旦遇到某个请求触发了推理引擎的异常行为比如死循环或显存溢出整个服务都会被拖垮。超时时间的设置需要根据实际业务来定。我一般会先统计P99延迟然后把超时时间设为P99的2到3倍。比如P99是500毫秒那超时时间就设1到1.5秒。这样既能覆盖绝大多数正常请求又不会让异常请求占用太长时间。优雅关闭同样重要。当你需要重启服务或更新模型时不能直接杀掉进程否则正在处理的请求会全部失败。正确的做法是首先停止接受新请求然后等待队列中的请求全部处理完毕最后再关闭推理引擎。这个过程可能需要几十秒甚至几分钟取决于队列长度和推理速度。在容器化环境中你需要确保容器的终止宽限期足够长否则编排系统会在请求处理完之前就强制杀掉容器。4. 性能调优从能用 to 好用4.1 延迟分解找到真正的瓶颈性能调优的第一步不是盲目调参而是搞清楚时间到底花在哪里了。一个AI推理请求的延迟可以分解为以下几个部分网络传输时间、请求解析时间、队列等待时间、推理计算时间、结果序列化时间、响应传输时间。如果你不知道哪一部分占了大头调优就是碰运气。我通常会在代码里埋点记录每个阶段的耗时然后输出到日志或监控系统。一个典型的发现是很多人以为推理计算是瓶颈但实际上队列等待时间可能更长——因为批处理调度器在等更多请求进来。另一个常见发现是结果序列化时间被严重低估尤其是当输出是长文本或大张量时JSON序列化可能比推理本身还慢。找到瓶颈之后优化方向就明确了。如果是队列等待时间长就调整批处理参数如果是推理计算时间长就考虑量化或换更高效的推理后端如果是序列化时间长就换用更高效的序列化格式比如MessagePack或Protocol Buffers。4.2 并发模型的选择线程、进程还是协程Python的GIL全局解释器锁是AI服务并发模型选择时必须面对的现实。如果你的推理引擎是C实现的大多数高性能引擎都是那么在执行推理计算时会释放GIL此时多线程可以真正并行。但如果你的服务层有大量Python代码比如请求解析、结果后处理多线程就会因为GIL而互相阻塞。我的经验是对于计算密集型的推理服务用多进程模型更稳妥。每个进程独立加载模型、独立处理请求进程之间通过共享内存或消息队列通信。缺点是显存占用会成倍增加因为每个进程都要加载一份模型权重。如果显存不够可以考虑用线程模型但要把Python代码的部分尽量精简把重活都交给C扩展。协程模型比如asyncio适合I/O密集型的场景比如需要调用外部服务或读写数据库。但对于纯推理服务协程带来的性能提升有限反而增加了代码复杂度。我一般只在服务层需要同时处理多种不同类型的请求比如有的请求需要调外部API有的只需要本地推理时才会考虑协程。4.3 缓存策略什么该缓存什么不该缓存缓存是提升性能的利器但用错了地方会带来一致性问题。在AI服务中最值得缓存的是那些计算成本高、结果确定性强的部分。比如如果你有一个固定的系统提示词system prompt每次请求都要重新编码一遍那就可以把编码后的结果缓存起来下次直接复用。但要注意不是所有东西都能缓存。模型推理的中间激活值通常不能缓存因为它们依赖于具体的输入。输出结果能不能缓存取决于你的业务是否允许相同输入返回相同输出。对于生成式任务即使输入相同输出也可能因为采样策略而不同这种情况下缓存就没有意义。我见过一个团队为了提升性能把用户查询的embedding向量缓存了起来。这个做法本身没问题但他们忘了在模型更新后清空缓存导致新旧模型的embedding混在一起检索结果乱七八糟。所以缓存一定要有失效策略要么基于时间要么基于版本号。5. 可观测性别等用户投诉了才知道服务挂了5.1 必须监控的核心指标AI服务的监控和普通Web服务有重叠但也有特殊之处。以下是我认为必须监控的指标清单请求量QPS或每分钟请求数按接口和状态码分组延迟分布P50、P90、P99、P999不要只看平均值队列深度当前排队等待的请求数这是过载的早期信号批大小分布实际执行的批大小判断批处理是否有效GPU利用率计算单元和显存的使用率显存碎片率已分配但无法使用的显存比例错误率按错误类型分组区分客户端错误和服务端错误超时率被超时机制终止的请求比例这些指标不能只存在日志里要接入监控面板设置合理的告警阈值。比如队列深度持续超过某个值就应该触发告警而不是等用户投诉了才发现。5.2 日志记录的正确姿势AI服务的日志比普通服务更容易失控因为输入和输出可能非常长。如果把完整的请求和响应都打进日志磁盘很快就会被撑爆。我的做法是记录请求的元信息长度、来源、时间戳和响应的元信息长度、耗时、状态但不记录完整内容。如果确实需要调试可以开启采样日志只记录一小部分请求的完整内容。另一个重要的是请求ID的贯穿。从请求进入服务的那一刻起就生成一个唯一的请求ID然后在所有日志和监控指标中都带上这个ID。这样当出现问题时你可以通过请求ID快速定位到完整的处理链路而不需要在海量日志中大海捞针。5.3 告警疲劳的避免告警太多和没有告警一样危险。如果监控系统每天发几百条告警运维人员很快就会麻木真正重要的告警反而被忽略。我建议把告警分为三个级别P0是服务不可用或严重降级需要立即处理P1是性能明显下降但服务仍可用需要在工作时间内处理P2是潜在风险可以等到例行维护时处理。每个告警都应该有明确的处理手册告诉值班人员第一步做什么、第二步做什么。没有处理手册的告警就是在制造焦虑。另外告警阈值不要设得太敏感要留出足够的缓冲空间。比如GPU利用率告警设到95%就太晚了设到80%比较合理这样你还有时间在服务真正过载之前采取措施。6. 从零搭建的实操路线图6.1 第一周跑通最小闭环如果你真的要从零开始我建议第一周的目标不是性能而是跑通一个最小闭环。具体来说选一个开源的小模型比如TinyLlama或Phi-2用最基础的推理方式不量化、不批处理、不优化加载起来写一个简单的HTTP接口能接收请求并返回结果。这个阶段的关键是理解数据从输入到输出的完整流转过程不要急着优化。我见过太多人一上来就追求“生产级”结果在配置各种参数时迷失了方向连最基本的推理流程都没搞明白。先让东西跑起来再让它跑得快最后让它跑得稳这个顺序不能乱。6.2 第二周加入批处理和队列第二周的目标是加入批处理和请求队列。你可以先用一个简单的Python队列比如queue.Queue来实现工作线程从队列中取请求攒够一批就调用推理引擎。这个阶段你会遇到很多细节问题批大小设多少合适等待窗口设多长队列满了怎么办这些问题没有标准答案需要根据你的硬件和业务场景来调。我的建议是先用保守的参数批大小设为4或8等待窗口设为10毫秒队列长度设为100。然后逐步调整观察延迟和吞吐量的变化。记住调优是一个迭代过程不要指望一次就找到最优解。6.3 第三周量化与性能剖析第三周可以开始尝试量化。先用训练后量化看看精度损失是否可接受。如果不可接受再考虑量化感知训练。同时开始做性能剖析用py-spy或cProfile找出代码中的热点。你可能会发现一些意想不到的瓶颈比如某个正则表达式匹配特别慢或者某个数据结构的选择不合理。这个阶段最重要的是建立性能基线。每次改动之后都要重新跑一遍基准测试记录延迟和吞吐量的变化。没有基线的优化就是盲人摸象。6.4 第四周监控与告警最后一周用来搭建监控和告警。你可以用Prometheus加Grafana也可以用更轻量的方案。关键是要把前面提到的核心指标都采集起来并且设置合理的告警规则。这个阶段不需要追求大而全先把最关键的几个指标监控起来后续再逐步完善。整个四周下来你会得到一个虽然简陋但五脏俱全的AI推理服务。更重要的是你会对每个环节的原理和取舍有切身的理解。这种理解是调包永远给不了你的。7. 那些只有踩过才知道的坑7.1 模型加载的隐藏成本很多人只关注推理延迟却忽略了模型加载时间。一个7B参数的模型从磁盘加载到GPU显存可能需要几十秒甚至几分钟。如果你的服务需要频繁重启比如因为显存碎片这个加载时间会严重影响可用性。解决办法是使用模型预热服务启动后先用几个典型请求跑一遍让模型完成初始化和显存分配然后再开始接受真实流量。另外如果条件允许可以把模型权重放在高速存储上比如NVMe SSD减少加载时间。7.2 温度参数对性能的影响生成式模型通常有一个温度参数控制输出的随机性。温度越高输出越多样但推理时间也越长因为模型需要更多的采样步骤。如果你的服务对延迟敏感可以考虑适当降低温度或者使用贪心解码温度设为0这样推理速度会快很多。但要注意降低温度会牺牲输出的多样性。在某些场景下比如创意写作这是不可接受的。所以这是一个需要和产品侧协商的取舍不是纯技术问题。7.3 批处理中的“害群之马”批处理虽然能提升吞吐量但一个超长请求会拖慢整批的速度。我遇到过这样的情况一批8个请求其中7个长度在100个token左右1个长度在2000个token。结果整批的推理时间由那个长请求决定其他7个请求的用户白白多等了好几倍的时间。解决办法是设置单请求的最大长度限制超过限制的请求单独处理不参与批处理。或者使用连续批处理continuous batching技术允许批次中的请求在不同时间完成新请求可以随时加入。连续批处理实现起来更复杂但对延迟的改善非常明显。7.4 显存碎片的长尾效应显存碎片是一个渐进式的问题。服务刚启动时显存充足碎片影响不大。但随着运行时间增长碎片越来越多最终导致明明有足够的空闲显存却无法分配出一块连续空间。这个问题在动态内存管理的推理引擎中尤其常见。我的应对策略是设置一个显存整理阈值当碎片率超过某个值时触发一次显存整理比如把所有活跃张量搬到一起释放碎片空间。如果整理失败就优雅地重启推理引擎。这个过程对用户应该是透明的通过队列和负载均衡来保证重启期间服务不中断。7.5 版本升级的兼容性陷阱AI服务的版本升级比普通服务更复杂因为模型权重、推理引擎版本、服务层代码之间可能存在兼容性问题。我见过一次升级推理引擎从v1升到v2API接口变了但服务层代码没同步更新导致所有请求都返回错误。我的做法是每次升级都先在预发布环境完整跑一遍回归测试包括功能测试和性能测试。升级过程中使用蓝绿部署新版本先接少量流量确认没问题后再逐步扩大。另外模型文件要带版本号服务层要能同时加载多个版本的模型以便快速回滚。8. 写在最后一些个人体会从零搭建AI工程这件事最大的价值不在于你最终做出了一个多快的服务而在于你在这个过程中建立起来的对系统的直觉。当你亲手处理过显存碎片、亲手调过批处理参数、亲手写过监控告警你对AI服务的理解就不再停留在“调个API”的层面了。我自己的经验是每次遇到性能问题先不要急着改代码而是先问三个问题瓶颈在哪里为什么会有这个瓶颈这个瓶颈是必须存在的吗很多时候答案会让你发现问题根本不在你以为的地方。另外不要迷信任何“最佳实践”。不同的硬件、不同的模型、不同的业务场景最优解可能完全不同。别人验证过的参数在你的环境里可能完全不work。保持怀疑保持实验保持记录这才是AI工程师的核心竞争力。最后分享一个小技巧在你的服务里加一个“调试模式”开启后会记录每个请求的详细处理链路包括每个阶段的耗时和显存变化。平时关着不影响性能出问题时打开能帮你快速定位。这个功能我每次搭建新服务都会加上已经帮我省了无数排查时间。
返回列表