
1. 从零搭建AI工程能力为什么“手搓一遍”比调包更值钱很多人第一次接触AI工程都是从一行pip install或者一个 API Key 开始的。调通一个模型接口返回一段像模像样的文本就觉得自己“会AI”了。但真到了要把这套东西塞进一个每天要跑几十万次请求的业务系统里问题就全冒出来了延迟忽高忽低、显存说爆就爆、并发一上来就超时、模型版本一换效果直接崩盘。这些坑调包是调不出来的只有自己从零把链路搭一遍才会真正长在手上。“ai-engineering-from-scratch”这个标题核心讲的不是“怎么用AI”而是“怎么把AI当成一个工程项目来做”。它面向的是那些已经会写Python、懂一点深度学习基础但没真正独立交付过一个AI系统的人。你要做的不是再学一遍Transformer的数学推导而是搞清楚一个模型从权重文件到线上服务之间到底要经过哪些环节、每个环节的瓶颈在哪、哪些参数是拍脑袋定的、哪些是必须算出来的。我自己带过几个从零起步的AI项目最深的体会是AI工程的门槛不在算法而在工程约束下的取舍。同样一个7B模型用不同的推理框架、不同的量化策略、不同的批处理方式吞吐量能差出五到十倍。这些差异不是看文档能看明白的必须自己动手测、动手调、动手踩坑。所以这篇内容我会按照一个真实项目的推进顺序来写先把环境底座搭稳再解决模型加载和推理的核心矛盾然后处理服务化和并发最后落到监控和迭代。每一块我都会说清楚“为什么这么选”而不只是“这么写就对了”。2. 环境底座别让依赖问题吃掉你第一周2.1 为什么我坚持用容器而不是裸机装环境刚入行的时候我也觉得裸机装环境快conda create一下pip install一堆跑起来就完事。但AI工程和普通后端开发最大的区别在于依赖的版本敏感度极高而且经常互相打架。CUDA版本、cuDNN版本、PyTorch版本、推理框架版本这四个东西只要有一个对不上轻则跑不起来重则跑起来了结果不对——这种“静默错误”最要命你根本不知道模型输出为什么变差了。用容器Docker的核心价值不是“时髦”而是把环境当成代码来管理。你的Dockerfile就是一份可复现的环境说明书换一台机器、换一个同事docker build一下环境完全一致。我踩过最惨的一次坑是本地用PyTorch 2.1跑得好好的部署到服务器上因为驱动版本问题自动降级到了2.0结果某个算子的行为变了输出质量肉眼可见地下降排查了整整两天。具体操作上我建议基础镜像直接选官方维护的CUDA镜像比如nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04。注意是runtime而不是devel除非你需要在容器里编译自定义算子否则runtime镜像体积小很多启动也快。然后在里面装Python和依赖用requirements.txt锁死版本不要用pip install torch这种不指定版本的方式。FROM nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04 RUN apt-get update apt-get install -y python3.10 python3-pip git RUN ln -s /usr/bin/python3.10 /usr/bin/python WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD [python, server.py]requirements.txt里每个包都要写死版本号比如torch2.1.2、transformers4.36.2。这不是强迫症是保命。AI生态的版本迭代太快了小版本之间行为不一致是家常便饭。2.2 显存规划先算账再动手环境搭好之后下一个必须面对的问题就是显存。很多人上来就加载模型跑到一半OOMOut of Memory然后开始瞎试把batch size从8降到4再降到1最后能跑了但吞吐惨不忍睹。正确的做法是先算账。模型推理的显存占用大致分三块模型权重、KV Cache、激活值。以FP16精度为例一个7B参数的模型权重占用大约是7B × 2字节 14GB。这是死账跑不掉。KV Cache和序列长度、批大小成正比公式是2 × 层数 × 头数 × 头维度 × 序列长度 × 批大小 × 精度字节。激活值相对小一些但也不能忽略。我一般会留出20%的显存余量给碎片和临时分配。假设你有一张24GB的卡14GB给权重剩下10GB里留2GB余量实际可用于KV Cache的只有8GB。按7B模型32层、32头、头维度128算单条512长度序列的KV Cache大约是2 × 32 × 32 × 128 × 512 × 2字节 ≈ 268MB。8GB大概能撑30条并发。这个数字就是你后面做批处理和调度的依据不是拍脑袋定的。提示如果你用的是量化模型比如INT8或INT4权重占用会大幅下降但KV Cache的精度通常还是FP16所以并发能力的提升没有你想象的那么大。量化主要省的是权重不是缓存。2.3 依赖锁定与镜像分层的小技巧Dockerfile里把requirements.txt的复制和安装放在COPY . .之前是为了利用镜像层缓存。只要依赖没变重新构建时这一步直接命中缓存几秒钟就过了。如果你把整个项目复制进去再装依赖每次改一行代码都要重装一遍构建时间从几秒变成几分钟开发体验极差。另外一个小细节pip安装时加上--no-cache-dir避免镜像里残留一堆下载缓存镜像体积能小不少。别小看这一点镜像小意味着推送快、拉取快、启动快在需要弹性扩缩容的场景下这几秒的差距会被放大很多倍。3. 模型加载与推理从权重文件到可用服务的关键取舍3.1 推理框架选型不是越新越好而是越匹配越好模型加载和推理这块市面上的框架很多各有各的定位。我列一个我自己用下来比较有体感的对比注意这不是绝对的好坏而是适用场景不同。框架优势劣势适合场景PyTorch原生灵活、调试方便性能一般、无内置优化实验阶段、自定义模型ONNX Runtime跨平台、启动快动态shape支持一般中小模型、CPU推理TensorRT极致性能、低延迟编译复杂、绑定NVIDIA生产环境、固定shapevLLM高吞吐、PagedAttention生态相对新大模型高并发服务我的建议是实验阶段用PyTorch原生快速验证想法生产环境如果追求吞吐优先考虑vLLM这类专门为大模型优化的框架如果是中小模型或者需要跨平台部署ONNX Runtime是很稳的选择。不要一上来就追求TensorRT编译和调试的成本很高除非你的场景对延迟极度敏感。选型的核心逻辑是看你的瓶颈在哪。如果是显存不够优先考虑量化和PagedAttention如果是延迟太高优先考虑算子融合和编译优化如果是吞吐上不去优先考虑批处理和调度策略。不同瓶颈对应不同工具没有银弹。3.2 量化省显存的代价是什么量化是AI工程里最常用的省显存手段但很多人只知道“INT8比FP16省一半”不知道省下来的代价是什么。简单说量化是用精度换空间和速度。INT8量化后权重占用减半推理速度通常也能提升30%到50%但输出质量会有一定下降。这个下降在有些任务上几乎看不出来在有些任务上就很明显。我实测下来的经验是生成类任务对量化比较敏感分类和检索类任务相对不敏感。如果你做的是文本生成INT8量化后可能会发现模型开始重复、逻辑断裂的概率变高。这时候可以考虑混合量化也就是对敏感层保持FP16其他层用INT8。具体哪些层敏感没有通用答案得自己测。量化的实现路径上PyTorch有内置的动态量化和静态量化Hugging Face的bitsandbytes库也提供了很方便的INT8和INT4加载方式。我一般先用bitsandbytes快速试如果效果可接受就直接用如果不行再考虑更精细的方案。from transformers import AutoModelForCausalLM, BitsAndBytesConfig quant_config BitsAndBytesConfig( load_in_8bitTrue, llm_int8_threshold6.0 ) model AutoModelForCausalLM.from_pretrained( your-model-path, quantization_configquant_config, device_mapauto )llm_int8_threshold这个参数控制的是异常值的处理阈值默认6.0。如果你的模型输出里经常出现极端值可以适当调高但调太高等于没量化。这个参数没有理论最优值只能实测。3.3 批处理与调度吞吐量的真正杠杆单条请求的延迟再低如果吞吐上不去整体成本还是降不下来。批处理是提升吞吐最直接的手段但批处理不是简单地把请求攒一起就完事。核心矛盾在于批越大吞吐越高但单条延迟也越高。你需要根据业务对延迟的容忍度来定批的大小和等待窗口。我常用的策略是动态批处理设置一个最大批大小和一个最大等待时间比如50毫秒哪个先到就触发一次推理。这样在低峰期延迟低高峰期吞吐高比较平衡。vLLM这类框架内置了这套机制用起来很省心。如果自己实现核心就是一个队列加一个定时器逻辑不复杂但细节很多比如要处理请求取消、超时、优先级等。还有一个容易被忽略的点是连续批处理Continuous Batching。传统批处理要等一整批全部推理完才能处理下一批中间有大量的等待时间。连续批处理允许在一条序列生成结束后立刻插入新的序列GPU利用率能提升好几倍。这个特性在vLLM和TensorRT-LLM里都有支持选框架时值得重点关注。4. 服务化与并发把模型变成别人能用的东西4.1 API设计别把模型细节暴露给调用方模型跑通了下一步是包装成服务。我见过很多项目直接把模型的输入输出格式暴露成API调用方要自己拼prompt、自己解析特殊token这种设计后期维护起来是灾难。正确的做法是在模型外面包一层业务语义调用方只需要传业务参数模型相关的细节全部封装在服务内部。比如你做一个文本摘要服务API应该接收{text: ..., max_length: 200}而不是让调用方传{prompt: 请总结以下文本..., temperature: 0.7}。prompt模板、温度参数、停止词这些都应该在服务端配置调用方不需要知道。这样做的好处是模型换了、prompt改了调用方无感知你也不用去协调一堆下游系统改代码。用FastAPI搭这类服务很顺手异步支持好性能也够。核心代码结构大概是一个加载模型的全局对象一个处理请求的异步函数加上健康检查和指标暴露的端点。from fastapi import FastAPI from pydantic import BaseModel import asyncio app FastAPI() model load_model() class SummaryRequest(BaseModel): text: str max_length: int 200 app.post(/summarize) async def summarize(req: SummaryRequest): loop asyncio.get_event_loop() result await loop.run_in_executor( None, model.generate, req.text, req.max_length ) return {summary: result}注意这里用run_in_executor把同步的模型推理放到线程池里跑避免阻塞事件循环。模型推理是CPU/GPU密集型的直接在async函数里调用会卡死整个服务。这个坑我踩过表现为单条请求正常并发一上来全部超时排查半天才发现是事件循环被阻塞了。4.2 并发控制限流不是可选项是必选项GPU资源是有限的不限制并发的结果就是所有请求一起变慢最后全部超时。限流的目的不是拒绝用户而是保证已经接受的请求能在可预期的时间内完成。我一般会在两个层面做限制一是服务层的并发信号量控制同时进入推理的请求数二是队列层的等待上限超过就快速失败返回。信号量的大小怎么定回到第2.2节的显存计算。如果你算出KV Cache能撑30条并发那信号量就设30再多就会OOM。但实际中我会设得保守一点比如25留一点余量给突发情况。队列等待上限根据业务延迟要求定如果业务要求P99在2秒内那排队时间超过1秒的请求基本就没希望了直接返回繁忙比让用户干等更友好。注意限流阈值不是设完就不管了要配合监控动态调整。业务低峰期可以放宽高峰期收紧。有些框架支持自适应限流根据当前GPU利用率和队列长度自动调整有条件的话优先用这种。4.3 优雅启停与健康检查服务上线之后难免要发版、扩缩容、重启。如果没有优雅启停正在处理的请求会被直接掐断用户看到的就是报错。优雅启停的核心是收到停止信号后不再接受新请求等正在处理的请求全部完成再退出。FastAPI配合uvicorn可以做到这一点关键是设置好timeout_graceful_shutdown给正在处理的请求留足时间。健康检查也容易被忽视。很多项目只检查进程是否存活不检查模型是否真的能推理。结果就是进程活着但模型已经挂了负载均衡还在往里转发流量。我的做法是健康检查里真的跑一次极短的推理比如生成一个token确认模型可用。这个检查频率不用太高30秒一次就够开销可以忽略。5. 监控与迭代上线只是开始不是结束5.1 必须盯住的几个核心指标AI服务的监控和普通后端服务有重叠也有差异。重叠的是CPU、内存、网络这些基础指标差异在于GPU相关的指标和模型质量指标。我列一下我必看的几个GPU利用率低于50%说明资源浪费高于90%说明快到瓶颈了。GPU显存占用接近上限就要警惕OOM尤其是流量突增的时候。推理延迟P50/P95/P99P99最能反映用户体验平均值会骗人。队列等待时间排队时间涨了说明吞吐跟不上该扩容或优化了。每秒生成token数这是大模型服务最核心的吞吐指标。输出质量抽检定期抽样人工看或者用自动化指标如困惑度监控。这些指标里我觉得最容易被忽视的是输出质量抽检。工程指标再好模型输出变差了用户照样跑。模型本身不会无缘无故变差但输入分布会漂移、依赖版本会变化、量化误差会累积。定期抽检能让你在用户投诉之前发现问题。5.2 日志与追踪出问题时能快速定位AI服务的日志比普通服务更重要因为出问题时往往不是简单的报错而是“结果不对”。我一般会记录请求ID、输入长度、输出长度、推理耗时、使用的模型版本、关键参数。不记录完整的输入输出内容一是隐私问题二是日志量太大。但会记录输入的哈希值方便复现。追踪方面如果服务链路比较长比如前面有网关、后面有后处理建议接入分布式追踪。一个请求从进来到出去经过哪些环节、每个环节耗时多少一目了然。没有追踪的话排查延迟问题基本靠猜。5.3 模型迭代怎么换模型而不翻车模型迭代是AI工程和传统软件工程最大的不同。传统软件发版逻辑是确定的测试通过就能上。模型发版效果是不确定的离线指标好不代表线上好。我的经验是灰度发布加A/B测试新模型先接1%的流量对比核心指标延迟、吞吐、质量抽检没问题再逐步放量。灰度期间要特别关注那些“软指标”比如用户反馈、下游系统的异常率。有些问题不会体现在模型自身的指标上但会在业务指标上暴露出来。比如新模型生成的文本更长导致下游存储成本上升或者生成的格式变了导致下游解析失败。这些都要在灰度期间盯住。还有一个实操细节模型版本要和代码版本绑定。不要出现代码回滚了但模型没回滚的情况那会非常混乱。我的做法是把模型文件也纳入版本管理每次发版记录代码commit和模型hash的对应关系回滚时一起回。6. 几个我踩过之后才明白的工程细节6.1 冷启动时间比你想象的更重要模型服务第一次加载模型可能要几十秒甚至几分钟。如果是常驻服务这个开销可以接受。但如果是弹性扩缩容的场景冷启动时间直接决定了你的扩容速度。流量突增时新实例还没加载完模型老实例已经扛不住了结果就是雪崩。优化冷启动有几个方向一是用更快的存储加载权重比如把模型文件放在本地SSD而不是网络存储二是用内存映射mmap方式加载避免一次性读入三是预热服务启动后先跑几条推理把各种缓存建好。我实测下来mmap加载能把冷启动时间缩短一半以上值得一试。6.2 输入长度分布决定了你的架构做容量规划时很多人只看平均输入长度这是不够的。真正影响显存和延迟的是长尾分布。如果99%的请求输入是100个token但1%是5000个token那你的显存规划必须按5000来算否则那1%的请求就会OOM。但按5000来算99%的请求又浪费了大量显存。解决办法是分级处理短请求走一个实例池长请求走另一个实例池各自按自己的分布规划资源。或者用支持PagedAttention的框架显存按需分配长请求不会预留大量空间。这个设计决策要在架构阶段就定下来后期改成本很高。6.3 别忽视CPU和IO的瓶颈大家做AI服务优化时眼睛都盯着GPU但实际中CPU和IO经常才是真正的瓶颈。比如tokenize和detokenize是CPU密集型的如果模型推理很快但tokenize很慢整体延迟还是下不来。再比如日志写入、指标上报这些IO操作如果同步做会拖慢整个请求链路。我的做法是tokenize用多进程池并行处理日志和指标用异步队列批量上报。这些优化单独看收益不大但叠加起来对P99延迟的改善很明显。尤其是高并发场景CPU瓶颈往往比GPU瓶颈更早出现。6.4 测试环境永远复现不了生产流量这是最让人头疼的一点。测试环境的流量是模拟的分布和生产完全不一样。生产环境的长尾请求、异常输入、突发流量测试环境根本造不出来。我的应对策略是流量录制与回放在生产环境录制真实请求脱敏后在测试环境回放这样能最大程度复现真实场景。回放时要注意不要直接打生产模型用测试模型跑对比输出差异。如果差异在可接受范围内说明测试模型可以上线。这个方法比离线评测靠谱得多因为离线评测的数据集往往和真实分布有偏差。7. 从能跑到好用一个AI工程项目的完整闭环把上面这些环节串起来一个AI工程项目的完整闭环大概是环境容器化保证可复现显存算账决定资源规划推理框架选型匹配业务瓶颈量化权衡精度与成本批处理和调度榨取吞吐服务化封装业务语义限流和优雅启停保证稳定性监控和灰度支撑迭代。每一环都不是孤立的前面的决策会影响后面的空间。我最大的体会是AI工程没有标准答案只有权衡。同样的模型在不同的业务约束下最优的工程方案可能完全不同。所以不要迷信任何“最佳实践”包括我上面写的这些。它们是我在特定场景下的经验你要做的是理解背后的逻辑然后根据你自己的约束条件做取舍。真正把一套AI系统从零搭起来、跑稳、迭代好收获的不只是技术能力更是一种工程判断力。这种判断力是调包调不出来的也是看文档看不来的。它只能来自你亲手踩过的每一个坑、亲手做的每一次取舍。