news 2026/9/26 19:33:01

从零手搓Agent:LLM工具调用、RAG检索与Rerank重排实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零手搓Agent:LLM工具调用、RAG检索与Rerank重排实战

1. 为什么我要从零手搓一个Agent

先说结论:如果你打算认真搞Agent开发,别一上来就抱着LangChain、AutoGPT这类框架啃。我见过太多人,包括我自己早期,花了两周把框架文档翻了个遍,结果连一次完整的工具调用链路都跑不通,出了问题完全不知道从哪查。那种感觉就像你开着一辆全黑盒的车,仪表盘只告诉你“出错了”,剩下的全靠猜。

Agent这个东西,拆开来看其实没那么玄乎。它的本质就是一个循环:LLM负责决策,代码负责执行,执行结果再喂回LLM,直到任务完成或触发终止条件。就这么简单。你完全可以用两三百行Python把它从零搭出来,而且搭完之后你对整个系统的掌控力,是直接用框架永远给不了你的。

这篇文章面向的是有一定Python基础、了解LLM基本调用方式、但还没真正动手写过Agent的开发者。我会从最裸的版本开始,一步步加上工具调用、记忆管理、RAG检索、Rerank重排,最后聊到工程化落地时那些框架不会告诉你的坑。全程不依赖任何重型框架,核心逻辑自己写,该用库的地方用库,但每一行代码你都知道它在干什么。

热搜词里那些agent、llm、rag、向量检索、rerank,我会在对应的章节里逐个拆解,不堆概念,只讲我实际踩过的路。

2. 先搞清楚Agent到底在干什么

2.1 Agent和普通LLM调用的本质区别

普通LLM调用是一问一答:你给一个prompt,模型返回一段文本,结束。Agent不一样,它是多轮自主决策。你给一个目标,模型自己决定下一步做什么,调用什么工具,拿到结果后判断是否继续,直到它认为任务完成。

举个具体例子。你问普通LLM“北京今天天气怎么样”,它要么说“我无法获取实时信息”,要么编一个。但你给Agent同样的任务,它会:第一步,判断需要调用天气查询工具;第二步,生成工具调用参数(城市=北京);第三步,拿到工具返回的真实数据;第四步,把数据整理成自然语言回复你。

这个“判断-调用-整合”的循环,就是Agent的核心。听起来简单,但工程上的复杂度全在细节里:模型怎么知道有哪些工具可用?工具调用的参数格式怎么保证正确?调用失败了怎么办?多轮之后上下文爆了怎么办?这些才是真正花时间的地方。

2.2 一个最小Agent的组成要素

我把一个能跑的Agent拆成四个必需件:

  • LLM:大脑,负责推理和决策。我用的是支持function calling的模型,这是前提,不支持工具调用的模型做Agent会很痛苦。
  • 工具集:Agent能调用的外部能力,比如搜索、计算、查数据库、调API。每个工具需要清晰的名称、描述和参数schema。
  • 循环控制器:管理“决策-执行-反馈”的循环,包括最大轮数限制、终止条件判断、错误处理。
  • 记忆:短期记忆就是对话历史,长期记忆可以接向量库做RAG检索。

这四个件里,LLM和工具集是基础,循环控制器是骨架,记忆是让Agent从“能用”到“好用”的关键。很多人做Agent只做了前三个,结果发现Agent记不住之前聊过什么,每次都要重新解释背景,体验很差。

2.3 为什么不用现成框架

我不是说框架不好。LangChain、AgentScope这些框架在快速原型阶段确实省事。但问题在于,当你需要调试一个诡异的行为时,框架的抽象层会让你抓狂。比如模型明明返回了正确的工具调用,但框架没执行,你翻源码翻了半天发现是某个parser的兼容性问题。这种时间成本,在项目紧的时候是致命的。

从零手搓的好处是:每一层你都能打日志,每一个决策你都能复现,每一个bug你都能定位到具体行。而且手搓一遍之后,你再用框架,就知道框架帮你做了什么、哪些地方可能出问题,用起来反而更顺手。

3. 手搓Agent的核心细节与实操要点

