☰
从零手搓AI工程:告别调包,掌握核心链路与排查能力
2026/11/3 13:07:45 网站建设 项目流程

1. 从零搭建AI工程能力:为什么“手搓”比调包更值得投入

这两年AI应用层的工具链成熟得吓人,LangChain、LlamaIndex、各种Agent框架几乎把“调用大模型”这件事压缩成了几行代码。但我观察到一个挺有意思的现象:越是能快速跑通Demo的人,越容易在真实项目里翻车。原因不复杂——框架帮你屏蔽了细节,也顺便屏蔽了你对细节的理解。一旦线上出现幻觉失控、检索召回率暴跌、Token成本失控,你连从哪一层开始排查都不知道。

ai-engineering-from-scratch这个方向,说白了就是反其道而行:不急着上框架,先把AI工程里那些绕不开的核心环节,用最原始的方式亲手实现一遍。它解决的不是“怎么快速做个聊天机器人”,而是“当聊天机器人不工作时,你知不知道问题出在哪”。适合谁看?适合已经会用API调模型、但总觉得自己在“黑盒上跳舞”的开发者,也适合想从传统后端/算法岗转向AI工程岗、需要补全工程直觉的人。

我自己走过这条路,最开始也是pip install一把梭,后来被一个检索精度问题折磨了整整两天,才发现问题出在文本分块的边界处理上——而那个分块逻辑,框架里是封装好的,我从来没看过。从那以后我就养成了一个习惯:任何核心链路,先用最朴素的方式实现一版,再决定要不要换框架。这篇文章就把这套“从零搭建”的思路和实操细节完整拆开讲。

2. 整体设计思路:把AI工程拆成可独立验证的积木

2.1 为什么选择“从零实现”而不是“直接上框架”

先说清楚一个前提:从零实现不等于拒绝框架。它的真正价值在于建立分层认知。一个典型的AI应用,从用户输入到最终输出,中间至少经过这几层:输入预处理、检索/工具调用、上下文组装、模型推理、输出后处理。框架把这几层揉成了一个chain,你调用的时候是一整条,出问题的时候也是一整条,很难定位。

从零实现的核心思路是:每一层都做成可单独测试、可单独替换的模块。比如检索层,你先用最朴素的余弦相似度实现一版,跑通了、知道它的瓶颈在哪了,再换成向量数据库。这样你对“为什么要用向量数据库”是有体感的,而不是因为教程这么说。

另一个考量是成本可控。框架往往默认帮你做很多事,比如自动重试、自动截断、自动加系统提示词,这些都会消耗Token。从零实现时你会清楚地知道每一次API调用花了多少钱,哪些调用是必要的,哪些是框架的“隐形开销”。我实测过一个场景,同样的任务,去掉框架的自动重试和冗余提示词后,Token消耗降了将近四成。

2.2 模块划分与依赖关系

我习惯把整个系统拆成五个核心模块,依赖关系是单向的,从下往上:

  • 基础层:模型调用封装、Token计数、重试与超时控制
  • 数据层:文档加载、文本分块、向量化、向量存储与检索
  • 编排层:提示词模板、上下文组装、多轮对话状态管理
  • 应用层:具体业务逻辑,比如问答、摘要、分类
  • 观测层:日志、指标、链路追踪

这个划分的好处是,每一层都可以独立测试。比如你想验证分块策略对检索效果的影响,只需要替换数据层里的分块模块,其他层完全不用动。这种“可插拔”的设计,是从零实现最大的工程收益。

提示:不要一上来就追求模块划分完美。我最初把编排层和应用层混在一起,后来发现改一个提示词要动业务代码,非常痛苦。建议第一版就先按“数据进、模型出”的粗粒度分,跑通后再细化。

2.3 技术选型的取舍逻辑

从零实现时,选型的原则是“最小依赖”。能用标准库就不装第三方包,能用一个包解决就不装两个。比如向量化,早期我直接用numpy手写余弦相似度,数据量小的时候完全够用,而且调试起来一目了然。等到数据量上万条、检索延迟明显了,再引入faiss或向量数据库。

模型调用这块,我建议先封装一个统一的LLMClient接口,把不同厂商的API差异屏蔽掉。这样后面换模型时,业务代码不用改。接口设计上至少包含三个方法:complete(单轮补全)、chat(多轮对话)、embed(向量化)。参数上暴露temperature、max_tokens、top_p这几个最常用的就行,别一上来就封装几十个参数,用不到还增加理解成本。

3. 核心细节解析:那些框架不会告诉你的关键点

