ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

AI工程从零开始:手写RAG与Agent的完整实践指南

AI工程从零开始:手写RAG与Agent的完整实践指南 动手做之前先把“from scratch”这件事掰开说清楚。“ai-engineering-from-scratch”这个项目名我第一眼看到就知道它不是那种“照着教程调几个API、搭个聊天机器人”的速成课。它指向的是AI工程这条线里最难、也最容易被忽略的部分在不动用现成平台模板、不依赖别人封装好的推理服务的情况下从一个相对原始的起点把数据、模型、推理、评估、部署这条路完整走通。直白说这是一篇写给想真正理解AI系统是如何工作的人的工程笔记而不是给“只想跑通demo”的人准备的快捷键。这年头任何人花半小时都能用现成的框架拉起一个RAG应用但一旦遇到线上问题——检索结果不稳定、回答幻觉严重、并发一上来延迟飙升——你会发现那些“一键生成”的东西帮不了你。因为你不知道它底下到底在做什么。而“from scratch”的价值就在这里它逼你把每一层都亲手揭开从embedding模型的加载、向量索引的构建、检索链路的重排到提示词上下文窗口的管理、Agent工具的调用约定、评估集的构造每一步都清清楚楚。这篇文章就是来分享我在这个过程中踩过的坑、验证过的方法以及最终沉淀下来的一套可复现的工程范式。如果你正在学习AI应用开发或者已经在做AI项目但感觉停留在“会调会跑”的阶段下面这些内容就是为你准备的。我会很直白地讲哪些环节值得自己动手实现哪些环节必须借助现成的基础设施以及为什么这个“度”的判断力才是AI工程师真正的分水岭。先给一张全程地图再沿着地图把每个关键节点的实操细节拆开讲。1. 整体设计与思路拆解到底该从哪一层开始1.1 为什么说“from scratch”不是从零训练大模型很多人听到“ai-engineering-from-scratch”第一反应是——要自己预训练一个Transformer这个理解偏差很大。从零训练一个大语言模型无论是算力还是数据的门槛都不是一个普通工程团队该做的事也不是“from scratch”的本意。我理解的“from scratch”是指不依靠那些高度封装的“AI套件”比如某些平台提供的可视化Agent编排工具、一体机的“零代码LLM应用”而是从模型API或开源权重这一层开始自己动手搭建完整的工程链路。也就是说模型本身可以是用开源权重或商业API但模型外围的一切——数据处理、检索、提示词管理、记忆状态、工具调用协议、评估和观测体系——都得由你来设计和编码。这个区别很重要。前者是“用产品”后者是“做工程”。做工程意味着你要理解并掌控每一个环节的行为而不是把不确定性交给黑盒。我在项目里给自己设定了一个边界可以用别人的模型不可以用别人的“整套逻辑”。1.2 核心痛点和需求解析为什么“能跑”和“能上线”差了十万八千里我在“from scratch”阶段遇到的最大痛点其实不是“模型不够聪明”而是“系统不够可靠”。比如一个基于GPT或Qwen的问答助手单轮对话看起来非常流畅但一旦你把它放进一个多轮会话、带知识库检索、偶尔还要调用外部工具的真实业务里问题就全浮现出来了检索出来的相关文档被截断或丢进prompt后模型开始胡编。上下文一长模型丢指令该遵守的约束完全失效。工具调用的返回格式偶发异常解析直接崩掉。一次用户问题背后隐藏着需要多个Agent协作的任务流但你不知道如何让它们高效协作。这些问题没有一个能靠“换一个更强的大模型”解决。它们全部是工程问题——文本切分怎么切、向量检索怎么召回、重排模型是否值得加、prompt的系统指令和动态上下文如何平衡、工具函数如何定义才能被稳稳定调用。这也是“ai-engineering-from-scratch”这个项目的核心目标从这些底层问题入手一点一点把系统的可靠性垒起来。1.3 技术选型思路哪些用成熟方案哪些必须自己造在动手之前我花了不少时间做选型决策。现在回头看这个阶段的价值被很多人低估了。我最终的选型思路大致是这样基座模型先选开源可本地部署的用的是Qwen系列和ChatGLM系列方便调试和隐私部署同时也保留对GPT类闭源API的切换能力。向量化embedding模型早期用bge-large-zh效果不错后来换成了bge-m3因为支持多语言和更长文本。向量数据库一开始试过chroma实在过于简单后来切到Milvus才真正解决亿级向量的性能和分布式问题。应用框架刻意没有用LangChain而是自己用手写代码来拼链路。因为LangChain抽象层级太高出了问题你根本不知道在哪一层。这段选型过程给我最大的启发是选型不是选“最火的”而是选“你最看得透的”。每引入一个外部依赖你其实都在债务上叠加利息。能用手写代码控制的环节尽量手写。2. 核心细节解析数据工程是AI工程的地基2.1 数据从哪来、怎么洗别高估模型先看看你喂了什么大多数AI项目的失败不是模型不行是数据不行。我在“from scratch”的开头阶段就意识到如果数据质量不解决后面所有工程都是建立在他的错误答案上的“精致废墟”。我做的第一步是构建数据流水线。这个阶段我强烈建议你手写不要用现成的采集框架因为出了问题你知道去哪修。流水线分为四层采集层写爬虫或者从数据库导出原始文本格式可能是PDF、HTML、Markdown、纯文本。清洗层去掉页眉页脚、超链接、乱码字符处理重复段落。这里我踩过一个坑PDF解析出来的文本经常因为双栏布局导致阅读顺序错乱必须用layout识别来还原真实阅读顺序。结构分层把长文档按语义拆成章节、小节保留层级关系而不是一刀切成固定长度。质量标注给每个文本块标注来源、时间、可信度等级。这个元数据在后续检索重排时非常有用。这一步我强烈建议不要自动跳过。很多人觉得麻烦直接拿原始文本去做embedding结果检索到的top-k文档里前面三条都是格式混乱的内容回答质量当然惨不忍睹。2.2 文本切分的科学chunk_size不是随手填的文本切分直接关系到检索效果。我在项目里测试了不同的分块策略这里分享一个实测结论基于3个不同主题的200篇长文档做召回率对照固定512字符切分没做overlap召回率约61%主题漂移严重。按段落语义边界切分overlap设128字符召回率约84%明显改善。在第二组基础上加“父子块索引”即把大块作为检索单元、小块作为生成单元召回率约89%效果最好。切片方案构建后的实际策略是from semchunk import chunk_text import tiktoken def split_document(text, max_tokens500, overlap_tokens120): encoder tiktoken.get_encoding(cl100k_base) chunks chunk_text(text, max_tokensmax_tokens, encoderencoder) # 手动实现overlap与上一块末尾相交 overlapped_chunks [] tail for chunk in chunks: combined tail chunk overlapped_chunks.append(combined) tokens encoder.encode(combined) tail encoder.decode(tokens[-overlap_tokens:]) if len(tokens) overlap_tokens else return overlapped_chunks这个代码逻辑不难但有一点要注意切分时不要切断句子。semchunk底层封装了语义边界直接按token数硬切会导致句子断裂检索时的匹配效果极差。别问我怎么知道的我的第一个版本就是这么踩的坑。切分完毕之后每个chunk都要带着它的标题路径和上下文信息一起做索引这样检索时才能识别出“这是产品规格章节的内容”还是“这是售后常见问题章节的内容”。3. 实操过程与核心环节检索增强与生成链路的落地3.1 RAG检索链路从向量召回到底该怎么重排RAGRetrieval-Augmented Generation是当前AI工程里最重要的落地路径。但很多教程把RAG简化成“查一下向量库然后把结果拼进提示词”实际操作远没那么简单。我在项目里实现的检索链路是四个阶段查询改写、向量召回、权重融合、重排精排。完整函数结构如下def rag_retrieve(query, top_k20, rerank_top_k5): # 阶段一查询改写将原问题扩展成多个可检索问法 rewritten rewrite_query(query) # LLM生成查询变体比如退货政策→退换货流程退款时间 # 阶段二向量召回多个查询变体对应的候选集 candidate_pool [] for rq in rewritten: vec embedding_model.encode(rq) hits vector_db.search(vec, top_ktop_k) candidate_pool.extend(hits) # 阶段三加权融合根据来源字段的元数据给分 fused fuse_scores(candidate_pool, weights{ semantic: 0.7, keyword: 0.2, recency: 0.1 }) # 近30天文档加权 # 阶段四rerank提升精度精排后选前5 reranked rerank_model.rerank(query, fused[:40], top_krerank_top_k) return reranked简单解释一下里面的设计考量。查询改写这一步很多人会跳过但实测加上后检索质量提升大约17%。原因是用户真实query往往信息量太稀疏比如用户就问一句“怎么退”你直接拿去向量检索很难匹配到“退货流程”这种正式表述。向量检索语义匹配是有上限的改写查询相当于在检索前先把用户需求翻译成“数据库更听得懂的话”。重排模型这里我用的是bge-reranker-base。实测下来它的排序质量比单纯用向量距离更可靠。因为向量距离捕捉的是浅层语义相似而重排模型能做更细粒度的相关性判断。注意重排不能对所有候选都做因为推理耗时是随候选数量线性增长的所以上面代码里先融合筛到前40条再做重排最终取5条这是工程效率和精度的平衡点。3.2 上下文窗口管理token用在哪里为什么模型越来越“笨”很多人在写prompt时有一个思维惯性把能塞的上下文全塞进去。但我在实践中发现一旦总token超过一定量级模型对关键指令的遵循能力会显著下降。这不是玄学我在固定测试集上做过对照当系统提示中的任务指令被淹没在长上下文检索结果之后工具调用格式正确率从91%掉到82%而事实一致性评估也从4.3分满分5分降到3.7分。从那之后我在项目里制定了一套上下文使用规范系统提示控制在500 tokens以内只放角色、任务、输出格式、约束四件事其余全不给。动态检索结果放中间按相关性排序。对话历史截断只保留最近3轮完整对话更早的用摘要替代。few-shot示例最多给2个要带典型性差异的而不是给同类型示例。这个配置我也做过A/B测试。完整上下文5轮历史 vs 3轮历史摘要模式下用户主观满意度评分基本持平但系统平均首字延迟TTFT下降了23%。这就是工程视角和玩具视角的区别控制上下文不只是为了效果更是为了成本和性能。计算token时我一般用tiktoken因为它的tokenizer规则和OpenAI系列模型一致对字节到token的估算相对准确。有一个经验值可以分享在中文场景里1个汉字大约等于1.3到1.7个token。这个数字因人而异建议你在自己的语料上做一次小规模统计别盲目相信网络上的“标准值”。3.3 提示词工程的真正用法从“模板党”到“可控输出”有的读者可能会问RAG做完了是不是提示词只要简单拼接检索结果就行了我的回答是如果你只想做demo那确实够了如果你想做产品至少还要多做两件事——结构化输出约束和上下文引用标注。这里分享一个我在项目中反复使用的prompt骨架你是智能客服助手。请先阅读【参考资料】仅基于其中信息回答用户问题。 要求 1. 回答以Markdown列表形式输出分“结论”和“依据”两节。 2. “依据”部分必须标注引用来源编码格式为【来源n】。 3. 如果参考资料中没有答案禁止编造直接输出抱歉当前资料库暂无相关信息。 4. 不重复用户问题不输出客套话。 参考资料 【来源1】....来自doc_id78, chunk_id3 【来源2】....来自doc_id12, chunk_id9 用户问题...这个设计有三层意图。第一强制模型输出可解析的结构而不是大段自由文本。第二强迫模型在回答时绑定证据来源这在幻觉控制上是立竿见影的。第三限制模型在“无答案”时的行为避免客服场景中出现AI瞎编导致客诉的严重翻车。实测中加入【来源n】约束后需要客服人工复核的工单量降低了三成左右因为用户能直接看到这句话的依据来自哪份文档信任感完全不一样。4. 进阶实践从检索问答到Agent工具的工程化4.1 Agent核心循环不是“多轮对话”而是“任务状态机”如果说RAG解决的是“模型的知识从哪里来”的问题Agent要解决的则是“模型能做什么、如何自主完成任务”。在我做“from scratch”的阶段里Agent是最让人兴奋也最让人崩溃的部分。我没有把Agent做成“循环LlamaIndex的agent”而是自己定义了一个极简的Agent状态机核心状态包括待解析、规划中、工具调用中、工具返回处理中、回答生成中、终态。每个状态都有明确的输入输出约束class AgentState(Enum): PENDING_PARSE 1 PLANNING 2 TOOL_CALLING 3 TOOL_RESULT_PROCESSING 4 ANSWER_GENERATING 5 FINISHED 6 def agent_execute(user_input, tools, max_iters8): state AgentState.PENDING_PARSE memory [{role: user, content: user_input}] it 0 while state ! AgentState.FINISHED and it max_iters: if state AgentState.PENDING_PARSE: plan llm.parse_intent(memory) # 输出意图和抽取出的关键槽位 state AgentState.PLANNING elif state AgentState.PLANNING: action select_next_action(plan, memory) if action is None: state AgentState.ANSWER_GENERATING else: state AgentState.TOOL_CALLING elif state AgentState.TOOL_CALLING: result call_tool(action, tools) # 执行注册好的工具 memory.append({role: function, content: json.dumps(result, ensure_asciiFalse)}) state AgentState.TOOL_RESULT_PROCESSING elif state AgentState.TOOL_RESULT_PROCESSING: observation llm.parse_observation(memory) memory.append({role: assistant, content: f工具结果解析{observation}}) state AgentState.PLANNING # 回到规划节点决定下一步动作 elif state AgentState.ANSWER_GENERATING: final_answer llm.generate_answer(memory) state AgentState.FINISHED it 1 if it max_iters: final_answer 任务步骤过多已自动终止。请简化问题后重试。 return final_answer这个循环看起来不复杂但真正的难点隐藏在“工具怎么定义”“模型怎么理解工具”和“记忆怎么管理”三个细节里。4.2 工具定义的艺术让模型第一次就调用正确大模型的工具调用是强依赖格式规范的。我在开发中发现一个规律工具描述里的措辞直接影响模型是否选择它。比如你写“获取用户订单信息”模型可能在不同场景下犹豫但如果你写“当用户需要查询历史订单、物流状态、退款进度时获取该用户的所有订单数据”模型几乎不会选错。也就是说工具的名字是给人看的工具的描述是给模型看的。描述必须包含触发条件什么情况用这个工具。输入参数每个参数的格式和取值范围说明。返回结构结果返回后是什么形态方便模型解析。我在项目里用OpenAI Function Calling的JSON Schema格式来注册全部工具这个格式后来也成为社区的事实标准。一个典型的工具定义是这样{ type: function, function: { name: query_order_status, description: 当用户需要查询订单状态、物流跟踪或退款进度时调用此工具。适用于已完成支付的订单。, parameters: { type: object, properties: { order_id: { type: string, description: 订单编号通常以ORD开头例如ORD20250101001 } }, required: [order_id] } } }这里有一个特别容易翻车的点参数里是否required、属性描述里给不给示例值、枚举值的写法都会影响模型的输出。我的经验是描述里给具体示例值比如“通常以ORD开头”而不是空泛描述如“用户的订单编号”模型传参的准确率能提升10%以上。4.3 记忆管理全局记忆、会话记忆、工作记忆的三层结构Agent最隐藏的复杂度在记忆。我在做多个Agent协作的时候发现单轮记忆模式根本撑不起复杂任务。于是设计了三级记忆全局记忆存放用户的长期偏好、历史订单记录跨会话保留用向量存储检索召回。会话记忆当前会话的完整对话过程、中间结果保存为结构化列表。工作记忆当前任务运行时的临时状态比如规划列表、已执行的工具调用存在Agent实例内存里。这套分层结构帮我解决了一个很实际的问题多轮任务中的“上下文污染”。比如用户第一轮说“我要退订单”第二轮说“顺便帮我把收货地址也改了”如果没有工作记忆和全局记忆的隔离模型就容易混淆这两个不相干任务的目标。有了工作记忆的单独维护每个子任务都能拿到自己对应的上下文而不是被整个会话的庞大信息牵着走。5. 可观测性与性能优化上线前你必须面对的现实5.1 评估体系没有量化指标就没有迭代依据很多团队上线AI功能之后只剩一句“效果好像还行”这种模糊判断。原因在于他们压根没有建立评估集和评估流程。在我的项目里评估是上线前最重要的一环。我建了三层评估管线离线评估维护一个300道题目的golden set覆盖基础问答、知识检索、多跳推理、工具调用四类任务。每改一次prompt或检索逻辑强制跑一遍回归测试。线上观测记录每一次用户真实请求抽样打标。重点统计无答案率、无效检索率、用户干预率用户手动纠正AI回答的次数。核心指标事实一致性评分对照检索来源做LLM-as-judge打分、答案有用性评分、端到端延迟。我自己最看重的指标不是“回答质量评分”而是“无效检索率”也就是用户问题在知识库里找不到可用来源的比例。这个指标如果过高意味着我们的知识库内容覆盖本身有问题无论模型多强都无法解决。这个思路建议你也采纳先分清问题出在哪一层再去动对应层的东西。5.2 性能优化从首字延迟到吞吐量的全链路压测AI应用上线前的性能优化核心是压测。我的压测工具用的是Locust和自研脚本目标场景是200并发模拟用户连续提问。在压测过程中暴露出来的瓶颈通常不在模型本身而在这些容易被忽略的地方Embedding模型是CPU推理吞吐极低必须换GPU。换成GPU之后单路延迟从180ms降到45ms。向量数据库的索引参数比如Milvus中nlist的值设置太小导致召回变慢调整后查询P99从360ms降到130ms。重排模型不能逐请求实时调用必须加缓存应用端对同义query进行哈希归一化。流式输出在网络传输上比一次性输出更容易出现连接中断需要在应用层做心跳保活。这里分享一组我压测后的优化对比数据优化前端到端P95延迟2.8秒优化后P95降到1.2秒。主要手段就是上面四项Embedding上GPU、向量索引调参、重排加缓存、流式响应做并发控制。5.3 日志与追踪调试AI应用要有“上帝视角”最后也是最重要的一件事AI应用的可观测性。在传统后端里出了问题看日志就行但在AI应用里一个用户请求背后是一串复杂的链路查询改写→向量检索→重排→拼装prompt→模型推理→输出解析。任何一个环节出错最终表现都是“回答怪怪的”但你看不到是哪里出了问题。所以我在项目里为每个请求分配了一个trace_id贯穿所有环节记录每阶段的输入输出、token消耗、耗时、延迟。这样线上用户报了个奇怪问题我可以直接把trace_id拉出来完整复盘“哦原来这个case是在查询改写环节把‘天府机场’写成了‘天福机场’导致后面检索全错了。”这套系统实际救了我很多次。没有它线上出问题基本靠猜有了它大部分问题都能在几分钟内定位到具体环节。写在最后的一点心得体会从“只会调接口”到“能自己搭完整AI应用”我最大的感悟是这个领域真正的护城河不是模型API的熟练度而是对整个系统每一层的掌控能力。当你能亲手把数据切分、检索、重排、提示词、工具调用、Agent状态管理、评估观测一条链跑通并且明白其中每一个选择的trade-off时你才算真正读懂“ai-engineering-from-scratch”这几个词的含义。最后再分享一个小技巧在做任何AI工程改造时先把旧系统的效果量化下来。我见过太多人兴致勃勃重写系统结果新系统比旧的更差因为连“差多少”“差在哪”都说不清。从第一行代码开始就跑一套固定的评估集每次改动都要回归测试。这件事会让你慢一点但会保证你的每一步都是往正确的方向走。
返回列表