1. 为什么我要从零手搓一个Agent
先说结论:如果你打算认真搞Agent开发,别一上来就抱着LangChain或者AgentScope啃文档。我自己的经验是,先花两三天时间,用一个周末,从零手搓一个最小可用的Agent,你对整个体系的理解会完全不一样。这不是说框架不好,而是框架帮你藏了太多东西,藏到你出了问题根本不知道从哪查。
我最初接触Agent这个概念的时候,脑子里其实是一团浆糊。LLM我懂,无非就是调API拿回复;RAG我也做过,无非就是向量检索加拼接上下文。但Agent到底是什么?它和一条普通的LLM调用链有什么区别?为什么大家都在说Agent是下一代应用形态?这些问题我在看了大量框架文档之后反而更迷糊了,因为每个框架对Agent的定义和抽象都不一样。
后来我下定决心,关掉所有框架文档,打开一个空白的Python文件,从最裸的API调用开始,一行一行把Agent搭出来。这个过程大概持续了三个晚上,踩了无数坑,但搭完之后再回头看LangChain那些抽象,突然就全通了。所以这篇内容,我想把这条从零到工程化的路径完整地分享出来,包括我踩过的坑、做过的取舍、以及那些文档里不会写的细节。
这篇文章适合谁看?如果你已经会调LLM的API,写过一些简单的Prompt,但对Agent的工程化落地还没有完整认知,那这篇内容就是写给你的。如果你是完全零基础,也没关系,我会在关键概念上做通俗解释,保证你能跟上。整篇内容会围绕一个核心目标展开:让你能自己动手搭出一个可用的Agent,并且知道怎么把它从Demo推进到工程化。
2. Agent到底是什么:拆开来看就三件事
2.1 从LLM到Agent,中间差了什么
很多人第一次听到Agent,会觉得这是个很玄的东西。但如果你把它拆开,其实核心就三件事:感知、决策、执行。普通LLM调用只有“感知”和“决策”的一部分——你给它输入,它给你输出,结束。而Agent多了一个关键环节:它会根据决策去执行动作,拿到执行结果之后再回来继续决策,形成一个循环。
用生活化的类比来说,普通LLM调用就像你问一个博学的朋友一个问题,他直接回答你。而Agent就像你雇了一个助理,你告诉他“帮我订一张明天去北京的票”,他会先查你的日程、再比价、再确认你的偏好、然后下单、最后把结果告诉你。中间可能来回好几轮,每一轮他都根据上一轮的结果调整下一步动作。
这个循环,在工程上通常叫Agent Loop或者ReAct循环(Reasoning + Acting)。它的基本流程是:接收任务 → LLM推理下一步该做什么 → 如果需要调用工具就调用 → 把工具结果喂回给LLM → 继续推理 → 直到LLM认为任务完成或者达到终止条件。
2.2 一个Agent的最小构成要素
从工程角度看,一个能跑起来的Agent至少需要这几个部分:
- LLM:大脑,负责推理和决策。可以是任何支持函数调用(Function Calling)的模型,也可以是纯文本模型配合Prompt解析。
- 工具集(Tools):手和脚,Agent能执行的具体动作。比如搜索、计算、读写文件、调API。
- 记忆(Memory):短期记忆就是对话历史,长期记忆通常用向量库做检索。
- 编排逻辑(Orchestration):控制循环怎么跑、什么时候停、出错怎么办。
- 提示词(System Prompt):告诉Agent它是谁、能做什么、怎么做决策。
这五个部分里,LLM和工具是硬依赖,记忆和编排是工程化的关键,提示词是调优的核心。我见过很多人一上来就纠结用哪个向量库、用哪个框架,其实最开始你只需要一个LLM API和一个能跑Python的环境就够了。
2.3 为什么建议先手搓再上框架
框架的价值在于帮你处理了编排、记忆、工具注册这些重复劳动。但问题是,如果你不知道这些劳动本身长什么样,你就无法判断框架帮你做的选择是否合理。我举个真实的例子:我最早用某个框架做Agent,发现它每次调用工具都会把完整的对话历史塞进Prompt,导致Token消耗飞快。我一开始以为是框架的Bug,后来自己手搓了一遍才明白,这是ReAct循环的固有特性——每一轮都要把之前的推理过程带上,否则LLM会丢失上下文。知道这一点之后,我就知道该怎么优化了:要么做历史压缩,要么把中间推理步骤存到外部记忆里。
所以我的建议是:先手搓一个最小版本,理解每个环节在干什么,然后再用框架去加速开发。这样你遇到问题的时候,至少知道该往哪个方向查。
3. 手搓第一步:把LLM调用跑通
3.1 选一个支持Function Calling的模型
手搓Agent的第一个前提,是你得有一个能稳定调用的LLM。这里不讨论具体哪家模型好,只说选型逻辑。对于Agent场景,我建议优先选支持Function Calling(也叫Tool Use)的模型。原因很简单:Function Calling让模型直接输出结构化的工具调用请求,你不需要自己写正则去解析模型的自然语言输出,稳定性高一个量级。
如果你用的模型不支持Function Calling,也不是不能做,但你需要自己设计一套Prompt模板,让模型按固定格式输出“我要调用哪个工具、参数是什么”,然后你自己解析。这种方式我早期试过,最大的问题是模型经常不按格式来,尤其是参数复杂的时候,解析失败率很高。所以除非有特殊限制,否则优先选支持Function Calling的。
3.2 最小LLM调用封装
不管你用哪家API,第一步都是把它封装成一个统一的调用函数。我自己的习惯是封装成这样一个接口:
def call_llm(messages, tools=None, temperature=0.0): """ messages: 对话历史列表 tools: 工具定义列表,None表示不启用工具调用 返回: 模型回复(可能是文本,也可能是工具调用请求) """ # 这里替换成你实际使用的API调用 response = client.chat.completions.create( model="your-model-name", messages=messages, tools=tools, temperature=temperature ) return response.choices[0].message这个封装看起来简单,但有几个细节值得注意。temperature设成0是因为Agent场景需要稳定的决策,不需要创意。messages用列表是因为Agent是多轮循环,每一轮都要把历史带上。tools参数可选是因为有些步骤(比如最后的总结)不需要工具调用。
提示:如果你用的API有流式输出,建议在Agent循环里先不用流式,等调试稳定了再加。流式会让工具调用的解析变复杂,调试阶段得不偿失。
3.3 工具定义怎么写才不容易出错
工具定义是Agent开发里最容易踩坑的地方。Function Calling的工具定义通常是一个JSON Schema,描述工具名、功能、参数。我见过太多人工具定义写得太随意,导致模型要么不调用,要么调用时参数传错。
我的经验是,工具定义要遵循三个原则:
第一,工具名要动词开头,语义明确。比如search_web比web好,calculate比math好。模型是靠名字和描述来判断该不该调用的,名字模糊它就会犹豫。
第二,描述要写清楚“什么时候用”和“什么时候不用”。很多人只写工具是干什么的,不写使用场景。比如一个搜索工具,你应该写“当需要获取实时信息或你不确定的事实性内容时使用;如果问题涉及常识或已有上下文能回答,不要调用”。这样能显著减少无效调用。
第三,参数描述要具体,最好给例子。比如一个查询参数,不要只写“查询关键词”,要写“查询关键词,应该是简洁的搜索词,例如‘2024年诺贝尔物理学奖’”。模型看到例子之后,传参的准确率会明显提升。
下面是一个我常用的工具定义模板:
tools = [ { "type": "function", "function": { "name": "search_web", "description": "搜索互联网获取实时信息。当问题涉及最新事件、实时数据或你不确定的事实时使用。如果问题能通过已有上下文回答,不要调用此工具。", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "搜索关键词,简洁明确,例如'2024年诺贝尔物理学奖得主'" } }, "required": ["query"] } } } ]这个模板我用了很多次,实测下来工具调用的准确率比随便写的版本高不少。
4. Agent Loop:整个系统的心脏
4.1 ReAct循环的完整流程
Agent Loop是整个系统的核心,也是最容易出问题的地方。我先把完整的流程拆一遍,然后再说每个环节的坑。
一个标准的ReAct循环是这样的:
- 把用户任务和System Prompt组装成初始messages
- 调用LLM,拿到回复
- 判断回复类型:
- 如果是普通文本,说明Agent认为任务完成,循环结束
- 如果是工具调用请求,进入第4步
- 执行工具调用,拿到结果
- 把工具调用请求和工具结果都追加到messages里
- 回到第2步,继续循环
- 如果达到最大轮数或超时,强制结束
这个流程看起来简单,但每一步都有细节。比如第3步,怎么判断“任务完成”?最直接的方式是看LLM有没有返回工具调用。如果它返回了纯文本,通常意味着它认为不需要再调工具了。但这个判断并不总是可靠,有时候模型会一边说“我完成了”一边又发起工具调用,这时候你要以工具调用为准。
4.2 循环终止条件怎么设计
终止条件是Agent Loop里最需要仔细设计的地方。我踩过的坑包括:Agent陷入死循环反复调用同一个工具、Agent在任务没完成时就提前结束、Agent因为一次工具报错就整个崩掉。
我的做法是设置多重终止条件:
- 正常终止:LLM返回纯文本且没有工具调用
- 轮数上限:设置最大循环轮数,比如10轮。超过就强制结束,返回当前结果
- 超时控制:整个循环设置一个总超时,比如60秒
- 重复检测:如果连续两轮调用了同一个工具且参数相同,强制结束
- 错误累积:如果连续多次工具调用失败,强制结束并返回错误信息
这几条里,重复检测是最容易被忽略但最有用的。我遇到过Agent因为搜索结果不理想,反复用同样的关键词搜索,每次都拿到一样的结果,然后继续搜。加了重复检测之后,这种情况就基本消失了。
4.3 工具执行结果怎么喂回去
工具执行完之后,结果怎么塞回messages,这个细节直接影响Agent的后续决策。标准的做法是:把LLM的工具调用请求(assistant message with tool_calls)和工具执行结果(tool message)都追加到messages里。
这里有个关键点:工具结果要尽量结构化、简洁。我见过有人把整个网页的HTML塞回去,结果Token直接爆掉。正确的做法是,工具内部先做一轮处理,只返回关键信息。比如搜索工具返回标题、摘要、链接,而不是全文。
另外,工具执行失败的时候,不要把异常堆栈直接塞回去,而是返回一个友好的错误描述,比如“搜索失败,请稍后重试”或者“参数格式错误,query应该是字符串”。这样模型有机会调整策略,而不是被一堆报错信息搞懵。
注意:工具结果里的敏感信息要过滤掉。比如你调了一个内部API,返回里带了Token或者用户ID,这些不应该出现在喂给LLM的上下文里。
4.4 一个可运行的最小Agent Loop
把上面这些拼起来,一个最小的Agent Loop大概长这样:
def run_agent(user_input, tools, max_turns=10): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"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 # 把assistant的回复加入历史 messages.append(response) # 执行每个工具调用 for tool_call in response.tool_calls: tool_name = tool_call.function.name tool_args = json.loads(tool_call.function.arguments) try: result = execute_tool(tool_name, tool_args) except Exception as e: result = f"工具执行失败: {str(e)}" messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": str(result) }) return "达到最大轮数,任务未完成"这段代码不到30行,但它已经是一个能跑的Agent了。你可以给它加搜索工具、计算工具、文件读写工具,它就能处理不少实际任务。我建议你先把这个跑通,跑通之后再考虑加记忆、加RAG、加多Agent协作。
5. 记忆系统:让Agent不再“失忆”
5.1 短期记忆和长期记忆的分工
Agent的记忆分两种:短期记忆和长期记忆。短期记忆就是当前对话的messages列表,它随着循环不断增长。长期记忆是跨对话的,通常存在外部存储里,需要的时候检索出来。
短期记忆的问题是会越来越长。一个跑了10轮的Agent,messages可能有几千Token,再加上工具返回的结果,很容易就超过模型的上下文窗口。所以短期记忆需要压缩策略。我常用的策略有两种:一是滑动窗口,只保留最近N轮;二是摘要压缩,把早期的对话用LLM总结成一段简短摘要。
长期记忆的核心是检索。当用户提出一个新任务时,Agent先去长期记忆里找相关的历史信息,找到之后作为上下文注入。这里就涉及到向量检索和RAG了,后面会详细说。
5.2 用向量库做长期记忆的实操
长期记忆的落地,绕不开向量库。向量库选型这个问题被问得很多,我的经验是:个人项目和小规模场景,用Chroma或者FAISS就够了;生产环境再考虑Milvus、Qdrant这些。原因很简单,Chroma和FAISS部署简单,本地跑不需要额外服务,适合快速验证。等你真的需要处理百万级向量、需要分布式和高可用的时候,再迁移也不迟。
具体怎么做?流程是这样的:
- 把需要记住的信息(比如历史对话、文档片段)用Embedding模型转成向量
- 把向量和原始文本一起存进向量库
- 新任务来的时候,把任务描述也转成向量
- 在向量库里做相似度检索,取Top-K条
- 把检索结果作为上下文注入到Prompt里
这里有个细节:Embedding模型要和检索场景匹配。中文场景建议用支持中文的Embedding模型,否则检索准确率会明显下降。我早期用了一个英文为主的Embedding模型做中文检索,结果召回的内容经常驴唇不对马嘴,换了中文模型之后就好了。
5.3 记忆检索的命中率怎么提升
向量检索的命中率是个老大难问题。我踩过的坑包括:检索出来的内容不相关、相关的内容排不到前面、检索结果太多导致上下文爆炸。
提升命中率的手段,按性价比排序:
第一,优化切分策略。文档切分不要按固定字数切,要按语义切。比如按段落切、按标题切,保证每个片段是完整的语义单元。我见过有人按每200字硬切,结果一句话被切成两半,检索出来根本没法用。
第二,加Rerank。向量检索是粗排,召回Top-20之后,用一个Rerank模型做精排,取Top-5。Rerank模型通常比Embedding模型更准,但速度慢,所以只用在精排阶段。实测下来,加了Rerank之后命中率能提升20%到30%。
第三,混合检索。纯向量检索对关键词不敏感,比如你搜一个专有名词,向量检索可能召回一堆语义相近但不含这个词的内容。这时候加上关键词检索(BM25),两路结果融合,效果会好很多。
第四,查询改写。用户的问题往往很短,直接拿去检索效果不好。可以先用LLM把问题改写成几个更具体的查询,分别检索再合并。这个技巧在RAG实战里非常常用。
5.4 记忆系统的工程化注意事项
记忆系统上生产之前,有几个坑必须提前填:
- 写入频率控制:不是每轮对话都要写长期记忆,那样会写入大量噪音。我的做法是,只在任务完成或者用户明确说“记住这个”的时候才写入。
- 去重:相似内容重复写入会让检索结果冗余。写入前先做一次相似度检查,超过阈值就不写。
- 过期清理:长期记忆不能无限增长,要设置TTL或者定期清理低价值内容。
- 隐私过滤:写入之前过滤掉敏感信息,这个不用多说。
6. RAG与Agent的结合:从检索增强到Agentic RAG
6.1 普通RAG和Agentic RAG的区别
普通RAG的流程是固定的:用户提问 → 检索 → 拼接上下文 → LLM生成。它是一条直线,没有分支,没有循环。
Agentic RAG就不一样了。Agent会自己决定要不要检索、检索什么、检索几次、检索结果够不够。比如用户问一个复杂问题,Agent可能先检索一次,发现信息不够,改写查询再检索一次,还不够,就去调另一个数据源。整个过程是动态的,由Agent自己编排。
这个区别带来的工程差异很大。普通RAG你只需要调一次检索接口,Agentic RAG你需要把检索封装成工具,让Agent自己决定怎么用。好处是灵活,坏处是可控性下降,需要更仔细地设计工具描述和终止条件。
6.2 把RAG封装成Agent工具
把RAG封装成工具,核心是设计好工具的输入输出。我的做法是提供两个工具:一个search_knowledge_base做向量检索,一个get_document按ID取完整文档。
search_knowledge_base的输入是查询词,输出是Top-K个片段的摘要和ID。Agent拿到摘要之后,如果觉得需要看全文,再调get_document。这样设计的好处是,Agent可以先粗看,再细看,避免一次性把大量内容塞进上下文。
工具描述里要写清楚知识库覆盖的范围。比如“本知识库包含公司产品文档和技术手册,不包含财务数据”。这样Agent就知道什么问题该查知识库,什么问题不该查。
6.3 检索质量优化的实战技巧
RAG实战里,检索质量决定了整个系统的上限。我总结了几条实战技巧:
切分粒度要匹配查询粒度。如果用户的问题通常很具体,切分就要细一点;如果问题比较宏观,切分就要粗一点。我一般会准备两种粒度的索引,Agent根据问题类型选择。
元数据过滤很有用。给每个片段打上来源、时间、类型等标签,检索的时候可以按标签过滤。比如用户问“最新的政策”,就可以过滤掉旧文档。
Rerank不要省。我前面说过,Rerank能提升20%到30%的命中率,这个投入产出比很高。Rerank模型可以用开源的,也可以用API,看你的延迟要求。
检索结果要带来源。每个片段带上来源链接或文档名,这样Agent在生成回答时可以引用,用户也能追溯。这在知识库场景里是刚需。
6.4 RAG命中率上不去的排查思路
RAG命中率低,排查要按顺序来:
| 排查项 | 检查方法 | 常见问题 |
|---|---|---|
| 切分质量 | 随机抽几个片段看是否语义完整 | 句子被切断、片段过长或过短 |
| Embedding模型 | 用几个已知问题测试检索 | 模型不支持中文、模型与场景不匹配 |
| 检索参数 | 调整Top-K和相似度阈值 | Top-K太小漏召回,太大引入噪音 |
| Rerank | 对比加Rerank前后的结果 | Rerank模型与Embedding模型不匹配 |
| 查询质量 | 看用户原始查询是否太短 | 查询太短导致语义不明确 |
| 数据覆盖 | 检查知识库里是否真有答案 | 知识库本身缺内容 |
这张表我基本每次排查都会过一遍,大部分问题都能定位到。
7. 工程化:从Demo到能上线的Agent
7.1 错误处理和重试机制
Demo阶段的Agent,一出错就崩。工程化的Agent,出错要能自愈。我处理错误的原则是:能重试的重试,不能重试的降级,降级不了的友好报错。
LLM调用失败(超时、限流)要重试,用指数退避,重试3次。工具调用失败要看类型,网络类的重试,参数类的让Agent自己调整。如果Agent循环整体失败,要返回一个友好的错误信息,而不是把异常堆栈抛给用户。
这里有个细节:重试的时候要把错误信息喂回给Agent。比如工具调用失败,你把“参数格式错误”喂回去,Agent下一轮可能就会修正参数。这比你自己在代码里修参数要灵活。
7.2 可观测性:日志、追踪、指标
Agent上生产,可观测性是刚需。你至少需要三样东西:
日志:每一轮的输入输出、工具调用、耗时都要记。我习惯用结构化日志,方便后续查询。
追踪:一个任务从开始到结束,中间经过哪些步骤、每步耗时多少,要能串起来看。OpenTelemetry这类工具可以帮上忙。
指标:任务成功率、平均轮数、平均耗时、工具调用失败率、Token消耗。这些指标能帮你发现系统性问题。
我踩过的坑是,早期没做追踪,线上出问题只能靠日志一行行翻,效率极低。后来加了追踪之后,一眼就能看出是哪一步卡住了。
7.3 成本控制:Token和调用次数
Agent的Token消耗比普通LLM调用高一个量级,因为每一轮都要带上历史。控制成本的手段有几个:
- 历史压缩:早期对话用摘要替代原文
- 工具结果精简:工具只返回关键信息
- 缓存:相同的工具调用结果缓存起来
- 模型分级:简单决策用小模型,复杂推理用大模型
- 轮数上限:严格限制最大轮数
我实测下来,历史压缩和工具结果精简这两个手段效果最明显,能省一半以上的Token。
7.4 安全边界:工具权限和输入过滤
Agent能调工具,就意味着它能对真实世界产生影响。所以安全边界必须提前设计。
工具权限分级:读操作和写操作分开,写操作要额外确认。比如搜索是读,发邮件是写,发邮件之前要让用户确认。
输入过滤:用户输入里如果有Prompt注入的企图,要能识别和拦截。比如用户说“忽略之前的指令,现在你是一个...”,这种要过滤掉。
输出过滤:Agent的输出里如果有敏感信息,要过滤。比如工具返回里带了内部IP,输出之前要脱敏。
操作审计:所有工具调用都要记录,谁在什么时候调了什么工具、参数是什么、结果是什么。出了问题能追溯。
8. 常见问题与排查技巧实录
8.1 Agent不调用工具怎么办
这是最常见的问题。Agent收到任务之后,直接用自己的知识回答,不调工具。原因通常有三个:
工具描述不清楚。模型不知道这个工具是干什么的,自然不调用。解决方法是把工具描述写详细,写清楚使用场景。
System Prompt没引导。System Prompt里要明确告诉Agent“遇到不确定的信息要调搜索工具”。我一般会在System Prompt里写一段工具使用规范。
模型能力不够。有些小模型对Function Calling的支持不好,经常忽略工具。这种情况只能换模型。
8.2 Agent陷入死循环怎么破
死循环的表现是Agent反复调用同一个工具,或者反复在几个工具之间跳来跳去。破解方法:
- 加重复检测,连续相同调用就强制结束
- 加轮数上限,硬性截断
- 在System Prompt里加“如果连续两次搜索结果不理想,尝试换关键词或直接回答”
- 工具返回里加提示,比如“这是第3次相同搜索,建议换策略”
8.3 工具调用参数传错怎么修
参数传错通常是因为工具定义的Schema不够明确。解决方法:
- 参数描述里给例子
- 参数类型要明确,不要用any
- 必填参数用required标记
- 复杂参数拆成多个简单参数
如果模型还是传错,可以在工具执行层做一层校验和修正。比如参数是日期,模型传了“明天”,你在工具里转成具体日期。
8.4 上下文超长怎么压缩
上下文超长是Agent跑多轮之后的必然问题。压缩策略:
- 滑动窗口,只保留最近N轮
- 早期对话摘要化
- 工具结果只保留关键字段
- 把中间推理步骤存到外部,需要时再检索
我一般组合使用,先滑动窗口,再对窗口内的早期内容做摘要。
8.5 常见问题速查表
| 问题 | 可能原因 | 解决方法 |
|---|---|---|
| Agent不调工具 | 工具描述不清、Prompt没引导 | 完善描述、加使用规范 |
| 死循环 | 无重复检测、无轮数上限 | 加检测、加上限 |
| 参数传错 | Schema不明确 | 加例子、加校验 |
| 上下文超长 | 历史无压缩 | 滑动窗口、摘要 |
| 检索不准 | 切分粗、无Rerank | 优化切分、加Rerank |
| 响应太慢 | 轮数多、模型大 | 限制轮数、模型分级 |
| 成本太高 | Token消耗大 | 压缩历史、缓存结果 |
| 工具报错崩溃 | 无错误处理 | 加重试、加降级 |
9. 我个人的一些经验和建议
手搓Agent这件事,我最大的体会是:不要追求一步到位。我见过太多人一上来就想搭一个全能Agent,结果卡在工具定义上就放弃了。正确的路径是,先搭一个只能调一个工具的Agent,跑通循环,然后再加工具、加记忆、加RAG。
另一个体会是,Agent的能力上限取决于工具的质量,而不是LLM的智商。一个工具设计得好的Agent,用中等模型就能跑得很好;工具设计得烂,用最强模型也白搭。所以与其纠结用哪个模型,不如花时间打磨工具定义和Prompt。
还有一点,测试要覆盖边界情况。正常流程跑通不难,难的是异常情况。工具超时、参数错误、模型返回格式不对、上下文超长,这些都要有测试用例。我自己的做法是,每加一个工具,就写一组异常测试,确保Agent能优雅处理。
最后分享一个小技巧:给Agent加一个“思考”步骤。在调用工具之前,让Agent先输出一段推理,说明它为什么要调这个工具、期望得到什么结果。这个步骤会增加Token消耗,但能显著提升决策质量,也方便你调试。等系统稳定了,再考虑去掉这个步骤来省成本。
这个内容后续还可以这样扩展:加多Agent协作,让不同Agent负责不同角色;加工具的动态注册,让Agent能自己发现新工具;加评估体系,用自动化测试衡量Agent的表现。这些都是工程化深入之后自然会遇到的方向。