ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从零构建AI Agent:深入解析ReAct框架与Python实战

从零构建AI Agent:深入解析ReAct框架与Python实战

1. 从“黑盒”到“白盒”:拆解Agent的运行骨架

最近和不少刚入行AI应用开发的朋友聊天,发现一个挺普遍的现象:大家用LangChain或者Dify这类框架搭Agent,流程跑通了,结果也出来了,但被问到“这Agent到底是怎么一步步跑起来的?”时,往往就卡壳了。感觉像个黑盒,输入问题,输出答案,中间的过程云里雾里。这种感觉我特别理解,几年前我刚接触时也一样。今天,我就想抛开那些复杂的框架术语,用一个最朴素的“白盒”视角,带你亲手拆解一个Agent从启动、思考到执行、再思考的完整生命循环。我们不用任何重型框架,就用最基础的Python代码,结合OpenAI的API,来还原其核心机制——ReAct(Reasoning + Acting)。你会发现,所谓智能体,其内核逻辑远比想象中清晰和优雅。

简单来说,一个能够自主完成任务的Agent,其核心是一个循环:它接收一个目标(比如“查一下北京今天的天气,然后告诉我是否需要带伞”),然后开始“思考”下一步该做什么,接着去“执行”这个动作(比如调用一个搜索工具),拿到结果后,再基于结果和原始目标进行下一轮“思考”,直到任务完成或无法继续。这个“思考-执行”的循环,就是Agent的引擎。而驱动这个引擎的燃料,是大语言模型(LLM)的推理能力;引擎连接的齿轮,则是各种工具(Tools)。我们的目标,就是亲手打造这个引擎。

2. 核心循环:ReAct模式深度解构

为什么是ReAct?在AI Agent的发展中,ReAct范式是一个里程碑。它正式将“推理”和“行动”明确地、结构化工地结合在一个循环里。在此之前,LLM要么是纯推理(Chain-of-Thought),要么是简单调用工具,缺乏在复杂、多步任务中动态规划行动的能力。

2.1 ReAct的核心思想:让LLM学会“三思而后行”

ReAct的精髓在于,它要求LLM在每一步都以一种特定的结构化格式进行输出。这个格式通常包含三个部分:

  1. Thought(思考):分析当前状况。我有什么信息?我的目标是什么?我下一步应该做什么?为什么?
  2. Action(行动):根据思考,决定一个具体的行动。这个行动必须是对一个可用工具的调用,格式如Action: 工具名称
  3. Action Input(行动输入):调用该工具所需要的输入参数,格式如Action Input: 参数

LLM输出这个结构后,系统会解析它,执行指定的工具调用,获取工具的返回结果,我们称之为Observation(观察)。然后,系统将之前的“思考-行动”历史和这个新的“观察”一并喂给LLM,让它进行下一轮的“思考”。如此循环,直到LLM在“思考”后认为任务已经完成,输出最终的Final Answer

这个过程,完美模拟了人类解决问题的方式:先想,再做,看结果,再想下一步。它极大地提升了LLM在需要与环境(工具、API、数据库)交互的任务中的可靠性和可解释性。

2.2 与简单链式调用的本质区别

你可能会问,这和LangChain里的LCEL链(LangChain Expression Language)有什么区别?一个简单的检索问答链(RAG)不也是“调用检索器 -> 组合上下文 -> 调用LLM生成答案”吗?

关键区别在于“状态”和“决策”的归属

  • 简单链式调用:流程是预设的、静态的。就像一条流水线,数据从A工序到B工序再到C工序,路径固定。如果B工序的结果不理想,C工序也只能基于这个不理想的结果工作,无法回头。
  • ReAct Agent:流程是动态的、由LLM实时决策的。Agent内部维护着一个“状态”,包含了目标、已执行的历史、工具的返回结果。LLM在每一轮都基于完整的“状态”来决定下一步。如果某一步工具返回了错误或无关信息,LLM在下一轮“思考”时可以意识到这一点,并尝试换一种方法或工具。决策权在LLM手中,而非在预设的流程图中

这就好比自动驾驶:固定路线的轨道电车是“链式调用”,而具备感知、决策、控制能力的汽车就是“Agent”。后者能处理突发状况,比如前方修路,它会自己决策是绕行还是等待。

