调了无数次大模型API之后,我终于想明白一件事:光会“聊天”的模型解决不了真实问题。你问它今天天气,它会一本正经告诉你“我无法获取实时数据”;你让它算一下某个复杂表达式的值,它可能心算到一半就开始胡编。这不是模型笨,而是它们天生没有手、没有脚,只能凭训练记忆说话。直到我把ReAct思想接到LangChain里,用十几行代码给模型安上“查资料的手”和“跑代码的脚”,上面的问题才算真正有了解法。
这篇内容就是写给零基础读者看的。你可能只写过几天Python,见过几次LangChain的报错,甚至还没搞明白Agent和Chain有什么区别——没关系,我会从“为什么需要ReAct”开始讲,到具体怎么用LangChain写一个能自己思考、自己调用工具的Agent,再带着你复现一个经典翻车现场,把调用过程中那些文档里不写的坑全部踩一遍。整个过程不需要你有深度学习背景,只要懂一点Python基础、能跑通一个大模型API,就够了。
1. ReAct到底解决了什么问题,以及为什么零基础也能直接上手
先花点时间理解ReAct本身。它不是什么高深莫测的新算法,而是一个极其朴素的思路:让模型在推理的过程中,一边“想”一边“做”,把思考过程(Reason)和动作执行(Act)交替进行。
在ReAct出现之前,大家让模型干活的方式主要有两种。一种是Chain of Thought(思维链),让模型一步步推理再给出答案,但它的推理完全发生在模型“脑子里”,无法接触外部信息,算不出真实数据,也查不了实时内容。另一种是把任务拆成固定的Pipeline,比如先调用一个检索接口,再调用一个总结接口,流程固定死,任务一变就得改代码。
ReAct聪明的地方在于把两者糅合在一起。模型不再一次性输出完整答案,而是进入一个循环:
- 观察当前问题,生成一段Thought(思考),说明“我现在打算怎么解决这个问题”
- 决定调哪个工具,生成Action(动作),带上工具名和参数
- 工具执行完,把结果作为Observation(观察结果)送回给模型
- 模型根据观察结果继续思考、继续行动,直到它认为已经拿到足够多的信息,才输出Final Answer
你可能会问,这不是让模型自己控制一切吗?模型乱调工具怎么办?这正是LangChain这类框架存在的意义。LangChain把“思考-行动-观察”这个循环封装成了AgentExecutor,你只需要负责两件事:给Agent注册好可用的工具,以及把大模型的API接进来。剩下的循环控制、历史记录管理、解析输出格式,框架都已经帮你处理好了。
所以“零基础”这件事是成立的。你不必从头实现ReAct的循环逻辑,不需要手写Prompt模板,也不用自己拼接工具调用结果。你需要做的,是理解这个循环的节奏,然后跟着示例把代码跑通,最后再根据自己的真实场景添加几个工具。就是这么个过程。
顺带说一句,LangChain这个框架在2023年前后火起来之后,迭代速度非常快。你现在如果在网上搜教程,会看到两种截然不同的写法:一种是创建AgentExecutor,另一种是使用langgraph的create_react_agent。这两者不冲突,后面我会专门对比。零基础读者最怕的就是看到不同版本代码不知道学哪个,我会在本篇里尽量给出当前稳定可用的方案。
2. 写Agent之前需要搞清楚的“零基础”准备清单
先明确一个事实:写ReAct Agent,本身不需要太多前置知识。但有两样东西你必须准备好:一个能调用的大模型API,以及一个能跑Python的环境。下面挨个说清楚。
2.1 Python环境和依赖安装
Python版本建议3.9以上,虚拟环境强烈建议创建。我见过太多人图省事直接装在全局环境里,结果不同项目依赖互相打架,最后连启动都费劲。创建虚拟环境很简单:
python -m venv .venv source .venv/bin/activate # Windows下是 .venv\Scripts\activate接下来安装LangChain相关包。这里有个版本细节需要注意。早期你只需要装一个langchain包就能用AgentExecutor,但到了LangChain 0.3之后,官方把Agent的底层实现逐步迁移到了langgraph,推荐用create_react_agent的方式创建Agent。为了让后面的内容能直接跑,我建议一次性装齐:
pip install langchain langchain-core langchain-openai langgraphlangchain是主框架,langchain-core是核心抽象层,langchain-openai是模型接口适配器,langgraph是新一代Agent运行时。这四个包现在的搭配最稳。
装完之后,你可以快速检查一下版本:
import langchain print(langchain.__version__)我写这篇文章时,LangChain已经到0.3.x系列,langgraph是0.2.x。如果你看到大版本号有跳跃,优先参考官方迁移文档,不要硬套旧代码。
2.2 大模型API的选择与配置
ReAct Agent本质上还是靠大模型的推理能力驱动,所以模型得支持函数调用或至少能稳定输出结构化文本。现在主流的模型API都支持这种用法,我之前在几个不同的模型上都跑通过,包括主流的闭源模型和某国产开源模型,效果差异主要体现在推理稳定性上。
配置方式很简单,通过环境变量把API密钥传进去:
import os os.environ["OPENAI_API_KEY"] = "你的Key"如果你用的是国内模型,换成对应的环境变量名即可,比如某国产模型的API Key设置方式略有不同。为了不暴露你的密钥,也为了方便团队协作,建议用.env文件配合python-dotenv来加载:
pip install python-dotenv然后在项目根目录建立.env文件:
OPENAI_API_KEY=你的Key代码里加载:
from dotenv import load_dotenv load_dotenv()这样密钥不会再硬编码在代码中,哪怕将来把代码提交到仓库,也不会泄露。
2.3 必须理解的两个概念:工具与模型绑定
在跑第一个Agent之前,你只需要理解两个核心概念,一个是工具(Tool),一个是模型绑定(bind)。
工具就是一个Python函数,只不过它多了一层描述信息——这个函数是干什么的、参数是什么。比如你写一个加法函数,工具的职责就是告诉模型:有个叫add的函数,接收两个数字参数,返回它们的和。模型在思考的时候看到这个描述,就知道在适当的时候调用它。
模型绑定则是指你使用的模型API必须支持工具调用。你可以在创建模型对象时,通过bind_tools方法把可用的工具列表传给模型。LangChain内部会把工具列表转换成模型API要求的格式,然后模型在生成回复时,如果决定需要调用工具,就会输出一个结构化的工具调用请求。
这两个概念理解透了,Agent的基本工作逻辑就已经通了:框架把整个对话历史和工具列表交给模型,模型在需要的时候发起工具调用,框架执行工具并把结果回传给模型,循环往复。
3. 第一个可运行的ReAct Agent:从零手写完整代码
纸上谈兵到此为止,现在开始写真正的代码。我会用一个非常容易理解的场景:让Agent帮我们计算复杂数学表达式,同时查一下某个物理常量。这个场景足够简单,又能证明Agent真的会调用工具而不是瞎猜。
3.1 定义两个基础工具
先定义两个简单的工具函数。第一个是计算器,第二个是获取物理常量。
import math from langchain_core.tools import tool @tool def calculate(expression: str) -> str: """计算数学表达式的值,支持 + - * / ** ( ) 等运算,例如 '13**7' 或 'sqrt(2)'。""" try: # 这里实名eval,仅在演示场景使用,生产环境千万别这么干 result = eval(expression, {"__builtins__": {}}, {"math": math}) return f"计算结果: {result}" except Exception as e: return f"计算失败: {str(e)}" @tool def get_physical_constant(name: str) -> str: """获取常见物理常数的数值,支持光速、重力加速度、普朗克常数等。""" constants = { "光速": "299792458 m/s", "重力加速度": "9.80665 m/s²", "普朗克常数": "6.62607015e-34 J·s", } return constants.get(name, f"未找到常数的信息: {name}")注意@tool装饰器是LangChain提供的工具注册方式。函数名和docstring不是摆设,模型就是靠这些描述来判断何时调用哪个工具的。所以docstring要写得清晰准确,这一点一定要重视。
3.2 创建模型并绑定工具
接下来创建模型对象。这里以OpenAI风格的接口为例,模型选择上建议用当前主流的gpt-4o-mini或者gpt-4o,推理能力越强,Agent的循环就越稳定。如果你用的是国产模型或开源模型的API,只要兼容OpenAI接口格式,改一下base_url和model名称就能跑。
from langchain_openai import ChatOpenAI llm = ChatOpenAI( model="gpt-4o-mini", temperature=0, # 工具调用场景建议temperature设为0,降低随机性 ) tools = [calculate, get_physical_constant] llm_with_tools = llm.bind_tools(tools)temperature设为0是工具调用场景的重要细节。Agent的输出需要稳定,一个随机性太强的模型可能这次想到调用工具,下次直接开始胡编答案。
3.3 通过ReAct提示词创建AgentExecutor
早期版本的LangChain中,创建ReAct Agent的标准姿势是先加载一个专门的Prompt模板,然后构建AgentExecutor。这个路径现在的写法稍作调整,但对理解ReAct有帮助,我先讲这个经典版本:
from langchain.agents import create_react_agent, AgentExecutor from langchain_core.prompts import PromptTemplate react_prompt = PromptTemplate.from_template( """尽可能回答问题。你可以使用以下工具: {tools} 请使用以下格式: 问题: 你需要回答的输入问题 思考: 你应该思考如何解决问题 动作: 你应该采取的动作,动作名称应该是 [{tool_names}] 中的一个 动作输入: 动作的输入内容 观察: 动作返回的结果 ... (这个思考/动作/动作输入/观察可以重复N次) 思考: 我现在知道最终的答案了 最终回答: 对原始问题的最终回答 开始! 问题: {input} 思考: {agent_scratchpad}""" ) agent = create_react_agent(llm, tools, react_prompt) agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)如果你去翻LangChain的官方文档,会发现他们还有一种不带agent变量的AgentExecutor用法。这个经典写法把一个完整的ReAct模板直接可视化地展示在你眼前:想法、行动、行动输入、观察,这四个字段构成了整个Agent循环的肉身。我觉得零基础读者看这个版本理解得更快,模板里已经把思考的过程、动作的格式都写得很直白。
现在更推荐的写法是langgraph版本,我先把代码贴出来,后面章节再详细拆解:
from langgraph.prebuilt import create_react_agent from langgraph.checkpoint.memory import MemorySaver memory = MemorySaver() graph_agent = create_react_agent(llm, tools, checkpointer=memory)这个版本代码更短,功能也更现代。所以接下来我给出的完整可运行示例,就用官方当前的推荐方式,再结合langgraph的流式输出接口一起跑。
3.4 完整可运行示例(LangGraph推荐版)
把上面所有片段串起来,下面是一份可以直接复制运行的完整代码:
import os from dotenv import load_dotenv load_dotenv() import math from langchain_core.tools import tool from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent from langgraph.checkpoint.memory import MemorySaver @tool def calculate(expression: str) -> str: """计算数学表达式的值,支持 + - * / ** ( ) 等运算,例如 '13**7' 或 'sqrt(2)'。""" try: result = eval(expression, {"__builtins__": {}}, {"math": math}) return f"计算结果: {result}" except Exception as e: return f"计算失败: {str(e)}" @tool def get_physical_constant(name: str) -> str: """获取常见物理常数的数值,支持光速、重力加速度、普朗克常数等。""" constants = { "光速": "299792458 m/s", "重力加速度": "9.80665 m/s²", "普朗克常数": "6.62607015e-34 J·s", } return constants.get(name, f"未找到常数的信息: {name}") llm = ChatOpenAI( model="gpt-4o-mini", temperature=0, ) tools = [calculate, get_physical_constant] memory = MemorySaver() agent = create_react_agent(llm, tools, checkpointer=memory) config = {"configurable": {"thread_id": "demo_thread"}} response = agent.invoke( {"messages": [{"role": "user", "content": "请帮我计算13的7次方是多少,另外光速的数值是多少?"}]}, config=config, ) for message in response["messages"]: print(f"{message.type}: {message.content}")运行这段代码,你会在控制台看到模型先输出思考过程,然后调用calculate工具,拿到结果,继续调用get_physical_constant工具,最后汇总两条信息给出最终答案。整个流程清晰地展示了ReAct的循环:思考-行动-观察-再思考-再行动-最终回答。
4. Agent内部到底怎么转起来的:反编译一下ReAct循环
很多教程讲到这里就停了,读者跑通一次之后只知道“它能跑”,内部完全是个黑盒。但如果你想把Agent用到真实项目里,不理解这个循环的细节,后面遇到问题根本无从下手。这一节我们把黑盒打开一点。
4.1 Thought/Action/Action Input/Observation四元组的完整循环
ReAct的每一次迭代,本质上都是这四元组的不断重复:
- Thought:模型表达当前对问题的判断,比如“我需要先计算表达式的值,这需要调用calculate工具”
- Action:模型从可用工具列表中选择一个,给出动作名称
- Action Input:模型生成该动作所需的参数
- Observation:工具执行后返回的结果
这个循环什么时候终止?两种情况下会。第一种是模型认为已经收集了足够的信息,决定输出Final Answer;第二种是达到最大迭代轮次上限,或者中间出现解析错误、工具异常等情况,Agent强制停下。
有一点需要注意,在LangChain的实现里,Thought和Action有时候不是分开生成的。模型在一次输出中可能同时给出Thought和Action以及Action Input,框架解析出动作后执行工具,把Observation拼接到对话历史中,然后再次把全部历史发给模型。这就是为什么你可以在verbose模式下看到那么完整的思考链条。
4.2 LangGraph版本的循环控制机制
新版create_react_agent是基于LangGraph的状态机实现的。它把整个Agent循环抽象成一张图:模型节点负责生成回复,工具节点负责执行工具调用,条件边根据模型是否发起了工具调用来决定下一步是继续循环还是结束。
状态机的核心是一个消息列表。每次模型生成一条消息,如果这条消息带有tool_calls,状态机就把消息追加到列表里,然后跳到工具节点执行对应工具,工具结果作为一条工具消息追加回列表,再回到模型节点。模型看到工具消息后继续生成下一条消息,如此反复。
用MemorySaver作为checkpointer的意义也在这里。它让Agent具备短期记忆能力,可以记住多次对话之间的上下文。在API调用中,你可以通过thread_id标识不同的会话,相同thread_id的请求会共享同一个对话状态。这对多轮对话场景非常重要。
4.3 Prompt模板在循环中的角色
你可能会好奇,我们使用create_react_agent的时候并没有显式提供Prompt模板,那ReAct的提示词是从哪来的?答案藏在langchain库的react prompt模板包里。create_react_agent在内部加载了一套默认的ReAct提示词,其中包括每个工具的名称、描述、参数结构,以及输出格式的完整说明。
这带来一个实用结论:工具函数的docstring写得好不好,直接影响Agent的推理质量。如果你给模型看一个含糊的docstring,那它遇到问题时就可能想不起来调用这个工具,或者传入错误的参数。这和给新同事写交接文档一样,描述清楚了,干活才靠谱。
我自己的一个习惯是,写完工具函数之后,先单独问模型一句话:“你现在有哪些工具可以用?分别能做什么?”如果模型能把每个工具的用途和参数说得和docstring基本一致,就说明你的工具注册没问题。这个自检方法简单高效。
5. 一次刻意制造的“失败”演示:当你给Agent一个它想硬算的问题
讲完原理,我们来做一个非常有意思的演示。有经验的工程师都知道,大模型在数学计算上经常翻车,尤其是复杂的乘方、开方,或者多位数的加减乘除。我们现在故意让Agent面对一个它试图心算但其实算不对的问题,看看它会不会“想起”自己还有计算器这个工具。
我构造了这样一个问题:
请计算 298374 * 91283 等于多少面对这个问题,能力强的模型第一次就会调用calculate工具。但如果你用的模型推理能力弱一些,或者你把工具的docstring描述得不清楚,模型可能就会开启“硬算模式”,在Thought里写下一步步手算,然后给一个离谱的答案。这正是ReAct在强调的事:模型必须先意识到“我该用工具”,然后这个循环才会启动。
我们把刚才代码里的问题改成这个,运行一次。一个可能的过程是:
模型先输出Thought,说“这是一个大数乘法,直接心算容易出错,我应该使用calculate工具”,然后生成Action调用calculate,传入"298374 * 91283"。工具返回正确结果27237491842。模型再输出Thought,确认结果合理,然后给出Final Answer。整个过程清清楚楚:模型不是靠记忆,而是靠工具拿到答案。
这个演示的意义在于它直观地展示了LangChain Agent和普通Chain的区别。普通Chain是预先编排好的,不会临场决定调用什么;Agent则是根据问题动态规划工具使用方式的。所以LangChain里经常说Agent是“规划者”,Chain是“执行计划”,就是这个原因。
为了让这个演示更有教育意义,我建议你把temperature调成一个比较大的值,比如1.2,然后再看看模型面对同一个问题时会不会更倾向于“硬算”。我自己实验下来,高temperature下模型确实更容易“自信地胡说”,这是一个能让零基础读者快速体感理解temperature作用的简单实验。
6. 跑通Demo之后必踩的坑:版本差异、解析失败与其他玄学问题
我敢打赌,只要你自己动手写过一次ReAct Agent,百分之百会遇到下面这些坑中的至少一个。提前看到,能帮你省好几个小时的排查时间。
6.1 版本差异引起的“新手大坑”
网上大量教程还在使用旧版写法,比如:
from langchain.agents import initialize_agent, AgentType或者:
from langchain.agents import load_tools这些代码在LangChain 0.1时代没问题,但在0.3版本之后,很多导入路径都已经变了。如果你按照旧教程写,最常见的结果是ImportError,提示找不到某个模块。这不是你代码的问题,就是版本换代导致的兼容性变化。
我的建议是:不要盲目复制老教程里的完整代码。优先看官方文档,或者找最近半年内更新的文章。LangChain官方迁移文档里有非常详细的导入路径对照表,遇到导入报错时先查官方文档,比在搜索引擎里大海捞针高效得多。
6.2 Agent最终答案解析失败与重试机制
ReAct Agent输出的最终答案必须以Final Answer开头,但模型的输出并不总是那么规矩。有时模型会在Final Answer后面继续啰嗦,有时会先输出Thought再输出Final Answer,甚至有时直接漏掉Final Answer标签。框架内部提供了一个OutputParser负责解析这些输出,但它也不是万能的。
碰到这种情况,有几个实用的办法:
- 把verbose=True打开,观察模型实际输出了什么,对比Prompt模板里要求的格式,往往一眼就能发现偏差
- 在Prompt模板里强化格式说明,比如增加“不要在任何字段中出现多余解释”或“Final Answer必须单独一行”
- 把temperature调低,随机性降低后输出格式的稳定性会明显提升
还有一个容易忽略的点是handle_parsing_errors的参数设置。在处理某些简单任务时,就算模型输出格式有误,如果你把这个参数配置好,LLM也会根据错误信息自行修正输出格式,不会直接中断整个Agent执行。这块在实际项目中用得非常多。
6.3 模型的工具调用幻觉:它以为它调了,其实没有
这个坑比较隐蔽。有些模型面对复杂问题时,会在Thought里说“我应该用calculate工具”,但动作部分却没有产生完整的工具调用结构,直接生成了文本。框架检测不到有效的tool_calls,自然也就不会执行工具。
这类问题容易出现在以下两种情况:一是模型的工具调用能力不够强,二是工具的docstring描述与实际功能不匹配,导致模型产生错误的调用意图。解决思路也不复杂:
- 换用工具调用能力更强的模型,同一套代码通常换个模型就能解决
- 把工具的docstring写得更精确更具体
- 对于关键工具,加上简单的测试用例,先单独跑一下工具再接入Agent
6.4 流式输出时的体验问题
Agent的实时输出体验和普通LLM不一样。普通LLM会逐字吐出内容,但Agent在工具调用阶段会有一段“沉默期”,因为框架正在调用工具、等待返回。如果你的程序没有正确处理这个阶段,用户会以为卡死了。
LangGraph的streaming接口可以把Agent的各个阶段暴露出来,方便前端做精细展示。你在代码里可以这样用:
async for chunk in agent.astream( {"messages": [{"role": "user", "content": "计算13的7次方"}]}, config=config, stream_mode="updates", ): print(chunk)stream_mode="updates"会按节点输出更新,你能看到模型节点和工具节点交替出现的完整轨迹。前端做展示时,就可以在模型节点输出时显示文字,在工具节点执行时显示一个“工具调用中”的占位动画,体验就好很多。
7. 从Demo到能用:单Agent架构的边界与下一步该怎么走
当你能让Agent顺利处理数学计算和常数查询这类简单任务时,下一步的问题很自然就来了:这套东西怎么用到真实业务里?
7.1 单Agent的能力边界
必须清醒认识到,单Agent架构一旦任务复杂度上升,马上会碰壁。最典型的问题是工具太多了。当工具数量超过十几个,模型每次都要在大量工具描述里寻找匹配项,选择准确率会明显下降。工具描述之间也可能出现语义重叠,模型无法判断该用哪个。
另一个问题是循环深度。如果一个任务需要模型连续调用五六次工具才能完成,过程中任何一次工具返回内容异常,都可能让模型陷入混乱,甚至开始循环调用同一个工具。LangGraph里有一个recursion limit参数可以控制最大循环次数,默认值通常是一个整数,你可以在invoke时通过config传参调大:
config = { "configurable": {"thread_id": "demo"}, "recursion_limit": 50, }但不建议一开始就把这个值调得很大,先用默认值暴露问题,再根据实际需求逐步放宽比较好。
7.2 多工具场景的组织策略
工具数量上来之后,一个有效的策略是给工具分组建命名空间。比如有搜索类工具、数据库类工具、计算类工具,分别用search_、db_、calc_前缀或统一命名。模型在选择工具时更容易识别工具归属,准确率会好很多。
更进一步的方案是使用Router思路,让上游Agent先判断当前问题属于哪个领域,再路由到对应的下游Agent。这就是多Agent协作的雏形。LangGraph本身支持多Agent图的构建,不过这是进阶内容,零基础读者先不用急着上,但可以知道方向所在。
7.3 人的介入:人类确认环节的价值
很多真实场景下,Agent执行到某个关键步骤时不应当完全自主行动。比如它准备执行一个不可逆的操作,或者需要支付一笔款项,这时需要人在中间确认。LangGraph提供了interrupt机制,可以让Agent在指定节点暂停,等待人工审批后继续执行。
这个设计思路非常符合真实业务的安全需求。我见过不少同学在Demo里把Agent做得无所不能,结果接入业务系统后被安全评审打回来,就是因为缺少人类确认机制。如果你从一开始就考虑这一点,后续项目推进会顺利得多。
7.4 继续深化可以看什么方向
跑通ReAct Agent只是Agent开发的入门。继续深入的话,有几个明确的进阶方向:
- Memory:当前MemorySaver是内存级别的存储,进程重启就丢失。生产环境可以考虑独立存储方案
- 结构化输出:让Agent返回结构化JSON,方便对接下游系统
- 多模态:工具不只是文本计算器,还可以是图像识别接口、语音合成接口
- 可观测性:记录Agent的每一步思考、每一次工具调用、耗时与成本,对线上系统必不可少
每一条都能单独写一篇长文。你在掌握本文内容之后再按兴趣深入,方向就不会跑偏。
最后说一个我在实操中发现的小技巧:Agent的开发调试过程中,别把模型温度设为0当成金科玉律。工具调用场景温度设为0通常更稳,但如果你的任务本身需要一定的探索性,比如让Agent从多个工具中找到最合适的方案,稍高一点的温度反而能带来意外的路径选择。这个参数不是越大越好也不是越小越好,关键是结合具体场景去试。我自己的做法是先固定温度0跑通逻辑,再慢慢调温度观察效果差异,找到当前任务的最优值,最后固定下来写入配置。