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_size | overlap | 说明 |
|---|---|---|---|
| 技术文档 | 500-800 | 100-150 | 按标题层级切,保持段落完整 |
| 法律合同 | 1000-1500 | 200 | 条款不能切断 |
| 对话记录 | 300-500 | 50 | 按轮次切 |
| 论文 | 800-1200 | 150 | 按章节切 |
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 call | LLM生成的参数格式错误 | 把错误返回给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 检索结果不相关怎么排查
按链路一步步查:
- 把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都打印出来,不要只看最终结果。很多问题在中间步骤就已经暴露了,只看最终输出会误导你的排查方向。