拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
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_key=api_key or os.getenv("LLM_API_KEY"), base_url=base_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( model=self.model, messages=messages, temperature=temperature, **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_size=600, chunk_overlap=80, 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_k=30) vector_results = vector_search(query, top_k=30) candidates = deduplicate(bm25_results + vector_results) reranked = rerank_by_cross_encoder(query, candidates, top_k=5)

先各自多召回一些候选,再用交互式排序模型把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工程的技术栈更新非常快,但工程化的底层方法论——拆解、度量和可控——从来没变过。你把这些沉淀下来,后面无论换什么模型、什么框架,手里都有一张打不乱的底牌。

返回列表