news 2026/10/19 15:08:29

从零实现智能Agent:ReAct循环与工具调用的完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零实现智能Agent:ReAct循环与工具调用的完整指南

这两年,智能Agent成了大模型落地最热门的方向。不少同学看完各种演示视频,以为它是一个黑盒,喂一句话进去就能全自动把事办完。其实没那么玄。拆开来看,一个Agent系统就是三件事:让大模型当大脑,给它接上外部工具当手脚,再用一套循环逻辑让它持续思考和行动,直到把任务办完。我开发过几个基于大模型的自主决策系统,从最早写死的规则脚本,到后来自主规划任务的最小Demo,中间踩了不少坑。这篇就把完整思路和代码实例整理出来,适合刚入门想动手的人,也适合想了解Agent内部原理的工程师。建议收藏慢慢看。

1. 先搞懂Agent:大模型只是大脑,不是全部

1.1 Chatbot和Agent的差异:一个在回答问题,一个在完成任务

很多人一开始会把Agent理解成"更聪明的聊天机器人",这个印象需要纠正。聊天机器人的核心是问答,你问一句,它答一句,知识再丰富,也停留在"嘴上"。Agent不一样,它的核心是完成任务——任务可能拆成好几步,做一步要看一步的结果,中途还要调用外部系统,最后交给你的是一个结果,而不是一段建议。

我用一个类比来理解这件事。Chatbot像一个知识渊博的图书馆管理员,你问他什么,他都能引经据典回答你。Agent像一个办事员,你交代他"帮我把这份材料整理好,顺便查一下相关数据和截止日期",他会打开电脑、查数据库、做计算、写摘要,每一步都在为目标服务。他需要的不只是知识,还有一连串的行动能力。

这个差异直接决定了系统设计方式。聊天机器人只需要考虑"怎么把下一句话生成好",Agent必须考虑"下一步该做什么、要不要调工具、调完工具看到结果再怎么办"。这也是为什么很多人拿现成的对话API直接当Agent用,效果总是不对劲——因为缺少了任务拆解与循环决策那套机制。

1.2 一个最小Agent系统的四个组成部分:大脑、工具、记忆、循环

我自己实现过几个Agent之后,发现不管任务多复杂,底层都离不开四样东西。

第一是大脑,也就是大模型本身。它负责理解用户意图、拆解子任务、判断下一步动作。第二是工具,包括计算器、数据库查询、外部API、文件读写等等。工具定义了Agent能力的边界——模型再聪明,接不到外部数据它也什么都干不了。第三是记忆,短期记忆就是当前对话的上下文消息列表,长期记忆通常落到向量数据库里,按相关性召回历史知识。第四是决策循环,这是Agent区别于普通API调用的核心。简单说就是"观察-思考-行动-再观察"的循环,每一轮模型先决定动不动作,然后执行,再把结果喂回去,直到任务收敛。

这四个部分不是串行关系,而是每轮决策都在它们之间穿梭。理解了这个骨架,后面所有代码都是往这四个框里填内容。

2. 设计一个自主决策系统:思路要先于代码

2.1 决策范式选择:为什么我会先用ReAct而不是Plan-then-Execute

开始写代码之前,要先选一套决策范式。主流的方案有两种:一种是先规划再执行(Plan-then-Execute),让模型先把完整步骤列好,然后按步骤执行;另一种是ReAct,也就是边推理边行动,走一步看一步。

我最早做Agent时倾向于先规划,因为想法上更直观——像项目管理一样,先排计划再落地。但实际操作下来,Plan模式有个很头疼的问题:计划一旦中途遇到意外,比如某个工具返回了意料之外的结果,模型就需要重新规划,而重新规划的成本很高,容易陷入"计划-推翻-再计划"的循环。除非任务流程非常固定,比如固定的审批流程,Plan模式才有优势。

现在大多数通用场景我选择ReAct。核心原因是它对不确定性更宽容。每执行一步,模型会看到真实的结果,再决定下一步。就像你开车去一个陌生地方,不是出发前就把所有路口背下来,而是开着导航,每个路口听播报再决定。这种边想边做的模式,正好匹配大模型擅长短程推理、长程规划容易丢失目标的特点。