3.1 文本分块:检索质量的隐形杀手

文本分块看起来简单,实际上是最容易埋坑的地方。框架通常提供RecursiveCharacterTextSplitter这类工具,默认按字符数切,但默认参数往往不适合你的数据。我踩过的坑是:按固定字符数切分,结果把一句完整的话从中间切断,检索出来的片段语义不完整,模型拿到后答非所问。

正确的做法是按语义边界切分。具体操作上,我通常按这个优先级处理:先按段落切,段落太长再按句子切,句子还太长才按字符切。句子切分可以用简单的标点规则,中文按。!?,英文按.!?,注意处理省略号和引号的情况。

分块大小也需要根据场景调。做问答检索时,块大小建议在200到500字之间,太小则上下文不足,太大则噪声多、检索精度下降。还有一个技巧是重叠切分,相邻块之间保留10%到20%的重叠内容,避免关键信息刚好落在边界上被切断。我实测下来,重叠比例设在15%左右,召回率和精度比较平衡。

注意:分块前一定要做文本清洗。PDF提取的文本经常有多余换行、页眉页脚、乱码字符,这些噪声会严重影响向量化质量。我一般会先做一轮正则清洗,去掉连续空白、特殊符号,再进入分块流程。

3.2 向量化与相似度计算:别迷信“越大越好”

向量化的核心是选模型。常见的有两类:一类是通用文本嵌入模型,一类是专门针对检索优化的模型。选型时不要只看维度,维度高不代表效果好。我试过用高维模型做短文本检索,效果反而不如中等维度的检索专用模型,原因是高维空间里短文本的区分度反而被稀释了。

相似度计算上,余弦相似度是最常用的,但它有个前提:向量要归一化。如果忘了归一化,点积的结果会受向量模长影响,导致长文本天然得分高。我建议在向量入库前统一做L2归一化,这样后续用点积或余弦都等价,计算还更快。

还有一个容易被忽略的点是查询改写。用户的问题往往很短,直接拿去检索效果一般。我通常会在检索前做一步查询扩展,比如用模型把问题改写成几个相关表述,分别检索后合并结果。这一步能明显提升召回率,代价是多几次模型调用,需要权衡。

3.3 上下文组装:Token预算的精细管理

上下文组装是把检索结果、对话历史、系统提示词拼成一个完整提示词的过程。这里最大的约束是模型的上下文窗口。很多人直接把检索到的所有内容塞进去,结果要么超限被截断,要么Token成本爆炸。

我的做法是先算预算再组装。假设模型窗口是8K Token,系统提示词占500,对话历史占1000,输出预留1000,那么留给检索内容的只有5500。然后按相关性排序,从高到低填充,填满为止。这样既保证不超限,又保证最相关的内容一定在。

对话历史的管理也有讲究。不是所有历史都需要保留,我通常只保留最近几轮,更早的做摘要压缩。摘要用模型生成,控制在200字以内,放在历史最前面。这样既保留了长期上下文,又不会占用太多窗口。

4. 实操过程:从零搭建一个可运行的问答系统

4.1 环境准备与基础封装

先建一个干净的项目结构,我习惯这样组织:

ai-from-scratch/ core/ llm_client.py token_counter.py data/ loader.py splitter.py embedder.py store.py pipeline/ retriever.py assembler.py qa.py config.py

llm_client.py里封装统一调用接口,关键是加超时和重试。超时设30秒,重试最多3次,每次间隔指数退避。重试只针对网络错误和限流错误,参数错误不重试,否则会浪费调用次数。

token_counter.py用tiktoken做精确计数,不同模型对应不同的编码器,这个要对应好。计数不准会导致预算算错,要么超限要么浪费。

4.2 数据加载与分块实现

加载器要支持多种格式,我至少实现了纯文本、Markdown、PDF三种。PDF用pypdf提取,注意处理加密和扫描件的情况,扫描件需要走OCR,这个另说。

分块器我写了一个SemanticSplitter,核心逻辑是:

def split(text, max_size=400, overlap=60): paragraphs = text.split('\n\n') chunks = [] current = '' for p in paragraphs: if len(current) + len(p) <= max_size: current += p + '\n\n' else: if current: chunks.append(current.strip()) if len(p) > max_size: sentences = split_sentences(p) # 按句子累积,超限则切分 ... else: current = p + '\n\n' if current: chunks.append(current.strip()) # 添加重叠 return add_overlap(chunks, overlap)

这段代码的关键是先按段落、再按句子的降级策略,以及最后的重叠处理。重叠不是简单复制,而是取前一块的尾部若干字符拼到后一块开头,保证语义连续。

