
1. 从零搭建AI工程体系为什么我劝你别急着调包很多人一上来就想跑通一个模型恨不得三行代码调用一个接口就完事。但真到了要把AI能力塞进一个真实业务系统里你会发现调包只是万里长征第一步。ai-engineering-from-scratch这个标题我理解它说的不是“从零训练一个大模型”而是从零构建一套能支撑AI应用落地的工程体系——数据怎么流转、模型怎么服务化、推理怎么加速、线上怎么监控、成本怎么控制这些才是真正卡住绝大多数团队的地方。我见过太多项目算法同学在 notebook 里跑出个 0.92 的准确率工程同学一接手就懵了模型文件怎么加载并发上来 GPU 显存爆了怎么办请求延迟从 50ms 抖到 2s 是哪里出了问题这些问题的答案不在任何一篇论文里而在工程实践的细节中。这篇文章适合那些已经了解基本机器学习概念、但还没完整走过一遍AI系统落地流程的开发者也适合后端工程师想补上AI工程这一课。我会按照一个真实项目的推进节奏把每个环节的选型逻辑、实操步骤、踩坑经验都摊开来讲你照着做就能搭出一套能扛住真实流量的最小可用系统。2. 整体架构设计与技术选型思路2.1 为什么我不建议一上来就上Kubernetes刚起步的AI工程项目最忌讳的就是过度设计。我见过一个团队三个人要做一个内部文档问答工具上来就搭了一套 K8s 集群配了 Istio 做流量治理结果光环境维护就耗掉了大半精力模型本身的效果反而没时间调。ai-engineering-from-scratch的核心思路应该是先跑通最小闭环再逐步替换组件。我的建议是分三个阶段演进。第一阶段用单机 Docker Compose 把推理服务、向量数据库、后端 API 串起来验证业务逻辑。第二阶段当单机扛不住时把推理服务单独拆出来做水平扩展前面加一层负载均衡。第三阶段如果确实有多模型、多版本、弹性伸缩的需求再考虑上 K8s。这个顺序不能反反了就是给自己找罪受。具体到技术栈推理服务我首选FastAPI Uvicorn的组合。原因很简单Python 生态和 AI 模型天然亲和FastAPI 的异步支持足够应付 IO 密集型的推理调度而且它的自动文档功能在联调阶段能省很多沟通成本。如果你追求极致性能可以考虑Triton Inference Server但它带来的运维复杂度在项目初期往往得不偿失。2.2 模型服务化的三种模式与选择依据模型怎么对外提供服务这里有三种常见模式我画个表对比一下模式适用场景优点缺点嵌入式模型小、请求量低部署简单无网络开销无法独立扩展语言绑定强独立服务通用场景解耦好可独立扩缩容需要处理网络通信和序列化Serverless流量波动大按需计费免运维冷启动延迟有状态处理麻烦绝大多数项目应该选择独立服务模式。把模型推理封装成一个 HTTP 或 gRPC 服务后端业务通过内网调用。这样做的好处是模型更新不影响业务代码推理服务的资源可以单独调配而且方便做 A/B 测试。gRPC 在性能上优于 HTTP但调试起来麻烦一些我一般建议先用 HTTP 把流程跑通等性能瓶颈确实出现在通信层了再换 gRPC。2.3 数据流与依赖关系的梳理一个完整的AI工程系统数据流向大致是这样的原始数据经过清洗和预处理变成模型可以吃的格式训练阶段产出模型文件和相关的配置推理阶段加载模型接收请求返回结果同时所有的请求和响应数据要落盘用于后续的监控和迭代。这里有个容易被忽略的点训练和推理的特征处理逻辑必须保持一致。我踩过这个坑训练时用 Pandas 做归一化推理时用 NumPy 手写了一遍结果因为浮点数精度处理不同线上效果比离线评估差了五个百分点。后来我们的做法是把特征处理逻辑抽成一个独立的 Python 包训练和推理都 import 同一个模块从根源上杜绝不一致。3. 核心模块拆解与实操要点3.1 推理服务的封装与性能调优把模型封装成服务听起来简单但细节很多。先看一个最基础的 FastAPI 推理服务骨架from fastapi import FastAPI from pydantic import BaseModel import torch app FastAPI() class PredictRequest(BaseModel): text: str max_length: int 128 class PredictResponse(BaseModel): label: str confidence: float model None app.on_event(startup) def load_model(): global model model torch.load(model.pt, map_locationcpu) model.eval() app.post(/predict, response_modelPredictResponse) def predict(req: PredictRequest): with torch.no_grad(): inputs tokenizer(req.text, return_tensorspt, max_lengthreq.max_length, truncationTrue) outputs model(**inputs) probs torch.softmax(outputs.logits, dim-1) conf, pred torch.max(probs, dim-1) return PredictResponse(labelstr(pred.item()), confidenceconf.item())这段代码能跑但直接上生产会出问题。第一个问题是模型加载时机。放在startup事件里是对的但要注意如果模型很大加载时间可能超过负载均衡的健康检查超时导致服务还没准备好就被判定为不健康。我的做法是在启动脚本里先加载模型再启动 Uvicorn或者把健康检查的初始延迟设长一点。第二个问题是并发处理。FastAPI 默认用线程池处理同步请求但 PyTorch 的推理本身会释放 GIL所以多线程下性能还可以。但如果你的模型推理是 CPU 密集型的线程数超过核心数反而会变慢。我一般会把 Uvicorn 的 worker 数设为 CPU 核心数然后在每个 worker 内部用torch.set_num_threads(1)避免线程争抢。第三个问题是批处理。单条推理的 GPU 利用率极低如果能攒一批请求一起推理吞吐量能提升好几倍。实现方式是在服务内部维护一个队列攒够一定数量或等待一定时间后统一推理。这个逻辑可以用asyncio实现但要注意超时控制不能让用户等太久。3.2 向量检索模块的搭建与索引选择如果你的AI应用涉及语义搜索或 RAG向量检索是绕不开的。从零搭建的话我建议先用FAISS把流程跑通它足够轻量单机性能也很好。等数据量上来了再考虑 Milvus 或 Qdrant 这类分布式方案。FAISS 的使用有几个关键决策点。第一是索引类型。IndexFlatL2最精确但最慢适合数据量小于十万的场景。数据量再大就得用IndexIVFFlat它通过聚类把向量空间划分成多个桶搜索时只查最近的几个桶。这里有个参数nlist表示桶的数量经验值是sqrt(N)N 是向量总数。还有一个nprobe参数表示搜索时查多少个桶越大越精确但越慢需要在精度和速度之间权衡。第二是向量归一化。如果你用余弦相似度记得先把向量归一化然后用内积索引这样比直接算余弦快很多。我见过有人每次查询都算一遍余弦白白浪费了计算资源。第三是索引的持久化。FAISS 索引可以保存到磁盘但要注意版本兼容性。不同版本的 FAISS 保存的索引文件可能不兼容所以生产环境要锁定版本。另外索引文件可能很大加载到内存需要时间可以考虑用内存映射的方式加载。3.3 请求队列与异步处理机制当推理请求的到达速度超过处理速度时就需要一个队列来缓冲。最简单的做法是用 Redis 的 List 结构生产者LPUSH消费者BRPOP。但这种方式有个问题如果消费者处理失败消息就丢了。更可靠的做法是用 Redis 的 Stream 或者专业的消息队列如 RabbitMQ。不过对于大多数AI应用来说同步处理加超时控制往往就够了。用户发一个请求服务端最多等 10 秒超时就返回错误。这样实现简单用户也能接受。只有那些确实需要异步的场景比如批量处理大量文档才需要引入队列。如果确实要用队列我建议用Celery Redis的组合。Celery 的任务重试、结果存储、监控都有现成的方案不用自己造轮子。配置上要注意设置task_acks_lateTrue这样任务执行完才确认避免 worker 崩溃导致任务丢失。还有worker_prefetch_multiplier要设小一点避免一个 worker 囤积太多任务而其他 worker 空闲。3.4 监控指标与日志采集的落地AI系统的监控和普通后端系统不太一样除了常规的 QPS、延迟、错误率还要关注模型层面的指标。比如输入数据的分布是否偏移、预测结果的置信度分布是否异常、GPU 显存和利用率是否正常。我一般用Prometheus Grafana做指标采集和展示。在推理服务里埋点记录每次请求的延迟、输入长度、输出类别。这些指标用 Prometheus 的 Python 客户端暴露出来Grafana 那边配好面板就能实时看到服务状态。日志方面结构化日志是关键。不要用print打日志用logging模块输出 JSON 格式的日志方便后续用 ELK 或 Loki 做检索。每条日志至少包含时间戳、请求 ID、输入摘要、输出结果、耗时。请求 ID 要贯穿整个调用链这样排查问题时能把一次请求的所有日志串起来。注意日志里不要记录完整的用户输入尤其是涉及隐私的场景。我一般只记录输入的长度和哈希值既能追踪问题又不会泄露数据。4. 完整实操流程从零到一跑通最小闭环4.1 环境准备与依赖锁定第一步是把环境搭起来。我强烈建议用Docker来管理环境因为 AI 项目的依赖往往很复杂不同版本的 CUDA、PyTorch、Python 之间兼容性很微妙。用 Docker 可以保证开发、测试、生产环境一致。Dockerfile 的写法有讲究。不要用latest标签的基础镜像要锁定具体版本。比如pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime。依赖安装用pip的requirements.txt但要注意把torch这类大包单独处理利用 Docker 的层缓存加速构建。FROM pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD [uvicorn, main:app, --host, 0.0.0.0, --port, 8000]requirements.txt里要锁定所有依赖的版本用pip freeze生成。不要用这种模糊版本否则某天自动构建时拉到一个不兼容的新版本服务就挂了。4.2 模型导出与格式转换训练好的模型不能直接扔到生产环境需要做格式转换。PyTorch 的.pt文件适合研究但生产环境我推荐用ONNX或TorchScript。ONNX 的好处是跨框架你可以在 Python 里训练用 C 或 Rust 推理。TorchScript 的好处是和 PyTorch 生态无缝衔接转换简单。导出 ONNX 的代码大概长这样import torch.onnx dummy_input torch.randn(1, 128, dtypetorch.long) torch.onnx.export( model, dummy_input, model.onnx, input_names[input_ids], output_names[logits], dynamic_axes{input_ids: {0: batch, 1: sequence}}, opset_version14 )这里dynamic_axes很关键它告诉 ONNX 哪些维度是动态的。如果不设置导出的模型只能接受固定 batch size 和序列长度线上请求一变就报错。opset_version也要注意不同版本的 ONNX Runtime 支持的 opset 不同要查清楚再选。导出后一定要验证。用同样的输入分别跑一遍 PyTorch 和 ONNX Runtime对比输出是否一致。我遇到过导出后精度损失的情况原因是某些算子在不同框架下的实现有细微差异。如果差异太大就得考虑换算子或者调整模型结构。4.3 服务编排与联调测试单机环境下我用Docker Compose把各个服务串起来。一个典型的docker-compose.yml包含推理服务、向量数据库、后端 API 和 Redisversion: 3.8 services: inference: build: ./inference ports: - 8001:8000 deploy: resources: reservations: devices: - driver: nvidia count: 1 capabilities: [gpu] vectordb: image: qdrant/qdrant:v1.6.0 ports: - 6333:6333 volumes: - ./data/qdrant:/qdrant/storage backend: build: ./backend ports: - 8000:8000 depends_on: - inference - vectordb environment: - INFERENCE_URLhttp://inference:8000 - QDRANT_URLhttp://vectordb:6333联调时最容易出问题的是网络超时。推理服务加载模型可能需要几十秒后端服务启动时如果立即去调推理服务会连接失败。解决办法是在后端加一个重试逻辑或者用depends_on配合健康检查。Docker Compose 的depends_on只保证启动顺序不保证服务就绪所以健康检查是必须的。4.4 压力测试与性能基线建立服务跑通后下一步是压测。我用Locust写压测脚本因为它用 Python 写和我们的技术栈一致而且支持分布式压测。压测脚本的核心是模拟真实请求分布。不要只用同一条输入反复打要准备一批多样化的输入随机选取。输入长度也要有变化因为不同长度的输入对推理时间影响很大。from locust import HttpUser, task, between import random class InferenceUser(HttpUser): wait_time between(0.1, 0.5) task def predict(self): text random.choice(load_test_samples()) self.client.post(/predict, json{text: text})压测时要关注的指标P50、P95、P99 延迟吞吐量QPS错误率以及GPU 利用率和显存占用。我一般会逐步增加并发数找到延迟开始急剧上升的拐点那个点就是系统的容量上限。生产环境的并发数不要超过这个拐点的 70%留出余量应对突发流量。提示压测前先把日志级别调到 WARNING否则大量日志写入会拖慢服务测出来的数据不准。5. 常见问题与排查技巧实录5.1 推理延迟忽高忽低的排查思路延迟抖动是最常见也最难排查的问题。我的排查顺序是这样的先看是不是请求长度分布导致的长请求处理慢是正常的但如果 P99 延迟远高于 P50可能是某些请求触发了慢路径。再看GPU 利用率如果利用率不高但延迟高说明瓶颈不在计算可能在数据预处理或网络传输。最后看系统资源CPU、内存、磁盘 IO 有没有异常。有一个隐蔽的坑是Python 的 GIL。如果你的推理服务里混用了同步的 CPU 密集操作和异步的 IO 操作GIL 争抢会导致延迟抖动。解决办法是把 CPU 密集操作放到单独的进程池里执行或者用run_in_executor包装。另一个常见原因是CUDA 的上下文切换。如果多个进程共享一块 GPU上下文切换开销很大。建议一个 GPU 只跑一个推理进程通过多实例来利用多卡。5.2 显存溢出与批处理大小的权衡显存溢出OOM是 GPU 推理的经典问题。根本原因是批处理大小乘以序列长度超过了显存容量。解决办法有几个一是减小批处理大小但会降低吞吐二是用梯度检查点或混合精度减少显存占用三是用PagedAttention这类技术动态管理显存。我一般会先估算显存需求。一个粗略的公式是显存 ≈ 参数量 × 精度字节数 × 2 激活值。比如一个 1 亿参数的模型FP16 精度参数量占 200MB加上激活值和中间结果大概需要 1-2GB。但这只是推理时的静态占用实际还要考虑 CUDA 上下文和碎片。动态批处理是平衡吞吐和显存的好方法。设置一个最大批处理大小和最大等待时间攒够一批就推理。这样短请求不会等太久长请求也不会把显存撑爆。实现时要注意批处理内的序列长度要 padding 到一致padding 太多会浪费计算所以最好把长度相近的请求放在一批。5.3 模型版本更新时的平滑过渡模型更新不能直接替换文件重启服务那样会有服务中断。平滑过渡的做法是蓝绿部署或金丝雀发布。蓝绿部署是起一个新版本的服务流量切过去观察没问题再把旧版本下线。金丝雀发布是先切一小部分流量到新版本逐步扩大比例。实现上可以在推理服务前面加一层网关网关根据配置把请求路由到不同版本的服务。新版本的服务用不同的端口或路径。切换时先改网关配置把 1% 的流量导过去观察错误率和延迟没问题再逐步加量。模型文件本身也要做版本管理。我习惯在模型文件名里带上版本号和日期比如model_v2.1_20240115.onnx。服务启动时从配置里读版本号加载对应的文件。这样回滚时只需要改配置重启不用重新构建镜像。5.4 常见问题速查表现象可能原因排查方法解决措施服务启动失败模型文件路径错误检查日志中的 FileNotFoundError确认挂载路径和文件权限首次请求特别慢模型懒加载观察首次请求耗时在 startup 事件中预加载延迟随并发上升急剧增加线程争抢或队列积压查看 CPU 和队列长度调整 worker 数或加队列显存溢出批处理太大或内存泄漏监控显存变化趋势减小 batch size检查循环引用输出结果不稳定随机种子未固定对比多次相同输入推理时设置 eval 模式和固定种子日志中大量超时下游服务慢或网络问题检查下游服务指标加超时和重试优化下游6. 工程化进阶从能用到好用6.1 配置管理与环境隔离项目初期配置写在代码里没问题。但随着环境增多开发、测试、生产硬编码的配置会成为噩梦。我推荐用环境变量 配置文件的方式。敏感信息如数据库密码、API Key 放在环境变量里其他配置放在 YAML 文件里通过环境变量指定加载哪个文件。Python 里可以用pydantic-settings来管理配置它支持从环境变量和文件读取还能做类型校验。配置项要有默认值但关键配置如模型路径、数据库地址必须显式指定否则启动时报错避免用错配置还不知道。6.2 持续集成与自动化测试AI 项目的 CI 和普通项目不太一样除了跑单元测试还要跑模型效果回归测试。每次代码变更除了验证功能没坏还要验证模型在固定测试集上的指标没有下降。我的做法是维护一个小型但多样的测试集覆盖各种边界情况。CI 流程里先跑单元测试再跑推理服务的集成测试最后跑模型效果测试。效果测试的阈值不要设得太死留一点波动空间否则正常的随机性会导致 CI 频繁失败。模型文件本身不建议放在 Git 里太大了。可以用DVC或Git LFS管理或者存在对象存储里CI 时下载。下载要加缓存避免每次构建都重新拉。6.3 成本控制与资源调度GPU 很贵能省则省。几个实用的省钱技巧一是用竞价实例价格便宜很多缺点是可能被回收所以要做好容错。二是自动伸缩根据队列长度或 GPU 利用率动态调整实例数闲时缩到零。三是模型量化把 FP32 转成 INT8推理速度提升两三倍显存占用减半精度损失通常在一个百分点以内。量化用ONNX Runtime或TensorRT都支持。ONNX Runtime 的量化工具比较易用校准数据集准备几百条代表性样本就行。量化后一定要重新跑效果测试确认精度可接受。注意量化不是万能的有些模型对量化很敏感尤其是输出层。如果量化后效果下降太多可以只量化中间层保留输出层为 FP16。6.4 安全与权限的最小化实践AI 服务的安全容易被忽视。最基本的是输入校验防止恶意输入导致服务崩溃或产生不当输出。输入长度要限制特殊字符要过滤必要时做内容审核。API 要有认证和限流。内部服务可以用简单的 Token 认证对外服务建议用 OAuth2 或 JWT。限流用Redis 令牌桶实现按用户或 IP 维度限制请求频率。限流阈值要根据压测结果来定太严会影响正常用户太松起不到保护作用。模型文件也要保护不要放在公开可访问的路径。推理服务只暴露必要的接口管理接口如模型热更新要单独鉴权。日志里不要打印敏感信息错误信息也不要暴露内部细节避免被利用。7. 我踩过的那些坑与个人体会第一个坑是过度追求最新技术。看到新出的推理框架就想换结果换来换去稳定性越来越差。后来我定了个规矩核心组件除非有明确的性能或功能瓶颈否则不轻易换。稳定比先进重要。第二个坑是忽视数据质量。花了很多时间调模型结构后来发现是训练数据里有大量噪声。清洗数据后同样的模型效果提升了一大截。AI 工程里数据的工作量应该占一半以上这个比例怎么强调都不为过。第三个坑是监控不到位。上线后没有完善的监控出了问题只能靠用户反馈。后来补上了指标采集和告警很多问题在用户感知之前就发现了。监控不是成本是保险。第四个坑是文档缺失。项目初期觉得代码就是文档后来人一多沟通成本急剧上升。现在我会强制要求每个模块有 README关键决策有记录接口有示例。这些文档在排查问题和交接时能救命。最后一个体会是AI 工程和传统后端工程最大的区别在于不确定性。模型的行为不是完全可预测的同样的输入可能因为浮点数精度、硬件差异、并发顺序而产生微小变化。所以系统设计要留有余地要有降级方案要有回滚能力。把 AI 系统当成一个概率系统来设计而不是确定性系统很多决策就会不一样。