从零手搓Agent:ReAct循环、RAG检索与Rerank重排实战
发布时间:2026/10/1 19:31:47来源:尧图网络
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是模型思考→决定用哪个工具→执行工具→拿到结果→继续思考→可能再用另一个工具→直到模型认为可以给出最终答案。这个模式最早由ReActReasoning Acting论文提出核心就是把推理和行动交替进行。我用伪代码展示最简结构def agent_loop(user_query, tools, max_steps10): 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_k1000把检索拖垮的情况。3. RAG检索层从向量检索到Rerank的完整链路3.1 RAG到底解决什么问题RAGRetrieval-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太小召回率下降太大速度变慢。我一般从nprobe10开始调根据召回率测试结果微调。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-50Rerank精排取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_fp16True) pairs [[query, doc] for doc in candidate_docs] scores reranker.compute_score(pairs) ranked_docs [doc for _, doc in sorted(zip(scores, candidate_docs), reverseTrue)]Rerank的代价是延迟。50个候选文档Rerank一次大概200-500ms取决于模型大小和硬件。如果对延迟敏感可以减少候选数量到20-30或者用更小的Rerank模型。实操心得Rerank的输入长度有限制通常512 token如果chunk太长会被截断。所以chunk_size不要超过Rerank模型的最大长度否则Rerank效果会打折扣。3.5 混合检索向量关键词的互补纯向量检索有个弱点对精确匹配不敏感。比如用户搜“错误码E5021”向量检索可能返回一堆语义相似但错误码不同的文档。这时候关键词检索BM25就能补上。混合检索的常见做法是一路向量检索一路BM25检索然后用RRFReciprocal Rank Fusion融合排名。def rrf_fusion(vector_results, bm25_results, k60): 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(), keylambda x: x[1], reverseTrue)RRF的好处是不需要调权重两路结果直接融合。k值一般取60这是原论文的推荐值我实测下来50-70之间差别不大。4. 记忆管理与上下文工程4.1 短期记忆对话历史的压缩策略Agent多轮对话时历史消息会越来越长最终超出context window。简单的做法是滑动窗口只保留最近N轮。但这样会丢失早期的重要信息。我的做法是分层记忆最近3轮保留完整内容3轮之前的做摘要压缩。摘要用LLM生成保留关键实体、决策和结论。def compress_history(messages, keep_recent3): 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_retries3, base_delay1): 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 检索结果不相关怎么排查按链路一步步查把query向量化和知识库里的向量算相似度看top-10里有没有相关的。如果没有说明向量模型不行或者切分有问题。如果有相关的但排名靠后说明Rerank没做好或者没加Rerank。如果检索结果相关但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都打印出来不要只看最终结果。很多问题在中间步骤就已经暴露了只看最终输出会误导你的排查方向。
网站建设高端定制企业官网