3.1 工具定义:让模型准确理解你的意图

工具定义是Agent开发里最容易被低估的环节。很多人随便写个描述就扔给模型,然后抱怨模型调用不对。实际上,工具描述的质量直接决定了Agent的可靠性。

我用的工具定义格式是JSON Schema,每个工具包含name、description、parameters三部分。关键在于description,它要回答三个问题:这个工具做什么、什么时候用、参数怎么填。

举个例子,一个查询天气的工具,差的描述是“查询天气”,好的描述是“根据城市名称查询该城市当前天气状况,包括温度、湿度、风力。当用户询问天气相关问题时使用此工具。城市名称需使用中文,如‘北京’、‘上海’。”

参数定义也要细致。每个参数要有type、description、是否required。对于枚举类型的参数,一定要用enum限定取值范围,否则模型可能返回一个你根本没处理的选项。

注意:工具描述不是写给用户看的,是写给模型看的。模型只能通过描述来判断何时调用、如何调用。描述里要包含使用场景、参数格式、边界条件。

3.2 循环控制:什么时候停,什么时候继续

循环控制是Agent的骨架。最核心的问题是:怎么判断Agent该继续还是该停。

我的做法是三层判断:

第一层,模型返回的finish_reason。如果模型返回的是tool_calls,说明它想调用工具,继续循环。如果返回的是stop,说明它认为任务完成了,输出最终答案。

第二层,最大轮数限制。我一般设10轮,超过就强制终止并返回当前结果。这是防止Agent陷入死循环的兜底。

第三层,工具执行结果判断。如果工具连续失败三次,我会中断循环,把错误信息返回给用户,而不是让模型继续瞎试。

这里有个细节:模型有时候会在一次返回里同时包含文本和工具调用。我的处理是,如果有工具调用,就执行工具,文本内容作为中间思考记录;如果没有工具调用,文本内容就是最终答案。

3.3 上下文管理:别让历史撑爆你的窗口

多轮循环之后,对话历史会越来越长。如果不做管理,很快就会超出模型的上下文窗口,然后你就看到那个经典的报错:llm request failed: provider rejected the request schema or tool payload。

我的策略是滑动窗口+摘要压缩。保留最近N轮完整对话,更早的历史用LLM生成一段摘要,把摘要作为系统消息放在最前面。这样既保留了关键信息,又控制了token数量。

具体实现上,我设了一个阈值:当历史token数超过模型窗口的60%时,触发压缩。压缩时把最早的几轮对话拿出来,让模型生成一段200字以内的摘要,然后把这几轮替换成摘要。

实操心得:压缩的prompt要明确告诉模型“保留关键事实、决策和结果,丢弃寒暄和重复内容”。我试过不指定,模型会把“好的”“明白了”这种废话也摘要进去,浪费token。

3.4 错误处理:Agent开发里最脏最累的活

错误处理是Agent从demo到可用的分水岭。demo里工具调用失败直接抛异常就完事了,但生产环境里,你需要考虑:网络超时、API限流、参数格式错误、工具返回空结果、模型返回格式不符合预期……

我的做法是给每个工具调用包一层try-except,把错误信息结构化后返回给模型,让模型决定是重试、换工具还是放弃。比如工具返回“参数city不能为空”,模型看到后会自动补上城市名重新调用。

但这里有个坑:如果错误信息太技术化,模型可能理解不了。比如“KeyError: 'city'”,模型看了也不知道怎么办。所以错误信息要转成自然语言:“调用天气查询工具失败,原因:缺少必要参数‘城市名称’。请补充城市名称后重试。”

4. 给Agent加上RAG和Rerank

4.1 为什么Agent需要RAG

Agent本身只能依赖模型内部的知识和工具返回的信息。但很多场景下,你需要Agent基于私有知识库回答问题,比如公司内部文档、产品手册、个人笔记。这时候就需要RAG(检索增强生成)。

RAG的核心思路是:把知识库文档切块、向量化、存入向量库;用户提问时,把问题也向量化,在向量库里检索最相似的几个块,作为上下文喂给LLM。

