1. 从零手搓AI工程:为什么“调包”救不了你
很多人对AI工程的理解,停留在“装个transformers库,调个pipeline,跑通一个demo”这个层面。我刚开始接触这块的时候也这样,觉得模型能输出结果就算完事。直到有一次,线上服务在高峰期直接雪崩,显存溢出、请求排队、响应时间从200毫秒飙到8秒,我才意识到——能跑通和能扛住,中间隔着一整个工程体系。
ai-engineering-from-scratch这个方向,核心不是教你调API,而是让你从底层理解一个AI系统到底由哪些部件组成、每个部件的瓶颈在哪、怎么在资源受限的情况下做出可用的东西。它适合那些不满足于“会调包”、想真正搞明白推理引擎怎么调度、显存怎么管理、算子怎么优化的人。说白了,就是从“会用”到“会造”的那条路。
这篇文章我会按我自己踩过的坑和实际搭建经验,把从零构建AI工程能力的关键环节拆开讲。不堆术语,不抄文档,只讲那些你真正动手时绕不开的东西。
2. 先搞清楚AI工程到底在工程什么
2.1 训练、推理、服务化是三件不同的事
新手最容易犯的错,是把“AI工程”当成一个整体。实际上它至少分成三层,每层的关注点完全不同:
- 训练层:关注梯度是否正确、损失是否收敛、数据管道是否高效。核心指标是吞吐量和收敛速度。
- 推理层:关注延迟、显存占用、批处理效率。核心指标是首token延迟和每秒生成token数。
- 服务层:关注并发、限流、容错、监控。核心指标是可用性和P99延迟。
我见过太多人把训练脚本直接改吧改吧就上线,结果连基本的并发都扛不住。训练时你可以慢慢跑,推理时用户可不会等你。
2.2 为什么必须从底层理解
有人会问:现在不是有vLLM、TensorRT-LLM这些现成方案吗,为什么还要从零学?
我的回答是:工具越高级,出问题时你越无能为力。当vLLM的显存碎片导致OOM,当TensorRT的算子不支持你的自定义层,当批处理策略和你的业务流量模式不匹配——如果你不理解底层的KV Cache管理、连续批处理、算子融合这些机制,你连日志都看不懂。
从零构建的意义在于,你亲手实现过一个简化版的推理引擎之后,再看那些工业级框架,就能一眼看出它在哪个环节做了什么取舍。这种判断力,是调包调不出来的。
2.3 最小可用AI系统的组成清单
一个能跑起来的最小AI工程系统,至少包含以下模块:
| 模块 | 职责 | 常见实现 |
|---|---|---|
| 模型加载器 | 读取权重、初始化参数 | safetensors、pickle |
| 计算图/算子 | 执行矩阵运算、激活函数 | PyTorch、手写CUDA |
| 内存管理 | 显存分配与回收 | 缓存分配器、内存池 |
| 调度器 | 请求排队与批处理 | 连续批处理、动态批处理 |
| 服务接口 | 对外提供API | HTTP、gRPC |
| 监控 | 指标采集与告警 | Prometheus、日志 |
这张表看着简单,但每一个模块往下挖都是一堆坑。接下来我按实际搭建顺序,逐个拆解。
3. 模型加载与内存布局:第一个性能分水岭
3.1 权重加载不只是“读文件”
很多人以为加载模型就是torch.load()一下完事。实际上,权重加载方式直接决定了你的冷启动时间和内存峰值。
我实测过一个7B参数的模型,用pickle加载峰值内存会到模型大小的2倍以上,因为反序列化过程中会同时存在序列化数据和反序列化后的张量。换成safetensors之后,峰值内存降到1.1倍左右,加载时间也缩短了将近40%。
原因在于safetensors是零拷贝的,它直接把文件映射到内存,不需要额外的反序列化缓冲区。这个细节在本地开发时感知不强,但在容器化部署、内存受限的环境里就是生死线。
3.2 显存分配策略决定你能跑多大模型
显存管理是AI工程里最容易被低估的环节。PyTorch默认的缓存分配器会预留显存,这导致一个现象:你明明只用了8GB,但nvidia-smi显示占了12GB。
这里的关键概念是显存碎片。当你频繁申请和释放不同大小的张量时,显存里会出现很多不连续的小空洞。这些空洞单个看都不够用,但加起来可能有好几个GB。
我的处理经验是:
- 推理场景尽量预分配固定大小的显存池,避免运行时动态申请
- 如果必须动态分配,用
torch.cuda.memory._set_allocator_settings调整分配策略 - 监控
torch.cuda.memory_summary()里的碎片率,超过15%就要警惕
注意:不要迷信
torch.cuda.empty_cache(),它只是把缓存还给驱动,频繁调用反而会拖慢性能。真正要解决的是分配策略问题。
3.3 量化不是万能药,选错格式反受其害
量化能显著降低显存占用,但不同量化格式的代价差异很大。我整理了一个实际对比:
| 量化方式 | 显存节省 | 精度损失 | 推理加速 | 适用场景 |
|---|---|---|---|---|
| FP16 | 基准 | 无 | 基准 | 通用 |
| INT8 | 约50% | 较小 | 1.5-2x | 对精度不敏感 |
| INT4 | 约75% | 明显 | 2-3x | 边缘部署 |
| GPTQ | 约75% | 中等 | 2-3x | 消费级显卡 |
| AWQ | 约75% | 较小 | 2-3x | 推荐优先尝试 |
我的建议是:先跑通FP16,再考虑量化。很多人的问题是模型还没跑起来就想着量化,结果量化后的精度问题排查起来更痛苦。AWQ目前是我用下来精度和速度平衡最好的方案,但需要模型本身支持。
4. 推理引擎的核心:KV Cache与批处理调度
4.1 KV Cache为什么是推理优化的命门
自回归生成的特点是:每生成一个token,都要用到之前所有token的Key和Value。如果不做缓存,每步都要重新计算整个序列的注意力,计算量随序列长度平方增长。
KV Cache的思路很直接:把已经算过的Key和Value存下来,下一步直接复用。这样每步只需要计算新token的注意力,复杂度从O(n²)降到O(n)。
但代价是显存。以7B模型为例,FP16精度下每个token的KV Cache大约占0.5MB。如果并发100个请求、每个请求平均500个token,光KV Cache就要25GB。这就是为什么长上下文和高并发很难同时满足。
4.2 连续批处理:吞吐量和延迟的平衡术
静态批处理的问题是:一批请求必须等最慢的那个生成完才能释放。如果批里有个请求要生成1000个token,其他只生成10个的请求就得干等。
连续批处理的思路是:每个生成步都重新组批。已经生成完的请求立刻退出,新来的请求立刻加入。这样GPU利用率大幅提升,但实现复杂度也上去了。
我手写过一个简化版的连续批处理调度器,核心逻辑是:
class Scheduler: def __init__(self, max_batch_size): self.running = [] # 正在生成的请求 self.waiting = [] # 等待加入的请求 self.max_batch_size = max_batch_size def step(self): # 移除已完成的请求 self.running = [r for r in self.running if not r.finished] # 尽可能多地加入新请求 while self.waiting and len(self.running) < self.max_batch_size: self.running.append(self.waiting.pop(0)) # 执行一步生成 if self.running: self._forward(self.running)这段代码看着简单,但实际要处理的问题很多:不同请求的序列长度不同怎么对齐、padding怎么处理、显存不够时怎么抢占。每一个都是坑。
4.3 PagedAttention解决了什么
vLLM的PagedAttention是我认为近几年推理优化里最巧妙的设计之一。它借鉴了操作系统虚拟内存的分页思想,把KV Cache切成固定大小的块,不需要连续存储。
这样做的好处是:
- 消除外部碎片:不再需要为每个请求预留连续显存
- 支持内存共享:多个请求如果前缀相同,可以共享KV Cache块
- 灵活扩容:序列变长时按需分配新块,不用提前预留
我实测下来,同样的硬件配置,用PagedAttention能把并发数提升2-4倍。这个提升在长上下文场景下更明显。
提示:如果你在用vLLM,
gpu_memory_utilization这个参数很关键。默认0.9,但如果你的服务还有其他显存开销,建议降到0.85左右,留出余量避免OOM。
5. 服务化与线上稳定性:从能跑到能扛
5.1 请求队列设计:别让用户无限等待
我见过最离谱的线上事故,是服务没有队列上限,请求一直堆积,最后内存爆掉整个进程挂掉。用户那边看到的是连接超时,体验极差。
正确的做法是设置多级队列:
- 快速队列:短请求优先,保证交互体验
- 批量队列:长请求或离线任务,可以等
- 拒绝策略:队列满了直接返回429,让客户端重试
队列长度怎么定?我的经验公式是:队列长度 = 平均QPS × 可接受等待时间。比如QPS是50,用户最多等2秒,那队列长度设100左右。超过就拒绝。
5.2 超时与重试:不是所有失败都值得重试
超时设置要分两段:首token超时和总超时。首token超时通常设短一些(比如5秒),因为如果模型连第一个token都出不来,大概率是卡住了。总超时根据业务定,但要有上限。
重试策略上,我的原则是:
- 连接失败可以重试
- 超时重试要谨慎,因为可能服务端还在处理,重试会加重负载
- 模型返回错误不要重试,那是逻辑问题
5.3 监控指标:没有度量就没有优化
线上服务必须监控的指标:
| 指标 | 含义 | 告警阈值建议 |
|---|---|---|
| 首token延迟P99 | 用户感知的响应速度 | > 2s |
| 每秒生成token数 | 吞吐能力 | 低于基线30% |
| 显存使用率 | 资源水位 | > 90% |
| 队列等待时间 | 拥塞程度 | > 1s |
| 错误率 | 服务质量 | > 1% |
这些指标我建议用Prometheus采集,Grafana做面板。不要等出事了才去看日志,那时候已经晚了。
6. 那些只有动手才会遇到的坑
6.1 显存泄漏:最隐蔽的杀手
显存泄漏在Python里特别难查,因为垃圾回收机制和CUDA缓存交织在一起。我遇到过一次,服务跑几个小时就OOM,重启就好。
排查过程是这样的:
- 先用
torch.cuda.memory_summary()看分配情况,发现预留显存持续增长 - 用
tracemalloc查Python对象,没发现异常 - 最后定位到是一个全局缓存字典,每次请求都往里塞东西,从来没清理
教训是:任何全局状态都要有清理机制。推理服务里,请求级别的数据绝对不要放到全局变量里。
6.2 批处理反而变慢:小批量的陷阱
批处理不是越大越好。我实测过一个场景,batch size从1加到8,吞吐量提升明显;但从8加到32,吞吐量几乎没变,延迟却翻倍了。
原因是GPU算力已经饱和,再加批量只是让每个请求等更久。最优批量取决于模型大小和硬件,需要实际压测找拐点。我的做法是从batch size 1开始,每次翻倍,记录吞吐和延迟,找到吞吐不再明显增长的那个点。
6.3 精度问题:量化后的模型可能“胡说八道”
量化后的模型有时候不是直接报错,而是输出变得莫名其妙。这种问题最难查,因为代码没bug,是数值精度的问题。
我的排查清单:
- 对比量化前后同一输入的输出,看偏差是否在可接受范围
- 检查是否有层对精度特别敏感(通常是attention的softmax和layer norm)
- 尝试混合精度,敏感层保持FP16,其他层量化
注意:INT4量化在小于7B的模型上精度损失往往不可接受,建议至少13B以上再考虑INT4。
7. 从零构建的学习路径建议
如果你真想走ai-engineering-from-scratch这条路,我的建议是按这个顺序来:
第一阶段:手写推理循环。不用任何推理框架,用PyTorch手动实现一个GPT的生成循环,包括KV Cache。这一步能让你彻底理解自回归生成的计算过程。
第二阶段:实现批处理。在第一步的基础上加入静态批处理,然后改成连续批处理。体会调度器设计的取舍。
第三阶段:内存优化。实现一个简单的分页KV Cache,理解PagedAttention的原理。
第四阶段:服务化。用FastAPI或gRPC把上面的东西包起来,加入队列、限流、监控。
第五阶段:性能调优。用profiler找瓶颈,尝试算子融合、量化、编译优化。
每一步都不需要做到工业级强度,但必须亲手写一遍。我自己的经验是,写完第一阶段的代码后,再看vLLM的源码,理解速度至少快了三倍。
这个方向没有捷径,但每一步的收获都是实打实的。等你亲手实现过一个能扛住并发的推理服务,再回头看那些“调包”教程,会有完全不同的感受。