拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

从零手搓Agent:ReAct循环、RAG检索与Rerank重排实战

从零手搓Agent:ReAct循环、RAG检索与Rerank重排实战

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

先说结论:如果你只是想调个API做个聊天机器人,那没必要看这篇。但如果你想搞清楚Agent到底是怎么运转的、为什么有些Agent能自己规划任务而有些只会复读、RAG的检索命中率为什么忽高忽低——那自己动手写一遍是最快的路径。

我在过去大半年里陆续用LangChain、AgentScope这类框架搭过几个Agent项目,说实话,框架确实省事,但坑也真不少。最典型的问题是:出了bug你根本不知道是框架的锅还是自己的锅。检索效果差,你分不清是向量模型不行、切分策略有问题、还是Rerank环节拖了后腿。所以我决定抛开框架,用最原始的方式手搓一个Agent,把每一层都拆开看明白。

这篇文章面向的读者是:有基本Python能力、了解LLM基本概念(知道什么是token、什么是prompt)、但没自己从头搭过Agent的开发者。我会从最核心的Agent循环讲起,然后逐步加入工具调用、RAG检索、Rerank重排、记忆管理,最后聊工程化落地时那些框架不会告诉你的坑。代码以伪代码和关键片段为主,重点是思路和取舍逻辑,不是复制粘贴就能跑的完整项目——那种东西网上太多了,但能讲清楚“为什么这么设计”的很少。

整个Agent的核心其实就三件事:LLM负责思考和决策,工具负责执行,记忆负责保持上下文。听起来简单,但每一层的实现细节都会直接影响最终效果。下面我按搭建顺序一层层拆。

2. Agent核心循环的设计与实现

2.1 最简Agent循环:ReAct模式的本质

Agent和普通LLM调用的本质区别在于循环。普通调用是:你问一个问题,模型答一个结果,结束。Agent是:模型思考→决定用哪个工具→执行工具→拿到结果→继续思考→可能再用另一个工具→直到模型认为可以给出最终答案。

这个模式最早由ReAct(Reasoning + Acting)论文提出,核心就是把推理和行动交替进行。我用伪代码展示最简结构:

def agent_loop(user_query, tools, max_steps=10): messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_query}] for step in range(max_steps): response = llm.chat(messages) if response.has_tool_call(): tool_name = response.tool_call.name tool_args = response.tool_call.arguments result = tools[tool_name].execute(**tool_args) messages.append(response.message) messages.append({"role": "tool", "content": result}) else: return response.content return "达到最大步数限制,未能完成任务"

就这么简单。但魔鬼在细节里。max_steps设多少?设少了任务没完成就断了,设多了模型可能陷入死循环反复调同一个工具。我的经验是:简单问答类任务5步足够,涉及多步推理的复杂任务给10-15步,再复杂你就该考虑拆分子Agent了。

另一个关键点是工具调用的格式。早期做法是在prompt里约定模型输出特定格式的JSON,然后正则解析。现在主流LLM都支持原生function calling,直接返回结构化的tool_call对象,省去了解析的麻烦。但如果你的模型不支持function calling(比如一些开源小模型),就得回到prompt约定格式的老路。我试过用Qwen2.5-7B做function calling,效果还行,但格式偶尔会飘,需要加few-shot示例来稳定。

2.2 System Prompt的设计:比你想的重要十倍

很多人搭Agent时System Prompt随便写两句就完事了,然后抱怨Agent不听话。实际上System Prompt是Agent行为的“宪法”,它决定了:

  • Agent的角色定位和能力边界
  • 工具调用的决策逻辑(什么时候该调工具,什么时候直接回答)
  • 输出格式的约束
  • 安全兜底规则

我踩过的坑是:一开始没在System Prompt里明确“如果工具返回结果为空怎么办”,结果Agent拿到空结果后开始胡编。后来加了一句“如果工具返回的结果不包含所需信息,如实告知用户,不要编造”,问题就解决了。

一个我实际在用的System Prompt骨架:

你是一个任务型Agent,可以使用以下工具帮助用户解决问题。 工具列表: {tool_descriptions} 决策规则: 1. 如果问题可以直接回答,不需要调用工具 2. 如果需要实时信息或外部数据,调用对应工具 3. 每次只调用一个工具,拿到结果后再决定下一步 4. 如果工具返回为空或报错,如实告知用户,不要编造结果 5. 最多调用工具{max_tool_calls}次,超过后基于已有信息给出最佳回答 输出要求: - 最终回答要简洁、准确、有依据 - 如果引用了工具返回的数据,标注来源

注意第4条和第5条,这两条是我在实际运行中反复调试后加上的。没有第4条,Agent会幻觉;没有第5条,Agent可能无限循环调工具。

2.3 工具注册与参数校验

工具是Agent的手脚。每个工具需要定义:名称、描述、参数schema、执行函数。描述特别重要——LLM是根据描述来决定用哪个工具的。描述写得好,工具选择准确率能差出30%以上。

举个例子,我有个“搜索知识库”的工具和一个“搜索网页”的工具。如果描述都写成“搜索信息”,模型经常选错。改成“搜索内部知识库,适用于公司产品文档、内部流程等问题”和“搜索互联网,适用于实时新闻、公开数据等问题”之后,选择准确率明显提升。

参数校验也不能省。LLM生成的参数经常有类型错误——该传整数的传了字符串,该传列表的传了单个值。我一般用Pydantic做参数校验和类型转换,校验失败时把错误信息返回给LLM让它重新生成,而不是直接抛异常终止。

from pydantic import BaseModel, field_validator class SearchArgs(BaseModel): query: str top_k: int = 5 @field_validator('top_k') def clamp_top_k(cls, v): return max(1, min(v, 20))

这个clamp_top_k看着不起眼,但防止了LLM传个top_k=1000把检索拖垮的情况。

3. RAG检索层:从向量检索到Rerank的完整链路

3.1 RAG到底解决什么问题

RAG(Retrieval-Augmented Generation)的核心思路是:LLM的知识是训练时冻结的,而且它不知道自己不知道什么。你问它公司内部文档的内容,它要么说不知道,要么一本正经地胡说。RAG的做法是:先从外部知识库检索相关内容,把检索结果塞进prompt里,让LLM基于这些内容回答。

听起来简单,但实际做起来,检索质量决定了RAG效果的上限。检索不到相关内容,后面LLM再强也没用。我见过太多项目在检索层偷懒,然后指望换个更强的LLM来救场——救不了的。

RAG的完整链路是:文档加载→切分→向量化→存储→查询向量化→向量检索→(可选)关键词检索→融合→Rerank→组装prompt→LLM生成。每一步都有讲究。

3.2 文档切分:最容易被忽视的关键环节

切分策略直接决定了检索粒度。切太大,一个chunk里混了好几个主题,检索时噪音大;切太小,上下文不完整,LLM拿到碎片拼不出完整答案。

我的经验参数:

文档类型chunk_sizeoverlap说明
技术文档500-800100-150按标题层级切,保持段落完整
法律合同1000-1500200条款不能切断
对话记录300-50050按轮次切
论文800-1200150按章节切

overlap的作用是防止关键信息刚好落在切分边界上被切断。但overlap也不是越大越好——太大会导致检索结果重复,浪费context window。

我现在的做法是递归切分+语义切分结合:先按标题/段落切,如果某段还是太长,再按句子边界切。LangChain的RecursiveCharacterTextSplitter就是这个思路,但它的默认分隔符对中文不太友好,需要自己加中文标点。

注意:切分时一定要保留元数据(来源文件、页码、章节标题)。后面Rerank和引用标注都依赖这些信息。

3.3 向量检索:模型选型和索引优化

向量模型的选择上,中文场景我实测下来,BGE-M3和GTE-large是目前性价比最高的选择。BGE-M3支持多语言、长文本(8192 token),而且可以同时输出稠密向量和稀疏向量,省了一套BM25的部署。如果追求极致效果可以用OpenAI的text-embedding-3-large,但成本和延迟都要考虑。

