先别急着找现成的“AI学习路线图”去收藏吃灰。如果你真想搞懂AI工程是怎么一回事,我的建议是:自己动手搭一个叫ai-engineering-from-scratch的个人项目,用几个月时间,把AI从“会调库”到“懂工程”这条路上该踩的坑全踩一遍。
这既是一个项目,也是一套学习系统的名字。它的核心思路很简单:不依赖任何封装好的高级框架,从最底层的数据处理、模型训练、服务化部署开始,一步步把AI应用造出来。适合那些已经会写Python,但面对LLM应用开发一头雾水,不想只当“调包侠”的开发者。这篇文章,就是我实操这个项目过程中的完整复盘、技术选型思路,以及那些文档里不会写给你的避坑经验。
1. 项目整体设计与思路拆解
1.1 为什么叫“from scratch”,而不直接学LangChain
我见过太多人一上来就啃LangChain、LlamaIndex的源码,结果问他“Embedding到底存哪儿了?”“RAG检索时TopK怎么定的?”就哑火了。问题就出在:框架把复杂度藏得太好,你根本没机会建立“工程直觉”。
这个项目取名ai-engineering-from-scratch,就是要反着来。这里的“from scratch”不是指从线性代数、反向传播的数学原理开始推,那太极端且没必要。我定义的“scratch”,是指从裸的Python环境、原生的API调用、最基础的数据结构开始搭建AI应用,不用任何高级编排框架,框架能做的事情自己亲手实现一遍。
这样做有三个实实在在的好处:
第一,你对系统里每个环节的“手感”是真实的。比如当你自己用NumPy写过一个简单的向量存储和余弦相似度计算之后,再去看FAISS、Milvus这类工具,就能立刻明白它们在解决什么问题,为什么需要索引压缩。
第二,调试成本极低。框架的报错信息经常是一堆抽象封装后的堆栈,而自己写的代码,报错是直白的,定位是秒级的。
第三,也是最重要的,当你理解全貌后,再回头用LangChain会觉得“就这?”,并且能一眼看出它在哪些场景下是过度设计。
1.2 这个项目解决的核心问题
当前AI开发者的典型困境是:技术栈断代。很多人会写Python,会在Jupyter Notebook里跑模型训练,但一涉及到把模型做成一个真正能被用户访问的服务,就卡住了。Docker怎么封装?API并发怎么处理?模型怎么在GPU上高效推理?数据回流怎么设计?
ai-engineering-from-scratch就是一条把“模型训练”和“工程上线”之间的空白地带补齐的路径。它要解决的是“AI应用如何以工程的规范落地”,而不是“AI模型如何调参刷分”。
围绕这个目标,整个项目被我拆成了四个递进的阶段:
- 阶段一:地基—— 补Python工程化短板、搞定环境与依赖管理。
- 阶段二:模型核心—— 自己动手微调一个小模型,走通训练全流程。
- 阶段三:应用搭建—— 用原生代码实现RAG流程,做知识库问答。
- 阶段四:上线运维—— 用Docker打包,提供API服务并监控运行状态。
这四个阶段正好对应了一个AI应用从“想法”到“用户可用”的全生命周期。
1.3 核心设计原则:一切可观测,一切可复现
在设计这个项目时,我给自己定了几条硬规矩,也是这个项目区别于“厕所读物型教程”的关键。
- 不使用任何Agent编排框架,即使最后觉得有必要,也要先自己写一个极其简陋的版本。
- 每行代码都有明确注释,不只是说“这行干了啥”,还要说“为什么这行必须存在”。
- 所有数据、模型权重、参数都固定下来,记录成配置文件,保证一个月后回来看还能完整复现。
- 运行日志就是最佳文档,每一步执行都输出有针对性的日志,而不是print("here")。
这些原则确保了项目本身就是一个完整的工程示范,而不是一个黏黏糊糊的PPT演示。你在看代码的时候,看到的不是一个好结果,而是一条清晰的、有据可循的思考路径。
2. 核心技能栈拆解:AI工程师的五大内功
如果把这个项目所需的技能画成一张地图,那是相当清晰的。总体来看,可以分成五大板块,我按照“投入产出比”和“学习优先级”排了个序。
2.1 工程地基:Python进阶、Docker与Git
很多人觉得自己Python “会了”,其实只是“能跑”。AI工程对Python的要求要深得多。你需要熟练使用dataclass管理配置,理解生成器和装饰器如何用于数据管道,知道asyncio在什么场景下能真正提速I/O。
举个例子,你在做RAG时,一次查询要调用Embedding接口、向量检索、LLM生成三个环节。如果串行跑,延迟是三者之和。但如果你把Embedding调用和向量检索做成异步并发,延迟就是最大者的延迟。这个优化在框架里可能是个隐藏开关,但在from scratch项目里,你需要亲手写。
Docker就更不用说了。AI应用最头疼的就是环境依赖,Python版本差一个补丁号,CUDA驱动不对,模型跑不出来。用Docker把运行环境锁死,到哪台机器上都是百分百复现。“在我电脑上明明能跑”这句话,在工程上就是一句废话。
Git是AI工程师最容易被鄙视的短板。模型训练过程中,代码迭代频繁,实验参数满天飞。如果没有良好的Git习惯,上来就在final_final_v3这种分支上狂奔,回头想对比实验,哭都来不及。
2.2 数据与算法基础:够用且扎实
对于做工程的开发者,我完全不建议陷入数学推导的汪洋大海。数据结构、数据库基础、线性代数里的矩阵乘法、概率论里的条件概率,这些是必须的。但至于“为什么Transformer注意力机制里的Softmax要除以根号d_k”,你只需要知道这是为了控制梯度,防止点积结果过大导致softmax饱和,这就足够了。
这块我的实操建议是:不要系统刷书,直接用项目反推。你的项目需要做向量检索,就去翻FAISS的文档,看它内部用的IVF索引原理;你的项目需要做数据处理,就去学Pandas的常用API。带着问题学,效率是漫无目的刷书的十倍。
2.3 模型训练与微调实操
很多做应用层的开发者觉得“我又不训练模型,学这个干嘛”。但这是大错特错。即使你只用API,清楚模型能吃多长的上下文、指令微调到底改变了什么,对你写Prompt、设计Agent的思考链路都有极大帮助。
在这个项目里,我强烈建议你至少完整走通一次微调流程。SFT(监督微调)也好,LoRA也罢。你要亲手准备数据集、写DataLoader、配置TrainingArguments、观察loss曲线、做模型合并。当你看到loss从2.0降到0.7,然后实际测试模型能接住你的“梗”时,你才算真正捅破了大模型那层神秘的面纱。
用到的核心工具比较固定:transformers、datasets、peft、bitsandbytes。模型可以先选一个很小的,比如中文领域常用的百川或Qwen的0.5B-2B版本。小模型的好处是:单张消费级显卡(12GB显存)就能跑,而且训练时间短,迭代试错成本低。
2.4 LLM应用工程:Prompt、RAG与Agent
这是当前最热、也是最能出成果的板块。这个项目最核心的交付物,就是一个不依赖LangChain、纯手工打造的RAG(检索增强生成)问答系统。
RAG的痛点在于知识召回的质量。初学者一般只会把文档切割、塞进向量库完事。但工程级的RAG要复杂的多。
- 文档切分:这块直接影响检索效果。按固定字符切分最省事,但效果最差,容易把语义割裂。我最终采用的方法是基于Markdown标题结构的语义切分,先按
##一级标题分块,再对超过长度阈值的段落进行递归切分,chunk_overlap设成50-100个字符,保证上下文连贯性。 - 混合检索:纯向量检索在关键词明确的场景下,反而不如BM25这种传统文本检索。我最终实现的方案是互补式混合:向量检索负责语义相关,BM25负责精确匹配,最后用
Reciprocal Rank Fusion把两边的排名结果融合。 - 重排(Rerank):这一环节极其关键但总被忽略。初筛出来的Top20候选里面,前十可能有一半是噪声。接一个精排模型(哪怕是个很小的
bge-reranker-base)进行二次排序,最终取Top5输入给LLM,回答质量提升立竿见影。
2.5 服务化与性能优化
模型调好了,流程跑通了,最后还是得面临“怎么给别人用”的问题。这块我单独用一个章节的篇幅来讲,因为它对工程成败影响巨大。
3. 实操过程与核心环节实现
3.1 阶段一:告别Notebook,拥抱工程化
项目初期,我做的第一件事就是“从Notebook里出走”。只要是在做正式项目,就坚持在IDE里开发,代码组织成标准Python包结构。目录结构大概长这样:
ai-engineering-from-scratch/ ├── configs/ # 所有实验配置 │ ├── data_config.yaml │ ├── train_config.yaml │ └── rag_config.yaml ├── src/ │ ├── data/ # 数据下载、清洗、切分 │ ├── models/ # 模型定义、加载、微调 │ ├── rag/ # 检索、重排、生成相关逻辑 │ └── serve/ # API端点、并发处理 ├── tests/ # 单元测试和集成测试 ├── scripts/ # 可执行脚本入口 └── pyproject.toml # 项目依赖声明这一步的价值在于,它强制你以“工程交付”的视角对待每一行代码。从第一行开始就结构化,后面项目大了不会变成一坨纠结的面条。
3.2 阶段二:手写一个标准RAG管线
这是整个项目的技术核心所在地。我花了差不多三分之一的时间在这个环节上,而最大的收获就是“亲手实现混合检索之后,我再也不迷信框架了”。
流程分四段走:
第一段:数据准备与切分。我选了公司内部的产品手册和若干技术文档作为测试数据。首先做文档解析,把PDF和Word转成纯文本,这里要留意表格的处理。然后按我上面提到的策略实施语义切分。代码实现不复杂,但逻辑要写清楚。
def chunk_document(text: str, base_level: int = 2, max_chunk_size: int = 800) -> list: """按Markdown标题级别进行语义切分,单元过小则向上合并,过大则向下切割。""" lines = text.split("\n") chunks = [] current_chunk = [] current_size = 0 for line in lines: # 检测标题级别 level = detect_heading_level(line) if level is not None and level <= base_level: if current_chunk: chunks.append("\n".join(current_chunk)) current_chunk = [] else: continue current_chunk.append(line) return chunks第二段:双路召回。向量检索用text2vec或者bge-small-zh这类轻量Embedding模型,维数不需要太高,小模型速度快、维护成本低。BM25检索我用的是rank_bm25库。两者结果各取前20,然后进入融合排序。
第三段:重排精筛。融合检索出Top20结果后,把问题同每篇文档拼接送入bge-reranker-base模型打分,按得分从高到低取Top5作为最终上下文。这个精排模型非常耗时,但为了效率可以做成异步,或者拿GPU单独跑一个独立服务。
第四段:LLM生成。最终,把原问题、Top5召回文档和指令模板拼接成一个Prompt发给大模型。有个技巧:Prompt里明确写“如果检索文档中没有对应用的信息,请直接回答‘知识库中没有相关内容’,不要编造”。这是减少幻觉最便宜的方案。
3.3 阶段三:API服务化与容器部署
RAG流程在本地跑通之后,就要考虑上线了。这里我强烈推荐用FastAPI,它的异步支持对AI服务简直是天作之合。
服务端需要处理的核心问题有三个:
并发控制。LLM的推理极耗显存,GPU是稀缺资源。如果不加控制,一个进程同时进来10个请求,直接就把显存打爆。解决办法是引入一个异步队列,请求先入队,由后台工人逐个处理,并用信号量控制最大并发数。这样用户体验是变慢了,但服务稳定了。
流式输出。ChatGPT那种一个字一个字往外蹦的效果,在工程上叫Streaming。实现方式是把LLM的输出token逐个yield,通过WebSocket或SSE推给前端。这个体验差异巨大,如果做一个面向用户的聊天产品,流式是标配而非加分项。
Docker部署。为了让容器安全可控,我做了镜像分层设计。基础层放CUDA和Python运行时,依赖层放全部pip包,代码层只放项目代码。这样的好处是:每次修改代码,只需要重新构建很小的代码层,推送和启动都飞快。
3.4 阶段四:可观测性建设
AI应用输出的不确定性,会让Bug排查变成噩梦。模型答错了,你很难判断是知识库没召回还是Prompt没写好还是模型本身的缺陷。没有观测手段,就是纯靠猜。
我在项目里集成了三根观测支柱:
- 链路追踪:给每次问答分配一个
request_id,记录检索到的文档ID列表、重排得分、最终使用的上下文截断长度。 - 指标监控:记录每次请求的召回延迟、首token延迟、总生成延迟,以及召回相关的
hit@5指标——就是看人工标注的正确上下文有没有出现在Top5里。 - 日常回测:每个版本更新后,我都会跑一遍积累的100条基准问题,对比新旧版本的正确率。这个基线积累得越早越有价值,是保证AI应用迭代不下滑的生命线。
这三根柱子立起来之后,再面对用户的“你的回答是错的”,你就能有数据地回应:“这次错误是检索阶段没召回相关文档,或者是重排模型没把正确结果排上去,具体是哪个环节我已经查到了。”
4. 常见问题与排查技巧实录
4.1 Docker容器里模型转圈加载
这是我在部署阶段遇到的第一个坑。镜像启动后,接口能通,但请求一到模型加载那一环就卡住,一看日志,居然是model.to(device)之后没有报错,但显存一直占用不释放。
排查了半天,问题出在CUDA torch版本与显卡驱动不兼容。其实在众多需要排查的兼容性问题中,信息清晰的大概就属这类了。解决方法是重新锁定pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime这个官方镜像的基础层,苦口婆心地说,不要自己从Ubuntu基础镜像开始装CUDA,那是通往地狱的捷径。
4.2 检索出来的东西驴唇不对马嘴
在混合检索落地之前,我遇到过几次召回内容毫不相关的问题。最常见的原因就一个:Embedding模型和你的文档领域不匹配。通用Embedding模型在技术名词、特定产品名上表现极差。
后来我换成了领域适配的Embedding模型,并在索引前做了文本预处理,把产品名简称为标准全称。同时加入BM25做关键词兜底之后,召回质量有了质的飞跃。在RAG应用里,完全没有必要迷信“大模型聪明所以啥都能理解”,先保住高精度的关键词与语义双通道,“用知识库的硬匹配兜底模型的软理解”,这才是工程思路。
4.3 长文本处理:上下文窗口溢出
文档切分做得再好,也架不住用户上传一本几百页的书。直接把整本书塞进上下文,显存直接爆炸。
我的策略是三级检索优化:第一章切块时,切出的chunk不会太大。第二章增加查询改写,不一定每次都用用户的原始query,而是让LLM把query扩展出3个相关子问题再检索。第三章是让LLM结合多个相关片段,形成一个摘要式回答。
4.4 检索、重排指标好,但最终回答还是烂
这种“指标悖论”我也踩过。检索Top5的命中率已经90%了,重排模型分数也极高,但用户还是说答得不对。
后来发现,问题出在Prompt的构造方式上。我把五个检索片段按照检索得分的顺序直接塞进Prompt,模型会把得分最高但可能是文档目录的片段当作主要内容。解决办法是把文档重排为“按问题相关逻辑重组”,用#### 文档1、#### 文档2清晰隔开,并在Prompt中明确:“所有文档提供的信息同等重要,请综合判断回答”。你把模型当人,把上下文当严谨的工作资料来排版,模型输出质量自然就上来了。
4.5 环境乱七八糟:Python环境隔离
用AI的人跟用Python生态的人一定会有一个共同的痛:环境冲突。今天项目要torch2.0,明天项目要torch1.12,装来装去最后系统库烂掉。
在这个问题上,我强烈建议直接将uv或conda作为默认容器,新项目一建就立刻隔离环境,依赖锁定精确到哈希值。如果团队协作,用Docker解决环境跨机器问题,用uv或poetry解决开发阶段依赖问题。
最后分享一个我在这整个项目中反复体会到的道理:AI工程的本质不是模型本身,而是你如何组织数据、设计流程、验证输出。from scratch的精神也不在于“重新发明轮子”,而在于你真的要把轮子的机械结构摸清楚一次,这样以后用任何先进的交通工具,你都不会心虚。
如果你正打算学习AI工程,别沉迷于囤积“大而全的资料包”,就按这个项目的四阶段顺序,手写一遍RAG,微调一个小模型,用Docker把服务推上去。几个月下来,你会得到一个完全属于自己的、真正“活”的AI项目,那种收获感,跟“跟完教程”完全不是一回事。