但Agent里的RAG和普通RAG有个关键区别:Agent可以自主决定什么时候检索、检索什么。普通RAG是每次提问都检索,Agent是把检索封装成一个工具,模型判断需要知识库信息时才调用。

4.2 向量库选型:别一上来就上重型数据库

热搜词里有人问“向量库检索需要什么数据库”,我的回答是:看数据量。

数据量在10万条以下,用FAISS就够了,纯内存,零依赖,pip装完就能用。数据量在百万级,可以考虑Milvus或Qdrant,支持持久化和分布式。再往上,才需要考虑专门的向量数据库集群。

我个人的项目大多在10万条以内,FAISS完全够用。它的索引构建快,检索延迟在毫秒级,而且不需要额外部署服务。对于个人项目和中小团队,这是性价比最高的选择。

注意:FAISS的索引默认在内存里,进程重启就没了。生产环境需要定期把索引持久化到磁盘,启动时加载。

4.3 文档切块:切得好不好直接决定检索质量

文档切块是RAG里最容易被忽视但影响最大的环节。切得太碎,语义不完整;切得太粗,检索精度下降。

我的经验是:按语义切,不按字数切。具体做法是先用换行符和标点做粗切,然后合并相邻的小块,直到接近目标长度(我一般用500字左右)。同时保留一定的重叠(overlap),我设的是50字,防止关键信息刚好被切断。

对于结构化文档(比如Markdown),我会按标题层级切,每个二级标题下的内容作为一个块。这样每个块都有明确的主题,检索时更容易匹配。

4.4 Rerank:让检索结果从“能用”到“好用”

向量检索有个天然缺陷:它基于语义相似度,但相似不等于相关。有时候检索出来的top5里,真正有用的可能只有第3条,但第1条因为字面相似度高被排在了前面。

Rerank就是解决这个问题的。它的思路是:先用向量检索召回一批候选(比如20条),然后用一个专门的Rerank模型对这20条做精细排序,选出最相关的5条。

我用的Rerank方案是交叉编码器(Cross-Encoder),它把query和document拼在一起输入模型,直接输出相关性分数。相比向量检索的双塔结构,交叉编码器的精度更高,但速度更慢,所以只用在召回后的精排阶段。

实测下来,加了Rerank之后,检索结果的相关性提升非常明显。原来top1经常是无关内容,现在top1基本都能命中关键信息。

4.5 Agentic RAG:让Agent自己决定怎么检索

Agentic RAG是最近很火的概念,核心思想是让Agent自主控制检索过程。具体来说,Agent可以:

  • 判断是否需要检索
  • 决定检索什么关键词
  • 对检索结果不满意时,改写query重新检索
  • 结合多个检索结果做综合推理

我实现的方式是把检索封装成一个工具,同时在系统prompt里告诉模型:“当你需要知识库信息时,调用search_knowledge工具。如果第一次检索结果不相关,尝试用不同的关键词重新检索。”

这样Agent就有了自主检索的能力,比固定流程的RAG灵活很多。实测在一些复杂问题上,Agentic RAG的准确率比普通RAG高出一截。

5. 完整实操:从零搭一个带RAG的Agent

5.1 环境准备与依赖安装

我用的技术栈很轻量:

pip install openai faiss-cpu numpy sentence-transformers
  • openai:调用LLM API
  • faiss-cpu:向量检索
  • numpy:向量运算
  • sentence-transformers:生成embedding和Rerank

如果你用的是其他LLM提供商,把openai换成对应的SDK就行,接口逻辑是一样的。

5.2 第一步:搭建LLM调用层

先封装一个统一的LLM调用函数,处理重试、超时和错误:

import openai import time def call_llm(messages, tools=None, max_retries=3): for attempt in range(max_retries): try: response = openai.chat.completions.create( model="gpt-4o", messages=messages, tools=tools, temperature=0.1 ) return response.choices[0].message except Exception as e: if attempt == max_retries - 1: raise time.sleep(2 ** attempt)

temperature设0.1是因为Agent需要稳定的决策,不需要创意。重试用了指数退避,避免短时间内频繁请求。