3. 从零构建:一个极简天气查询Agent

理论说再多不如动手。我们来实现一个具体的例子:一个能理解复杂意图的天气查询Agent。用户可能问“北京和上海明天天气对比如何?”,我们的Agent需要自己拆解出需要查询两个城市,然后对比结果。

3.1 环境准备与工具定义

首先,我们需要“燃料”(LLM)和“齿轮”(工具)。这里我们使用OpenAI的GPT-3.5-turbo作为推理引擎,并模拟一个天气查询工具。

import openai import json import re # 设置你的OpenAI API Key (实践中请使用环境变量等安全方式) openai.api_key = "your-api-key-here" # 模拟一个天气查询工具函数 def get_weather(city: str, date: str = "today") -> str: """ 模拟天气查询工具。 参数: city: 城市名 date: 日期,如 'today', 'tomorrow' 返回: 模拟的天气信息字符串 """ # 这里本应调用真实天气API,如和风、OpenWeatherMap等 # 为了演示,我们返回一个模拟数据 weather_data = { "北京": {"today": "晴,15~25°C,微风", "tomorrow": "多云转阴,18~28°C,东南风3-4级"}, "上海": {"today": "小雨,18~22°C,东风2级", "tomorrow": "阴,19~24°C,微风"}, "广州": {"today": "雷阵雨,25~32°C,南风3级", "tomorrow": "多云,26~33°C,微风"}, } city_data = weather_data.get(city) if not city_data: return f"错误:未找到城市 {city} 的天气信息。" weather = city_data.get(date, city_data.get("today", "信息暂不可用")) return f"{city}{date}的天气是:{weather}" # 定义工具列表,供LLM知晓和选择 TOOLS = [ { "name": "get_weather", "description": "查询指定城市在指定日期的天气。日期可以是'today'或'tomorrow'。", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名称,例如:北京、上海"}, "date": {"type": "string", "description": "日期,'today'或'tomorrow',默认为'today'"} }, "required": ["city"] } } ]

这里的关键是工具描述。我们必须清晰、准确地向LLM描述每个工具能干什么、需要什么参数。LLM就是根据这些描述来决定何时调用以及如何调用工具的。描述写得模糊,Agent就容易出错。

3.2 构建系统提示词:为LLM设定角色与规则

接下来,我们需要给LLM一个明确的“工作说明书”,也就是系统提示词(System Prompt)。这个提示词定义了Agent的角色、工作流程、输出格式和可用工具。

def build_system_prompt(): tool_descriptions = "\n".join([f"- {tool['name']}: {tool['description']}" for tool in TOOLS]) prompt = f""" 你是一个智能助手,能够通过使用工具来帮助用户解决问题。 你必须严格按照以下格式进行回应: Thought: 你需要在这里思考当前的情况。分析用户的最终问题是什么,你已经掌握了哪些信息(Previous Thought, Action, Observation),以及下一步应该做什么。 Action: 根据Thought,选择你要使用的工具名称。必须是以下工具之一:{', '.join([t['name'] for t in TOOLS])} Action Input: 你选择的工具所需要的输入参数,必须是一个合法的JSON字符串。 或者,当你认为已经获得了足够的信息来回答用户的问题时,你必须使用: Final Answer: 你的最终回答。 你可以使用的工具: {tool_descriptions} 开始! """ return prompt

这个提示词是Agent行为的“宪法”。它强制LLM以固定的格式(Thought/Action/Action Input 或 Final Answer)进行输出,便于我们程序化地解析。没有这个强约束,LLM的输出会天马行空,无法形成有效的循环。

3.3 解析与执行引擎:让循环转起来

现在,我们创建最核心的部分——驱动循环的引擎。这个函数负责与LLM对话、解析其输出、调用工具、并管理整个对话历史(即Agent的状态)。