如果你的Agent是面向"多步骤、依赖实时数据、执行过程可能有变化"的任务,我建议直接上ReAct。如果任务类型单一、步骤百年不变,再考虑Plan模式。别一上来两种都想做,先跑通一种最重要。

2.2 工具抽象层:Agent的能力边界由工具决定

工具是Agent真正"触达世界"的窗口,但这个窗口开多大、怎么开,非常讲究。我见过不少人写工具,随手给个名字和一句话描述就完事,结果模型经常在错误的时机调用错误的工具。原因很简单:大模型只能通过"名字+描述+参数定义"来理解一个工具,你给的信息不精确,它就只能猜。

工具描述要写清楚三件事:工具什么时候用、什么时候不要用、参数怎么填。举个例子,一个计算器工具的坏描述是"计算器",好描述是"计算数学表达式,表达式仅支持加减乘除和括号,例如(3+5)*2;如果你的问题涉及日期和星期计算,请使用日期工具"。加了使用边界和示例之后,模型的选择准确率会明显上升。

参数定义也值得重视。每个参数都要写清类型和含义,尤其是枚举值。比如一个查询天气的工具,城市参数可以说明"支持中文城市名",避免模型给你传入拼音或者经纬度。工具不是越多越好,每多一个工具,模型的选择空间就大一分,选错的概率也会跟着上升。我一般先把高频能力做成工具,把低频能力放到对话里解决。

2.3 记忆与上下文:短期记忆怎么管理,长期记忆怎么落地

Agent跑几步之后,上下文里的消息条数会迅速增加。每一轮模型输出、每次工具调用和返回结果都要写回消息列表,几轮下来可能就上万token了。短期记忆管理的核心问题,就是怎么在塞进模型之前控制上下文大小。

最简单的办法是窗口截断,只保留System Prompt、最近N轮对话和最近几次工具结果。麻烦在于一旦截断,模型可能丢掉任务早期的关键信息。更好一点的办法是"摘要压缩",就是当消息快要超出限制时,让模型把之前的对话浓缩成一段任务摘要,当作一条system消息保留下来。这个方案信息密度高,很多生产级Agent都在用。

长期记忆则是另一套逻辑。任务涉及历史知识时,可以先把相关资料做向量化入库,每次用户提出请求,先根据问题做相似度检索,把最相关的几段资料拼进上下文,再让大模型决策。很多人的误区是把长期记忆塞进对话里让模型自己翻,检索量一大,效果和成本都不可控。正确的做法是把“检索”做成一个工具,Agent觉得需要历史信息时主动去查,而不是被动等着喂。

3. 核心代码实现:从零手写一个最小可用Agent

3.1 准备环境:依赖清单与接口配置

这一节直接进入实操。我在本地用Python 3.10+写了一个最小Agent,依赖只用到两个核心库:requests用来发HTTP请求,python-dotenv用来读环境变量。安装命令很简单:

pip install requests python-dotenv

为什么不用各家大模型厂商的官方SDK?因为我想让代码保持通用。现在的模型平台普遍提供兼容的HTTP接口,自己封装一个几十行的client,反而方便切换不同模型。你在环境变量里配上三家不同的服务商,代码一行不用改。

创建一个.env文件:

LLM_API_KEY=你的密钥 LLM_BASE_URL=https://你的接口地址/v1 LLM_MODEL=你的模型名称

这里我把模型提供商抽象成BASE_URL和MODEL,适配面很广。跑通一次之后,你会理解这种"接口兼容层"在工程上有多顺手。

3.2 封装大脑:一个通用的LLM客户端

先写一个最精简的客户端,核心就一个方法chat,接收消息列表,返回模型回复。这里给代码加上类型注解,后续Agent主循环调用起来会更清晰。

