从零手搓AI工程核心链路:检索、向量化与工具调用实战
1. 从零搭建AI工程能力为什么“手搓”比调包更值得投入这两年AI应用层的工具链成熟得吓人LangChain、LlamaIndex、各种Agent框架几乎把能封装的都封装了。打开文档三行代码就能跑通一个RAG问答复制一段示例十分钟就能搭出一个能调用工具的智能体。表面上看门槛被踩到了地板以下但我自己带过几批人之后发现一个很尴尬的现象很多人能跑通Demo却说不清楚一次检索到底发生了什么向量是怎么算出来的上下文窗口被谁吃掉了模型返回的JSON为什么偶尔会崩。一旦线上出问题排查基本靠猜。这就是“ai-engineering-from-scratch”这个方向真正的价值所在。它不是让你拒绝框架而是让你在依赖框架之前先把AI工程里那几个最核心的零件亲手做一遍。做完之后你再看那些封装好的库会有一种“哦原来你帮我干的是这件事”的踏实感。这篇文章面向的是有一定Python基础、想真正搞懂AI应用底层运转逻辑的开发者也适合那些被框架黑盒坑过、想补一补内功的人。我会把整个从零搭建的路径拆成可复现的步骤包括关键参数怎么算、坑在哪里、哪些地方可以偷懒、哪些地方绝对不能省。先说清楚一个前提从零不等于从原始论文开始手推反向传播。那是研究员的活。AI工程师的“从零”指的是不依赖高层封装用最基础的库把数据流、模型调用、检索、编排这几条主线自己串起来。你依然可以用NumPy、可以用HTTP请求、可以用现成的推理接口但中间那层“魔法”要由你自己写。这个定位很重要它决定了你花的时间是投在工程能力上而不是投在数学证明上。我个人的判断是一个合格的AI工程师至少要能手写实现四样东西一个能跑的文本切分与向量化流程、一个不依赖框架的向量检索、一个带工具调用的对话循环、一个能观测token消耗和延迟的简易监控。这四样覆盖了当前绝大多数AI应用的核心链路。下面我就按这个顺序把每一块的设计思路、实操细节和踩坑经验摊开讲。2. 核心链路的整体设计与选型思路2.1 为什么先做检索而不是先做模型微调很多人一上来就想微调模型觉得那才叫“硬核”。但从工程投入产出比看检索增强是绝大多数场景的第一选择原因有三。第一微调需要标注数据而检索只需要你已有的文档冷启动成本低得多。第二微调后的模型知识是“冻结”的文档一更新就得重训而检索的索引可以随时重建。第三微调容易让模型在特定任务上过拟合泛化能力下降而检索把“知识”和“能力”解耦了模型负责推理和表达索引负责提供事实。所以从零搭建的第一站我建议放在检索链路上。这条链路拆开看就是文档加载、文本切分、向量化、向量存储、相似度检索、结果拼装。每一环都有坑而且每一环的坑都会在后面被放大。比如切分没做好检索出来的片段语义不完整模型再强也答不对向量模型选错了中文语义相似度算出来一塌糊涂后面全白搭。2.2 技术选型的三个硬约束从零做不代表什么都自己造。选型时我给自己定了三条约束。第一核心逻辑必须自己写比如切分算法、相似度计算、检索排序这些是理解链路的关键不能调包。第二重活可以借力比如向量化用现成的embedding接口向量存储先用NumPy数组顶着没必要一上来就上专业向量库。第三所有中间结果必须可打印、可检查这是从零搭建最大的好处——你能看到每一步的输入输出而不是被框架吞掉。基于这三条我的最小技术栈是这样的Python标准库处理文件和文本NumPy做向量运算一个embedding接口做向量化一个简单的JSON文件或内存列表做存储HTTP请求直接调对话模型。整套下来依赖极少但链路完整。等你把这套跑通再换成专业向量库和编排框架你会清楚每个替换动作到底换掉了什么。2.3 整体数据流的鸟瞰在动手前先把数据流在脑子里过一遍。用户提问进来先经过查询改写可选然后被同一个embedding模型向量化得到一个查询向量。这个向量和索引里所有文档向量算相似度取Top-K个片段。这些片段按相似度排序后和原始问题一起拼成一个提示词塞进对话模型的上下文。模型生成回答同时我们记录这次调用的token数和耗时。如果涉及工具调用模型会返回一个结构化的调用意图我们解析后执行对应函数把结果再喂回模型形成第二轮对话。这条流里查询向量和文档向量必须来自同一个模型这是铁律换模型就得重建整个索引。相似度计算我一般用余弦相似度因为它对向量长度不敏感适合文本语义比较。Top-K的K值需要根据上下文窗口和片段长度反推不能拍脑袋定。这些细节后面会展开。3. 文本切分与向量化最容易被低估的环节3.1 切分粒度到底怎么定文本切分看着简单实则决定了检索质量的上限。切太大一个片段里混了好几个主题检索时相似度被稀释模型拿到一堆无关信息切太小语义碎片化模型拼不出完整答案。我的经验是中文场景下每个片段控制在300到500字比较稳英文大概200到400个token。但这个数字不是死的要看文档类型。技术文档结构清晰可以按标题层级切对话记录适合按轮次切长篇文章就得用滑动窗口加重叠。重叠overlap这个参数很多人忽略但它很关键。如果两个相邻片段完全不重叠正好在边界处的信息就会被切断检索时两边都匹配不上。我一般设置重叠为片段长度的10%到20%。比如片段500字重叠就设50到100字。这样边界信息至少会完整出现在其中一个片段里。代价是索引体积变大但对检索召回率的提升很值。具体实现上我不用现成的文本分割器而是自己写一个基于标点和长度的切分函数。逻辑是先按段落分段落超长再按句子分句子还超长才硬切。切的时候维护一个缓冲区达到目标长度就吐出一个片段同时保留尾部一部分作为下一个片段的开头。这个逻辑不到五十行代码但你能完全掌控切分行为出问题也好调。3.2 向量化模型的挑选与验证向量化模型的选择直接决定检索的天花板。市面上模型很多但挑选时我只看三个指标语义区分度、维度、推理成本。语义区分度是核心简单测法就是拿几组语义相近和不相近的句子算它们的余弦相似度看相近的能不能明显高于不相近的。如果相近和不相近的分数挤在一起这个模型就不能用。维度不是越高越好。高维度理论上能表达更多信息但也会放大噪声而且存储和计算成本线性上升。我实测下来中文场景768维到1024维是比较舒服的区间。低于512维语义区分度往往不够高于1536维收益递减明显除非你的文档量极大且语义极其细腻。还有一个坑是模型的语言偏向。有些模型英文很强中文一塌糊涂因为训练语料里中文占比低。验证方法很简单拿一组中文同义句和一组中文无关句看相似度分布。如果同义句相似度只有0.6出头无关句也有0.4那区分度就不行。好的模型同义句应该在0.85以上无关句在0.3以下。3.3 向量化的批量处理与缓存向量化是整条链路里最慢也最贵的一步必须做批量和缓存。批量是指一次请求处理多条文本而不是一条一条调。大多数embedding接口都支持批量一次传几十条没问题。批量大小要看接口限制和内存我一般设32或64太大容易超时太小浪费往返时间。缓存更重要。文档索引一旦建好除非文档更新否则不需要重新向量化。所以我会把每个片段的文本哈希和对应向量存下来下次启动直接加载。哈希用文本内容的MD5或SHA256只要文本没变哈希就不变直接命中缓存。这样重建索引时只有新增或修改的片段需要重新算向量速度能快一个数量级。这里有个细节缓存要跟模型绑定。如果你换了embedding模型旧缓存必须全部失效因为同一个文本在不同模型下的向量完全不可比。我的做法是在缓存文件名里带上模型标识换模型就换缓存文件避免混用。4. 不依赖框架的向量检索实现4.1 余弦相似度的计算与优化余弦相似度的公式很简单就是两个向量点积除以它们模长的乘积。但真到实现层面有几个点要注意。第一如果向量已经归一化模长为1那余弦相似度就等于点积省掉除法快很多。所以我在向量化之后会统一做一次归一化存进去的就是单位向量。第二批量计算时不要用循环用矩阵运算。把所有文档向量堆成一个矩阵查询向量和这个矩阵做一次矩阵乘法就能得到所有相似度NumPy一行搞定。第三要注意数值稳定性。浮点数运算偶尔会出现模长极小导致除零的情况虽然归一化后基本不会但保险起见还是加一个极小值epsilon。另外如果向量里出现NaN整个相似度计算就废了所以向量化后要检查有没有NaN有的话得排查是模型问题还是文本问题。4.2 Top-K检索与阈值过滤算出相似度后取Top-K有两种做法。一种是直接排序取前K个另一种是用堆维护前K个。文档量小的时候排序无所谓几万条以上堆会更省内存。但更关键的是阈值过滤。光取Top-K有个问题如果用户问的问题和文档完全不相关Top-K里依然会返回K个片段只是相似度都很低。这些低质量片段塞进上下文会干扰模型甚至诱导它编造答案。所以我会设一个相似度下限比如0.5或0.6低于这个值的片段直接丢弃。如果过滤后一个都不剩就明确告诉模型“没有找到相关资料”让它基于常识回答或者直接说不知道。这个阈值需要根据你的embedding模型和文档特点调没有通用值。调法是拿一批已知相关和已知不相关的问题跑一遍看相似度分布找一个能分开两类的点。4.3 检索结果的去重与重排Top-K里经常出现内容高度相似的片段尤其是文档里有重复表述的时候。这些重复片段占着上下文位置浪费token。所以检索后要做去重。简单做法是算片段之间的相似度超过某个阈值的只保留一个。更省事的做法是看文本本身的重复度比如前N个字符是否高度重合。去重之后还可以做重排。重排是指用一个更精细的模型对Top-K结果重新打分排序。这个模型可以比embedding模型更重因为它只处理少量候选。重排能显著提升相关性但会增加延迟。我的建议是如果Top-K本身质量还行可以先不做重排如果发现检索结果经常“差一点”再考虑加。从零搭建阶段先把基础检索跑稳重排作为后续优化项。5. 对话循环与工具调用的手写实现5.1 消息结构的组织对话模型接收的是一个消息列表每条消息有角色和内容。角色一般有system、user、assistant、tool几种。system消息放全局指令比如“你是一个严谨的助手只基于提供的资料回答”。user消息是用户输入。assistant消息是模型之前的回复。tool消息是工具执行的结果。这个结构必须严格维护顺序错了模型会懵。我见过有人把所有内容拼成一个大字符串塞进user消息短期能跑但一旦涉及多轮对话和工具调用就乱套了。正确做法是维护一个消息列表每轮往里面追加。system消息放最前面然后按时间顺序排user和assistant。工具调用时assistant消息里会带一个tool_calls字段说明它想调哪个工具、传什么参数我们执行完把结果作为tool消息追加进去角色是tool还要带上对应的tool_call_id让模型知道这是对哪次调用的回应。5.2 工具调用的解析与执行工具调用的本质是让模型输出一个结构化的意图而不是自然语言。所以我们要在system提示里明确告诉模型有哪些工具可用、每个工具的参数格式是什么。模型返回时如果它决定调用工具会在assistant消息里给出tool_calls数组每个元素包含函数名和参数通常是JSON字符串。解析这一步要防御性编程。模型偶尔会返回格式不对的JSON比如多了个逗号、少了引号。所以解析要包在try里失败就告诉模型“你的调用格式有误请重新输出”让它重试。执行工具时也要包异常处理工具内部报错不能直接崩掉整个循环而是把错误信息作为tool消息返回给模型让它决定下一步。工具执行完结果要序列化成字符串。如果结果是复杂对象转成JSON。注意长度控制工具返回的内容太长会挤爆上下文必要时做截断或摘要。我一般会给工具结果设一个字符上限超了就截断并加提示。5.3 循环终止条件与最大轮次对话循环不能无限跑必须设终止条件。正常终止是模型返回的assistant消息里没有tool_calls说明它决定直接回复用户。异常终止包括达到最大轮次、超时、连续多次工具调用失败等。最大轮次我一般设5到8轮因为绝大多数任务两三