如果你刷技术社区,多半会看到ai-engineering-from-scratch这个项目名。乍一看像是又一份“30天学会AI”的教程清单,点进去才发现完全不是那回事:它从头到尾不让你用现成框架,而是把tokenizer、向量检索、RAG链路、Agent循环这些AI工程里的核心模块,一个一个用最底层的方式自己写出来。我花了三周完整跟了一遍,实话实说,这比市面上绝大多数AI入门课都硬核,它不教你怎么调API,它教你怎么理解API背后到底发生了什么。
这个项目解决的是很典型的痛点:很多同学会跑通LangChain的demo,会说“RAG就是把文档切块塞进向量库”,可真到生产环境,切块粒度怎么定、检索召回后怎么排序、模型输出格式不稳定怎么兜底,全都答不上来。适合谁呢?适合已经有Python基础、跑过一两个LLM调用、但对“AI工程”这四个字还停留在感觉层面的开发者。如果你想补上从“会调库”到“会设计”之间的那层能力,这篇文章就是我完整实操过程的全记录,包括我踩过的坑和调试思路。
1. 认清这个项目:不是教程,是拆解AI工程的练习场
1.1 为什么说它跟刷几个教程完全不一样
我们通常学AI工程的方式是“用”——用现成的embedding模型、用封装好的向量库、用编排框架把链条串起来。项目用得好不好,完全取决于对每个环节的理解深度。而这个项目反其道而行之,它把整个AI应用拆成一块一块的积木,每一块都要求你亲手拼一遍。
以RAG为例,常规做法是调一个Retriever接口就完事了。但这个项目会让你亲手做:文档怎么加载、文本怎么切分、向量怎么生成、索引怎么构建、检索时用什么相似度度量、召回结果怎么去重排序、最后怎么把上下文模板化地拼进Prompt。每一步的原理,全都要经历一遍“从零推导”的过程。
我的直观感受是,刷完一轮之后,再看别人的AI项目,眼睛会“毒”很多。别人说“我的RAG效果不好”,你能立刻追问:你是Embedding模型选型问题,还是切块策略问题,还是检索阈值设置问题?这种诊断能力,靠调用别人封装的接口是练不出来的。
1.2 适合谁,不适合谁
先说不适合:如果你只是想快速做出一个能聊天的AI助手,说实话不需要这个项目,用现成的服务几小时就能搞定。如果你对底层原理没有好奇,遇到报错只想搜索复制粘贴,那这项目大概率会让你劝退。
再说适合:适合那些已经能用现成工具跑通基本流程,但总觉得“虚”的人。适合那些在生产环境被诡异问题折磨过——比如效果不稳定、上下文长度不够用、Agent逻辑乱跑——却不知道根源在哪的工程师。也适合准备做AI应用架构设计的人,因为你在自己动手的过程中积累的判断力,会在设计系统选型时直接转化成决策质量。
我个人的建议是,这个项目别当课去刷,当成“拆解+重组”的练习场。就像学做菜,教程教你照着菜谱做一道鱼香肉丝,这项目是逼你先去理解“为什么肉丝要切那么细”“为什么锅要烧到冒烟再下油”,然后让你自己设计一套流程,把菜做出来。
2. 整体设计思路拆解:工程能力从哪里长出来
2.1 从外到内的三个层级
我把它拆成三层来理解。第一层叫“接口层”,就是你现在看到的那些开发框架、SDK、API,你写代码是跟这层打交道。第二层叫“机制层”,是框架背后做的事情,比如上下文管理、向量索引、模型调用时的参数控制。第三层叫“原理层”,是模型本身的机制,比如注意力机制、Token化逻辑。
大多数人学AI工程,永远停留在第一层,遇到解决不了的问题就加提示词、调参数碰运气。这个项目的思路是:你至少要把第二层彻底打通,能自己写出替代半熟框架的简化实现,同时要对第三层的关键机制有认知,这样才能在问题发生时准确归因。
打个比方:只会调用接口的人,像只会开自动挡车的人,日常通勤没问题,但车子一响就慌。做过这个项目的人,像学过汽车原理的人,至少知道发动机、变速箱、刹车系统各自在干什么,异响时能判断出大致的故障方向。
2.2 为什么坚持不碰框架
这是个好问题。明明LangChain、LlamaIndex这些框架能省下一大堆麻烦,为什么还要自虐?我的体会是:框架最大的价值是抽象,最大的风险也是抽象。框架帮你封装了太多细节,一旦封装的假设与你的真实场景不符,你就要花大量时间去“绕过框架”。
比如框架默认的文档切分器是按固定字符长度切的,如果你的文档是一堆代码文件,或者带有大量Markdown结构,默认切法就会把语义砍碎。这时候你去看框架文档,发现要传自定义splitter,但splitter的接口又套了一层又一层。如果你亲手写过切分逻辑,你会清楚知道:代码文件应该按函数切,Markdown应该按标题切,纯文本按段落加滑动窗口切,然后你直接用几行代码就实现了。
框架当然有用,但它的前提是你能分辨“哪些场景框架能罩得住”“哪些不能”。这个项目真正训练的就是这种分辨力。
2.3 模块如何串联成完整链路
说实话,拆开一个个模块并不难,难的是串起来。这项目也有意识地把模块设计成可以拼装的。一个有代表性的链路长这样:原始文档 -> 加载解析 -> 切块 -> 向量化 -> 构建索引 -> 用户查询向量化 -> 检索 -> 相似度排序与过滤 -> 拼接上下文 -> 组装Prompt -> 模型生成 -> 输出校验与重试。
每一步的输出和下一步的输入如何对接,是工程上最容易出问题的地方。比如向量化模块输出的维度不统一,索引模块构建时就容易报维度错误;检索模块返回的结果是按相似度降序的,但你要不要设置最低分阈值?要不要做MMR去重?这些看似小的决策,叠加起来就是系统效果差异的来源。
我建议你在动手时,不要急着追求模块独立运行,而是先把一条“通到模型输出”的最小链路贯通,再回头逐个模块优化。先让它跑起来,再让它跑好,这是工程迭代的基本节奏。
3. 核心模块的细节与实操要点
代码说明
我在复现过程中用到一种最接近底层实现的最小最小波索引结构(为便于理解,下面给出的是结构示意,不是生产级实现)。它展示了携带定向搜索能力的核心原理:```python class Tokenizer: definit(self, vocab_size=8000): self.vocab_size = vocab_size self.merges = {}
def train(self, corpus): # 统计相邻pair频率,反复合并最高频pair for step in range(self.vocab_size - 256): pairs = {} for word in corpus: symbols = word.split() for i in range(len(symbols)-1): pair = (symbols[i], symbols[i+1]) pairs[pair] = pairs.get(pair, 0) + 1 if not pairs: break best = max(pairs, key=pairs.get) self.merges[best] = ''.join(best) return self def encode(self, text): tokens = list(text) while True: pairs = [(tokens[i], tokens[i+1]) for i in range(len(tokens)-1)] best = None for pair in pairs: if pair in self.merges: if best is None or self.merges[pair] > self.merges.get(best, ''): best = pair if best is None: break tokens = self._merge_pair(tokens, best) return tokens这只是一个最小结构示例,但足够说明问题:tokenizer的核心不是“按空格切词”,而是通过语料统计学到高频片段,再把高频片段合并成新token。掌握了这个过程,你就能理解为什么有些tokenizer会把中文单字拆得很碎,为什么模型对罕见词的生成能力弱——因为它们根本不在词表里,只能被拆碎后拼回来。 ### 3.2 向量化与检索:把语义变成坐标 向量化的目标是让语义相近的文本在向量空间里距离相近。工作流的实现通常包括:加载文本、切块、对每块调用一个模型接口或本地模型生成向量,然后把向量和原始文本一起存储。这个项目建议你先别管花里胡哨的向量数据库,第一步直接用一个嵌入模型把向量算出来,再用一个简单的数组存下来。 检索的关键指标就两个:召回率和精确率。召回率指相关文档有没有被捞回来,精确率指捞回来的东西里多少是相关的。你可能需要调Embedding模型的版本、查询提示词、检索TopK,以及是否做相似度阈值过滤。这些参数没有一个放之四海而皆准的组合,都得靠你的场景数据来试。 我踩过一个比较深的坑:一开始直接用一个通用Embedding模型处理技术文档,检索质量奇差。后来换了一个经过领域微调的模型,同一个查询的召回效果立刻提升明显。我的建议是,先花点小钱试几个模型,看它们的实际检索效果再定,不要只看榜单分数。 ### 3.3 RAG链路:检索、拼接、生成的完整闭环 RAG链路并不是简单把检索结果贴到Prompt里就完事。完整的链路应该这样设计: - 对用户输入做改写或替换,如果原始提问太短,先用上一轮对话上下文把它补全。 - 对改写后的查询做向量检索,拿到TopK候选文本块。 - 做一个“相关性后置判断”,比如计算相似度分布,把分布异常的块过滤掉。 - 设置一个“用户输入是否与检索结果相关的开关”,如果相关性都很低,就应该明确让模型说“不知道”,而不是硬编一个答案。 - 把阈值过滤后的文本块拼接进Prompt模板,在模板里标注哪些是参考内容,哪些是用户问题。 - 调用模型生成。生成时如果有引用标注需求,要在Prompt里定义清晰的引用格式。 这个链路里,每一步都在为“结果可信”服务。我自己的感受是,80%的RAG问题出在“该拒绝回答的时候没拒绝”和“上下文里噪声太多”这两件事上,重检、过滤这两步不能省。 ### 3.4 Agent循环:让模型学会自己决定下一步 如果说RAG是“搜完再答”,Agent就是“边想边做”。一个最基础的Agent循环包含四个环节:理解任务、选择工具、执行动作、观察结果。这四个环节循环迭代,直到模型判断任务完成。 循环本身不复杂,复杂的是在“选择工具”这一环。你给模型暴露哪些工具,工具的描述写得好不好,直接决定了Agent能力的天花板。工具描述要写清楚“这个工具什么时候用、输入是什么、输出是什么、有什么限制”,否则模型就会瞎选。 很多人在这一步容易陷入“想让模型自己搞定一切”的执念。我的经验是反过来:先给Agent设计固定的步骤模板,再慢慢放开自由决策的空间。比如第一步永远是“解析用户意图并列出可用工具”,第二步“执行工具调用并捕捉异常”,第三步“汇总结果生成最终答复”。等这套框架稳定了,再去尝试让模型自由编排步骤。你把控得越少,越容易失控,这是Agent工程里最本质的规律。 ## 4. 实操过程与关键环节记录 ### 4.1 环境搭建与技术选型 我的环境配置供你参考:Python 3.10,一个支持3.10的版本管理器,虚拟环境单独隔离项目依赖。主流程里用到的基础库尽量少,因为项目要求从零实现,我甚至把向量索引都用自己的数据结构模拟了第一版。 模型方面,我用的是开放模型平台提供的一个轻量Embedding接口和一个小尺寸的对话模型接口,因为本地跑大模型对显存要求太高,实践时会分散精力。但这也引出一个实用的心态:从零写工程逻辑不代表不用外部模型服务,真正的从零对象是工程结构本身:链路、数据流、控制逻辑。 选型上有个大原则:工程逻辑自己写,模型调用用已有的服务。这样既能学到核心知识,又不会被环境问题卡死。 ### 4.2 按什么顺序做,每步要盯住什么 我是按这个顺序推进的,每个节点都有一条验收标准: - 做Tokenizer训练和编解码:验收标准是“能反向还原原文的信息,且token数量比原始字符数有明显压缩”。 - 做文本加载与切分:验收标准是“代码文件按函数切分,Markdown按标题层级切分,切块之间保留合理重叠”。 - 做向量索引:验收标准是“查询向量能和正确的文本块匹配上,检索延迟可接受”。 - 做提示词模板与生成调用:验收标准是“模型能稳定按模板输出,不会跑偏格式”。 - 做RAG闭环:验收标准是“用户提问后,能拿真实文档内容生成有依据的回答”。 - 做Agent循环:验收标准是“给它一个多步任务,它能顺次调用工具并汇总结果”。 - 做评估集:验收标准是“至少准备20组输入,能自动判断每次回答的好坏”。 很多人在Tokenizer和索引上一直打磨死磕细节,迟迟不进入RAG链路。其实第一遍不用追求完美,先跑通链路,等有整体感知了再回头优化。这个“先跑通再优化”的节奏特别重要。 ### 4.3 几个关键参数的取舍 这个项目里经验值很重要。我记录几个踩过之后沉淀下来的参数经验: - 切块大小:中文技术文档我常用256个字符左右,重叠32-64个字符。代码文件按函数切,不按字符切。 - 检索TopK:先取5到10,再根据评测集调。TopK太小会漏掉关键信息,太大会给模型带去噪声。 - 相似度阈值:没有万能数值,我的经验是先跑一遍评测集,统计“答对时的相似度分布”和“答错时的相似度分布”,选分界点做阈值。 - 重试次数:调用模型接口做Agent工具调用时,我设置最多重试3次,超过就放弃并返回兜底话术。 - 温度参数:普通问答设0.2到0.5,Agent工具调用设0,因为工具参数解析错了会连锁出错。 这些参数没有标准答案,关键是你要有一套评测方式,能客观判断参数是不是更优。我自己做了一个20条问题的评测集,每次改参数就跑一遍,用正确率说话。 ## 5. 高频问题排查与避坑技巧 ### 5.1 最容易翻车的四个地方 第一,上下文被截断。图片来源是从网上抓来的长文本,切块时没有考虑后续拼接长度,结果拼接后的Prompt超过了模型上下文限制。解决办法是给拼接后的内容做一个硬长度限制,超长时按重要度裁剪,而不是直接截断尾部。 第二,输出格式不稳定。尤其在从零实现Agent时,模型返回的JSON经常多一个换行或少一个引号。我后来在Prompt模板里给了严格示例,而且要求模型“只输出JSON,不要任何解释”,并在代码里做了容错解析,先正则抽取大括号再解析。 第三,检索到的内容互相矛盾。多个文档对同一问题说法不一致时,模型会无所适从。我做过一个去重和“投票排序”的逻辑:让检索结果里相似度过高的块只保留一个,再把多个块的结论汇总,让模型做“找出多数意见”的二次提炼。 第四,召回不到东西。这种问题通常是切块太小、查询改写不够、Embedding模型不合适,三者之一。排查思路是:先用同一段原文做查询,如果相似度都很低,问题在Embedding模型;如果能召回但召回的块不对,问题在切块;如果召回正确但最终回答不好,问题在Prompt拼接。 ### 5.2 问题排查速查表 下表中总结了我在实际操作中最常遇到的问题,供你快速对照: | 现象 | 最常见原因 | 处理思路 | |---|---|---| | 回答与检索内容无关 | Prompt模板中上下文定位不清 | 把Prompt改写为“仅基于参考资料回答,参考资料不足以回答时明确说明” | | Agent一直循环调用同一工具 | 工具调用后返回的观察结果没有更新 | 检查是否把每次观察结果都追加到对话状态里 | | 相同问题两次回答不一致 | 温度过高或检索结果不稳定 | 调低温度、固定检索逻辑、增加排序缓存 | | 检索出现大量无用块 | 切块粒度不当或相似度阈值过低 | 查看检索块分布,按阈值分界点重新过滤 | | 中文文档切块后语义被切碎 | 按字符随机切分导致上下文断裂 | 优先按段落、标题切块,必要时做重叠窗口 | | 模型输出JSON总解析失败 | 提示词中没有给严格示例 | 给出单行JSON示例,禁止返回Markdown代码块标记 | ### 5.3 随手可用的调试技巧 我强烈建议你在开发AI工程时养成“记录过程数据”的习惯。比如每次检索后把Top10的相似度得分打出来,每次模型调用后把Prompt和Completion存一份日志。别嫌麻烦,这些数据在你排查问题时价值巨大。 另一个好用的技巧是“最小复现法”。当你觉得链路哪里不对劲,先写一段固定输入,不走完整业务逻辑,直接手动调用检索模块、手动构造Prompt、手动调模型,看看哪一步开始不对劲。这样能把“整个链路坏了”缩小成“某个环节坏了”。 还有一个经验:在把LLM接入主业务之前,先用一个假模型(返回固定文本的模拟函数)把整个工程链路跑通。这个假模型能暴露工程结构上的问题,而不用每次都被模型输出的不确定性干扰。等链路确认没问题了,再换成真模型逐步调。 ## 6. 工具选型与后续扩展思路 ### 6.1 从零造轮子和用成熟框架的边界 做完这个项目后,你可能会陷入“框架无用论”。我得说句公道话:框架一定是好东西,关键是你得带着“我知道你在帮我做什么”的意识去用它。 我的边界判断标准很简单:如果某个环节的失败模式我们见过、原因清晰、而且成熟的框架行为和我们手写实现吻合,那就直接用框架;如果某个环节是业务核心、需要深度定制,就保留自己的实现。 举例来说,向量存储我用成熟库没问题,因为这部分要不就是在做工程封装,要不就是在做性能优化,和我们手写版本的逻辑一样。但Prompt拼接和Agent控制流程我倾向保留自研,因为这是业务差异最大的地方,也是我们从零学习中真正沉淀下来的资产。用框架的核心原则是:你明白它在做什么,也知道自己为什么选它。 ### 6.2 基于这个项目的扩展方向 这个项目只是起点,后续可以扩向很多方向。最直接的是做“自建的本地知识库问答系统”,把你手写的切分、检索、Prompt逻辑应用到一个真实业务场景里。然后再往下走,你会遇到多用户会话隔离、数据增量更新、缓存设计、安全过滤、成本控制这些问题,全都是在实战中真刀真枪锻炼工程能力的机会。 更深的扩展方向是“评估驱动开发”:把评测集自动化,每次改动都跑一批测试用例,用分数决定是否合入改动。你会发现,一旦有了这套测试机制,你对代码改动的信心会大幅提升。 ## 7. 写在最后:一点个人体会 我花了大量篇幅讲参数、讲排查、讲步骤,但真正想放到最后说的,是你做这整个项目时的心态。全过程不全是“顿悟”时刻,更多时候是卡在一个诡异问题上反复查日志、打日志、看数据,直到某个瞬间发现原来是索引维度没对齐,或者是一个加号漏了才导致检索结果错乱。 个人体会是:AI工程的高墙不在模型有多难调,而在所有模块之间的缝隙里。那些缝隙,才是真正区分“会调用AI”和“会做AI工程”的地方。这个项目逼着我亲手把每一个缝隙摸了一遍,等再回头看那些成熟框架时,我不再是一个只会用接口的人,而是一个能评判接口设计带来的后果的人。如果你也想在AI这个领域沉下心,我建议你挑一个周末,关掉教程,自己从第一行代码开始写。别怕慢,慢才是这一路最值得的代价。