import os import requests from dotenv import load_dotenv load_dotenv() class LLMClient: def __init__(self): self.api_key = os.getenv("LLM_API_KEY") self.base_url = os.getenv("LLM_BASE_URL").rstrip("/") self.model = os.getenv("LLM_MODEL") def chat(self, messages: list, tools: list = None, temperature: float = 0.2): url = f"{self.base_url}/chat/completions" payload = { "model": self.model, "messages": messages, "temperature": temperature, } if tools: payload["tools"] = tools resp = requests.post( url, headers={"Authorization": f"Bearer {self.api_key}"}, json=payload, timeout=60, ) resp.raise_for_status() return resp.json()["choices"][0]["message"]

temperature参数我直接设置为0.2。Agent执行任务和创意写作不一样,要的是稳定和可靠,而不是发散。如果你在做创意类Agent可以适当调高,但工具调度场景我实测下来,温度越高越容易乱调工具。

3.3 定义工具:让大模型知道它可以做什么

接下来定义Agent能用的工具。这个Demo里我准备了三个:一个计算器、一个取当前时间、一个算N天后的日期和星期。工具函数本身很普通:

import datetime def calc(expression: str) -> str: """计算数学表达式,生产环境请使用安全的表达式解析库,禁止直接eval用户输入""" try: # 仅供Demo演示,实际项目用 asteval 等安全库替换 return str(eval(expression)) except Exception as e: return f"计算失败: {e}" def get_current_time() -> str: return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S %A") def get_weekday_after_days(days: int) -> str: future = datetime.date.today() + datetime.timedelta(days=int(days)) return future.strftime("%Y-%m-%d %A")

真正关键的是给模型看的工具元信息,也就是JSON Schema。这里我强烈建议在描述里写"什么时候用、什么时候别用、参数示例",这会直接影响模型调用的准确率。