def run_agent(user_query: str, max_steps: int = 5): """ 运行Agent主循环。 """ # 初始化对话历史,包含系统提示和用户问题 messages = [ {"role": "system", "content": build_system_prompt()}, {"role": "user", "content": user_query} ] print(f"用户问题: {user_query}") print("="*50) for step in range(max_steps): print(f"\n--- 步骤 {step + 1} ---") # 1. 调用LLM进行“思考” try: response = openai.ChatCompletion.create( model="gpt-3.5-turbo", messages=messages, temperature=0, # 温度设为0,保证输出的格式稳定性 max_tokens=500 ) llm_output = response.choices[0].message.content.strip() except Exception as e: print(f"调用LLM失败: {e}") break print(f"LLM原始输出:\n{llm_output}") # 2. 解析LLM的输出 thought_match = re.search(r'Thought:\s*(.*?)(?=\nAction:|\nFinal Answer:|$)', llm_output, re.DOTALL) action_match = re.search(r'Action:\s*(\w+)', llm_output) action_input_match = re.search(r'Action Input:\s*(.*?)(?=\n|$)', llm_output, re.DOTALL) final_answer_match = re.search(r'Final Answer:\s*(.*)', llm_output, re.DOTALL) # 3. 判断输出类型并处理 if final_answer_match: # 任务完成! final_answer = final_answer_match.group(1).strip() print(f"\n✅ 任务完成!最终答案: {final_answer}") return final_answer elif action_match and action_input_match: # 需要执行动作 thought = thought_match.group(1).strip() if thought_match else "" action = action_match.group(1).strip() action_input_str = action_input_match.group(1).strip() print(f"Thought: {thought}") print(f"Action: {action}") print(f"Action Input: {action_input_str}") # 4. 执行工具调用 observation = "" if action == "get_weather": try: # 解析JSON格式的输入参数 params = json.loads(action_input_str) city = params.get("city") date = params.get("date", "today") observation = get_weather(city, date) except json.JSONDecodeError: observation = f"错误:Action Input 不是有效的JSON格式: {action_input_str}" except Exception as e: observation = f"工具执行出错: {e}" else: observation = f"错误:未知的工具名称 '{action}'。" print(f"Observation: {observation}") # 5. 将本轮(Thought/Action/Action Input/Observation)添加到历史,供下一轮思考 # 注意:这里我们把LLM的完整输出和Observation都加进去,这是标准的ReAct格式。 messages.append({"role": "assistant", "content": llm_output}) messages.append({"role": "user", "content": f"Observation: {observation}\n\n现在,请基于以上观察继续思考。"}) else: # LLM的输出不符合预期格式,这是一个常见错误! print(f"❌ 无法解析LLM的输出格式。输出内容为:\n{llm_output}") # 一种容错策略:将错误信息作为Observation反馈给LLM,让它纠正 observation = f"错误:你的回复格式不正确。请严格按照Thought/Action/Action Input或Final Answer的格式回复。" messages.append({"role": "assistant", "content": llm_output}) messages.append({"role": "user", "content": f"Observation: {observation}\n\n请纠正你的格式并继续。"}) # 如果循环结束还没得到Final Answer print(f"\n⚠️ 已达到最大步数({max_steps}),任务未完成。") return "任务超时未完成。"

这个run_agent函数就是Agent的“心脏”。它清晰地展示了单次循环的五个阶段:思考 -> 解析 -> 判断 -> 执行 -> 更新状态。其中,messages列表承载了全部的历史对话,它就是Agent的“工作记忆”或“状态”。每一轮新的LLM调用,都是基于这个完整的上下文进行的,这使得Agent具备了连贯的推理能力。

3.4 实战运行:看Agent如何思考

让我们用一个稍微复杂的问题来测试一下。

if __name__ == "__main__": # 测试一个需要多步推理的问题 result = run_agent("我想知道北京明天和上海今天的天气有什么区别?", max_steps=6)

运行这段代码,你会在控制台看到类似下面的输出(具体文字可能因模型版本略有差异):