4.3 向量化与检索实现

向量化模块先做归一化,再存成numpy数组。检索时计算查询向量与所有文档向量的点积,取Top-K。数据量小的时候这样完全够用,我实测一万条以内,单次检索在50毫秒左右。

def retrieve(query_vec, doc_vecs, top_k=5): scores = doc_vecs @ query_vec # 已归一化,点积即余弦 idx = np.argsort(scores)[::-1][:top_k] return [(i, scores[i]) for i in idx]

这里有个优化点:如果文档量大,可以先用faiss建索引,把暴力检索换成近似检索。但近似检索有精度损失,需要评估召回率是否可接受。我的经验是,数据量超过五万条再考虑换,否则暴力检索的精度优势更值得保留。

4.4 提示词组装与模型调用

组装器负责把检索结果、历史、系统提示拼成最终提示词。我用的模板大致是:

[系统] 你是一个基于给定资料回答问题的助手。只使用资料中的信息,资料中没有的不要编造。 [历史摘要] {summary} [最近对话] {recent_turns} [参考资料] {context} [用户问题] {question}

关键约束是“只使用资料中的信息”,这一句能显著降低幻觉。但要注意,如果检索没召回相关内容,模型会直接说“资料中没有”,这时候需要有个兜底策略,比如提示用户换个问法,或者触发联网搜索(如果业务允许)。

模型调用时,temperature设0.1到0.3之间,问答场景不需要太高的创造性。max_tokens根据预期答案长度设,一般500到1000够用。

5. 常见问题与排查技巧实录

5.1 检索召回不准的排查路径

召回不准是最常见的问题,排查要按顺序来。先看分块是否合理,把检索到的块打印出来,人工判断语义是否完整。如果块本身就不完整,问题在分块层。再看向量化模型是否适合你的领域,通用模型在专业领域可能表现差,需要换领域微调过的模型。最后看查询本身,用户问题太短或太模糊时,加查询改写。

我整理了一个速查表:

现象可能原因排查方法解决方向
召回内容不相关分块过大含噪声打印块内容人工检查减小块大小,增加重叠
召回内容不完整分块切断语义检查块边界改按语义边界切分
相关文档没召回向量模型不匹配换模型对比测试用领域适配的嵌入模型
短查询效果差查询信息量不足对比改写前后加查询扩展

5.2 Token超限与成本失控的处理

Token超限通常是因为上下文组装没做预算控制。解决方法是先算可用预算,再按相关性填充。成本失控则要检查是否有不必要的调用,比如重复检索、冗余的模型调用。我建议加一个调用日志,记录每次调用的输入输出Token数和费用,定期review。

提示:模型调用失败重试时,要确保不会重复计费。有些厂商的重试是重新计费的,这个要在封装层做好幂等控制。

5.3 模型输出不稳定的应对

输出不稳定表现为同样的问题有时答得好有时答得差。原因通常是temperature太高,或者检索结果波动。先把temperature降到0.1,如果还不行,检查检索是否稳定。检索不稳定往往是向量化或索引的问题,比如归一化没做、索引没更新。

还有一个隐蔽原因是提示词里的示例(few-shot)顺序影响输出。如果用了few-shot,示例的顺序和数量都会影响结果,建议固定下来,不要随机打乱。

6. 从零实现后的进阶方向

把基础链路跑通后,你会对每个环节的瓶颈有清晰认知,这时候再引入框架或优化就有针对性了。比如检索慢,你知道是向量计算的问题,可以换faiss;上下文组装复杂,可以引入模板引擎;多轮对话状态难管理,可以上状态机。

我个人的体会是,从零实现最大的收获不是代码本身,而是排查问题的能力。当系统出问题时,你能沿着数据流一层层往下查,而不是对着框架的报错发呆。这种能力在真实项目里比会调几个API值钱得多。

后续可以扩展的方向包括:加入缓存层减少重复计算、做A/B测试对比不同分块策略、引入评估指标量化检索和生成质量。每一步扩展都建立在你对基础链路的理解之上,不会变成“为了用而用”。

最后分享一个小技巧:每次改动核心模块后,跑一遍固定的测试集,记录检索命中率和答案质量分。这样你能清楚知道每次改动是变好了还是变差了,避免凭感觉优化。我维护了一个50条问题的测试集,覆盖常见和边界场景,每次改动后跑一遍,几分钟就能得到反馈。这个习惯帮我避免了好几次“以为优化了实际退步”的情况。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询