从ReAct到AI Agent:工程化实现与架构设计实战指南
1. 项目概述:从ReAct到Agent的工程化之路
最近在AI应用开发圈里,ReAct框架和AI Agent成了高频词。很多朋友看了论文或者概念介绍,觉得思路很清晰——让大模型(LLM)学会“思考-行动-观察”的循环,再结合函数调用(Function Call)去执行具体操作,一个能自主完成复杂任务的智能体(Agent)似乎就呼之欲出了。但真到了动手写代码的时候,往往就卡壳了:循环怎么设计?工具怎么管理?状态如何维护?错误怎么处理?这些问题,论文里可不会细说。
我花了不少时间,基于LLM + Function Call的模式,从零搭建并迭代了好几个Agent项目。今天就想抛开那些宏大的概念,聚焦在代码实现上,聊聊如何把一个ReAct的理论框架,落地成一个稳定、可扩展、真正能用的AI Agent。这不仅仅是调用几个API,更是关于系统设计、状态管理和工程实践的思考。
2. 核心架构设计:构建Agent的“大脑”与“手脚”
一个基于ReAct的AI Agent,其核心架构可以类比为一个具备反思和执行能力的智能体。它的“大脑”是LLM,负责规划和决策;“手脚”则是通过Function Call暴露的各种工具(Tools)。而ReAct框架,就是连接大脑和手脚、并使其协同工作的“神经系统”。
2.1 ReAct循环的工程化拆解
在论文中,ReAct(Reasoning + Acting)通常被描述为Thought -> Action -> Observation的循环。但在工程实现上,我们需要将其细化、健壮化。
一个更完整的工程化循环通常包括以下步骤:
- 任务解析与初始化:Agent接收一个用户查询(Query),LLM首先需要理解任务的最终目标,并将其分解为可能的子步骤。这一步的输出是一个初始的“思考”(Thought)。
- 思考(Thought):LLM基于当前任务目标、已有的历史观察(Observation)和可用的工具列表,分析现状,决定下一步要做什么。关键输出是:a) 是否需要调用工具;b) 如果调用,调用哪个工具以及传入什么参数。
- 行动(Action):如果思考决定要行动,则根据上一步的决策,格式化一个标准的工具调用请求。这通常是一个结构化的JSON,包含
tool_name和arguments。 - 工具执行与观察(Observation):系统根据Action请求,找到对应的函数并执行,将执行结果(成功或失败)以及返回的数据,整理成文本形式的“观察”,反馈给LLM。这里极易出错,比如工具执行异常、返回数据格式不符合LLM预期等。
- 循环判断与终止:LLM结合新的观察,再次思考。判断任务是否已经完成(生成最终答案),或者需要继续下一步行动。需要设定明确的终止条件,防止无限循环。
注意:在代码中,这个循环需要一个“执行引擎”来驱动。这个引擎负责维护循环状态、调用LLM、分发工具调用、收集结果,并判断循环是否应该继续。
2.2 工具(Function)的管理与注册机制
Function Call是Agent的“手脚”。如何优雅地管理这些手脚,是架构设计的重点。我们不能把一堆函数散乱地扔在代码里。
一个常见的工具管理模块应具备以下功能:
- 工具注册表:一个中心化的地方(比如一个Python字典或一个专门的类)来注册所有可用的工具。每个工具需要提供:唯一名称、功能描述、参数JSON Schema(定义参数名称、类型、是否必需、描述)。
- 工具描述生成:LLM需要知道它能用什么工具。因此,我们需要将注册的工具列表,按照LLM提供商(如OpenAI, Anthropic)要求的格式,生成一个“工具描述列表”。这个列表会在每次调用LLM时附上,相当于告诉LLM:“这是你能用的所有工具说明书”。
- 工具调用路由与执行:当LLM返回一个工具调用请求时,执行引擎需要根据
tool_name从注册表中找到对应的Python函数,并将arguments解析后传入执行。 - 工具结果的标准化:工具执行后,可能返回任何Python对象(字典、列表、字符串、甚至None)。我们需要一个标准化的处理器,将这些结果转换为LLM容易理解的文本格式(Observation)。对于复杂对象,可能需要序列化为JSON字符串或进行摘要。
# 一个简化的工具注册与执行示例 class ToolRegistry: def __init__(self): self._tools = {} # {‘tool_name‘: {‘func‘: callable, ‘schema‘: dict}} def register(self, name: str, func: callable, description: str, params_schema: dict): """注册一个工具""" self._tools[name] = { 'func': func, 'schema': { 'name': name, 'description': description, 'parameters': params_schema } } def get_tool_schemas_for_llm(self): """生成LLM所需的工具描述列表""" return [tool_info['schema'] for tool_info in self._tools.values()] def execute(self, tool_name: str, arguments: dict): """执行指定工具""" if tool_name not in self._tools: raise ValueError(f"Tool '{tool_name}' not found.") tool_func = self._tools[tool_name]['func'] try: # 这里可以加入参数验证、类型转换等 result = tool_func(**arguments) return str(result) # 简单转换为字符串作为观察 except Exception as e: return f"Error executing tool '{tool_name}': {str(e)}"实操心得:工具的描述(description)和参数Schema的描述至关重要。LLM完全依赖这些文本来决定是否以及如何调用工具。描述要清晰、具体,说明工具的用途、输入输出。参数Schema要尽可能严格,这能极大减少LLM传参格式错误的概率。
3. 与LLM的交互:Prompt工程与消息流设计
LLM是Agent的决策核心。如何与它“对话”,直接影响Agent的表现。
3.1 系统提示词(System Prompt)的精心设计
系统提示词定义了Agent的角色、能力和行为规范。一个好的系统提示词是成功的一半。
一个有效的ReAct Agent系统提示词通常包含:
- 角色定义:明确告诉LLM它现在是一个AI助手,并且具备调用外部工具的能力。
- 任务流程说明:清晰地阐述ReAct循环的步骤(思考-行动-观察),并给出格式示例。强制要求LLM以特定格式(如JSON)输出,便于代码解析。
- 工具使用说明:指示LLM在需要时从提供的工具列表中选择,并严格按照参数Schema来构造调用。
- 终止条件:告诉LLM,当任务完成时,应输出一个特殊的标记(如
Final Answer:)来结束循环。 - 约束与规范:例如,禁止编造工具、一次只调用一个工具、思考要简洁等。
示例片段:
你是一个强大的AI助手,可以通过调用工具来获取信息或执行操作以完成用户任务。 请遵循以下步骤: 1. 思考:分析当前情况和目标,决定下一步。 2. 行动:如果需要调用工具,请以严格的JSON格式输出,仅包含`action`和`action_input`字段。`action`是工具名,`action_input`是参数字典。 3. 观察:你将收到工具执行的结果。 工具列表:{{TOOL_SCHEMAS}} 如果你认为已经收集到足够信息,可以给出最终答案,请以“Final Answer:”开头输出。 现在开始。用户查询是:{{USER_QUERY}} 历史记录:{{CONVERSATION_HISTORY}}3.2 对话历史(Message History)的管理
Agent需要记忆。它必须能看到之前的思考、行动和观察,才能进行连贯的推理。我们需要维护一个对话消息列表。
常见的消息角色包括:
system: 系统提示词,通常在对话开始前注入一次。user: 用户的最新查询,以及拼接的历史观察和新的问题。assistant: LLM之前的回复(包含思考和行动指令)。tool(或function): 工具执行后返回的观察结果。在OpenAI等API中,这是一个特殊的角色。
管理策略:
- 长度控制:LLM有上下文长度限制。需要设计策略来裁剪或总结过长的历史,尤其是那些包含大量工具返回数据的观察。一种简单策略是只保留最近N轮循环。
- 格式保持:确保历史消息的格式与LLM API期望的一致。例如,将(Action, Observation)对正确地格式化为
user和tool消息。
实操心得:直接将庞大的工具返回数据(如一整篇网页内容)塞进上下文,会快速消耗Token并可能干扰LLM的思考。更好的做法是,让工具本身或一个后处理函数,对返回数据进行摘要提取,只将关键信息作为Observation。例如,一个搜索工具返回10条结果,可以只提取每条结果的标题和摘要前100字。
4. 状态机与执行引擎:Agent的“心脏”
这是将以上所有部分粘合起来的核心代码。我们可以将其概念化为一个状态机(State Machine)。
4.1 核心循环的实现
class ReActAgent: def __init__(self, llm_client, tool_registry, system_prompt): self.llm = llm_client self.tools = tool_registry self.system_prompt = system_prompt self.max_steps = 10 # 防止无限循环 self.conversation_history = [] def run(self, user_query: str) -> str: # 初始化对话历史 messages = [{"role": "system", "content": self.system_prompt}] # 将初始查询加入历史 self.conversation_history.append({"role": "user", "content": user_query}) messages.append({"role": "user", "content": self._format_current_context(user_query)}) for step in range(self.max_steps): # 1. 调用LLM进行思考/决策 llm_response = self.llm.chat_completion(messages) assistant_message = llm_response.choices[0].message messages.append(assistant_message) self.conversation_history.append(assistant_message) # 2. 解析LLM响应 content = assistant_message.content # 2.1 检查是否最终答案 if "Final Answer:" in content: final_answer = content.split("Final Answer:")[-1].strip() return final_answer # 2.2 尝试解析工具调用 (这里需要健壮的解析逻辑) tool_call = self._parse_tool_call(content) if not tool_call: # LLM可能输出了无法解析的内容,视为一次“观察”(即它说了些话但没调用工具) # 我们可以将其作为虚拟观察加入历史,并继续循环 observation = f"The assistant said: {content}" messages.append({"role": "user", "content": f"Observation: {observation}"}) continue # 3. 执行工具调用 tool_name = tool_call["action"] tool_args = tool_call["action_input"] observation = self.tools.execute(tool_name, tool_args) # 4. 将观察结果格式化并加入对话历史,供下一轮思考 observation_msg = f"Observation: {observation}" messages.append({"role": "user", "content": observation_msg}) self.conversation_history.append({"role": "user", "content": observation_msg}) # 循环超过最大步数 return f"Agent stopped after {self.max_steps} steps. Unable to complete task." def _format_current_context(self, query): # 一个简单的上下文格式化函数,可以整合历史 # 更复杂的实现可能会对长历史进行摘要 context = f"Current user query: {query}\n\n" if self.conversation_history: context += "Previous conversation:\n" for msg in self.conversation_history[-5:]: # 只保留最近5轮 context += f"{msg['role']}: {msg['content']}\n" return context def _parse_tool_call(self, text): # 这是一个关键且脆弱的部分! # 需要从LLM的输出中可靠地提取JSON。 # 方法1:使用正则表达式查找JSON块。 # 方法2:要求LLM输出纯JSON,并在提示词中强调。 # 方法3(推荐):使用支持结构化输出的LLM API(如OpenAI的JSON Mode,或Anthropic的tool_use)。 import json, re json_match = re.search(r'\{.*\}', text, re.DOTALL) if json_match: try: data = json.loads(json_match.group()) # 验证必要字段 if "action" in data and "action_input" in data: return data except json.JSONDecodeError: pass return None4.2 错误处理与鲁棒性增强
上面的基础循环非常脆弱。生产级的Agent必须考虑各种错误。
- LLM输出解析失败:LLM可能不按格式输出。策略:1) 优化提示词;2) 在解析失败时,向LLM发送错误信息并要求重试(例如,“你上次的响应格式不正确,请严格按照JSON格式输出行动指令。”)。
- 工具执行错误:工具可能抛出异常(网络错误、参数错误等)。策略:捕获异常,将清晰的错误信息作为Observation返回给LLM,让它有机会调整策略。
- 循环停滞:LLM可能陷入死循环,反复调用同一个工具或进行无意义的思考。策略:1) 设置最大步数;2) 在历史中检测重复模式并中断;3) 引入“超时”或“人工干预”机制。
- 上下文超长:随着循环进行,历史消息会越来越长。策略:实现一个“历史摘要”功能,定期将旧的对话压缩成一段摘要,而不是保留所有原始文本。
5. 实战:构建一个简单的网页搜索Agent
让我们用一个具体例子,把上面的零件组装起来。我们将构建一个能回答实时性问题的Agent,它可以使用一个“搜索网络”的工具。
5.1 定义工具
我们使用一个模拟的搜索函数(实际项目中可接入SerpAPI、Google Search API等)。
import json from typing import List, Dict import requests # 假设我们有一个搜索API def search_web(query: str, num_results: int = 3) -> str: """ 在互联网上搜索给定查询词,并返回结果的摘要。 Args: query (str): 要搜索的关键词或问题。 num_results (int): 返回的最大结果数量,默认为3。 Returns: str: 搜索结果的摘要文本,每条结果包含标题和摘要。 """ # 这里是模拟代码,实际应调用搜索API # 例如: response = requests.get(f"https://api.search.com/?q={query}&num={num_results}") # 下面是一个模拟返回 mock_results = [ {"title": "什么是ReAct框架?", "snippet": "ReAct是一种将推理和行动结合的大模型交互范式..."}, {"title": "AI Agent开发指南", "snippet": "本文介绍了使用LLM和工具调用构建自主Agent的步骤..."}, {"title": "最新天气预报", "snippet": "北京,晴,15-25摄氏度。"} ] formatted_results = [] for i, r in enumerate(mock_results[:num_results], 1): formatted_results.append(f"{i}. {r['title']}: {r['snippet']}") return "\n".join(formatted_results) # 注册工具 tool_registry = ToolRegistry() tool_registry.register( name="search_web", func=search_web, description="当需要获取最新的、非模型训练数据内的信息时使用此工具,例如当前事件、天气、股价、新闻等。", params_schema={ "type": "object", "properties": { "query": {"type": "string", "description": "搜索查询词"}, "num_results": {"type": "integer", "description": "返回结果数量,默认3", "default": 3} }, "required": ["query"] } )5.2 配置LLM客户端与提示词
假设我们使用OpenAI的ChatCompletion API(其他厂商类似)。
import openai # 初始化客户端 client = openai.OpenAI(api_key="your-api-key") system_prompt = """ 你是一个有帮助的AI助手,可以调用搜索工具来回答用户问题。 请严格按照以下格式输出: 思考:<你的推理过程> 行动:{"action": "tool_name", "action_input": {"arg1": "value1"}} (仅在需要调用工具时输出此行) 最终答案:<你的最终回答> (当你能直接回答或通过工具获得信息后回答时,以此开头) 可用工具: {tools_description} 现在开始。记住,一次只调用一个工具。 用户问题:{user_input} """5.3 运行Agent
agent = ReActAgent(llm_client=client, tool_registry=tool_registry, system_prompt=system_prompt) question = "北京今天的天气怎么样?" answer = agent.run(question) print(answer) # 预期输出(基于我们的模拟搜索工具): # 最终答案:根据搜索结果,北京今天的天气是晴,气温在15到25摄氏度之间。执行过程推演:
- LLM看到问题“北京今天的天气怎么样?”,思考后认为需要最新信息。
- 它输出行动:
{"action": "search_web", "action_input": {"query": "北京今天天气"}}。 - 引擎调用
search_web工具,获得模拟的天气结果。 - 引擎将结果“Observation: 1. 最新天气预报: 北京,晴,15-25摄氏度。”加入历史,并再次调用LLM。
- LLM看到观察结果,判断信息已足够,输出“最终答案:北京今天晴,15-25摄氏度。”。
6. 高级话题与优化方向
实现基础循环只是起点。要打造强大的Agent,还需要考虑以下方面。
6.1 复杂工具与工作流
- 组合工具:一个工具可以封装更复杂的子流程。例如,一个“订机票”工具,内部可能需要先调用“搜索航班”,再调用“获取价格”,最后调用“创建订单”。
- 并行与顺序执行:有些任务可以并行调用多个工具(如同时查询天气和交通)。这需要扩展Action的格式和执行引擎,使其支持一个步骤内发起多个工具调用。
- 子Agent:对于极其复杂的任务,可以设计一个“主Agent”来规划和协调多个“子Agent”,每个子Agent负责一个特定领域(如数据分析、文案撰写、代码生成)。这本质上是将ReAct循环层级化。
6.2 记忆与知识管理
- 短期记忆:即当前的对话历史。管理策略上文已讨论。
- 长期记忆:让Agent记住跨对话的信息。这通常需要引入向量数据库。将对话中的关键信息(如用户偏好、事实结论)提取并存入向量库,在后续对话中通过检索增强(RAG)的方式提供给LLM。
- 反思与学习:高级Agent可以对自己的行动历史进行“反思”,总结成功和失败的模式,并更新自己的策略或知识。这属于更前沿的研究范畴。
6.3 评估与监控
- 可观测性:在Agent运行时,详细记录每一步的思考、行动、观察和耗时。这对于调试和优化至关重要。
- 评估指标:如何评价一个Agent的好坏?除了最终任务的完成度(成功率),还可以评估其步骤效率(用了多少步)、工具调用准确性、成本(Token消耗)等。
- 人机协同(Human-in-the-loop):在关键步骤或Agent不确定时,暂停并请求人类反馈。这对于高风险或高价值任务非常必要。
7. 常见陷阱与调试技巧
在实际开发中,你肯定会遇到各种问题。以下是一些常见坑点和解决思路。
问题1:LLM不按格式输出,导致解析失败。
- 排查:首先检查系统提示词是否清晰、强制地规定了输出格式。使用“必须”、“严格”等词。提供更清晰的示例。
- 技巧:使用LLM的“结构化输出”功能(如OpenAI的
response_format={ "type": "json_object" })。这能极大提高输出稳定性。 - 兜底:在解析代码中,如果第一次解析失败,可以将错误信息连同原始上下文再次发送给LLM,要求它纠正。例如:“你上次的响应不是有效的JSON。请重试,只输出JSON。”
问题2:Agent陷入无效循环,反复调用同一个工具或进行无意义思考。
- 排查:观察历史。LLM是否收到了足够的观察信息?工具返回的结果是否清晰?任务目标是否在上下文中被遗忘?
- 技巧:在提示词中强调“避免重复行动”。在引擎中实现简单的重复检测,如果连续N步行动相同,则中断循环并返回错误。在每轮思考时,都重新强调一遍用户的原始问题,防止LLM跑偏。
问题3:工具调用参数总是出错。
- 排查:检查工具的参数Schema描述是否足够精确?LLM是否理解每个参数的类型(字符串、数字、布尔值)和含义?
- 技巧:在Schema中使用
enum来限制参数的可选值。为每个参数提供详细的description和examples。在工具执行前,可以加入一层参数验证和类型转换的预处理。
问题4:上下文太长,导致响应慢或LLM性能下降。
- 排查:是工具返回的数据太大,还是历史轮次太多?
- 技巧:对于工具返回的大数据(如长文档、多行数据),强制要求进行摘要。可以设计一个“摘要”工具,或者让工具函数本身返回摘要版本。对于历史,实现一个滑动窗口,只保留最近K轮交互。
问题5:Agent在简单任务上表现良好,但复杂任务完全失败。
- 排查:复杂任务可能需要多步规划和信息整合。你的Agent是否具备“规划”能力?还是只是走一步看一步?
- 技巧:在系统提示词中,引导LLM在第一步先进行“任务分解”。例如:“请先分析这个问题,并列出需要哪些步骤来解决它。” 然后让Agent逐步执行这些步骤。这相当于将ReAct循环提升了一个层次。
构建一个可靠的AI Agent是一个迭代过程。从最简单的单一工具循环开始,逐步增加复杂性,并辅以完善的日志和监控,你才能清晰地看到问题所在并持续改进。代码实现是骨架,而提示词、工具设计和错误处理策略则是赋予其灵魂和韧性的血肉。