很多朋友问过我一个问题:入门AI工程,最有效的路径到底是什么。我的答案一直是同一个——别急着上框架,先亲手把一个极简AI工程从零拼一遍。因为只有自己搭过一遍,你才会真正理解那条RAG链路里每一步为什么存在、Agent工具调用是怎么闭环的、上下文窗口到底是怎么被吃掉的。这个项目(ai-engineering-from-scratch)就是我做这件事时的完整记录和代码沉淀。它不追求跑出什么惊艳的Demo,而是把AI工程的核心骨架逐层拆开,让你看到、摸到、改到那些在高层框架里被封装掉的关键细节。这篇梳理会覆盖模块设计、核心代码逻辑、踩坑实录,以及我认为后续最值得扩展的方向。
1. 项目要解决的真问题:把AI工程从“魔法”变回“工程”
1.1 为什么我决定从零写一套AI工程系统
前两年AI应用爆发式增长,市面上主流的开发方式几乎都变成了“调框架、托管道”:LangChain搭链条、LlamaIndex接索引、AutoGen配Agent,几行代码把能力串起来。这种方式在快速验证阶段确实好使,但非常容易让工程师陷入一个被动的处境——线上出问题时,框架内部执行的究竟是什么,像个黑盒一样看不透。
我印象很深的一次线上事故:某个知识库问答的召回率直线下降,配置没改、索引没动,可就是找不回正确的上下文。用LangChain封装的检索链去排查,日志只能看到“Retriever returned 4 docs”,但到底召回了哪几段、为什么是这几段、向量检索在哪个环节丢失了语义,根本无从下手。最后只能绕开管道逐段打点,才定位到问题其实出在Embedding批次处理时文档长度截断策略变了。
那次之后我就下定决心,用完全自主的代码从底层构建一套最小可用的AI工程系统。目标不是“取代框架”,而是让自己能够完全掌控数据流和控制流。框架给我的是封装与妥协,而我想要的是理解与透明。当你能亲手写一个向量检索、手动实现一次工具调用闭环的时候,任何上层框架对你来说都只是一层薄薄的语法糖,出了问题可以直接穿透到原理层。
1.2 项目边界:做什么,不做什么
做这个项目之前,我的第一个动作其实是划定边界。没有边界的技术项目会像无底洞,今天想做多模态,明天想加图谱,后天又想去微调模型,最后什么都做不成。
我的边界是:不碰模型训练,不碰底层算子优化,不追求完整生产级部署方案。专注做AI应用工程中三个最核心的横向能力——RAG(检索增强生成)管线、Agent工具调度、上下文生命周期管理,外加一套简单的评估与可观测体系。
为什么不碰训练?因为那不是应用工程师的主战场。AI工程的核心难点从来不在“怎么训出一个模型”,而在“怎么把已有模型的能力稳定地、可控地、低成本地编排进业务流程”。如果你能清晰地把文档切分、向量召回、提示词组装、工具调用、上下文管理、结果评估这六件事讲清楚并落地,就已经解决了生产环境中80%以上AI应用的真实痛点。
2. 系统骨架设计:四个核心模块与依赖关系
2.1 模块拆解:RAG管线、Agent调度、上下文管理、模型网关
动手写代码之前,我在纸上把这套系统分成了四个模块,后来实际开发中几乎没有调整过这个划分,说明最初的设计是站得住脚的。
第一个模块是RAG管线。它负责把外部知识转换成模型可以检索并使用的上下文。再往下拆,管线里包含文档加载器、文本切分器、Embedding调用器、向量索引和检索器,以及最容易被忽略的“上下文格式化器”。很多人以为RAG就是“检索+拼接提示词”,但格式化这一步决定了拼接结果是否真的适配模型指令,稍后我会单独展开。
第二个模块是Agent调度。它解决的是“模型决定调用哪个工具”的问题。一个合格的Agent闭环,不只是让模型输出一段JSON,而是要做到:定义工具、绑定工具、解析模型意图、执行工具、把执行结果回填给模型、模型继续推理、直到完成任务或主动终止。这个循环里每一步都有坑。
第三个模块是上下文管理,或者说记忆系统。大模型上下文窗口有限,而真实业务场景里的对话轮次、检索结果、工具执行结果非常多。上下文管理要解决三件事:什么东西该保留,什么东西该丢弃,窗口快满时怎么压缩。这个模块设计得不好,系统跑不了几轮就会“失忆”或者被无关信息淹没。
第四个模块是模型网关。它负责屏蔽底层模型提供方的差异,统一提供流式输出、重试、限流、费用统计、超时控制等能力。实际项目里,团队用的模型一定不会只有一个,可能是不同厂商的开源或闭源模型,网关上手之后,上层模块永远不需要关心模型来源,只需要面对一个统一接口。
2.2 依赖方向与数据流向
四个模块之间有一条明确的依赖链:模型网关在最底层,所有模块都要调用它;RAG管线和Agent调度横向并列,都依赖网关;上下文管理像一个横切的皮层,覆盖在RAG和Agent之上,任何进入模型的文本都要经过上下文管理器;评估系统则附着在外围,单独抓取各个环节的数据样本。
数据流方向上,用户请求进来之后,先经过一个路由器。路由器判断这个请求是“直接回答型”还是“需要检索知识型”还是“需要调用外部工具型”。属于检索型的请求走RAG管线,产出候选段落后格式化进上下文;需要执行操作的请求则走Agent调度循环,模型可能反复调用工具直到拿足信息。整个过程中,每次与模型的交互请求,都要先把历史摘要、本轮输入、检索片段、工具结果拼接到既定模板里。
我特别想强调一下路由判断的分寸。新手容易犯的错是让路由规则过于简单——“只要问题里有‘是什么’就检索,有‘帮我’就调用工具”。这在实际场景里误判率很高。我在系统里是用“意图+实体”双信号判断的:既要有提问意图类型,也要能抽取到需要检索或操作的业务实体。两套信号同时命中才路由,宁可走保底回答,也不乱接管道。
2.3 技术选型逻辑:框架能不用就不用,但数据库和工具不重复造轮子
项目叫“from scratch”,但我不做“从零造一切”这种极端原教旨主义。如果什么东西都自己造,那就成了重复发明轮子,项目的核心目标也会被淹没掉。
所以我的技术选型原则是:所有“业务逻辑编排”全部手写,所有“重型底层基础设施”用成熟方案。业务逻辑编排包括流程控制、工具注册、上下文拼装、评估指标计算——这些是项目要教学的核心,必须自己实现。而底层基础设施,比如向量存储,我选择直接用Chroma这类嵌入式方案;模型调用统一走OpenAI兼容接口,这套接口现在已经成了事实标准,没必要自己造HTTP客户端。
选型时还要考虑可替换性。系统里通过环境变量控制模型Endpoint和Key,这样开发时可以用开源小模型跑通流程,生产时无缝切到商业模型。同一个代码库,上行下效,周围的同事拿过去改几行配置就能跑,不用改任何业务代码。这种“配置与逻辑分离”的做法,我建议所有类似项目都要坚持,它会在后面调试的时候给你省下大量时间。
3. 核心环节实操:手把手实现RAG与Agent闭环
3.1 环节一:RAG完整管线
RAG管线第一步是文本切分,这一步决定了检索质量的上下限。我在这个项目里尝试过三种策略:固定长度切分、按段落结构切分、父子块切分。固定长度最简单,但极易切断句子语义;按段落结构切分适合Markdown或HTML这类带标题层级的内容;父子块切分则是把小块用于语义匹配、大块用于上下文内容,实战效果最稳。
我最终的实现选择了“结构优先+父子块”的组合方案。以Markdown文档为例,先按标题层级把文档拆成语义独立的章节,再对每个章节做滑动窗口切分,每个父块保留完整上下文,每个子块作为检索单元。这样子块命中之后,可以向上回溯父块放进模型上下文,模型能看到的上下文完整且语义连贯。
切分之后进入Embedding阶段。这里有个很实际的问题:批量Embedding时文档越长,语义越会被稀释。我在系统里做了文本截断配置,同时维护了一张内容哈希表作为Embedding缓存,相同内容不需要重复计算向量。这一点在反复调试的时候效率提升非常明显,省下来的API费用也很可观。
向量索引我用的是Chroma的默认HNSW实现。HNSW是一种近似最近邻算法,意思是不用全量比较,而是通过多层跳表结构的图来快速逼近最相似的向量。小规模项目里它完全够用,性能也远超暴力检索。但必须注意,HNSW的召回质量受参数影响,比如M值(每个节点的最大连接数)和efConstruction(建图时的动态列表大小),我在项目里都显式设置固定参数,避免默认值变化导致行为不可预期。
检索器实现上,我做了混合召回。向量检索召回Top 20,BM25关键词召回Top 20,然后用RRF(倒数排名融合)算法合并排名。BM25是经典的关键词匹配算法,对专有名词、型号、编号等精确匹配场景非常有效;而向量召回擅长语义近义和模糊表达。两者融合后,召回精度肉眼可见地提升。RRF的实现只是几行代码,核心逻辑是对每条文档在两种召回结果里的排名取倒数并求和,融合后的分数就是最终排序依据。
最后是上下文格式化。这一步直接决定模型输出质量,可大部分教程几乎不聊。我的做法是:让格式化器输出一种视觉分离度很高的模板,让检索段落带上文档来源和层级路径标签。模型读到这些内容,能清楚区分哪些是用户问题、哪些是参考资料、哪些是系统指令,从而大幅减少答非所问和“自行脑补”的情况。
# 简化版混合检索 + RRF融合 def hybrid_search(query, top_k=5): vector_hits = vector_index.search(query, top_k=20) bm25_hits = bm25_index.search(query, top_k=20) fused = {} for rank, (doc_id, _) in enumerate(vector_hits): fused[doc_id] = fused.get(doc_id, 0) + 1 / (60 + rank + 1) for rank, (doc_id, _) in enumerate(bm25_hits): fused[doc_id] = fused.get(doc_id, 0) + 1 / (60 + rank + 1) ranked = sorted(fused.items(), key=lambda x: x[1], reverse=True) return [doc_id for doc_id, _ in ranked[:top_k]]这段代码里60是常见平滑常数,作用是压低单路召回的排名权重差异,避免某个极端高分直接碾压另一路的有效结果。实际项目里这个常数可以按召回量级微调,但60作为经验起步值很稳定。
3.2 环节二:Agent工具调用闭环
Agent模块是我花时间最多的地方。一个工具调用闭环最少包含六个环节:工具注册、工具说明生成、模型意图解析、参数校验、执行与回填、终止判断。
工具注册我使用装饰器模式,函数加上一个@register_tool装饰器,系统通过内省机制自动提取函数的名称、文档字符串、参数签名。模型看到的工具说明,就是这些元信息的JSON Schema格式。这个设计的优点是你永远只需要维护一份工具定义,不会出现“接口文档和实际函数签名不一致”这类低级但致命的问题。
模型意图解析环节最容易出幺蛾子。不同模型输出的工具调用格式五花八门,有的给完整JSON,有的给Markdown代码块,有的夹带额外解释文本。我在模型网关里做了一个标准化解析层:先尝试标准JSON解析,失败则用正则抽取代码块,再失败就把内容交给一个小模型做“格式修正”。经历过线上模型API升级导致输出格式突变之后,我对这个环节的敬畏心特别重。
参数校验必须做,而且要严格做。模型生成的参数偶尔会有幻觉情况,比如把日期格式撰写错误,或者传了一个完全不在枚举范围内的选项。我的校验层会在执行前做类型强转、枚举检查、必填项检查,不合法就直接返回一条结构化错误给模型,让模型感知错误后重新生成调用。这比闷头执行然后报异常要优雅得多,因为模型可以自我纠正。
工具执行回填是Agent闭环里最影响“智能感”的一环。工具执行结果回填给模型时,不能直接把原始返回值堆进上下文。我做了两层处理:第一层是裁剪,只保留结果中与调用目标相关的字段;第二层是格式化,把返回值改成简明的文本摘要,并附带上执行状态。比如查订单接口返回2000字的JSON对象,裁剪后只给模型一段“订单状态:已发货,物流公司:顺丰,单号:SF123456”这样的结果。这样既节省上下文空间,又降低模型被无关字段干扰的概率。
终止判断我采用的是“双保险”:模型显式输出结束标志时视为自然终止;同时系统侧设置最大工具调用轮次上限。设定上限这个习惯尤其重要——模型在复杂任务里有时会陷入死循环,反复调用同一个工具试图获取永远得不到的信息,没有上限的Agent会一直空转到费用爆表。我的经验值是普通任务6轮足够,复杂任务可以放宽到10轮,超过就直接终止并返回“任务超时,请简化请求”。
# 工具调用闭环核心循环(伪代码结构) def run_agent(user_input, max_rounds=6): messages = build_messages(user_input) for _ in range(max_rounds): reply = gateway.chat(messages, tools=tool_schemas) if reply.is_finish(): return reply.content action = parse_tool_call(reply) validated = validate_params(action) result = execute_tool(validated) messages.append(tool_result_message(result)) return "任务超时,建议简化请求"这段循环里最值得品味的是messages的累积方式。每一轮工具结果都作为一条新消息追加进对话历史,上下文管理器会持续监控消息总量,一旦逼近窗口上限就触发压缩策略,而压缩策略正是接下来要说的模块。
3.3 环节三:上下文工程与记忆管理
大模型的上下文窗口就像办公室里的白板,空间有限。你要决定哪些内容值得写在白板上,哪些该擦掉。我的上下文管理器实现了一套分层记忆策略:工作记忆、摘要记忆、长期记忆。
工作记忆保存当前任务相关的原始消息。摘要记忆是历史对话的迭代式压缩摘要,每隔几轮对话就把最早的部分浓缩成几十字的要点。长期记忆则落在外部存储里,按用户的会话维度持久化重要信息,比如用户偏好、前置决策、已完成步骤。
压缩策略我用的是“摘要替换法”:当消息总量超过阈值时,选取最早的两轮对话,合并做一次摘要生成,把原始消息替换成摘要消息。这样做的好处是白板永远不会被无限制地占用,坏处是摘要也会累加噪声。为了控制噪声,我规定摘要消息在经历一定轮次之后自身也会被压缩,形成多级摘要,类似于会议记录的“今日纪要→周报→月报”的层级归档。
还有一个很实用的小技巧:把检索到的文档片段都放到一个独立的上下文区域内,区域之间用标识符分隔。模型对这种结构化输入的响应质量,远高于把所有内容揉成一团的消息。实测同一批检索片段,格式化前后的答案准确率差距非常明显,这说明上下文工程不是简单的“拼字符串”,而是有信息架构设计的。
3.4 环节四:评估体系与可观测性
很多项目死在“感觉效果不错但说不清好在哪里”上,AI应用尤甚。没有评估体系,你改一版提示词都不知道是变好了还是变坏了。我在项目里建了一个迷你评估集,包含三类样本:标准问答对、模糊表述对、需要多轮工具调用的任务。每轮改动跑一遍评估集,记录每个样本是否成功以及耗时和费用。
客观指标上,我追踪三个数字:准确率(人工标注或LLM评判)、上下文命中率(检索片段是否被最终答案真正引用)、浪费Token比例(上下文里未被使用的片段占总量的比例)。第三个指标特别能反映检索和上下文管理质量,浪费Token比例高于40%就说明检索精度不够或者上下文塞了太多冗余内容。
可观测性方面,我设计了一个可视化日志。每次请求可以产出一条完整的时间线,包含:路由决策、检索命中文档列表、混合排序分数、最终放入上下文的片段编号、工具调用参数、模型回复耗时、Token消耗明细。这套时间线在排障时简直是救命稻草,因为AI应用的输入输出是动态的,做不了传统软件那样的固定日志断言,只有完整链路记录才能让你复现和分析问题。
4. 高频问题与排查实录
4.1 常见问题速查表
AI工程系统的排查思路和传统后端不太一样,很多问题具备概率性、语义性和上下文依赖性,没法靠“看报错”一招解决。这里我把项目过程中遇到的高频问题整理成一张速查表,每一条都是实际踩过的坑。
| 问题现象 | 根因 | 解决方法 |
|---|---|---|
| 答案总出现幻觉 | 上下文里检索片段缺失关键信息 | 检查切分策略是否割裂实体;提升Top-K召回数量 |
| 召回的文档与问题无关 | Embedding精度不足 | 切换更高维Embedding模型;检查文本截断策略 |
| 模型频繁重复调用同一工具 | 工具结果回填信息不足 | 检查结果格式化的裁剪逻辑;附带执行状态和错误信息 |
| 多轮对话之后模型“失忆” | 摘要压缩过度 | 调整摘要触发阈值;关键事实放入长期记忆 |
| 单次请求Token费用过高 | 上下文冗余严重 | 启用浪费Token比例监控;收紧检索Top-K |
| 某个工具调用间歇性失败 | 模型输出JSON格式不稳定 | 标准化解析层加强正则抽取;增加格式修正模型兜底 |
| 混用多个模型时输出不一致 | 各模型遵循指令能力不同 | 网关层做系统性提示词归一化 |
4.2 三个让我印象深刻的调试案例
第一个案例是检索时灵时不灵的诡异问题。开始时我以为是Embedding模型的问题,换了好几个模型都没用。后来把用户输入做了分词统计才发现,用户习惯性地把型号名“AX-3000”连在一起写,而文档里存的是“AX 3000”。向量检索对这种字符级差异不敏感,但关键词检索接口对空格极其敏感。最后修复方式是在切分阶段把这类连写词做了归一化处理,统一去掉空格和横线,召回率立刻回升。这个案例告诉我,工程问题有时就藏在你觉得“应该没问题”的文本预处理里。
第二个案例是工具的循环调用。Agent为了查询“本周某地区销售排名”,反复调用订单统计工具了十几次,每次参数都一样。日志分析发现,工具返回的JSON里数字是字符串类型,字段名也嵌套了三层。模型在第三层努力寻找“排名”字段却找不到,于是不断重试。修复方式是让工具结果格式化器统一输出平铺的、类型明确的摘要文本,而不是把原始JSON直接丢回给模型。从此之后循环调用问题几乎绝迹。
第三个案例很有意思,是模型上下文被“消息类型标注”污染。我起初为了让模型识别历史对话,在每条消息前面加了[HUMAN]和[AI]前缀。但某个版本的模型微调数据里也用了类似标记,导致模型把前缀当成了某种结构化指令,输出格式偶尔会变成它见过的“内部风格”。最后我把前缀从消息文本里移出,改成系统层文件级别的角色字段,一切恢复正常。这个教训是:提示词里的任何符号都可能是模型训练时见过的特殊标记,不要凭空发明奇怪的标记组合。
5. 项目后续的扩展方向与我的个人体会
5.1 可以往下深挖的两个方向
完成核心闭环之后,我陆续把项目延伸到了两个方向上。
第一个方向是评测集持续运营。我从线上真实请求里挑选了一些高价值样本回流进化语料库,再对模型输出做自动打标。这套机制跑起来之后,系统的可回归性越来越强。以后每换一次模型或改一次提示词,都能快速看到对整体效果的影响。我强烈建议任何认真做AI工程的人都别跳过这一步,没有评测集的AI项目,本质上就是在裸奔。
第二个方向是把RAG从“检索静态文档”升级为“检索动态业务数据”。静态文档切分一次就完事,但业务数据会持续更新,还需要考虑权限过滤。我在最新版本里加了数据源的增量同步插件,以及基于用户身份的文档级权限过滤。权限过滤这个点很容易被忽视,可生产环境里一旦越权检索,后果可能很严重。
5.2 我的个人体会
这套从零构建的AI工程系统做下来,对我最大的改变不是技术上能徒手写出这些模块,而是看待AI应用的方式变了。用框架时,我是在“使用”一个系统;从零构建后,我是在“运营”一套系统。这个转变带来的直接结果是——线上出问题的时候,我不再凭感觉拍脑袋猜原因,而是顺着自己设计的链路逐层查,基本都能快速定位到问题根因。
最后想说一个建议:如果你也想复现这个项目,别急着把代码拉全就跑,先自己闭卷回答三个问题——你要处理的数据长什么样?你要支持哪几类用户请求?模型在什么情况下可以承认自己不知道?想清楚这三件事,再动手写代码也不迟。AI工程不是堆模型,是把围绕模型的整条生产链路管好,这个认知越早建立,你的项目就越不会翻车。