5.3 第二步:定义工具集

我定义了三个工具:天气查询、知识库检索、计算器。

tools = [ { "type": "function", "function": { "name": "search_knowledge", "description": "在知识库中检索相关信息。当用户询问需要私有知识才能回答的问题时使用。", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "检索关键词,使用自然语言" } }, "required": ["query"] } } }, { "type": "function", "function": { "name": "calculate", "description": "执行数学计算。当需要进行数值运算时使用。", "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "数学表达式,如 '2 + 3 * 4'" } }, "required": ["expression"] } } } ]

5.4 第三步:实现RAG检索工具

这是核心部分,包含向量化、检索和Rerank:

import faiss import numpy as np from sentence_transformers import SentenceTransformer, CrossEncoder # 初始化模型 embed_model = SentenceTransformer('BAAI/bge-small-zh-v1.5') rerank_model = CrossEncoder('BAAI/bge-reranker-base') # 构建索引 def build_index(documents): embeddings = embed_model.encode(documents, normalize_embeddings=True) index = faiss.IndexFlatIP(embeddings.shape[1]) index.add(embeddings.astype('float32')) return index, documents # 检索+Rerank def search_knowledge(query, index, documents, top_k=5, recall_k=20): query_vec = embed_model.encode([query], normalize_embeddings=True) scores, indices = index.search(query_vec.astype('float32'), recall_k) candidates = [documents[i] for i in indices[0]] pairs = [[query, doc] for doc in candidates] rerank_scores = rerank_model.predict(pairs) ranked = sorted(zip(candidates, rerank_scores), key=lambda x: x[1], reverse=True) return [doc for doc, _ in ranked[:top_k]]

这里用了BGE系列的中文模型,embedding用small版本够快,Rerank用base版本精度更好。normalize_embeddings=True是为了用内积做余弦相似度。

5.5 第四步:实现Agent主循环

def run_agent(user_input, index, documents, max_turns=10): messages = [ {"role": "system", "content": "你是一个智能助手,可以调用工具来完成任务。需要知识库信息时调用search_knowledge,需要计算时调用calculate。"}, {"role": "user", "content": user_input} ] for turn in range(max_turns): response = call_llm(messages, tools=tools) if not response.tool_calls: return response.content messages.append(response) for tool_call in response.tool_calls: name = tool_call.function.name args = json.loads(tool_call.function.arguments) try: if name == "search_knowledge": result = search_knowledge(args["query"], index, documents) result = "\n---\n".join(result) elif name == "calculate": result = str(eval(args["expression"])) else: result = f"未知工具:{name}" except Exception as e: result = f"工具执行失败:{str(e)}" messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": result }) return "达到最大轮数限制,任务未完成。"

这个循环就是Agent的心脏。每一轮,模型决定是否调用工具;如果有工具调用,执行后把结果追加到消息历史;如果没有,返回最终答案。

5.6 第五步:跑起来看看

docs = [ "公司年假政策:入职满一年享受5天年假,满三年10天,满五年15天。", "报销流程:填写报销单,附上发票,提交给直属领导审批,财务审核后打款。", "产品定价:基础版99元/月,专业版299元/月,企业版需联系销售。" ] index, documents = build_index(docs) print(run_agent("公司年假有多少天?", index, documents)) print(run_agent("专业版多少钱?帮我算一下买一年比按月买省多少", index, documents))

第二个问题会触发两次工具调用:先检索价格,再计算年付节省金额。这就是Agent的价值——它能组合多个工具完成复杂任务。

6. 工程化落地时踩过的坑

6.1 模型返回格式不稳定

即使用了function calling,模型偶尔还是会返回格式不对的JSON,或者参数类型不对。我的处理是在解析时加一层校验,如果解析失败,把错误信息返回给模型让它重新生成。

还有一种情况是模型返回了工具调用,但参数是空的。这时候不要直接执行,而是把“参数缺失”的信息返回给模型,让它补充。

6.2 工具调用陷入死循环

我遇到过模型反复调用同一个工具,每次参数都一样,结果也一样,但它就是不停。后来加了检测:如果连续两次工具调用的name和arguments完全相同,就强制中断,返回当前结果。

6.3 上下文token增长过快

RAG检索返回的文档块如果太长,会迅速吃掉上下文。我的做法是限制每个块的长度(500字以内),并且只返回top3,而不是top5。如果模型觉得信息不够,它会自己再检索一次。

6.4 Rerank模型的选择

Rerank模型不是越大越好。我试过用large版本的reranker,精度确实高一点,但延迟从50ms涨到了200ms,对于交互式Agent来说体验下降明显。最后选了base版本,精度和速度平衡得比较好。

6.5 向量库的持久化

FAISS索引在内存里,进程重启就没了。我的做法是每次更新知识库后,把索引和文档一起保存到磁盘:

faiss.write_index(index, "index.faiss") with open("documents.json", "w") as f: json.dump(documents, f, ensure_ascii=False)

启动时先检查文件是否存在,存在就加载,不存在就重新构建。

7. 常见问题速查

问题可能原因解决方法
模型不调用工具工具描述不清晰补充使用场景和参数说明
工具调用参数错误参数schema不明确加enum限定、加description
检索结果不相关切块太粗或太细调整切块大小,加Rerank
上下文超限历史太长滑动窗口+摘要压缩
循环不终止模型反复调用同一工具加重复调用检测
工具执行超时外部API慢加超时和重试机制
Rerank太慢模型太大换base版本或减少候选数

8. 关于Agent开发的一些个人体会

手搓Agent这件事,最大的收获不是写出了一个能跑的系统,而是对整个链路有了肌肉记忆。现在我看到任何一个Agent框架,都能快速判断它的抽象层在哪里、可能出问题的地方在哪里。

如果你刚开始学Agent开发,我的建议是:先别碰框架,用最裸的方式跑通一个最小闭环。哪怕只有两个工具、一个循环、没有RAG,跑通了你就理解了Agent的本质。然后再逐步加上记忆、RAG、Rerank、错误处理,每加一个模块都打日志观察行为变化。

RAG和Rerank这块,不要追求一步到位。先用FAISS加embedding跑通检索,发现精度不够再加Rerank,发现切块不合理再调切块策略。每一步都有明确的优化目标,而不是一上来就堆一堆组件。

最后说一个我踩过的坑:Agent的prompt不要写太长。我早期喜欢在system prompt里塞一大堆规则,结果模型反而容易忽略关键指令。后来精简到只保留核心行为约束,把细节放到工具描述里,效果反而更好。模型和人一样,信息太多会抓不住重点。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 19:32:32

数据库课后习题答案(第四版)PDF:SQL Server实操验证与自动化脚本

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 19:32:18

Windows电源管理优化:通过最大处理器状态降低CPU频率与发热

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 19:31:32

openubmc 新增硬件资源对象

新增硬件资源对象 ​ CSR中的对象定义来源于MDS模型,通过实例化MDS类定义,我们便可以很方便的在对应的设备配置中,将所需要的硬件管理数据配置齐全,然后在组件中对资源对象进行业务逻辑编写。 MDS对象定义 ​ 首先我们需要在my_ap…

作者头像 李华
网站建设 2026/9/26 19:31:30

从一枚手机镜头到毕业论文:光学人的 AI 工具链怎么选

如果你在理学 / 物理学 / 光学方向学习,大概率会遇到一类很典型的毕业任务:基于 Zemax OpticStudio 的手机广角物镜优化设计。 题目看起来不长,但事情不少:要确定焦距、视场角、F 数、传感器尺寸,要选初始结构&#x…

作者头像 李华
网站建设 2026/9/26 19:30:51

华为光学工程师面试全解析:考点体系与备考策略

华为的光学工程师面试,在圈子里一直是个热度很高的话题。每年校招和社招,光通信、手机镜头、车载光学这几个方向的候选人,都会翻来覆去地找面经,但能找到的往往是零散几条,什么“考了球差和彗差”“让我推导了高斯公式…

作者头像 李华