专栏第3篇:前两篇我们理解了Agent的架构全貌和LLM推理原理,但Agent到底是怎么"自己想办法"的?今天我们抛开所有框架,手写一个最精简的ReAct Agent——50行代码,跑通Thought→Action→Observation闭环,彻底搞懂Agent的运转机制。
目录
- 一、Agent的核心循环:Thought → Action → Observation
- 二、手写ReAct Agent:50行代码跑通闭环
- 三、逐行解析:核心机制拆解
- 四、Thought为什么不能省?
- 五、死循环:Agent的头号敌人
- 六、总结
一、Agent的核心循环:Thought → Action → Observation
第一篇我们讲了Agent的四步闭环:感知→规划→行动→反思。ReAct框架把这套循环具体化为一个更紧凑的执行循环:
三步循环的含义:
| 步骤 | 含义 | 谁来做 |
|---|---|---|
| Thought | 分析当前情况,决定下一步做什么 | LLM |
| Action | 调用某个工具,获取信息 | 工具 |
| Observation | 工具执行后返回的结果 | 工具 |
💡术语说明:Observation 字面是"观察",但在 ReAct 中它特指工具执行后返回的结果(如搜索API返回的数据、计算器的计算结果),不是"观察"这个动作本身。因此译为"执行结果"更贴切。
关键洞察:这个循环和第一篇的"感知→规划→行动→反思"是对应的——Thought 同时承担了感知和规划,Action 是行动,Observation 为下一轮的反思提供依据。
二、手写ReAct Agent:50行代码跑通闭环
抛开 LangChain、LlamaIndex 等框架,我们用最少的代码实现 ReAct 核心。你会发现,Agent 的本质并不复杂。
2.1 第一步:定义工具
工具是Agent的"手脚",没有工具,Agent只能"想"不能"做":
classTool:def__init__(self,name:str,func,description:str):self.name=name self.func=func self.description=descriptiondefrun(self,*args,**kwargs):returnself.func(*args,**kwargs)defsearch(query:str)->str:returnf"搜索结果:{query}的相关信息是..."defcalculator(expression:str)->str:try:result=eval(expression)returnf"计算结果:{expression}={result}"except:return"计算错误,请检查表达式"TOOLS=[Tool(name="Search",func=search,description="用于搜索实时信息、未知信息、最新资讯"),Tool(name="Calculator",func=calculator,description="用于数学计算,输入是数学表达式")]工具的设计很简单:一个名字(让LLM知道叫什么)、一个函数(实际执行的逻辑)、一段描述(告诉LLM什么时候用)。
2.2 第二步:设计Prompt模板
Prompt 是 Agent 的"灵魂",它告诉 LLM 应该按什么格式输出:
REACT_PROMPT=""" 你是一个优秀的AI助手,能够通过思考(Thought)、行动(Action)和执行结果(Observation)循环来解决问题。 可用工具: {tool_descriptions} 严格按照以下格式回答: Question: 输入的问题 Thought: 你对当前问题的思考,下一步该做什么 Action: 工具名称[工具参数] Observation: 工具返回的结果 ... (重复 Thought/Action/Observation 直到你知道答案) Thought: 我现在知道最终答案了 Final Answer: 你的最终答案 开始! Question: {question} """关键点:格式约束是 ReAct 的核心——强制 LLM 先想(Thought),再做(Action),而不是直接给答案。
2.3 第三步:实现循环
这是整个 Agent 最核心的部分,只有不到 20 行:
classReActAgent:def__init__(self,tools,max_steps=5):self.tools={tool.name:toolfortoolintools}self.max_steps=max_steps self.llm=create_llm_instance(temperature=0)defrun(self,question:str):prompt=REACT_PROMPT.format(tool_descriptions=self.get_tool_description(),question=question)forstepinrange(self.max_steps):response=self.llm.invoke(prompt)result=response.content# 找到最终答案,返回if"Final Answer:"inresult:returnresult.split("Final Answer:")[-1].strip()# 解析 Action,执行工具tool_name,tool_input=self.parse_action(result)iftool_nameandtool_nameinself.tools:observation=self.tools[tool_name].run(tool_input)# 把 LLM 输出 + 工具结果拼回 prompt,继续循环prompt=prompt+f"\n{result}\nObservation:{observation}"else:prompt=prompt+f"\n{result}\nObservation: 工具调用错误,请重新思考"return"达到最大步数,未能解决问题"循环逻辑:
- 把问题喂给 LLM
- LLM 输出 Thought + Action
- 解析 Action,执行工具,得到 Observation
- 把 LLM 的输出 + Observation 拼回 prompt
- 再次调用 LLM,它基于新的上下文继续推理
- 直到 LLM 输出 “Final Answer” 或达到最大步数
三、逐行解析:核心机制拆解
3.1 parse_action:从文本中提取工具调用
LLM 输出的是自然语言文本,我们需要从中解析出"调哪个工具、传什么参数":
defparse_action(self,text:str):pattern=r"Action: (\w+)\[(.*?)\]"match=re.search(pattern,text)ifmatch:returnmatch.group(1),match.group(2)# (工具名, 参数)returnNone,None比如 LLM 输出Action: Search[2024年中国人口],解析后得到("Search", "2024年中国人口")。
3.2 prompt 拼接:上下文如何累积
这是理解 ReAct 循环的关键——每一步的 LLM 输出和工具结果都会拼回 prompt,让 LLM "记住"之前发生了什么:
第1轮 prompt: Question: 2024年中国的人口乘以2等于多少? 第2轮 prompt(拼接后): Question: 2024年中国的人口乘以2等于多少? Thought: 我需要先查一下2024年中国的人口 Action: Search[2024年中国人口] Observation: 搜索结果:2024年中国人口约为14.1亿 第3轮 prompt(再拼接): Question: 2024年中国的人口乘以2等于多少? Thought: 我需要先查一下2024年中国的人口 Action: Search[2024年中国人口] Observation: 搜索结果:2024年中国人口约为14.1亿 Thought: 我现在知道人口是14.1亿,需要乘以2 Action: Calculator[14.1 * 2] Observation: 计算结果:14.1 * 2 = 28.2每次循环,prompt 变长一点,LLM 的上下文也丰富一点——这就是 ReAct 的"记忆"机制。
3.3 停止条件:什么时候结束循环
两个停止条件:
- 正常结束:LLM 输出包含
Final Answer:,表示它认为自己找到了答案 - 强制结束:达到
max_steps,防止无限循环
⚠️max_steps 永远不要设成无限!Agent 陷入死循环是生产环境的头号问题,后面我们会详细讲。
四、Thought为什么不能省?
有人可能会想:能不能跳过 Thought,直接让 LLM 输出 Action?
答案是:不能。原因有三:
4.1 Thought 让 LLM 先想后做
没有 Thought 约束时,LLM 倾向于直接输出答案——哪怕它其实不确定。强制先写 Thought,相当于逼它在行动前"停下来想一想"。
4.2 Thought 是调试的唯一线索
当 Agent 出错时,Thought 是你理解"它为什么这么做"的唯一窗口:
# 有 Thought —— 能看出问题出在哪 Thought: 用户问北京天气,我应该调用天气API Action: Search[北京天气] ← 搜索了天气 ✓ # 没有 Thought —— 出错了也不知道为什么 Action: Calculator[北京天气] ← 为什么用计算器查天气???4.3 Thought 让循环更稳定
Thought 为 Action 提供了"理由",LLM 基于这个理由选择下一步,逻辑链更连贯。没有 Thought,Action 的选择容易变得跳跃和随机。
💡一句话总结:Thought 是 ReAct 的"推理显式化"——把 LLM 的内心独白变成可观测、可调试的文本。
五、死循环:Agent的头号敌人
手写 Agent 跑起来后,你会发现一个头疼的问题:它容易陷入死循环。
5.1 五种典型死循环模式
5.2 四层防御体系
Level 1 预防——在Prompt里写规则:
在 System Prompt 里加入: 「禁止重复做同一个动作!」 给 Few Shot 示例,每个例子都有变化的动作Level 2 检测——代码层面识别循环:
# 精确匹配:同样的工具+同样的参数,执行了两次defdetect_exact_dup(history):seen=set()foraction,inpinhistory:key=f"{action}|{inp}"ifkeyinseen:returnTrueseen.add(key)returnFalse# 连续同类检测:同一个工具连续用了3次defcheck_progress(history):recent_actions=[h[0]forhinhistory[-3:]]iflen(set(recent_actions))==1:return"连续3次相同动作,建议强制换动作!"return"继续"Level 3 干预——把循环这件事告诉LLM:
❌ 错误做法:检测到循环就抛错 ✅ 正确做法:在 Observation 里明确指出它在循环: 「你已经连续搜索「北京天气」3次了! 搜索结果不会有变化,请你: 1. 换一个搜索关键词 2. 或者换一个工具 3. 或者直接给出答案」LLM 看到这个提示,90% 的情况会改变策略——这比直接抛错有效得多。
Level 4 兜底——硬性限制:
max_steps = 10(永远不要设成无限!)- 每步设置超时时间
- 达到限制后转人工或返回兜底答案
5.3 为什么人不会陷入死循环?
因为人有元认知——知道自己正在做什么、有没有进展。给 Agent 加上"元认知"(循环检测 + 明确反馈),它就不会傻呵呵地无限循环了。
六、总结
本文从零手写了一个 ReAct Agent,梳理了:
- ReAct 循环:Thought → Action → Observation,对应感知→规划→行动→反思
- 手写实现:工具定义 + Prompt模板 + 循环逻辑,核心代码不到50行
- 上下文累积:每步的输出和结果拼回 prompt,形成 Agent 的"短期记忆"
- Thought 的价值:推理显式化,让 LLM 先想后做,也便于调试
- 死循环防御:四层体系——预防、检测、干预、兜底
参考资源:
- 《ReAct: Synergizing Reasoning and Acting in Language Models》(Yao et al., 2023)
- 《Reflexion: Language Agents with Verbal Reinforcement Learning》(Shinn et al., 2023)
- Anthropic: “Building effective agents”(2024)