
1. 项目概述“ai-engineering-from-scratch”这个名字我第一眼看到就想拍大腿这不就是我自己踩了两年坑总结出来的那条路吗去年面试过不少自称“AI工程师”的候选人简历上全是“调用OpenAI API做了个聊天机器人”但问到Token怎么算、上下文窗口怎么管理、RAG检索为什么召回率上不去就支支吾吾了。这个项目的核心就是把我从零开始构建AI工程能力的完整路径记录下来不是教你调一个API而是把大模型应用开发里那些“看似简单、实际全是坑”的环节从原理到底层实现全部趟一遍。在这个项目里所谓from scratch是指不依赖任何封装好的AI开发框架比如LangChain、LlamaIndex这类直接用Python和基础库去搭建一套完整的大模型应用。你可能要问有现成的框架不用非要自己造轮子这正是这个项目最有价值的地方——当你亲手写过一遍你才会真正理解那些框架到底帮你干了什么、哪些环节是性能瓶颈、哪些设计是妥协的结果。这套项目对三类人特别有用想转行做AI应用开发的工程师、刚读完LLM原理想动手实践的在校生以及公司里被安排“两周内做出一个AI功能”却不知道从哪下手的后端开发者。我在这篇文章里会完整拆解这个项目的技术选型、架构设计、核心代码实现和踩坑记录。文章里所有代码都是可以在普通笔记本上跑通的不需要几十万的算力集群。项目不追求做一个多炫酷的demo而是用最小成本验证一条最扎实的学习路径从API调用方式、Prompt工程、RAG检索增强、评估体系到部署上线每一步都有对应的原理说明和可复现代码。2. 为什么选择从零手写而不是直接上框架2.1 框架帮你解决的问题恰恰是你需要学会的问题现在市面上的AI应用开发框架确实很香几行代码就能把一个带记忆的聊天机器人跑起来。但问题恰恰出在这里框架把太多东西自动化了导致很多开发者根本不知道自己的应用背后发生了什么。举个最典型的例子LangChain里调用一个Retriever你只需要一行代码但它内部做了文本切分、向量化、相似度检索、重排序任何一个环节出了问题你都不知道该从哪里排查。这个项目坚持手写底层就是为了把每一层都拆开看清楚。就像你学做饭用预制菜包确实五分鐘就能端出一道宫保鸡丁但你永远学不会真正的烹饪。手写一遍Prompt模板管理、手写一遍向量检索、手写一遍上下文拼接逻辑之后你再去用任何框架都会有一种“尽在掌握”的感觉。我在项目里做了个有点“偏执”的约定除了调用大模型API本身其余所有逻辑全部用标准库和轻量级依赖实现。向量化用sentence-transformers这是一个模型库而不是应用框架文本切分自己写递归字符切分器向量存储直接用NumPy数组加暴力检索。这么做的结果就是整个项目的依赖只有十几个部署的时候几乎不会遇到环境冲突问题。2.2 从零开始更适合建立系统化思维而不是碎片化搭积木AI工程和传统的后端工程有个显著区别它不是一个“输入-处理-输出”的线性流程而是一个多组件协同的复杂系统。数据清洗、检索策略、Prompt设计、模型调用、结果评估每一个环节都会影响最终效果。如果一开始就抱着框架搭积木你很容易陷入“试来试去但不知道改了什么起作用”的困境。就拿RAG检索增强生成来说用框架的话你只需要配置一个向量库地址、一个Embedding模型名、一个LLM模型名然后调用chain接口就完事了。但如果自己手写你就会经历一个完整的决策过程文本该按什么粒度切分chunk_size设多大overlap设多少Embedding模型用哪一款检索返回TopK个片段这些参数直接决定最终答案的质量。框架确实有默认值但默认值不是为你这个场景优化的。我自己实际跑下来的经验是手写版本第一次跑通大概用了两天但之后做优化时效率反而更高因为我知道每个参数在系统里的准确位置和作用路径。对比下来我见过不少直接用框架搭的原型跑是能跑但一旦效果不好只能盲目地把参数乱调一通完全找不到北。3. 整体架构设计与技术选型3.1 项目目录结构与模块拆分这个项目我没有做成一个大文件而是按照“数据层-检索层-生成层-评估层”的思路拆成了六个模块ai-engineering-from-scratch/ ├── config.py # 全局配置模型名称、路径、参数 ├── data_loader.py # 数据加载与清洗 ├── text_splitter.py # 递归文本切分器 ├── embeddings.py # Embedding模型封装与向量化 ├── vector_store.py # 基于NumPy的向量存储与检索 ├── rag_pipeline.py # RAG完整流程检索拼接生成 ├── prompt_templates.py # Prompt模板管理与版本控制 ├── evaluator.py # 回答质量评估脚本 ├── main.py # 命令行入口 ├── data/ # 原始文档存放目录 └── tests/ # 单测与集成测试每个模块都有独立的职责模块之间只通过函数接口交互。这个设计思路是我从传统后端开发里带过来的尽量降低模块间的耦合度为后面单独替换某个组件留好余地。比如今天你用的是OpenAI的Embedding接口明天想换成开源的BGE模型只需要改动embeddings.py一个文件就够了。3.2 核心依赖为什么只选这三样整个项目的核心依赖经过多次裁剪最后只剩三个openai用于调用GPT系列模型的API、sentence-transformers本地向量化模型、numpy向量存储与相似度计算。如果你用的是国内大模型平台openai库可以换成对应的SDK整体思路完全一致。这里重点说一下不使用LangChain的另一个原因可调试性。LangChain把一次RAG调用包装成了一个Chain对象内部的RetrievalQA链会帮你自动拼接Prompt、调用模型、解析输出看起来非常方便但其实你很难在中间插入调试代码。而手写版本的pipeline是线性的每一步的输入输出都可以打印出来检查。我在项目开发过程中有超过一半的时间是在看检索结果和Prompt拼接结果而不是看模型输出——因为绝大多数问题都出在前两个环节。3.3 一个让人意外但好用的技术选择暴力检索在向量检索这个环节我一度纠结要不要上FAISS或Milvus这类专用向量数据库。后来实测下来发现在数据量不超过5万条文本片段的情况下用NumPy直接算余弦相似度完全够用5万条向量的暴力检索一次大约耗时50ms对于问答场景完全可以接受。而且这样做省去了部署向量数据库的运维成本整个项目在任何一台普通笔记本上都能跑。当然如果数据量达到百万级别或者你需要实时更新索引那还是得上专用向量数据库。这个项目在设计时有意做了一个可替换的抽象层vector_store.py里只暴露了add_texts和search两个方法内部实现无论是NumPy还是FAISS都不影响上层逻辑。这种“先用最简方案跑通再按需替换”的思路我觉得是应对技术选型焦虑的通用解法。4. 核心模块实现细节与实操要点4.1 文本切分RAG系统里最容易被低估的环节文本切分看似是个预处理步骤实际上直接决定检索质量的上限。我见过太多人随便调一个split_text就把文档扔进去结果检索出来的片段要么割裂了关键的上下文要么一个片段太长挤掉了其他有价值的内容。这个项目里我实现了一个递归字符切分器核心逻辑是优先按段落\n\n切分段落太长时再按句子切分句子还太长就按固定长度硬切同时每个片段保留与其相邻片段的重叠字符。这个设计背后有个重要的直觉切分粒度决定了信息的“最小可理解单元”。如果片段太短比如不够100字检索回来的内容可能只是一段没有上下文的碎片模型无法准确理解如果片段太长超过1000字又会引入大量无关信息稀释关键内容的权重。我在项目里默认配置是chunk_size500字、overlap50字这个数值得到了比较理想的召回效果。提示不要依赖任何框架内置的默认切分参数。不同文档的类型差异极大——代码仓库、合同条款、学术论文、客服对话的最优切分策略可能完全不同。建议先肉眼检查10个以上切分结果再决定下一步。4.2 Embedding模型选型中文场景的实测对比Embedding是RAG系统里另外一个大坑。通用场景我直接用了OpenAI的text-embedding-3-small但在需要本地化部署或者处理中文专业内容时实测下来开源的BGE系列bge-large-zh-v1.5效果更好。它们的核心差异在于训练数据里中文占比和领域适配度你在选型时不能只看榜单一定要拿自己的真实业务文档做小范围评测。我在项目里封装了一个Embedding抽象层支持在API调用和本地模型之间随时切换。切换方式就是改一行配置不需要动任何业务代码。这里有个经验可以分享如果文档内容偏专业领域比如医学、法律、金融强烈建议用领域微调过的Embedding模型而不是通用模型。通用模型对常用词理解好但专业术语和行业黑话往往是它的盲区。4.3 Prompt模板管理把提示词当成代码来维护很多人把Prompt当纯文本随手写这是一个很危险的习惯。Prompt是逻辑的一部分它应该像代码一样有版本管理、有模块化设计、有输入输出规范。我在项目里用了一个非常朴素的方案把Prompt模板放在单独的Python文件里每个模板对应一个函数函数的参数就是模板变量。这样既容易测试也能在调用时自动校验必填参数是否完整。举个例子检索增强问答的Prompt模板我设计成这样def build_rag_prompt(query: str, contexts: list[str]) - str: context_text \n\n.join( f[片段{i1}]\n{c} for i, c in enumerate(contexts) ) return f你是一个严格基于给定资料回答问题的助手。 请仅根据以下资料片段回答问题不要使用内部知识补充。 如果资料中没有相关信息请直接回答资料中未找到相关信息。 相关资料 {context_text} 用户问题{query} 请给出简洁准确的回答这种模板设计有几点讲究第一明确告诉模型“只用资料回答”抑制幻觉第二每个片段加序号模型可以引用不同片段的组合信息第三给了一个无信息时的兜底回答方式避免模型自作聪明。这些都是我在反复测试中总结出来的比写一句“请回答以下问题”效果好得多。4.4 RAG流程的完整实现从检索到生成的每一步RAG完整流程的实现集中在rag_pipeline.py里。核心步骤是先把用户问题向量化然后用相同的向量检索逻辑找到最相关的TopK个文本片段再把片段按顺序拼接进Prompt模板最后调用大模型生成答案。class RAGPipeline: def __init__(self, config): self.store VectorStore() self.embedder EmbeddingWrapper(config.embedding_model) self.llm LLMWrapper(config.llm_model) self.top_k config.top_k def answer(self, query: str) - str: # 1. 检索向量化相似度计算 query_vec self.embedder.embed(query) results self.store.search(query_vec, kself.top_k) # 2. 拼接构造完整Prompt contexts [r[text] for r in results] prompt build_rag_prompt(query, contexts) # 3. 生成调用大模型 response self.llm.generate(prompt, temperature0.3) return response这个流程看着简单但实际跑起来有几个容易被忽视的细节。第一个是TopK的选择K太小时可能漏掉关键信息K太大时又会让上下文过长、模型注意力被稀释。我实测下来对于一个500字左右的基础文档集K4到K6是一个比较合适的区间。第二个是temperature参数检索问答场景建议调低0.2-0.3让模型更忠实于资料头脑风暴类的生成场景才需要调高0.7-0.9。这两个参数虽然只是一行代码的差别但对输出质量的影响甚至大于换一个大模型。5. 完整实操流程与参数选择逻辑5.1 准备数据五步清洗法项目第一步是准备知识库文档。我强烈建议不要拿现成的网盘资料直接开跑而是花时间做一遍数据清洗。我总结了一个五步清洗流程去重删除内容完全相同的段落、去噪删除HTML标签、多余空白、乱码字符、格式统一统一为UTF-8编码的纯文本、质量筛选删除明显不完整或语义不连贯的段落、人工抽验抽10条人工确认质量。这个环节有个典型的反面教材我刚开始做的时候图省事直接把几年前的PDF转Word再转TXT结果文本里全是断行和乱码。向量化之后的Embedding质量差得离谱检索出来的片段经常是半句话。后来规规矩矩做了清洗同样的Embedding模型检索准确率提升的幅度肉眼可见。数据质量决定系统上限这句话在AI工程里是绝对真理。5.2 建立向量库索引构建全流程向量库的构建大概是整个项目里最“无脑”但最耗时的部分。流程就是对每一段文本调用Embedding接口拿到一个固定维度的向量比如1024维把所有向量按顺序堆叠成一个大的NumPy矩阵。但这里也有两个实践细节值得关注。第一个是批量处理。如果逐条调用Embedding接口百万级片段需要百万次请求时间成本不可接受。我实现的方案是按64条为一个批次批量提交大幅减少接口调用次数。第二个是向量归一化。在计算相似度之前先把所有向量归一化到单位向量这样点积就等于余弦相似度计算更简洁。这个细节看起来不起眼但能避免很多数值精度问题。class VectorStore: def __init__(self): self.vectors [] # 存储归一化后的向量 self.texts [] # 存储原始文本 def add_texts(self, text_list, embedder, batch_size64): for i in range(0, len(text_list), batch_size): batch text_list[i:ibatch_size] batch_vecs embedder.embed_batch(batch) for text, vec in zip(batch, batch_vecs): vec vec / np.linalg.norm(vec) self.vectors.append(vec) self.texts.append(text) self.vectors np.array(self.vectors) def search(self, query_vec, k5): query_vec query_vec / np.linalg.norm(query_vec) scores self.vectors query_vec top_indices np.argsort(scores)[::-1][:k] return [{ text: self.texts[i], score: float(scores[i]) } for i in top_indices]5.3 参数选择的三组对照实验很多人在配置RAG参数时是“拍脑袋”我的习惯是每改一个核心参数都跑一组对照实验来验证效果差异。拿文本切分的chunk_size举例我用同一份技术文档分别跑了三组参数参数组合检索召回效果生成回答质量响应时长chunk300, overlap30片段过碎关键信息被切散回答不完整有明显遗漏560mschunk500, overlap50检索准确率较高上下文完整回答完整且关键信息覆盖到位780mschunk800, overlap80片段过长无关信息增多回答有冗余偶尔跑题920ms这个结果并不是绝对的但在绝大多数通用文档场景下都有参考价值。我的结论是chunk_size不是越大越好也不是越小越好而是取决于文档的逻辑单元大小。技术文档的逻辑单元通常是“段落示例代码”500字左右刚好能覆盖一个完整知识点如果改成法律合同每个条款很长那就适合更大的chunk_size。6. 评估与调优没有度量就没有优化6.1 建立一套最小可行的评估集这个项目里我最自豪的一个设计是最小评估集。很多团队做RAG开发时效果好不好全凭“感觉”这是个非常大的隐患。我在项目里做了一个包含20条QA对的评估集覆盖了四类场景直接可答型文档里有现成答案、推理型需要组合多个片段推理、否定型文档里没有答案、边界型答案跨多个片段边界。每次修改任何一个模块我都用这个评估集全量跑一遍记录准确率变化。这个习惯拯救我太多次了。有一次我优化了文本切分策略自己测了两三个问题都觉得效果变好了结果一跑评估集否定型场景的准确率从80%掉到了40%。原因是新切分策略会让干扰文本更容易被检索进来模型开始“硬答”。如果我当时没有先建立评估集这个退化问题至少会晚两周才能被发现。6.2 召回质量判定的三个维度判断检索质量不能只看单个问题是否答对了我建议从三个维度分别评估相关度检索回来的片段是否与问题主题相关、完整度关键信息是否被完整召回、精度片段中有多少是无关信息。这三个指标对应三种典型问题——相关度差说明Embedding模型或查询改写有问题完整度差说明TopK太小或切分粒度不对精度差说明检索策略需要加粗排或过滤条件。我在每次调参之后会把检索结果打印出来做一次人工目检。虽然这个方法很“土”但机器无法替代的语义判断尤其是在信息层次比较丰富的场景里。建议每个调优周期至少做20次的人工目检形成基础的质量感知后再交给评估集自动化跑。6.3 从评估集到自动回归测试有了评估集之后下一步就是把它自动化。我在项目里写了一个简单的回归测试脚本每次改完代码自动跑一遍全部20条QA计算回答准确率和检索命中率如果准确率低于设定阈值就直接报错。这个流程的核心理念是把“效果保障”这个模糊目标转化为“可重复、可量化、可回归”的工程行为。这个思路和大厂里的CI/CD是一个道理。你可以先用20条评估集跑起来后续再逐步扩充到200条、2000条。评估集不是一次性的项目资产而是随着系统演进持续沉淀的知识库。我见过不少团队把RAG上线之后就再也不碰评估数据了结果模型升级、文档更新之后系统效果悄悄退化了大半年没人发现。7. 常见问题与避坑指南7.1 四个高频故障与排查手册我在整个开发过程中记录了大量故障案例这里整理出最高频的四类问题现象可能原因排查步骤检索结果与问题完全无关Embedding模型不适配领域换领域相关模型检查文档清洗质量回答明明有资料却答“不知道”检索到的片段未包含关键信息打印检索结果检查TopK与chunk_size答案编造资料里没有的内容Prompt没有强约束或temperature过高加强“仅根据资料回答”约束调低temperature回答结构混乱前后矛盾多个片段拼接顺序不合理尝试按相关度排序或按文档原文顺序排列每遇到这些问题我都会有一个“三板斧”排查顺序先看检索结果把TopK片段打印出来目检、再看Prompt拼接检查片段是否完整、顺序是否正确、最后才看模型调用参数。这个顺序对应的是系统信息流的方向数据进检索、检索进Prompt、Prompt进LLM逐层定位可以极大缩小排查范围。7.2 Token预算为什么你的成本总超预期Token是最容易让人忽略的成本黑洞。很多人以为RAG只是“多加了几个文本片段”对成本影响不大。实际上一个500字的chunk大约对应700个Token如果TopK5那么检索结果就要吃掉3500个Token再加上系统Prompt和问题本身一次请求的输入Token数可能高达4500。如果用户同时开了多轮对话历史记录还会让Token数进一步上涨。我分享一个计算方法每次RAG请求的预估输入Token数 系统Prompt长度 所有检索片段长度 用户问题长度 历史对话长度。你可以用这个公式提前估算成本而不是等月底账单出来才傻眼。另一个实用的策略是给历史对话设置一个窗口上限比如最多保留最近6轮超过就用摘要压缩否则对话一长成本会线性爆掉。7.3 匿名自查上线前问自己十个问题项目接近完成时建议大家回答以下十个问题如果任何一个是“否”建议先不要上线是否有至少20条覆盖不同场景的评估数据是否验证过无相关信息时的回答不会胡编是否检查过中文编码异常和乱码是否对长文档做过切分效果目检是否考虑过用户多轮对话的成本增长是否对模型版本做过锁定而不是用默认最新版是否有基础的日志记录能追踪每次请求的检索片段是否在更新知识库后做过全量回归测试是否对用户输入做过prompt注入的基础过滤是否对超时、限流、API报错做过兜底处理8. 进阶方向与扩展思考8.1 从单轮问答走向多轮对话的缓存策略项目基础版本只支持单轮问答但真实业务场景绝大多数是多轮对话。多轮对话最大的挑战是成本和记忆管理。我的建议是先做结果缓存对完全相同的用户问题直接把上一次的回答返回不再调用模型。这个方案实现成本极低但在客服、文档问答这类重复率高的场景可以省下30%以上的API调用。缓存命中率提升之后再考虑语义缓存对问题做Embedding相似度超过某个阈值就直接命中历史回答。这个方案能进一步压缩成本但要注意语义混淆的风险——两个看似相似的问题可能意图完全不同。我在试验语义缓存时吃过亏最后把相似度阈值从0.85提到0.93误命中率才降下来。8.2 本地模型与API的混合架构如果你想进一步降低成本可以考虑“本地小模型做检索重排、API大模型做生成”的混合架构。本地跑的Embedding模型和精排模型成本几乎为零只有最后生成答案时才调用大模型API。我在测试中发现用一个本地的小模型对检索结果做一次重排可以显著减少大模型的输入噪声甚至能一定程度替代调大TopK的暴力方案。这个混合架构的权衡点在于本地模型会消耗CPU/GPU资源和运维精力如果只是一个小项目API方案更省事。我的建议是日请求量低于1000时用纯API方案超过1000且成本压力明显时逐步引入本地模型。迁移路径在项目里已经预留好了改一行配置就行。8.3 下一步可以做评测自动化和主动学习进阶方向里最值得做的就是评估集自动化与主动学习闭环。自动化评估我前面已经提过主动学习是指每次用户觉得回答没用点踩或明确反馈系统自动把这条数据加入待标注队列定期人工确认后扩充进评估集。这样评估集会越来越贴近真实业务分布系统的优化方向也就越来越精准。这个思路和产品运营里的“用户反馈闭环”是一个逻辑但落地到AI工程里价值完全不同。没有反馈闭环的AI系统就像一个没有考试的学生学多久都不会进步。我见过太多团队在“调参数”上打转却很少在“收集高质量反馈数据”上下功夫这才是真正值得投入的方向。9. 项目心得与持续迭代建议整个项目做下来我最深的感受是AI工程最难的从来不是模型本身而是围绕模型搭建的那一圈工程能力。数据怎么清洗、片段怎么切、检索怎么优化、Prompt怎么管理、效果怎么评估这些才是决定了应用能不能落地的核心。很多人一上来就追最新的模型却忽略了“如何用好一个模型”这个基本功。这个项目从零搭建的全部代码加起来不到2000行但每一行背后都有它存在的理由。你现在看到的版本已经是第三个大版本了第一个版本我甚至想过直接调LangChain算了后来还是咬着牙坚持手写。回头看这个决定给我带来的收益远超预期——不仅让我能快速定位生产环境里的各种问题也让团队里的新人可以沿着这套代码快速建立起对RAG系统的完整认知。如果你也要从零开始类似的AI工程我的建议是先定一个小目标比如用一千条真实的领域问答数据把准确率从70%做到85%。这个过程中你会遇到论文里不会写、教程里不会教的无数个细节问题而恰是这些问题的解决过程构成了你真正的工程能力。项目做完之后也别停持续维护评估集、持续记录线上日志、持续分析失败的案例AI系统是一个需要持续养的东西。数据在变、模型在变、用户需求也在变只有你的评估和分析能力是复利增长的。