1. 从零搭建AI工程体系,为什么我劝你别一上来就啃框架
“ai-engineering-from-scratch”这个标题,第一次看到的时候我愣了一下。市面上讲AI工程化的内容,十篇里有八篇是从调包开始的——装个transformers,拉个预训练模型,写二十行推理代码,跑通了就敢叫“AI工程实战”。但真正在生产环境里趟过坑的人都知道,从零构建一套AI工程体系,最不该先碰的就是框架本身。
这个项目标题的核心价值,恰恰在于“from scratch”这四个字。它指向的不是“从零开始学AI理论”,也不是“从零训练一个大模型”,而是从零搭建一套能支撑AI应用落地的工程基础设施。说白了,就是当你要把AI能力塞进一个真实产品里时,那些绕不开的脏活累活:数据怎么流转、模型怎么部署、推理怎么加速、服务怎么监控、版本怎么管理、成本怎么控制。
我见过太多团队在这件事上栽跟头。算法工程师在notebook里跑出个漂亮指标,工程团队接手后花三个月做服务化,上线第一天就被真实流量打崩。问题出在哪?不是模型不行,是工程体系没搭对。这个项目要解决的,就是让AI应用从“能跑”到“能扛”之间的那道鸿沟。
适合谁来参考?如果你是刚转入AI方向的后端工程师,或者带团队做AI产品落地的技术负责人,再或者你是算法出身但需要自己把模型推上线的全栈选手,这套从零构建的思路会帮你省下大量试错成本。前提是你得会写代码,至少熟悉Python和基本的服务端概念,不需要你懂反向传播的数学推导,但得知道一个HTTP请求从进来到出去中间经过了什么。
2. 整体架构设计:先画数据流,再选组件
2.1 为什么我坚持“数据流优先”的设计顺序
很多团队做AI工程化,第一步是选型——用什么推理框架、用什么向量数据库、用什么编排工具。这个顺序是反的。正确的做法是先把数据流画出来:一个用户请求进来,经过哪些环节,每个环节输入什么输出什么,数据在哪里落盘、在哪里缓存、在哪里被消费。
我自己的习惯是拿一张白纸,从最左边画用户输入,最右边画最终响应,中间用方框标出每一个处理节点。比如一个典型的RAG应用,数据流大概是:用户query → 接入层 → 查询理解 → 向量检索 → 上下文组装 → 大模型推理 → 后处理 → 响应返回。每个方框旁边标注:输入数据格式、输出数据格式、预期延迟、失败时的降级策略。
这张图定下来之后,选型才有依据。向量检索环节要求P99延迟低于50毫秒,那你就知道该选内存型索引而不是磁盘型;大模型推理环节要求吞吐量支撑每秒50个并发,那你就得考虑批处理推理和GPU显存管理。反过来,如果先选了某个热门框架,再硬往数据流里塞,最后一定是削足适履。
我踩过的一个坑:早期做对话系统时,先选了某个当时很火的编排框架,结果发现它的异步模型和我们的流式响应需求根本不兼容,硬改了两周最后还是换掉了。从那以后,任何项目我都先画数据流,再拿着数据流去匹配工具。
2.2 分层架构的取舍:哪些层必须自己写,哪些可以借力
从零构建不等于所有轮子都自己造。我的原则是:跟业务逻辑强相关的层自己写,通用基础设施层优先用成熟方案。具体来说,接入层、业务编排层、Prompt管理层、评估层,这些跟你的产品形态和领域知识紧密相关,自己写才能保证灵活性和可控性。而模型推理层、向量索引层、日志监控层,这些有大量成熟开源方案,没必要重复造轮子。
但这里有个关键判断:借力不等于黑盒。你可以用现成的推理服务器,但必须搞清楚它的批处理策略、显存管理机制、超时行为。我见过团队用某个推理框架上线后,发现并发一高就OOM,排查半天才发现是框架默认的批处理窗口设得太大,每个请求都等满窗口才发出去,显存里堆了几十个请求的中间状态。这种问题,你不理解底层机制,光看文档是发现不了的。
分层架构还有一个容易被忽视的点:层与层之间的契约要明确。接入层传给编排层的数据结构是什么?编排层调用推理层的接口签名是什么?这些契约一旦定下来,就不要轻易改。我习惯用Pydantic模型把每一层的输入输出都定义清楚,这样任何一层出问题,排查范围立刻缩小到相邻两层之间。
2.3 技术选型的三个硬指标:延迟、吞吐、成本
选型的时候,我只看三个硬指标:延迟、吞吐、成本。其他什么“生态活跃度”“社区规模”都是软指标,可以参考但不能作为决策依据。
延迟分P50和P99。P50决定用户体验的“典型感受”,P99决定用户会不会骂人。一个AI应用,P50延迟200毫秒、P99延迟2秒,用户会觉得“有时候快有时候卡”;P50延迟500毫秒、P99延迟800毫秒,用户反而觉得“一直挺稳”。所以优化的时候,优先压P99,而不是压P50。
吞吐量要区分“峰值吞吐”和“持续吞吐”。很多方案在压测时能扛住高并发,但跑上半小时就性能衰减,这是因为散热、内存碎片、连接池耗尽等问题在短时间压测中暴露不出来。我的做法是至少做一次持续30分钟以上的稳定性压测,观察吞吐量随时间的变化曲线。
成本这块,很多人只算GPU租用费用,忽略了工程复杂度带来的隐性成本。一个需要三套独立服务、两套消息队列、一套分布式缓存的方案,和另一个单进程就能跑起来的方案,即使前者推理速度快20%,后者的总拥有成本可能更低。我的经验是:在满足延迟和吞吐要求的前提下,选架构最简单的那个。
3. 核心模块拆解:从请求进来到响应出去
3.1 接入层:别小看这一层,80%的线上问题出在这里
接入层看起来简单——收请求、转发、返回响应。但实际生产环境中,这一层是问题最集中的地方。我统计过自己经手的线上故障,超过一半跟接入层有关:请求体过大导致内存溢出、超时设置不合理导致级联失败、并发限流没做好导致雪崩、流式响应中断导致客户端挂起。
接入层要做的第一件事是请求校验和清洗。用户传上来的东西永远比你想象的离谱:超长文本、非法字符、嵌套十层的JSON、空指针。这些必须在接入层就拦掉,不能让它流到后面的推理环节。我的做法是定义一个严格的请求模型,用Pydantic做校验,字段长度、类型、取值范围全部卡死。校验失败的请求直接返回400,不消耗任何后端资源。
第二件事是超时和重试策略。AI推理的延迟波动很大,同一个请求可能这次200毫秒下次2秒。超时设太短,正常请求被误杀;设太长,慢请求拖垮整个服务。我的经验值是:接入层超时设为推理层P99延迟的1.5倍,同时开启请求级别的超时中断,一旦超时立刻释放后端资源。重试只对幂等请求开放,且重试次数不超过1次,重试间隔用指数退避。
第三件事是流式响应的处理。现在很多AI应用用SSE做流式输出,用户体验好,但工程复杂度高。流式响应最大的坑是客户端断开连接后,后端还在继续推理,白白浪费GPU资源。解决办法是在接入层监听连接状态,一旦客户端断开,立刻通过上下文取消信号通知推理层终止。这个机制不做,你的GPU账单会莫名其妙高出一截。
实操心得:接入层一定要做请求级别的唯一ID透传。从请求进来生成一个trace_id,贯穿整个处理链路,日志、监控、链路追踪全部带上这个ID。出问题的时候,拿一个trace_id就能把整条链路的日志串起来,排查效率提升十倍不止。
3.2 编排层:AI应用的“大脑”,也是最容易写乱的地方
编排层是AI应用区别于普通后端服务的核心所在。普通后端服务的业务逻辑是确定的——if else、循环、数据库查询,路径清晰。AI应用的编排层要处理的是不确定的模型输出、多路召回结果的融合、上下文窗口的动态管理、失败降级策略的切换,复杂度高出一个量级。
我见过最乱的编排层代码,是一个三千行的函数,里面嵌套了十几层if else,每个分支都在调不同的模型和工具。这种代码没法维护,加一个新功能就要动全身。我的做法是把编排层拆成可组合的步骤,每个步骤是一个独立的处理单元,有明确的输入输出定义,步骤之间通过一个上下文对象传递数据。
比如一个典型的RAG编排流程,我会拆成这些步骤:查询改写 → 向量检索 → 结果重排 → 上下文组装 → 模型调用 → 响应解析。每个步骤单独实现、单独测试、单独监控。步骤之间用管道串联,管道支持条件分支和并行执行。这样加一个新步骤,只需要实现一个新的处理单元,注册到管道里就行,不影响已有逻辑。
编排层还有一个关键设计:降级策略。AI系统的不确定性决定了你必须为每个环节准备Plan B。向量检索超时了怎么办?降级到关键词检索。模型调用失败了怎么办?降级到缓存响应或默认回复。上下文组装时token超限了怎么办?按优先级截断低分文档。这些降级逻辑必须在编排层统一管理,不能散落在各个步骤里。
3.3 推理层:把模型跑起来容易,跑得稳跑得快难
推理层是AI工程化的核心战场。把模型加载进来跑个demo,半天就能搞定。但要支撑生产流量,需要考虑的东西就多了:批处理策略、显存管理、并发控制、模型热更新、多模型路由。
批处理是提升吞吐量最有效的手段。原理很简单:把多个请求攒在一起,一次前向传播处理完,摊薄单次推理的开销。但批处理窗口设多大有讲究。窗口太小,攒不够请求,吞吐量上不去;窗口太大,每个请求的等待时间变长,延迟飙升。我的经验值是:根据P99延迟目标反推批处理窗口。如果P99目标是1秒,模型单次推理耗时200毫秒,那批处理窗口最多设800毫秒,留200毫秒给其他环节。
显存管理是另一个大坑。模型权重、KV Cache、中间激活值都在抢显存。尤其是大模型,KV Cache会随着并发数和生成长度线性增长。我见过服务跑着跑着突然OOM,就是因为某个长文本请求把KV Cache撑爆了。解决办法是设置显存水位线,超过阈值就拒绝新请求或排队等待,同时限制单请求的最大生成长度。
模型热更新在生产环境中是刚需。你不能每次换模型都重启服务,那意味着几分钟的服务不可用。我的做法是双缓冲加载:新模型在后台加载到显存,加载完成后原子切换推理指针,旧模型等所有进行中的请求处理完再释放。这样切换过程对用户完全无感。
注意:推理层一定要做输入长度校验。我遇到过用户传了一篇十万字的文章进来,模型直接OOM。后来在推理层入口加了硬性长度限制,超过最大上下文长度的请求直接拒绝,返回明确的错误提示。
3.4 数据层:AI应用的“记忆”,设计不好就是灾难
AI应用的数据层比普通应用复杂得多。除了常规的业务数据,还要管理向量索引、对话历史、Prompt模板、模型版本、评估数据集。这些东西的存储需求、访问模式、一致性要求各不相同,混在一起管理就是灾难。
我的做法是按数据特征分而治之。向量索引用专门的向量数据库,关注的是检索速度和召回率,数据可以最终一致。对话历史用文档数据库或关系数据库,关注的是写入吞吐和读取延迟,需要强一致。Prompt模板用配置中心或版本控制系统管理,关注的是变更审计和灰度发布。模型版本用对象存储加元数据数据库,关注的是大文件存储和版本追溯。
对话历史的管理有个容易被忽视的点:上下文窗口的滑动策略。大模型的上下文长度有限,对话轮次多了之后,必须决定保留哪些历史、丢弃哪些历史。简单的做法是保留最近N轮,但这样会丢失早期的重要信息。我的做法是用一个轻量级的摘要模型,把早期对话压缩成摘要,保留关键信息的同时控制token消耗。
向量索引的更新策略也值得细说。全量重建索引成本高、耗时长,通常只在模型更换或数据大规模变更时做。日常更新用增量索引,新数据实时写入,定期合并到主索引。这里的关键是删除的处理——向量数据库通常不支持原地删除,需要标记删除加定期压实。如果不做压实,索引会越来越大,检索速度越来越慢。
4. 实操全流程:从零到一搭建一个可用的AI服务
4.1 环境准备与依赖管理
动手之前,先把环境理清楚。我的习惯是用Docker Compose管理本地开发环境,把推理服务、向量数据库、缓存、消息队列全部容器化。这样做的好处是环境一致,换台机器也能一键拉起。
依赖管理用Poetry或PDM,不用pip直接装。AI项目的依赖树通常很深,版本冲突是家常便饭。Poetry的锁文件能保证每次安装的依赖版本完全一致,避免“在我机器上能跑”的经典问题。Python版本建议3.10或3.11,3.12有些AI库的兼容性还没跟上。
GPU环境要特别注意CUDA版本和驱动版本的匹配。我踩过的坑:CUDA 12.1的容器跑在驱动版本525的宿主机上,报错信息含糊不清,排查了半天才发现是版本不兼容。建议在Dockerfile里显式指定CUDA版本,并在启动脚本里加一个版本检查。
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04 RUN apt-get update && apt-get install -y python3.11 python3-pip RUN pip install poetry WORKDIR /app COPY pyproject.toml poetry.lock ./ RUN poetry config virtualenvs.create false && poetry install --no-dev COPY . . CMD ["python", "-m", "app.main"]4.2 推理服务的部署与调优
推理服务我推荐用现成的推理服务器,比如vLLM或TGI,不要自己从transformers开始写。这些推理服务器内置了连续批处理、PagedAttention、张量并行等优化,自己实现这些至少需要几个月。
以vLLM为例,启动命令大概是这样:
python -m vllm.entrypoints.openai.api_server \ --model /path/to/model \ --tensor-parallel-size 2 \ --max-model-len 8192 \ --gpu-memory-utilization 0.9 \ --max-num-seqs 64 \ --enforce-eager几个关键参数的解释:tensor-parallel-size是张量并行度,等于GPU数量;max-model-len是最大上下文长度,设太大浪费显存,设太小长请求会被截断;gpu-memory-utilization是显存利用率上限,0.9表示留10%给系统;max-num-seqs是最大并发序列数,直接影响吞吐量;enforce-eager关闭CUDA图优化,牺牲一点性能换取稳定性,调试阶段建议开启。
调优的时候,先用默认参数跑一轮压测,记录P50延迟、P99延迟、吞吐量、显存占用。然后逐个调整参数,观察指标变化。我的经验是:max-num-seqs对吞吐量影响最大,但调太高会导致延迟飙升;gpu-memory-utilization调到0.95以上容易OOM,0.85到0.9是比较安全的区间。
4.3 向量检索的搭建与索引调优
向量检索用Milvus或Qdrant都行,我选Qdrant是因为它的过滤检索做得比较好,支持在向量检索的同时做标量过滤,适合需要按用户ID、时间范围等条件筛选的场景。
索引类型选HNSW,参数m和ef_construct控制索引质量和构建速度。m是每个节点的邻居数,越大索引越精确但内存占用越高,一般设16到32。ef_construct是构建时的搜索宽度,越大索引质量越好但构建越慢,一般设100到200。检索时的ef参数控制搜索宽度,越大召回率越高但延迟越高,需要根据召回率要求调。
from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams, HnswConfigDiff client = QdrantClient(host="localhost", port=6333) client.create_collection( collection_name="documents", vectors_config=VectorParams(size=1024, distance=Distance.COSINE), hnsw_config=HnswConfigDiff(m=16, ef_construct=200) )向量维度要和embedding模型对齐。用BGE-large的话是1024维,用OpenAI的text-embedding-3-small是1536维。维度越高检索越精确但内存和计算成本越高。我的经验是:中文场景用BGE-large,1024维足够;多语言场景用multilingual-e5-large,也是1024维。
4.4 监控与告警的落地
监控是AI工程化的生命线。没有监控,你就是在盲飞。我至少要监控四个层面:基础设施层(GPU利用率、显存占用、CPU、内存、网络)、服务层(QPS、延迟分布、错误率、超时率)、模型层(推理延迟、批处理大小、KV Cache命中率、生成长度分布)、业务层(请求成功率、用户反馈、降级触发次数)。
工具链用Prometheus + Grafana + Alertmanager。推理服务器通常自带Prometheus指标端点,直接抓取就行。业务指标需要自己在代码里埋点,用prometheus_client库暴露自定义指标。
告警规则设置有讲究。不要什么都告警,告警疲劳比没有告警更可怕。我的原则是:只对影响用户体验的指标告警。P99延迟超过阈值告警,错误率超过1%告警,GPU显存超过95%告警。其他指标只做看板展示,不触发告警。
实操心得:告警一定要带上下文。光说“P99延迟超过2秒”没用,要带上当前QPS、批处理大小、GPU利用率、最近一分钟的请求量变化。这些信息能帮你快速判断是流量突增、模型变慢还是资源瓶颈。
5. 常见问题与排查技巧实录
5.1 推理服务OOM的排查思路
OOM是推理服务最常见的故障。排查的时候按这个顺序来:先看显存占用曲线,是缓慢增长还是突然飙升。缓慢增长通常是内存泄漏,检查KV Cache有没有正确释放、有没有循环引用。突然飙升通常是某个大请求导致的,检查请求长度分布,看是否有超长请求。
如果显存占用正常但依然OOM,检查是不是批处理窗口设太大,导致同时处理的请求太多。调小max-num-seqs或max-model-len试试。还有一种可能是CUDA上下文占用了额外显存,用nvidia-smi看实际占用和推理服务器报告的是否一致。
5.2 延迟毛刺的定位方法
延迟毛刺是指P99延迟远高于P50延迟的情况。定位毛刺来源,我习惯用分段计时:在请求处理链路的每个环节打时间戳,最后输出各环节耗时。毛刺通常集中在某一个环节,找到那个环节再深入排查。
常见的毛刺来源:向量检索的索引合并、推理服务的批处理等待、垃圾回收的Stop-The-World、网络抖动、磁盘IO。如果是批处理等待导致的,调小批处理窗口;如果是GC导致的,换用更高效的序列化方案或调优GC参数。
5.3 检索召回率低的优化路径
召回率低的表现是:用户问的问题明明有相关文档,但检索结果里就是没有。排查路径:先看embedding模型是否适合当前领域,通用embedding模型在专业领域表现通常不好,需要微调或换用领域模型。再看索引参数是否合理,ef设太小会导致召回率低,调大试试。最后看文档切分策略,切得太碎会丢失上下文,切得太大会引入噪声,需要根据文档类型调整chunk size。
我的经验是:chunk size设512到1024个token比较合适,相邻chunk之间保留10%到20%的重叠,避免关键信息被切断。如果文档有天然的结构(标题、段落),按结构切分比按固定长度切分效果好。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 推理服务OOM | 请求过长、批处理过大、内存泄漏 | 看显存曲线、请求长度分布 | 限制请求长度、调小批处理、修复泄漏 |
| P99延迟毛刺 | 批处理等待、GC、索引合并 | 分段计时、GC日志 | 调小批处理窗口、优化GC、错峰合并 |
| 检索召回率低 | embedding不匹配、索引参数不当、切分策略差 | 人工评估检索结果 | 换领域模型、调大ef、调整chunk size |
| 流式响应中断 | 客户端断开、超时、后端异常 | 检查连接状态、超时配置 | 加连接监听、调超时、加异常处理 |
| 模型切换失败 | 显存不足、版本不兼容 | 看加载日志、显存占用 | 双缓冲加载、版本校验 |
| 吞吐量上不去 | 批处理窗口小、并发限制低 | 压测、看批处理大小 | 调大批处理窗口、提高并发限制 |
6. 我踩过的坑与独家避坑指南
第一个坑:过早引入复杂编排框架。刚开始做AI应用的时候,觉得LangChain很酷,什么都往上套。结果发现它的抽象层太厚,出问题的时候根本不知道是哪一层的问题。后来我把编排层用纯Python重写,代码量少了三分之一,排查问题的时间少了一半。我的建议是:先用最朴素的方式实现,等确实遇到无法解决的复杂度时,再考虑引入框架。
第二个坑:忽视冷启动问题。模型加载需要时间,大模型加载几分钟很正常。如果服务重启或扩容,新实例在模型加载完成前无法处理请求。解决办法是加一个就绪探针,模型加载完成前不接收流量。同时保持至少一个热实例,避免全部实例同时冷启动。
第三个坑:日志打太多。AI应用的日志量惊人,每个请求的输入输出、每个环节的中间结果,全打出来一天就是几十GB。日志存储成本高不说,排查问题的时候在海量日志里找关键信息也很痛苦。我的做法是分级打日志:INFO级别只打关键节点和耗时,DEBUG级别打详细数据但只在需要时开启。请求的原始输入输出单独存到对象存储,需要时再查。
第四个坑:不做评估就上线。AI应用的效果很难用传统测试用例覆盖,必须建立评估体系。我至少会准备三套评估集:功能评估集验证基本能力,边界评估集验证异常处理,回归评估集防止版本迭代引入退化。每次模型或Prompt变更,先跑评估集,指标不降才能上线。
第五个坑:成本失控。AI应用的成本主要是GPU,而GPU成本跟并发数、生成长度、模型大小强相关。我见过团队为了追求效果用最大的模型,结果单次推理成本是普通模型的十倍,商业化根本跑不通。我的建议是:先用小模型验证产品逻辑,等用户量上来了再考虑用大模型提升效果。同时做好成本监控,按请求维度统计GPU耗时,找出成本大户针对性优化。
7. 后续扩展方向:这套体系还能怎么进化
这套从零构建的AI工程体系,跑通之后可以往几个方向扩展。多模型路由是一个方向:根据请求的复杂度动态选择模型,简单请求走小模型,复杂请求走大模型,在效果和成本之间找平衡。在线学习是另一个方向:把用户反馈实时回流到模型或检索层,让系统越用越准。多模态扩展也值得考虑:把图像、音频的处理能力接入现有的编排管道,复用已有的监控、降级、评估体系。
我个人最看好的是评估驱动的持续优化。把评估集做成CI/CD的一部分,每次变更自动跑评估,指标达标才允许上线。这样能把AI应用的迭代从“凭感觉”变成“看数据”,长期来看是提升效果最靠谱的路径。
最后分享一个小技巧:在编排层加一个影子模式。新模型或新Prompt上线前,先让它在影子模式下处理真实流量,但不返回给用户,只记录结果。对比影子结果和线上结果,评估新版本的效果。这样能在不影响用户体验的前提下完成验证,比离线评估靠谱得多。