用户问题: 我想知道北京明天和上海今天的天气有什么区别? ================================================== --- 步骤 1 --- LLM原始输出: Thought: 用户想比较北京明天和上海今天的天气。我需要先分别获取这两个信息。我可以使用get_weather工具。先查北京明天的天气。 Action: get_weather Action Input: {"city": "北京", "date": "tomorrow"} Observation: 北京明天的天气是:多云转阴,18~28°C,东南风3-4级 --- 步骤 2 --- LLM原始输出: Thought: 我已经得到了北京明天的天气。现在我需要获取上海今天的天气。然后我才能比较两者的区别。 Action: get_weather Action Input: {"city": "上海", "date": "today"} Observation: 上海今天的天气是:小雨,18~22°C,东风2级 --- 步骤 3 --- LLM原始输出: Thought: 现在我有了北京明天(多云转阴,18~28°C,东南风3-4级)和上海今天(小雨,18~22°C,东风2级)的天气信息。我需要分析它们的区别。区别可能包括天气现象、温度范围和风力风向。 Final Answer: 北京明天和上海今天的天气区别如下: 1. **天气现象**:北京明天是“多云转阴”,而上海今天是“小雨”。上海有降水,北京没有。 2. **温度范围**:北京明天温度较高,为18~28°C;上海今天温度较低,为18~22°C。北京的最高温明显高于上海。 3. **风力风向**:北京明天是东南风3-4级,风力稍大;上海今天是东风2级,风力较小。 总结:北京明天天气以阴云为主,温度较高,风力较大;上海今天则有小雨,温度凉爽,风力柔和。

看,Agent活起来了!它自动将用户的一个复合问题,拆解成了两个顺序执行的工具调用(get_weather)。在获得所有必要数据后,它没有再次调用工具,而是直接输出了分析和比较的最终答案。整个过程完全自主,逻辑清晰。

注意:这里我们设置了temperature=0以保证格式稳定。在实际复杂应用中,可能需要稍微调高temperature(如0.1-0.2)以激发LLM更多样的推理,但同时要辅以更强大的输出解析(Parser)和错误处理机制。

4. 工程化挑战与进阶架构

我们上面实现的是一个最简化的、单线程的ReAct Agent。它能够清晰地展示原理,但距离一个健壮的、可投入生产的Agent系统还有很大距离。LangChain、LangGraph这些框架的出现,正是为了解决这些工程化难题。

4.1 简化版Agent的局限性

  1. 脆弱的输出解析:我们依赖正则表达式来解析LLM的输出。一旦LLM的回复格式稍有偏差(比如多了一个换行,或者用中文写了“动作”而不是“Action”),解析就会失败。生产系统需要更鲁棒的解析器,比如基于Pydantic的模式强制解析(LangChain的OutputParser就干这个)。
  2. 有限的工具管理:工具是硬编码的,增加新工具需要修改代码。理想情况是能动态注册和管理工具。
  3. 缺乏状态管理:我们的“状态”就是完整的对话历史(messages列表)。对于长对话或复杂任务,这会导致上下文长度快速增长,增加成本并可能触及模型上下文窗口限制。需要更精细的状态管理,比如只保留关键的摘要信息。
  4. 无错误恢复与超时控制:除了格式错误,工具调用可能失败(网络超时、API限流)。我们的Agent缺乏重试、降级或向用户求助的机制。
  5. 单一执行流:我们的Agent是顺序思考的。对于一些任务,可能需要并行执行多个工具调用(比如同时查询三个城市的天气),或者根据条件走不同的分支(if-else逻辑)。

4.2 LangChain Agent:提供了标准化组件

LangChain的Agent框架为我们解决了上述大部分问题。它提供了:

  • 标准化的Agent类型:如ZERO_SHOT_REACT_DESCRIPTION(就是我们实现的这种)、OPENAI_FUNCTIONS(利用OpenAI的函数调用特性,格式更稳定)、STRUCTURED_CHAT_REACT_DESCRIPTION等。
  • 强大的工具抽象:将Python函数、API封装成标准工具,并自动生成LLM可理解的描述。
  • 内建的输出解析器:能可靠地将LLM的输出解析为AgentActionAgentFinish对象。
  • 执行器(AgentExecutor):它封装了循环逻辑、错误处理、最大迭代次数控制等,让我们只需关注Agent和工具的定义。

用LangChain重写上面的天气Agent,代码会更简洁、健壮。

4.3 LangGraph:为Agent引入“工作流”与“记忆”