向量索引方面,数据量小于10万条时用FAISS的Flat索引就够了,召回率100%,速度也不慢。超过10万条考虑IVF索引,但要注意nprobe参数的调整——nprobe太小召回率下降,太大速度变慢。我一般从nprobe=10开始调,根据召回率测试结果微调。

import faiss dimension = 1024 # BGE-M3的向量维度 index = faiss.IndexFlatIP(dimension) # 内积索引,配合归一化向量等价于余弦相似度 # 向量必须归一化 faiss.normalize_L2(vectors) index.add(vectors)

这里有个细节:用内积索引时向量必须归一化,否则内积不等于余弦相似度。我见过有人忘了归一化,检索结果完全不对,排查了半天。

3.4 Rerank:检索效果提升最明显的一步

向量检索是双塔模型,query和document分别编码,速度快但精度有限。Rerank是交叉编码器,把query和document拼在一起过模型,精度高但速度慢。所以典型做法是:向量检索召回top-50,Rerank精排取top-5。

Rerank模型我用过BGE-Reranker-v2-m3和Cohere的rerank接口。BGE-Reranker-v2-m3本地部署方便,效果也不错。实测下来,加上Rerank之后检索命中率(hit rate)能从60%左右提升到85%以上,提升非常明显。

from FlagEmbedding import FlagReranker reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True) pairs = [[query, doc] for doc in candidate_docs] scores = reranker.compute_score(pairs) ranked_docs = [doc for _, doc in sorted(zip(scores, candidate_docs), reverse=True)]

Rerank的代价是延迟。50个候选文档Rerank一次大概200-500ms(取决于模型大小和硬件)。如果对延迟敏感,可以减少候选数量到20-30,或者用更小的Rerank模型。

实操心得:Rerank的输入长度有限制(通常512 token),如果chunk太长会被截断。所以chunk_size不要超过Rerank模型的最大长度,否则Rerank效果会打折扣。

3.5 混合检索:向量+关键词的互补

纯向量检索有个弱点:对精确匹配不敏感。比如用户搜“错误码E5021”,向量检索可能返回一堆语义相似但错误码不同的文档。这时候关键词检索(BM25)就能补上。

混合检索的常见做法是:一路向量检索,一路BM25检索,然后用RRF(Reciprocal Rank Fusion)融合排名。

def rrf_fusion(vector_results, bm25_results, k=60): scores = {} for rank, doc in enumerate(vector_results): scores[doc.id] = scores.get(doc.id, 0) + 1 / (k + rank + 1) for rank, doc in enumerate(bm25_results): scores[doc.id] = scores.get(doc.id, 0) + 1 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True)

RRF的好处是不需要调权重,两路结果直接融合。k值一般取60,这是原论文的推荐值,我实测下来50-70之间差别不大。

4. 记忆管理与上下文工程

4.1 短期记忆:对话历史的压缩策略

Agent多轮对话时,历史消息会越来越长,最终超出context window。简单的做法是滑动窗口,只保留最近N轮。但这样会丢失早期的重要信息。

我的做法是分层记忆:最近3轮保留完整内容,3轮之前的做摘要压缩。摘要用LLM生成,保留关键实体、决策和结论。

def compress_history(messages, keep_recent=3): if len(messages) <= keep_recent * 2: return messages old_messages = messages[:-keep_recent*2] recent_messages = messages[-keep_recent*2:] summary = llm.summarize(old_messages) return [{"role": "system", "content": f"之前的对话摘要:{summary}"}] + recent_messages

摘要的prompt要明确要求保留:用户提到的关键信息、已执行的工具调用及结果、未解决的问题。我试过不加约束让LLM自由摘要,结果它把关键的数字和名称都丢了。

4.2 长期记忆:向量化的历史经验