tools = [ { "type": "function", "function": { "name": "calc", "description": "计算数学表达式。仅支持 + - * / 和括号,例如 (3+5)*2。只在需要做算术运算时使用,不要用于日期计算。", "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "数学表达式,例如 (3+5)*2" } }, "required": ["expression"] } } }, { "type": "function", "function": { "name": "get_current_time", "description": "获取当前日期、时间和星期。当用户询问今天日期、当前时间或星期几时使用。", "parameters": {"type": "object", "properties": {}} } }, { "type": "function", "function": { "name": "get_weekday_after_days", "description": "计算从今天起N天后的日期和星期。例如用户问'15天后是星期几',传入 days=15。", "parameters": { "type": "object", "properties": { "days": {"type": "integer", "description": "从今天算起的天数"} }, "required": ["days"] } } } ]

这段JSON Schema看起来枯燥,但它是模型和真实世界之间的桥梁。calc的描述里我特意写了"不要用于日期计算",就是为了防止模型拿计算器硬算日期差,这类问题我后面会专门说。

3.4 决策循环:Agent主循环的完整代码

工具和大脑都就位了,现在写核心的ReAct循环。逻辑非常直接:把消息发给模型,如果模型返回tool_calls,就执行工具并把结果追加到消息列表里,然后再次调用模型;如果没有工具调用,说明模型觉得任务已完成,直接返回最终回答。

import json SYSTEM_PROMPT = """你是一个可靠的任务执行助手。你可以使用工具完成用户请求。 决策规则: 1. 需要实时信息、计算或日期运算时,先调用工具,再基于工具结果作答。 2. 工具参数必须来自用户问题或之前的工具结果,严禁编造。 3. 如果一次需要多个独立信息,可以同时调用多个工具。 4. 所有信息齐全后,用自然语言给出最终回答,不要复述工具调用过程。 """ def dispatch_tool(name: str, args: dict) -> str: if name == "calc": return calc(args.get("expression", "")) if name == "get_current_time": return get_current_time() if name == "get_weekday_after_days": return get_weekday_after_days(args.get("days", 0)) return f"未知工具: {name}" def agent_loop(user_input: str, max_iter: int = 8): client = LLMClient() messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input}, ] for step in range(max_iter): message = client.chat(messages, tools=tools) messages.append(message) tool_calls = message.get("tool_calls", []) if not tool_calls: return message.get("content", "").strip() for tc in tool_calls: func_name = tc["function"]["name"] func_args = json.loads(tc["function"]["arguments"] or "{}") result = dispatch_tool(func_name, func_args) print(f"[step {step}] 调用工具 {func_name}, 参数={func_args}, 结果={result}") messages.append({ "role": "tool", "tool_call_id": tc["id"], "content": result, }) return "已达到最大迭代轮数,任务未能完成,建议拆分子任务或检查工具定义。"

有几个细节值得说。max_iter一定要有上限,否则模型可能在同一个问题上反复打转,既烧token又不出结果。每次工具返回的消息必须通过tool_call_id关联到对应的调用,这是函数调用协议的要求,少了这个关联模型就分不清哪个结果对应哪次调用。日志打印也很重要,后面排查问题全靠它。

如果你用的是不支持函数调用协议的模型,还有一个替代方案:在System Prompt里要求模型输出固定的JSON格式,比如{"action": "call_tool", "tool": "...", "args": {...}},然后用正则或JSON解析代码去解析。思路完全一样,只是少了协议层的保障,解析时要多写很多容错逻辑。我建议能用协议就用协议,自己手写解析容易截断和乱码。

3.5 运行效果:一个复合任务的自主拆解实录

写一个稍微复杂的任务来验证:让Agent先计算(1234*5678)%97,再查一下120天后是星期几。这个任务有两个独立子问题,需要调用两个不同工具,正好能看出Agent的自主决策能力。

运行上面的agent_loop,控制台输出大概是这样的:

[step 0] 调用工具 calc, 参数={'expression': '(1234*5678)%97'}, 结果=67 [step 0] 调用工具 get_weekday_after_days, 参数={'days': 120}, 结果=2025-05-15 星期四

模型在第一步就识别出两个独立子问题,并行发起两次工具调用。第二轮看到结果已经齐全,就没有再调用工具,直接给出最终回答:

计算结果是67。120天后是2025年5月15日,星期四。

注意看这个过程的细节——模型没有问用户"要不要拆开算",没有把计算过程外包给聊天,而是自己决定"先调用工具,再汇总结果"。这个自动拆解和调用的行为,就是Agent和Chatbot最本质的区别。

4. 我把这个Agent丢进真实场景后,踩到过的坑

Demo跑通只是开始,一旦任务变复杂,问题会一个个冒出来。下面这几个坑我都在真实场景里踩过,每个都浪费了不少时间排查,写出来给你当参考。

4.1 死循环:模型像复读机一样反复调用同一个工具

最让人崩溃的现象是Agent卡在一个工具上反复调用,参数一模一样,结果也不变。看起来就像程序死循环。原因通常是模型已经拿到了结果,但System Prompt里没告诉它"拿到结果后就可以停止",或者它对结果不自信,想再确认一遍。

解决思路有三层。第一层是System Prompt里明确写"工具调用成功后,只要结果足以回答用户,立即给出最终回答"。第二层是设置max_iter上限,防止一次任务消耗过多token。第三层是加一个重复调用检测——记录最近三次工具调用的"工具名+参数哈希",如果完全一样,直接打断并告诉模型:"你已经拿到结果了,请基于已有信息回答,不要再调用相同工具。"这一招实测能解决八成以上的死循环。

4.2 幻觉参数:模型会编造一个不存在的城市

我第一次做天气Agent时就栽在这里。用户问"北京明天天气怎么样",模型的工具调用参数里出现了Beijing而不是"北京",工具端没做别名处理,直接返回错误。更奇怪的是有一次用户只说了"明天的会议地点需要带伞吗",模型直接编了一个城市名传给天气工具。

这类问题的根源在于参数生成依赖模型对上下文的理解,一旦用户没明确给出参数值,模型就可能脑补。我的经验是两层防御。第一层在工具描述里写清楚参数填法,比如"城市参数必须与用户原话保持一致;如果用户没有说明城市,请先询问,不要杜撰城市名"。第二层在代码里做参数校验,比如城市工具里维护一个白名单,不在名单里就返回"参数不合法,请让用户提供城市名"。防御性编程不是过度设计,对模型这种"自信的错误"尤其有效。

4.3 上下文膨胀:任务一长就爆token

Agent每执行一步,就要把之前所有的消息重新发送一遍。有一次我做一个多步骤数据整理任务,前后调用了十几次工具,每轮工具结果都很大,跑到第6轮就报token超限,任务直接失败。

解决方案是摘要压缩。在消息长度快要达到上限时,调用一次大模型把当前对话压缩成一个小结,然后重建消息列表。实际操作时我会把System Prompt、压缩摘要、最近两轮对话和当前这条工具结果保留,其余全部丢弃。

def compress_messages(messages: list, client: LLMClient) -> list: summary = client.chat([ {"role": "system", "content": "你是对话摘要器,把以下对话压缩成包含已完成步骤、关键结论和未完成目标的短摘要。"}, {"role": "user", "content": json.dumps(messages[-8:], ensure_ascii=False)}, ]) return [ messages[0], {"role": "system", "content": f"历史摘要:{summary}"}, messages[-1], ]

这个启发式方法不完美,但比直接截断信息保留度高很多。实测下来,一个原本第6轮就会爆掉的长任务,在加入摘要压缩后能稳稳跑完15轮以上。

4.4 工具调用序列化失败:输出JSON被截断

还有一个很隐蔽的坑。模型返回结构化的工具调用参数时,偶尔会出现JSON被截断的情况,特别是参数很长或模型本身输出不稳定的时候。json.loads直接抛异常,整个Agent当场崩溃。

我在解析工具参数的地方加了异常兜底,并且把错误信息原样返回给模型去自我修复:

try: func_args = json.loads(tc["function"]["arguments"] or "{}") except json.JSONDecodeError as e: result = f"参数解析失败: {e},请重新给出合法JSON参数" messages.append({ "role": "tool", "tool_call_id": tc["id"], "content": result, }) continue

这样做的好处是,模型看到工具返回"参数解析失败"之后,通常会主动修正自己的输出。不要小看这个环节,生产环境里工具参数解析失败的比例比我预想的高不少,做了兜底之后Agent的稳定性会上一个台阶。

5. 从Demo到生产:Agent系统还要补哪些课

5.1 可观测性:Agent的每一步决策都要留痕

Debug过Agent的人都有一种感受:模型是个黑盒,它为什么调用这个工具、为什么拒绝某个任务,完全没有常规日志可查。所以生产环境里的第一堂必修课是可观测性。

我在真实项目里会给每个用户请求生成一个request_id,然后把每一轮的完整决策痕迹结构化落盘,包括模型输入消息、输出内容、工具名称、参数、执行耗时、返回结果。排查问题时,直接按request_id拉出整条决策链路,一眼就能看出是哪一步想岔了。没有这套记录,Agent出错你根本无从下手。

5.2 安全与权限控制:不要让模型直接操作危险工具

Agent能力越强,失控风险越大。比如一个能发送邮件的Agent,如果被恶意提示词诱导,可能会在用户不知情的情况下批量发信。我在工具层加了两道保险。

第一道是白名单机制。工具执行前校验当前用户是否有权限调用这个工具,没权限直接拒绝。第二道是敏感操作二次确认。发送邮件、删除文件、转账这类不可逆操作,工具不直接执行,而是先返回"是否确认执行?请用户输入确认码",只有确认码匹配才真正执行。这个设计是为了对抗所谓的"提示词注入",攻击者可能在对话内容里埋入恶意指令,直接操作系统的Agent一旦中招,后果会很严重。

5.3 从单Agent到多Agent:后续的扩展方向

等单个Agent稳定了,你会发现有些任务天然适合分角色协作。比如做一个行业分析报告,可以拆成数据收集Agent、数据分析Agent、文案撰写Agent,各管一段,再有一个协调者统一调度。这就是多Agent协作。

多Agent不是直接把几个单Agent拼在一起就行,难点在于信息传递和冲突仲裁。谁来决定把子任务分给谁?两个Agent结果矛盾时听谁的?这些都是新问题。我的建议是不要急于追逐多Agent架构,先用一个Agent跑通核心流程,等真的出现"多个专业角色并行工作"的明确需求,再改造成多Agent模式。过早引入只会增加系统的复杂度和出错面。

5.4 回头看:这个Demo系统离生产还有多远

把它自己和自己比,Demo的优点和缺点都很明显。优点是核心闭环已经完整:模型决策、工具调用、结果回传、循环终止,这是所有Agent应用的地基。缺点同样突出:没有异步任务管理,长耗时任务会直接卡住;没有持久化存储,会话一断记忆全丢;没有并发控制,多个用户同时访问时只能排队。

要走到生产环境,至少还需要补上任务队列、状态存储、权限体系、监控告警和成本统计这几块。很多人以为生产化是大模型的活,其实大部分工作还是常规后端工程。所以我的判断是,先把这一套最小闭环跑熟,再往里面加工程能力,比一开始就铺一个大而全的框架要靠谱得多。

最后分享一个我自己的习惯。我每写一个Agent,都不急着写业务逻辑,而是先花半小时把日志打全,再花一小时把工具边界和责任范围想清楚,最后才动手写代码。这个顺序帮我少加了很多班。Agent系统的本质不是模型有多强,而是你围绕它搭的那套工程体系有多稳。希望这套从零开始的思路,能帮你把第一个Agent稳稳落地。

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

open-code-review:让代码评审从走流程到真有效的自动化规则实践

1. 为什么我会盯上"open-code-review"这个项目代码评审这件事,做了十几年,我越来越觉得它是个"理论上都懂、实践全靠缘分"的活儿。团队里每个成员都知道评审重要,可真到了提PR的时候,要么是"LGTM"刷…

作者头像 李华
网站建设 2026/10/19 15:07:25

硬件中断、时钟中断、软中断:搞懂Linux中断机制与排查实践

你是不是也有过这种经历:写设备驱动的时候,在中断处理函数里多打了一行日志,结果整个系统就像被人掐住了脖子,鼠标都开始卡顿。或者在调试网络性能时,发现 CPU 的消耗高得离谱,却说不清楚时间到底花在了哪里…

作者头像 李华
网站建设 2026/10/19 15:06:40

Go+微信小程序校园论坛源码全解析:架构部署与避坑指南

简介:基于Go语言开发的校园论坛微信小程序设计源码,面向高校学生、校园开发者及需要构建校内交流平台的团队,可快速搭建包含用户、帖子、评论、后台管理等完整模块的论坛类小程序,有效提升校园信息共享与交流效率。压缩包共56个文…

作者头像 李华
网站建设 2026/10/19 15:06:24

工业智能体落地实战:从单点智能到系统智能的破局路径

1. 工业智能体到底在解决什么层面的问题 工业智能体这个词最近一年被提得越来越多,但很多人第一次听到会下意识把它等同于"工业互联网平台"或者"AI质检",这其实是个挺大的误解。我接触过几个不同阶段的制造类项目,从产线…

作者头像 李华
网站建设 2026/10/19 15:05:11

生成式AI超分辨率加速天气预测:低算力实现高分辨率预报的工程实践

你们有没有遇到过这种情况:业务方要的预报分辨率越来越高,机房给的算力却原地不动。我所在的团队去年接了一个区域天气预报产品的优化任务,最初的想法很朴素——把数值模式分辨率从 9 公里抬到 3 公里,结果一算账直接放弃&#xf…

作者头像 李华
网站建设 2026/10/19 15:05:11

OCR长文本理解评测解析:从识字数数到读懂语义的跨越

OCR 模型这几年其实一直处于一个比较微妙的位置:单看字符识别率,各家都接近天花板了,可一旦把整本书、整套档案、一整年的合同文本丢进去,让它做长文本理解,问题就全冒出来了。最近某国家级研究机构发布了一套新的评测…

作者头像 李华