当任务超越简单的线性循环,变得复杂、需要分支、循环、甚至多个Agent协作时,LangChain Agent就显得力不从心了。这时就需要LangGraph

LangGraph的核心思想是用“图”来定义Agent的工作流。节点(Node)可以是执行一个工具调用、调用一个LLM、或者执行一段自定义逻辑。边(Edge)定义了节点之间的流转条件。这带来了几个质变:

  1. 显式的状态管理:LangGraph有一个明确的State对象,你可以自定义其中包含哪些字段(如messages,intermediate_steps,selected_city等)。每个节点读取和更新这个状态的一部分,而不是传递整个对话历史。
  2. 复杂控制流:你可以轻松实现:
    • 条件分支:根据工具执行结果,决定下一步是调用工具A还是工具B。
    • 循环:可以定义一个“规划器”节点,只要任务未完成,就循环回到该节点。
    • 并行与汇聚:可以同时运行多个查询,然后在一个节点汇总结果。
  3. 持久化与检查点:由于状态是结构化的,可以方便地将其保存到数据库,实现Agent的“长期记忆”和任务恢复。比如一个客服Agent可以在对话中断后,下次接着上次的状态继续。
  4. 多Agent协作:你可以定义多个具备不同能力的Agent作为图中的不同节点,让它们通过共享状态来协同完成一个宏大任务。

例如,一个电商客服Agent的工作流用LangGraph定义可能是这样的:

开始 -> 意图识别节点 -> [是退货吗?] -> 是 -> 退货流程子图 -> 否 -> [是查订单吗?] -> 是 -> 调用订单查询工具 -> 生成回答 -> 结束 -> 否 -> 转人工节点 -> 结束

这个工作流清晰、可维护、可可视化,远比一堆if-else嵌套在代码里要强大。

5. 避坑指南与效能优化实战

基于我过去几年搭建各类Agent的经验,下面这些坑你大概率会遇到,这里给出一些实用的解决方案。

5.1 提示词工程:让Agent更“听话”

Agent的表现,九成由提示词决定。除了基本的格式指令,还有几个关键点:

  • 明确工具选择逻辑:在系统提示中,加入类似“一次只使用一个工具”、“如果你不确定用哪个工具,请先思考用户问题最核心的需求是什么”的指令,可以减少LLM的困惑。
  • 提供丰富的示例(Few-Shot):在系统提示里加入1-2个完整的、格式正确的ReAct循环示例,能极大地提升LLM输出格式的稳定性。这就是Few-Shot Prompting的威力。
  • 限制行动空间:当工具很多时,LLM可能选择困难。可以在每轮提示中,根据当前对话上下文,动态筛选出最相关的3-5个工具描述给LLM,而不是每次都列出所有工具。

5.2 工具设计的艺术

工具是Agent的手和脚,设计不好会处处掣肘。

  • 单一职责:一个工具只做一件事。不要设计一个search_and_summarize的工具,而应该拆成search_websummarize_text两个工具,由LLM来组合调用。这更符合ReAct的哲学,也更具灵活性。
  • 输入输出标准化:工具的输入参数尽量简单、明确,输出也应该是结构化的字符串或JSON。模糊的工具描述(如“查询信息”)会导致LLM误用。
  • 健壮性高于一切:工具函数内部必须有完善的异常处理(try-catch)。永远返回一个字符串结果,即使是错误信息,如“网络请求失败,请稍后重试”。不要让工具抛出未处理的异常,这会直接导致Agent崩溃。
  • 为工具结果添加元数据:有时,除了结果文本,工具还会返回一些置信度、来源URL等元数据。可以考虑将这些信息以特定格式(如[结果正文]\n来源:xxx)附加在观察中,供LLM在后续思考时参考。

5.3 处理“循环失控”与“幻觉”