有些信息需要跨会话保留,比如用户的偏好、之前解决过的问题。这部分我用向量库存储,每次新对话开始时检索相关历史。

关键设计是存什么。我的做法是:每次对话结束后,让LLM提取值得记住的信息(用户偏好、重要事实、解决方案),存成结构化条目再向量化。不是把整段对话存进去——那样检索噪音太大。

memory_entry = { "type": "user_preference", "content": "用户偏好简洁的回答,不喜欢冗长的解释", "timestamp": "2025-01-15", "source_conversation": "conv_12345" }

检索时按相似度召回top-3,塞进System Prompt里。注意长期记忆不能塞太多,否则会干扰当前对话。我一般限制在3条以内。

4.3 上下文窗口的分配策略

Context window是稀缺资源。我的分配比例大概是:

  • System Prompt(含工具描述):15%
  • 长期记忆:5%
  • 对话历史:30%
  • RAG检索结果:40%
  • 当前用户输入:10%

这个比例不是固定的,根据任务类型调整。纯问答任务RAG占比可以更高,多轮对话任务历史占比要增加。关键是要有意识地去分配,而不是让某个部分无限膨胀把其他部分挤掉。

踩坑记录:有一次RAG检索返回了20个chunk,每个chunk 500字,直接把context占满了,对话历史被挤掉,Agent完全忘了之前聊了什么。后来我强制限制RAG结果最多5个chunk,每个chunk截断到300字。

5. 工程化落地:从Demo到可用的距离

5.1 错误处理与重试机制

Demo里LLM调用失败就失败了,生产环境不行。常见的错误类型:

错误类型原因处理策略
Rate limit请求频率超限指数退避重试
Timeout网络或模型响应慢重试+降级到小模型
Invalid tool callLLM生成的参数格式错误把错误返回给LLM重新生成
Context overflow超出context window触发历史压缩
Empty retrieval检索无结果告知LLM无相关信息,不要编造

重试策略我用的是指数退避:第一次等1秒,第二次2秒,第三次4秒,最多重试3次。超过3次就降级——比如从GPT-4降级到GPT-3.5,或者返回兜底话术。

import time def call_with_retry(func, max_retries=3, base_delay=1): for attempt in range(max_retries): try: return func() except RateLimitError: if attempt == max_retries - 1: raise time.sleep(base_delay * (2 ** attempt))

5.2 可观测性:日志、追踪与评估

Agent出问题时,你需要知道是哪一步出的问题。我的做法是给每个请求打一个trace_id,记录每一步的输入输出:LLM的prompt和response、工具调用的参数和结果、检索的query和召回文档、Rerank的分数。

这些日志不仅用于排查问题,还用于评估。我定期抽样一批请求,人工标注Agent的回答质量,然后分析bad case集中在哪个环节。是检索没召回?是Rerank排错了?还是LLM没用好检索结果?定位到环节才能针对性优化。

评估指标我关注这几个:

  • 检索命中率:top-5里包含正确答案的比例
  • 工具调用准确率:选对工具且参数正确的比例
  • 任务完成率:Agent给出有效回答的比例
  • 平均步数:完成任务平均需要几轮循环
  • P95延迟:95%的请求在多少时间内完成

5.3 成本控制:Token消耗的优化

Agent的token消耗比普通LLM调用高得多,因为每轮循环都要把完整历史发给LLM。一个5步的Agent任务,token消耗可能是单次调用的10倍以上。

优化手段:

  • 精简System Prompt:工具描述能短则短,但别牺牲清晰度
  • RAG结果去重:多个chunk内容重复时只保留一个
  • 历史压缩:前面讲过的分层记忆
  • 小模型做路由:用便宜的小模型判断是否需要调工具、调哪个工具,复杂推理再交给大模型
  • 缓存:相同query的检索结果缓存,相同文档的向量化结果缓存

我实测下来,这些优化加起来能降低40%-60%的token消耗,效果还是很明显的。

5.4 安全边界:Agent不能做什么

