ARTICLE DETAIL

资讯详情

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

AI工程从零开始:手写RAG、Agent与提示词工程的实践指南

AI工程从零开始:手写RAG、Agent与提示词工程的实践指南 说实话没系统做之前我以为“AI工程”就是调API——拿个Key把Prompt拼好然后等着看输出。直到自己从零开始搭了一套完整的LLM应用才发现工程化这事远不是“调包”那么简单。RAG怎么做检索、Agent怎么设计工具调用、Prompt怎么在真实业务里稳定复现每一步都是坑。我梳理的这条路径就叫 ai-engineering-from-scratch它不是一个单一的demo而是一条从模型接入到上线监控的完整闭环。如果你已经会写点Python、还没完整做过一个AI项目这篇内容能帮你少走至少两个月的弯路。1. 内容整体设计与思路拆解1.1 为什么非要“从零开始”而不是直接套框架现在LangChain、LlamaIndex这些东西已经很成熟了直接拿来用不行吗能用但我强烈建议你在碰框架之前先用原生代码把整条链路手工走一遍。原因很简单框架把太多细节藏起来了。举个真实例子我早期用某个框架搭RAG检索结果不对框架日志里只显示一句“retriever returned 0 results”你根本不知道是切分的问题、embedding的问题还是向量库查询写错了。后来我自己用原生代码实现了同样的链路才发现是我切分时把chunk_size设得太大导致语义被稀释。这种问题只有在手动实现时才会有体感。打个比方直接上框架就像开了辆自动挡的车你踩油门它就走出了故障只能望洋兴叹从零开始实现一遍相当于先在驾校把手动挡练熟了之后开自动挡反而觉得哪哪都懂。所以我给这条路线的定位是“完整手写一遍再上框架优化”。1.2 主线设计从“能说话”到“说对话”再到“会办事”整条路线我分成三段递进。第一段模型接入和提示词工程解决的是“让模型能正常输出”第二段RAG检索增强解决的是“让模型用正确的知识输出”第三段Agent工作流解决的是“让模型调用工具把事办完”。这个顺序不是随便排的背后是调试复杂度的递进。你先得掌握模型输入输出的基本脾气才能去做知识增强有了稳定的知识问答做底子再谈工具调用和任务拆解才不容易被一堆bug淹没。我见过不少人一上来就搞AutoGPT式的多Agent框架结果一大半时间耗在处理工具调用失败上连基本问答都做不稳。这条主线恰好能避开这种坑。1.3 技术栈选型原则是“能调试、能省事、能换供应商”具体选型上我的参考组合是这样的Python 3.10、OpenAI兼容的模型调用接口base_url和api_key都做成可配置、Chroma或FAISS做向量检索、FastAPI做服务层、Redis做缓存。选这套主要看三点。第一生态成熟。Python的AI库、文档和社区讨论最多遇到问题随便一搜就有答案。第二可调试性强。Chroma可以直接在本地跑FAISS是纯内存索引出问题好排查不用一上来就搞分布式向量库。第三厂商无关。模型调用层我统一用OpenAI兼容格式今天用A模型明天换B模型只需要改环境变量业务代码一行不动。这一点在后面实际迭代中帮了我大忙——某个模型效果不行我半小时就换完了完全不用重构。2. 模型接入与提示词工程先让模型“说对话”2.1 先封装一个统一的模型调用层别让厂商锁死你很多人第一步就写错了直接在业务代码里硬编码模型API的调用方式。比如调文心就写文心的SDK调通义就写通义的SDK后面想切换模型得满项目找调用点改到怀疑人生。我会先做一个极简的客户端代码大概长这样import os from openai import OpenAI class LLMClient: def __init__(self, model: str, base_url: str | None None, api_key: str | None None): self.model model self.client OpenAI( api_keyapi_key or os.getenv(LLM_API_KEY), base_urlbase_url or os.getenv(LLM_BASE_URL), ) def chat(self, messages: list[dict], temperature: float 0.3, **kwargs) - str: resp self.client.chat.completions.create( modelself.model, messagesmessages, temperaturetemperature, **kwargs, ) return resp.choices[0].message.content这里的核心是base_url可配置。现在很多模型厂商都提供OpenAI兼容的接口你只需要把base_url换成服务商地址api_key换成对应的key模型名换成对应的model id就能跑通。建议把这几个参数全部放到环境变量或配置中心不要写死在代码里。这样设计之后切换模型、灰度一个模型、跑模型对比评测都只是改配置的事。2.2 提示词不是“写作文”是“写合同”模型接入没问题之后下一个瓶颈就是Prompt。我见过很多人写Prompt像发微博三五行字扔给模型就想要结构化结果。实际上拿LLM做工程任务时Prompt应该像一份合同每个条款都要写清楚尤其是角色、任务、输入、输出格式、约束条件、示例这六要素。举一个最常用的信息抽取Prompt你是一个信息抽取助手。从用户提供的工单文本中抽取以下字段 故障类型、影响范围、紧急程度、建议处理人。 要求 1. 只输出JSON不要包含任何解释 2. 如果字段在文本中无法确认填未知 3. 紧急程度只允许取值为低、中、高。 示例 文本数据库连接超时订单服务整体不可用请DBA处理。 输出{故障类型:性能故障,影响范围:订单服务,紧急程度:高,建议处理人:DBA} 文本{input}注意我放了一个few-shot示例。这是我试下来最有效的手段与其在系统提示里写一百个字描述“你应该怎么做”不如给模型一个输入输出对它模仿得又快又准。特别是抽取、分类、改写这类型任务few-shot的效果立竿见影。2.3 温度、结构化输出与重试策略三个参数一起调Temperature是典型的“玄学参数”。我一贯的经验是信息抽取、分类、数据清洗这些确定性任务温度调到0.1以下最好直接用0文案生成、头脑风暴想有点花样可以开到0.7到1.0。如果模型输出不稳定先检查温度大多数情况下你根本不该用0.7去做精准抽取。结构化输出方面优先让模型输出JSON并用代码校验。很多模型接口支持JSON mode或function calling强制模型返回结构化的内容。配合上校验函数不合格就重试我给这类调用设了最大重试次数3次超过就返回错误而不是硬解析。这样能避免一大类“解析JSON失败”的线上事故。为了格式正确解析函数最好自己写一个兼容注释和尾逗号的版本严格解析的库在部分场景会直接抛错。到这里模型已经能“说对话”了。下一层要做的是把业务知识喂给它。3. RAG实现细节让模型“带着资料回答”3.1 为什么需要RAG开卷考试永远比闭卷稳很多场景下模型没见过你的内部文档。直接用基础模型回答业务问题结果就是一本正经地编。RAG的思路特别直白把文档切碎、向量化、检索把最相关的片段拼进上下文再让模型基于这些片段回答。你可以理解成开卷考试模型带着参考资料答题自然比闭卷瞎编靠谱得多。有人可能会问现在上下文窗口越来越长直接把整本手册塞进去不行吗我的实测结论是能塞但没必要。第一成本高每次请求都传几万字token费用和延迟都吃不消第二长上下文质量不稳模型对中间位置的注意力会明显下降第三文档多了总会超过窗口上限。所以RAG不是过渡方案而是真正的工程化选择。3.2 文档切分和向量化的三个关键参数怎么定RAG最影响效果的环节一个是切分一个是检索。切分的核心目标是“让每个片段语义尽量完整长度尽量均匀”。我常用的方案是结构切分加固定窗口兜底先按Markdown标题分块大标题下面的内容如果还是太长再用固定窗口切。from langchain_text_splitters import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size600, chunk_overlap80, separators[\n\n, \n, 。, , , , ], ) chunks splitter.split_text(document)chunk_size和chunk_overlap是两个最该调的参数。chunk_size决定每个片段多大我通常从600起步中文场景建议不超过1000太大了片段里话题太多检索时匹配不到核心语义太小了上下文不完整模型回答缺少背景。chunk_overlap的用处是避免句子被从中间截断我习惯设80到120也就是约10%到20%的重叠。向量化环节中文场景我推荐优先考虑中文表现好的embedding模型比如BGE系列英文场景可以直接用OpenAI的text-embedding-3-small。选embedding模型有两个原则一是跟检索文本的语言要匹配二是不要贪大embedding维度越高存储和延迟都上去了但效果未必提升多少。3.3 检索不是最简单的“找最像”混合检索与重排更靠谱只用向量检索最常见的翻车场景是用户搜“2024年第四季度营收”结果查出一堆包含“第四季度”但跟营收无关的内容。原因很简单向量检索擅长语义相似但遇到数字、型号、精确术语效果会大打折扣。我的做法是BM25关键词检索与向量检索并行再把两路结果合并重排。# 伪代码用于说明混合检索流程 bm25_results bm25_search(query, top_k30) vector_results vector_search(query, top_k30) candidates deduplicate(bm25_results vector_results) reranked rerank_by_cross_encoder(query, candidates, top_k5)先各自多召回一些候选再用交互式排序模型把Top N精排。如果项目刚开始不想引入重排序模型也可以先做一个简单策略完全命中关键词的片段加权排序效果也能改善不少。这一块是最容易被忽视又最值钱的部分。3.4 生成环节引文从哪里来怎么约束模型不乱编检索做完了最后一步是把片段拼进Prompt让模型基于这些材料回答。Prompt里我会把每个片段标上序号并要求模型在回答里标注引用的片段编号。这样用户能看到答案出自哪份文档出了问题也好溯源。提示词里我会明确写一行规则“如果给定的材料中没有相关信息请直接回答不知道不要编造。”不要小看这句话加上引文约束之后模型的幻觉率下降非常明显。我实际跑过一个内部知识库问答没有引文约束时模型经常把两份文档的信息缝在一起输出一个看似合理但实际错误的结果加了引文编号后这类错误暴露得更快定位也容易多了。4. Agent工作流实现从“生成文字”到“解决问题”4.1 Agent的核心循环观察、规划、执行、反馈如果说RAG解决的是“知识从哪来”Agent解决的就是“事情怎么做”。我实现的Agent不复杂核心就是一个循环把用户目标交给模型模型决定调用哪个工具拿到工具结果后继续判断下一步直到任务完成或达到最大轮数。这就是经典的ReAct模式。for step in range(max_steps): response llm.chat(messages tools_schema) if response.finish_reason tool_calls: for tool_call in response.tool_calls: result execute_tool(tool_call) messages.append(tool_result_message(tool_call, result)) else: final_answer response.content break这里最容易被忽略的是最大轮数。我一开始没设上限结果Agent在某个分支上反复调用同一个工具白白烧了几十万token才发现。现在所有Agent任务都强制设max_steps默认10轮复杂任务也最多等用户确认后再续跑。执行前先声明终止条件是Agent工程化的底线。4.2 工具怎么注册和描述模型才用得顺手工具本身不难写难的是让模型“正确选择并调用”。我给工具注册做了统一规范每个工具要有name、description、parameters三个字段description必须说清楚“什么时候用、什么时候不用”parameters要写清楚类型和必填项。TOOLS [ { type: function, function: { name: search_orders, description: 按用户ID或订单号查询订单仅当用户询问订单信息时使用不要用于退货或售后问题。, parameters: {...}, }, } ]工具返回结果也要标准化。我不喜欢让工具直接返回一段给用户看的文案而是返回结构化数据比如code、message、data再由模型组织语言给用户。这样模型不会被工具返回值里的格式带偏后续做意图分析、留痕也都更方便。工具内部异常要在返回值里体现而不是直接抛异常否则Agent会直接卡死。4.3 多步任务拆解与人工审核的平衡Agent真正麻烦的是复杂任务。用户说“帮我查一下这个客户的订单如果逾期就提醒他补款”这其实包含查单、判断逾期、联系客户三个动作。我的经验是不要指望模型一步到位先把任务拆成可执行的子步骤并让关键动作必须经过人工确认。比如涉及发消息、改数据、扣款这类有副作用的行为我要求Agent先输出一个操作计划等用户点了确认再执行。这就是所谓human-in-the-loop。听起来耽误效率但实际能省掉无数售后问题。AI本来是帮你省事的结果Agent自作主张发了错误短信反而更麻烦。至少在项目早期凡是不可逆操作一律加人工确认。5. 评估与监控AI工程的隐形地基5.1 离线评估先造一把“尺子”再谈优化很多项目死在同一个地方没有评测集凭感觉调Prompt。今天觉得效果好明天换个Case又崩了自己还不知道是哪次改动导致的。所以我建议项目第一天就建评测集不需要很多先挑100条真正业务里会出现的问答标注好期望答案之后每次改Prompt、换模型、调参数都用同一把尺子量。我用的评估脚本很简单就是批量跑用例对比几个维度的得分def evaluate(predictions: list[str], references: list[str]) - dict: return { exact_match: sum(p r for p, r in zip(predictions, references)) / len(predictions), format_valid: format_ok(predictions), contains_citation: citation_ok(predictions), }别小看这种土办法。它能让你在改了一个参数之后客观看到得分是涨了还是跌了。等项目到了中后期再考虑引入LLM作为裁判来自动打分比如判断回答与标准答案在语义上是否一致。但一开始用LLM评LLM噪声太大很难判断变化是模型造成的还是裁判造成的。5.2 线上监控怎么做每个请求都要可追溯离线评估管的是“发版前”线上监控管的是“发版后”。我的习惯是每个请求都打结构化日志至少包含用户输入、Prompt模板ID、模型输出、检索到的片段ID、总token数、延迟、用户反馈。落库之后哪怕哪天线上出了个离谱回答也能顺着日志把根因找出来。用户反馈是整个闭环里最有价值的信号。在界面里加一个简单的“有帮助/没帮助”按钮结果要回流到日志系统。我在实际项目中就发现用户点“没帮助”的Case里相当一部分是检索阶段就错了——接进来的文档根本不对。没有反馈闭环这类问题你可能永远发现不了。监控数据汇总之后设置基本告警错误率超过阈值、延迟P95超过3秒、token消耗异常增长都需要第一时间通知到人。6. 常见问题与排查技巧实录6.1 Prompt改了很多遍都没效果先检查三个变量如果你改Prompt已经改到想放弃先别急着加句子检查这三件事一是示例够不够。只写规则不写示例模型往往理解不到位各类任务至少放1到2个输入输出示例。二是输出格式约束是否明确。如果要求JSON就直接写“只输出JSON”并且上线前用代码校验不要依赖模型自觉地不带多余文字。三是temperature是否过高。排查类任务温度一定要低哪怕是0.1输出也仍然可能有一定随机性要稳定就设0。我之前做分类任务时规则写了很长效果还是飘查来查去发现是温度自动沿用默认的0.7。调成0之后同一批用例的准确率直接涨了8个点。这种低级错误但确实容易犯。6.2 RAG检索出来的东西牛头不对马嘴检索结果不对最常见的坑有三个切分把语义切成碎片、embedding模型语言不匹配、query太长太口语化。排查方法很直接——把检索返回的前5条结果全部打印出来肉眼看一遍基本就能定位问题在哪一层。如果是query的问题可以在检索前加一步query改写比如把口语“上个月的单子怎么还没发”改写成“上月未发货订单查询”检索效果会好很多。匹配不上的情况再试试混合检索加关键词命中加权。我在文档问答里很少纯粹只用向量检索混合检索基本是标配优先处理命中关键词的片段。6.3 Agent跑飞、死循环、乱调工具Agent出问题大多是两个原因工具描述不清楚或者缺少终止条件。工具描述没说清“什么时候不该用”模型就会在边界场景里误调工具。比如有一个查询天气的工具没写“只能查当前城市”用户问“上海明天天气”它可能会先查别的城市。描述越具体误用越少。死循环的解法前面提过限死max_steps同时每一轮都要检查是否已经产出用户需要的最终结果一旦有了就直接跳出不要等模型自己说“完成”。还有一个经验是给Agent加白名单只能调用预设好的工具不能让模型自由发挥地调系统命令——你在本地玩玩可以一旦上了服务那都是事故隐患。6.4 接口延迟高、成本下不来怎么办延迟高的第一解是流式输出用户看到第一个字的时间能减少一半以上体感上会快很多。再就是加缓存相同或相似问题直接命中缓存不必要的重复调用全省掉。成本方面能用小模型解决的任务不要上大模型很多分类抽取场景用轻量模型就够了。并发请求还能用连接池和批量处理的技巧。这些优化做完整体成本和延迟通常能降30%到50%而且不影响质量。关于 ai-engineering-from-scratch 这条路最后说句掏心窝的话。如果你现在准备走这条路先别急着上框架也先别想着一步到位搞个全自动Agent。我犯过最大的错就是刚开始就搭“全能管家”结果大量时间花在调试工具调用上连最基本的问答质量都没顾上。回到原点把模型调用、提示词、RAG这三个基本功磨扎实再研究Agent和复杂任务编排进度反而更快。说实话AI工程的技术栈更新非常快但工程化的底层方法论——拆解、度量和可控——从来没变过。你把这些沉淀下来后面无论换什么模型、什么框架手里都有一张打不乱的底牌。
返回列表