Agent最常见的两个故障模式:一是陷入死循环,二是基于错误观察进行“幻觉”推理。

  • 设置硬性限制max_steps(最大步数)是必须的,通常设为5-10步。对于开放式任务,可以设得大一些,但一定要有。
  • 检测重复动作:在状态中记录最近几次的(Action, Action Input)对。如果检测到完全相同的动作在短期内被重复执行,可以中断循环,并将“检测到重复操作,可能陷入循环”作为Observation反馈给LLM,让它调整策略。
  • 验证观察结果:对于关键的工具返回结果(尤其是来自外部API的),可以设计一个“验证器”节点或工具。例如,在调用计算器工具后,再用一个简单的规则验证计算结果是否在合理范围内。或者,对于搜索工具返回的文本,让LLM自己判断其是否与问题相关。
  • 引入“人类审核”节点:在关键决策点(比如要执行一个具有副作用的操作,如发送邮件、下单),可以让工作流暂停,将决策和相关信息发送给人类审核,确认后再继续。LangGraph很容易实现这种“暂停”机制。

5.4 成本与延迟优化

Agent需要多次调用LLM和工具,成本和延迟是现实问题。

  • 选择性价比模型:对于“思考”步骤,可以使用能力强但贵的模型(如GPT-4);对于简单的文本格式化或摘要,可以换用便宜快速的模型(如GPT-3.5-Turbo)。这就是混合模型策略。
  • 压缩历史上下文:随着步数增加,messages会越来越长。可以在每轮结束后,用一个LLM对之前的对话历史进行摘要,只保留关键信息,然后用摘要替换掉冗长的原始历史。这能显著减少token消耗。
  • 并行化工具调用:如果Agent需要执行多个独立的工具调用(如查询多个不相关城市的天气),可以在工作流中设计并行分支,同时发起请求,最后再汇聚结果。这能大大减少总体延迟。
  • 设置超时与降级:为每个工具调用设置超时。如果超时,则提供一个默认的降级结果(如“查询超时,请参考其他信息”),让Agent能够继续运行,而不是卡死。

6. 面向未来:Agent架构的演进思考

我们目前构建的,还属于“单一LLM驱动”的经典Agent架构。这个领域正在飞速演进,一些更先进的模式开始出现。

1. 分层规划与执行(Hierarchical Planning)对于极其复杂的任务(如“策划一场公司年会”),让一个LLM直接进行每一步的ReAct思考可能效率低下且容易迷失。更先进的架构是引入一个“规划器”Agent。它先进行高层任务分解(分解成“预订场地”、“安排餐饮”、“组织节目”等子任务),然后每个子任务再由一个“执行器”Agent(可能就是我们上面实现的这种)去完成。规划器还可以根据执行器的反馈动态调整计划。这模仿了人类项目经理的工作方式。

2. 多专家Agent协作(Multi-Agent Collaboration)一个Agent包打天下是不现实的。未来的方向是让多个具备专业技能的Agent协作。比如,一个数据分析Agent、一个文案撰写Agent、一个代码生成Agent共同协作来完成一份市场分析报告。它们之间需要通过一个共享的工作区或消息总线进行通信和协调。LangGraph的多Agent支持正是为此而生。

3. 工具学习(Tool Learning)与自我进化目前的工具都是开发者预先定义好的。更智能的Agent应该能够“学习”使用新工具。一种方向是给Agent提供工具的API文档(如OpenAPI Spec),让它自己学习如何调用。更进一步,Agent甚至可以根据频繁出现的任务模式,向开发者建议创建新的工具,或者自动组合现有工具来形成新的“复合工具”。

4. 具身Agent(Embodied Agent)与长期记忆当Agent不再局限于数字世界,而是需要控制机器人、软件界面(RPA)时,就进入了具身AI的范畴。这对动作的精确性、环境的实时感知提出了更高要求。同时,这样的Agent需要“长期记忆”,记住过去几天、几周甚至几个月与环境和用户的交互历史,才能表现出连贯的个性与能力。向量数据库与LangGraph的持久化状态结合,是构建长期记忆系统的常见方案。

构建一个稳定、可靠、高效的Agent系统,依然充满挑战。但理解其最核心的“思考-执行”循环原理,是我们应对一切复杂性的基石。从我们这几十行代码的极简Demo,到支撑庞大业务的智能体平台,其内核精神一脉相承。希望这次“白盒”拆解,能帮你拨开Agent神秘的面纱,在下次使用LangChain或设计自己的Agent时,能更清楚地知道每一行代码背后,那个忙碌的“小机器人”究竟在如何运转。

返回列表