Agent有工具调用能力,意味着它能执行实际操作。必须设置安全边界:

  • 工具白名单:只注册经过审核的工具,不允许动态注册
  • 参数校验:所有工具参数必须经过schema校验
  • 敏感操作二次确认:删除、修改、发送类操作需要用户确认
  • 输出过滤:Agent的最终输出经过敏感词和格式检查
  • 步数限制:防止无限循环消耗资源
  • 超时控制:整个Agent任务设置总超时时间

重要提醒:永远不要给Agent直接操作生产数据库或文件系统的权限。所有操作通过API封装,API层做权限控制和审计日志。

6. 常见问题与排查实录

6.1 Agent不调用工具怎么办

这是最常见的问题。Agent收到问题后直接回答,不调工具。原因通常有三个:

第一,System Prompt里没有明确要求调工具。解决:在prompt里加“对于需要外部信息的问题,必须先调用工具获取信息”。

第二,工具描述不够清晰,LLM没理解这个工具能解决当前问题。解决:优化工具描述,加使用示例。

第三,LLM本身能力不足。一些小模型对function calling的支持不好。解决:换模型,或者在prompt里加few-shot示例教它怎么调。

6.2 检索结果不相关怎么排查

按链路一步步查:

  1. 把query向量化,和知识库里的向量算相似度,看top-10里有没有相关的。如果没有,说明向量模型不行或者切分有问题。
  2. 如果有相关的但排名靠后,说明Rerank没做好或者没加Rerank。
  3. 如果检索结果相关但LLM没用上,说明prompt组装有问题,或者检索结果被截断了。

我遇到过一次检索结果明明相关但LLM说“没有找到相关信息”,查了半天发现是RAG结果塞进prompt时被context截断截掉了。后来加了日志记录实际发给LLM的完整prompt,才定位到问题。

6.3 Agent陷入循环怎么破

Agent反复调同一个工具,或者在两个工具之间来回跳。原因通常是:工具返回的结果没有推进任务进展,Agent不知道下一步该干什么。

解决:在System Prompt里加“如果连续两次调用同一工具且结果相似,停止调用并基于已有信息回答”。另外检查工具返回结果是否包含了Agent需要的所有信息——有时候工具返回格式不对,Agent解析不了,就会反复重试。

6.4 常见问题速查表

现象可能原因排查方向
Agent不调工具Prompt未要求/工具描述不清检查System Prompt和工具描述
检索结果不相关向量模型差/切分不合理检查向量模型和chunk策略
Rerank后效果更差Rerank模型不匹配/输入超长检查Rerank模型和输入长度
Agent循环调用工具结果无进展/缺少终止条件加终止规则和步数限制
回答幻觉检索为空但LLM编造加“无信息时如实告知”约束
延迟过高Rerank慢/LLM调用多减少候选数/换小模型/加缓存
Token消耗大历史太长/RAG结果太多历史压缩/限制RAG结果数

7. 一些个人体会

手搓Agent最大的收获不是写出了一个能跑的东西,而是搞清楚了每个环节的瓶颈在哪里。用框架的时候,效果不好你只能猜;自己搭的时候,你可以精确地定位到是检索没召回、Rerank排错了、还是LLM没用好上下文。

如果让我给刚入门的开发者一个建议,我会说:先把最简的ReAct循环跑通,然后一个环节一个环节地加。不要一上来就上LangChain、上AgentScope,那些框架封装了太多东西,你学不到底层逻辑。等你手搓过一个完整链路之后,再用框架,你会知道框架帮你做了什么、哪些地方需要自己定制。

另外,RAG的检索质量真的是重中之重。我见过太多项目在LLM上花大钱,在检索上抠成本,结果效果一塌糊涂。检索做不好,GPT-4也救不了你。Rerank那一步的投入产出比极高,强烈建议加上。

最后分享一个小技巧:调试Agent时,把每一步的完整prompt和response都打印出来,不要只看最终结果。很多问题在中间步骤就已经暴露了,只看最终输出会误导你的排查方向。

返回列表